Slide 1

Slide 1 text

Copyright (C) 2021 Toranoana Inc. All Rights Reserved. freshで普通のWebアプリを作る
 
 虎の穴ラボ 藤原 佳顕
 1

Slide 2

Slide 2 text

Copyright (C) 2021 Toranoana Inc. All Rights Reserved. アジェンダ
 ● 概要 ● freshでFormを扱う ● freshでcookieセッションを扱う ● freshでフロントエンドを扱う ● DBを扱う 2

Slide 3

Slide 3 text

Copyright (C) 2021 Toranoana Inc. All Rights Reserved. 自己紹介
 3 ● 名前
 ○ 藤原佳顕
 ● 仕事
 ○ FantiaとかCreatiaとか社内アプリとか
 ● 好み
 ○ Clojure、Rust
 ● 趣味
 ○ 格闘ゲーム
 ■ Melty Blood、Guilty Gear
 ○ STG(ダライアスとか)も好き


Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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.
); }

Slide 6

Slide 6 text

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


Slide 7

Slide 7 text

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


Slide 8

Slide 8 text

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


Slide 9

Slide 9 text

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))); }

Slide 10

Slide 10 text

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); }

Slide 11

Slide 11 text

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; }

Slide 12

Slide 12 text

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 ? : ( <> Logout > )}
); }

Slide 13

Slide 13 text

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 }); } // 略 }, };

Slide 14

Slide 14 text

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トークンが異なります。"); } }

Slide 15

Slide 15 text

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の暗号化をしているだけ 


Slide 16

Slide 16 text

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, }); } }, };

Slide 17

Slide 17 text

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


Slide 18

Slide 18 text

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
; }

Slide 19

Slide 19 text

Copyright (C) 2021 Toranoana Inc. All Rights Reserved. フロントエンドで動く地図表示
 
 19 ● dynamic importしたライブラリはfreshがよしなに制御してくれる


Slide 20

Slide 20 text

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