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でFormを扱う • freshでcookieセッションを扱う • freshでフロントエンドを扱う • DBを扱う 2
  2. Copyright (C) 2021 Toranoana Inc. All Rights Reserved. 自己紹介
 3

    • 名前
 ◦ 藤原佳顕
 • 仕事
 ◦ FantiaとかCreatiaとか社内アプリとか
 • 好み
 ◦ Clojure、Rust
 • 趣味
 ◦ 格闘ゲーム
 ▪ Melty Blood、Guilty Gear
 ◦ STG(ダライアスとか)も好き

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

    • 新しい言語とかフレームワークを触るときにテンプレとしてWebアプリを作ってみてる のでDeno + Freshでもやってみます
 ◦ Clojureでやったときのもの
 ◦ https://gitlab.com/y-fujiwara/earth-clj
 • 基本的な機能としては以下
 ◦ FormでのPOST
 ◦ ログイン処理及びセッション管理
 ◦ フロントエンドで動く地図表示
 ◦ (場合によってはWebSocket)
  4. Copyright (C) 2021 Toranoana Inc. All Rights Reserved. freshでFormを扱う
 5

    • freshのroutesにあるものはssrされるので普通にformタグをかけばそのまま使えるはず
 function Login() { return ( <form method="post" action="/api/login"> <input type="text" name="username" /> <input type="password" name="password" /> <button type="submit">Submit</button> </form> ); } export default function Home({ data }: PageProps<Data>) { return ( <Layout> <Head> <title>Earth Deno</title> </Head> <div> <div> You currently {data.isAllowed ? "are" : "are not"} logged in. </div> <Login /> </div> </Layout> ); }
  5. Copyright (C) 2021 Toranoana Inc. All Rights Reserved. freshでFormを扱う
 6

    • CSRF対策をどうするか? 
 ◦ トークン埋め込み
 ▪ Formにトークンを埋め込み、Cookieやセッションの内容との一致をもってリクエスト成功と する方法
 ▪ よく見られる一般的な対応 
 ◦ 独自ヘッダー
 ▪ X-Requested-With: XMLHttpRequestなどのカスタムヘッダーを埋め込むことで、preflight requestを強制し、かつ上記カスタムヘッダーがなければ応答しないようにする 
 ▪ APIならこちらでもOK 
 ◦ 今回は古典的な物を作りたかったのでよく使われるトークン埋め込みを採用 

  6. 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で生成される安全なランダム文字列をセッションにセット 
 ◦ 何度も生成しないようにセッションに存在すれば生成されない 

  7. 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に埋め込むトークは毎回ランダムかつ、セッションの値との整合性をチェックできるもの でなければいけない

  8. 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))); }
  9. 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); }
  10. 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<ContextState>, ) { 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; }
  11. Copyright (C) 2021 Toranoana Inc. All Rights Reserved. freshでFormを扱う
 12

    • 最終的なLogin Form
 export const handler: Handlers<any, ContextState> = { 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 ( <form method="post" action="/api/login"> <input type="hidden" value={props.csrfToken} name="csrfToken" /> <input type="text" name="username" /> <input type="password" name="password" /> <button type="submit">Submit</button> </form> ); } export default function Home({ data }: PageProps<Data>) { return ( <Layout> <Head> <title>Earth Deno</title> </Head> <div> <div> You currently {data.isAllowed ? "are" : "are not"} logged in. </div> {!data.isAllowed ? <Login csrfToken={data.csrfToken} /> : ( <> <div> <a href="/auth">User Page</a> </div> <Map /> <a href="/logout">Logout</a> <Counter start={3} /> </> )} </div> </Layout> ); }
  12. 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 }); } // 略 }, };
  13. 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トークンが異なります。"); } }
  14. 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の暗号化をしているだけ 

  15. 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, }); } }, };
  16. Copyright (C) 2021 Toranoana Inc. All Rights Reserved. フロントエンドで動く地図表示
 17

    • ログイン後の画面に地図を表示したい 
 • 利用ライブラリ
 ◦ leaflet.js https://leafletjs.com/
 • Deno用のライブラリはなさそう
 • 基本的にDOM関連ライブラリなのでクライアントサイドだけで動く 
 • 実装どうするか
 ◦ islandsで全部やる(dynamic importをうまく使う) 
 ◦ ライブラリは使わずに、cdn経由でファイル読み込んで使う 
 ▪ グローバル変数に対して型定義が必要になる 

  17. 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<HTMLDivElement>(null); const [map, setMap] = useState<L.Map | null>(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: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', }).addTo(m); setMap(map); }); } }, []); return <div id="map" style={{ height: "180px" }} ref={mapContainer}></div>; }
  18. Copyright (C) 2021 Toranoana Inc. All Rights Reserved. フロントエンドで動く地図表示
 


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

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

    denoで普通のWebアプリを作ってみました 
 • セキュリティ関連の実装はできれば自作したくないので、FWに混同されていると嬉しいなと 思いました
 • DOM依存系のnpmモジュールは扱いに注意が必要だなと思いました 
 ◦ islandsであってもエラーになるパターンがある 
 20