Upgrade to Pro — share decks privately, control downloads, hide ads and more …

【toranoana.deno#11】freshで普通のWebアプリを作る

 【toranoana.deno#11】freshで普通のWebアプリを作る

toranoana.deno#11での発表資料です
https://yumenosora.connpass.com/event/272012/

虎の穴ラボ株式会社

February 20, 2023
Tweet

More Decks by 虎の穴ラボ株式会社

Other Decks in Technology

Transcript

  1. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshで普通のWebアプリを作る


    虎の穴ラボ 藤原 佳顕

    1

    View full-size slide

  2. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    アジェンダ

    ● 概要
    ● freshでFormを扱う
    ● freshでcookieセッションを扱う
    ● freshでフロントエンドを扱う
    ● DBを扱う
    2

    View full-size slide

  3. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    自己紹介

    3
    ● 名前

    ○ 藤原佳顕

    ● 仕事

    ○ FantiaとかCreatiaとか社内アプリとか

    ● 好み

    ○ Clojure、Rust

    ● 趣味

    ○ 格闘ゲーム

    ■ Melty Blood、Guilty Gear

    ○ STG(ダライアスとか)も好き


    View full-size slide

  4. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    概要

    4
    ● 新しい言語とかフレームワークを触るときにテンプレとしてWebアプリを作ってみてる
    のでDeno + Freshでもやってみます

    ○ Clojureでやったときのもの

    ○ https://gitlab.com/y-fujiwara/earth-clj

    ● 基本的な機能としては以下

    ○ FormでのPOST

    ○ ログイン処理及びセッション管理

    ○ フロントエンドで動く地図表示

    ○ (場合によってはWebSocket)

    View full-size slide

  5. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshでFormを扱う

    5
    ● freshのroutesにあるものはssrされるので普通にformタグをかけばそのまま使えるはず

    function Login() {
    return (



    Submit

    );
    }
    export default function Home({ data }: PageProps) {
    return (


    Earth Deno



    You currently {data.isAllowed ? "are" : "are not"} logged in.




    );
    }

    View full-size slide

  6. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshでFormを扱う

    6
    ● CSRF対策をどうするか? 

    ○ トークン埋め込み

    ■ Formにトークンを埋め込み、Cookieやセッションの内容との一致をもってリクエスト成功と
    する方法

    ■ よく見られる一般的な対応 

    ○ 独自ヘッダー

    ■ X-Requested-With: XMLHttpRequestなどのカスタムヘッダーを埋め込むことで、preflight
    requestを強制し、かつ上記カスタムヘッダーがなければ応答しないようにする 

    ■ APIならこちらでもOK 

    ○ 今回は古典的な物を作りたかったのでよく使われるトークン埋め込みを採用 


    View full-size slide

  7. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshでFormを扱う

    7
    ● どうトークンを生成するか? 

    ● いわゆるフルスタックフレームワークには大抵デフォルトで備わっている 

    ○ 今回はRuby on Rails(RoR)を参考にする 

    ○ 参考:https://techracho.bpsinc.jp/hachi8833/2021_11_26/46891 

    ● RoRではヘッダーに埋め込むcsrf_meta_tagsヘルパーおよび、controller側でのprotect_from_forgery
    フィルターでCSRFトークンの生成と検証を行っている 

    ● 検証用トークン本体の生成ルール 

    ○ SecureRandom.base64で生成される安全なランダム文字列をセッションにセット 

    ○ 何度も生成しないようにセッションに存在すれば生成されない 


    View full-size slide

  8. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshでFormを扱う

    8
    ● Formに埋め込むトークンの生成

    ○ セッションに入れたものと同じものを入れてもいいけど・・・

    ■ 実際の実装

    ○ コメント抜粋

    ■ The masking is used to mitigate SSL attacks like BREACH.

    ■ SSLに対するBREACH攻撃のためにマスキングされている

    ● https://docs.digicert.com/ja/certcentral/certificate-tools/discovery-user-guide/tls-ssl-endpoi
    nt-vulnerabilities/breach.html

    ○ HTTPの圧縮機能が有効なときに、HTTPSでの暗号化が有効であっても中身の情報が推測できてしまう

    ○ 固定値のCSRFトークンを埋め込んでいると値が推測できてしまう

    ■ したがって、Formに埋め込むトークは毎回ランダムかつ、セッションの値との整合性をチェックできるもの
    でなければいけない


    View full-size slide

  9. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshでFormを扱う

    9
    ● RoRではもとのトークンと同じ長さのランダム文字列とのxorを取って暗号化しているので真似する

    ● まずはもとのトークン生成

    ○ 乱数生成後、Base64化

    function csrfToken(): string {
    return encode(crypto.getRandomValues(new Uint8Array(AUTHENTICITY_TOKEN_LENGTH)));
    }

    View full-size slide

  10. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshでFormを扱う

    10
    ● masked token生成

    function maskedToken(token: string): string {
    const bytes = crypto.getRandomValues(
    new Uint8Array(AUTHENTICITY_TOKEN_LENGTH),
    );
    const tokenBytes = decode(token);
    const ret = bytes.map((b, idx) => {
    return tokenBytes[idx] ^ b;
    });
    const maskedBytes = new Uint8Array(bytes.length + ret.length);
    maskedBytes.set(bytes);
    maskedBytes.set(ret, bytes.length);
    return encode(maskedBytes);
    }

    View full-size slide

  11. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshでFormを扱う

    11
    ● これらまとめて_middlewareとすることで、各ページロードのたびに新しいトークンが生成できるようにします

    const AUTHENTICITY_TOKEN_LENGTH = 32;
    export async function handler(
    req: Request,
    ctx: MiddlewareHandlerContext,
    ) {
    const cookie = await getDecryptedSessionCookie(req);
    let realToken = cookie?.csrfToken;
    let cookieHeaders = null;
    if (typeof realToken !== "string") {
    realToken = csrfToken();
    cookieHeaders = await buildSessionCookieHeader(req, {
    csrfToken: realToken,
    });
    }
    ctx.state.maskedCsrfToken = maskedToken(realToken as string);
    const resp = await ctx.next();
    if (cookieHeaders) {
    resp.headers.set("Set-Cookie", cookieHeaders.get("Set-Cookie") as string);
    }
    return resp;
    }

    View full-size slide

  12. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshでFormを扱う

    12
    ● 最終的なLogin Form

    export const handler: Handlers = {
    async GET(req, ctx) {
    const cookies = await getDecryptedSessionCookie(req);
    return ctx.render!({
    isAllowed: cookies?.userId === "deno",
    csrfToken: ctx.state.maskedCsrfToken,
    });
    },
    };
    function Login(props: FormProps) {
    return (




    Submit

    );
    }
    export default function Home({ data }: PageProps) {
    return (


    Earth Deno



    You currently {data.isAllowed ? "are" : "are not"} logged in.

    {!data.isAllowed ? : (
    <>

    User Page


    Logout
    >
    )}


    );
    }

    View full-size slide

  13. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshでFormを扱う

    13
    ● チェック処理

    ○ Base64関連でエラーになることもあるので、検証失敗は一括で例外扱い

    export const handler: Handlers = {
    async POST(req) {
    const form = await req.formData();
    const maskedToken = form.get("csrfToken") || "";
    const cookie = await getDecryptedSessionCookie(req);
    const sessionToken = cookie?.["csrfToken"] as string;
    try {
    validCsrfToken(maskedToken as string, sessionToken);
    } catch(e) {
    console.error(e);
    return new Response(null, { status: 403 });
    }
    // 略
    },
    };

    View full-size slide

  14. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    freshでFormを扱う

    14
    ● タイミングアタック(トークンの検証時間でトークンそのものを推測しようとする攻撃)を防ぐために単純な文字比較にはしな
    い

    function validCsrfToken(maskedToken: string, sessionToken: string) {
    const unmaskToken = unmask(maskedToken);
    return compareToken(unmaskToken, sessionToken);
    }
    function unmask(maskedToken: string): Uint8Array {
    const maskedTokenBytes = decode(maskedToken);
    const oneTimePad = maskedTokenBytes.subarray(0, 32);
    const encCsrfToken = maskedTokenBytes.subarray(32);
    const ret = oneTimePad.map((b, idx) => {
    return encCsrfToken[idx] ^ b;
    });
    return ret
    }
    function compareToken(unmaskBytes: Uint8Array, sessionToken: string) {
    const sessionTokenBytes = decode(sessionToken);
    if (unmaskBytes.byteLength !== sessionTokenBytes.byteLength) {
    throw new Error("CSRFトークンの長さが異なります。");
    }
    const ret = sessionTokenBytes.every((byte, idx) => {
    return !(byte ^ unmaskBytes[idx]);
    });
    if (!ret) {
    throw new Error("CSRFトークンが異なります。");
    }
    }

    View full-size slide

  15. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    ログイン処理及びセッション管理

    15
    ● 基本的に以前の発表でお話したことと同様のことを行います 

    ○ https://www.slideshare.net/toranoana-lab/github-apifresh 

    ● 基本的にはCookieセッションを扱います 

    ● Cookieの内容はcryptoを使って暗号化します(AES-GCM) 

    ● 今回は、ログインIDの他、先述のCSRFトークンを保持する必要があるので、単一の値での上書きは
    NGです

    ○ 取得→merge→再追加 

    ● 2022/11/4のDenoブログに似たような内容があったりする 

    ○ https://deno.com/blog/setup-auth-with-fresh 

    ● 今回の実装は基本的に上記+Cookieの暗号化をしているだけ 


    View full-size slide

  16. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    ログイン処理及びセッション管理


    16
    ● ほぼサンプル通り

    export const handler: Handlers = {
    async POST(req) {
    // TODO: csrfトークン検証処理
    const form = await req.formData();
    if (
    form.get("username") === USERNAME && form.get("password") === PASSWORD
    ) {
    const headers = await buildSessionCookieHeader(req, { userId: "deno" });
    headers.set("location", "/");
    return new Response(null, { status: 303, headers });
    } else {
    return new Response(null, {
    status: 403,
    });
    }
    },
    };

    View full-size slide

  17. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    フロントエンドで動く地図表示

    17
    ● ログイン後の画面に地図を表示したい

    ● 利用ライブラリ

    ○ leaflet.js https://leafletjs.com/

    ● Deno用のライブラリはなさそう

    ● 基本的にDOM関連ライブラリなのでクライアントサイドだけで動く

    ● 実装どうするか

    ○ islandsで全部やる(dynamic importをうまく使う)

    ○ ライブラリは使わずに、cdn経由でファイル読み込んで使う

    ■ グローバル変数に対して型定義が必要になる

    View full-size slide

  18. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    フロントエンドで動く地図表示


    18
    ● islandsでdynamic importした

    import { useEffect, useRef, useState } from "preact/hooks";
    export function MapContainer() {
    const mapContainer = useRef(null);
    const [map, setMap] = useState(null);
    useEffect(() => {
    if (typeof document === "object" && mapContainer && mapContainer.current) {
    import("leaflet").then((L) => {
    const m = L.map("map").setView([51.505, -0.09], 13);
    L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
    maxZoom: 19,
    attribution:
    '© OpenStreetMap',
    }).addTo(m);
    setMap(map);
    });
    }
    }, []);
    return ;
    }

    View full-size slide

  19. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    フロントエンドで動く地図表示


    19
    ● dynamic importしたライブラリはfreshがよしなに制御してくれる


    View full-size slide

  20. Copyright (C) 2021 Toranoana Inc. All Rights Reserved.
    まとめ

    ● denoで普通のWebアプリを作ってみました

    ● セキュリティ関連の実装はできれば自作したくないので、FWに混同されていると嬉しいなと
    思いました

    ● DOM依存系のnpmモジュールは扱いに注意が必要だなと思いました

    ○ islandsであってもエラーになるパターンがある

    20

    View full-size slide