Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
【toranoana.deno#11】freshで普通のWebアプリを作る
Search
虎の穴ラボ株式会社
February 20, 2023
Technology
1.3k
0
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
【toranoana.deno#11】freshで普通のWebアプリを作る
toranoana.deno#11での発表資料です
https://yumenosora.connpass.com/event/272012/
虎の穴ラボ株式会社
February 20, 2023
More Decks by 虎の穴ラボ株式会社
See All by 虎の穴ラボ株式会社
関西Ruby登壇資料
toranoana
0
30
Tailwind CSSとAtomic Designで実現する効率的な Web 開発の事例
toranoana
1
700
Denoについて、同人誌記事を出しました+update
toranoana
0
240
【虎の穴ラボ Tech Talk #2】プロンプトエンジニアリング
toranoana
0
160
20241121_[TechTalk#2]虎の穴ラボでのLLMについて取り組み紹介
toranoana
0
180
社内チャットへRAG導入した話(Tech Talk #2)
toranoana
0
250
Deno Deploy で Web Cache API を 使えるようになったので試した知見
toranoana
1
760
【虎の穴ラボ Tech Talk】虎の穴ラボTech Talk説明資料
toranoana
0
530
虎の穴ラボ Tech Talk_CDKでFargate環境構築
toranoana
1
700
Other Decks in Technology
See All in Technology
Guerilla InnerSource in enterprises, during the AI hype
onenashev
PRO
0
150
[DroidKaigi 2026] Making UI specifications visible: Android UI development in the AI agent era supported by Compose Screenshot Testing and galleries
syarihu
0
570
Sigmaユーザーのための有用リソース一挙公開 & Sigmaで使えるMCP #sigma_ucj /useful-resources-for-sigma-computing-users-and-mcps-with-sigma
shinyaa31
0
100
AI活用の現在地、 ちゃんと見えてますか?/XPfest-2026
visional_engineering_and_design
0
160
#jawssonic2026 あの時代が悪かった ~動かなかったSageMakerと共に迎えたイベント当日~
ktkn1129
0
130
振り返りこそエンジニアの本領
negima
0
300
全員がプロダクトへ向き合う組織を持続成長させるために——組織づくりのフライホイールと4象限 / The Flywheel Model and Four Quadrants for Organizational Design
hiro_torii
3
840
Kiro Crewしか勝たん!?
miu_crescent
PRO
0
180
Where Is JetBrains AI Heading- — Central CLI, Air Alpha, and the Agentic Development Stack
x5gtrn
PRO
0
130
Hub & Spoke 環境のネットワークルーティングを分解してみる
tsuyataku
1
530
書籍『生成AIの安全性入門』の入門
wataoka
0
210
Bet AI Day 2026丨AIを「使う」から、AIが「働く」へ ― LayerXが進める「組織AI」の社会実装
layerx
PRO
3
2.3k
Featured
See All Featured
Speed Design
sergeychernyshev
33
2.1k
Scaling GitHub
holman
464
140k
Visualization
eitanlees
152
17k
HU Berlin: Industrial-Strength Natural Language Processing with spaCy and Prodigy
inesmontani
PRO
0
690
WCS-LA-2024
lcolladotor
0
820
Unsuck your backbone
ammeep
672
58k
The MySQL Ecosystem @ GitHub 2015
samlambert
251
13k
Connecting the Dots Between Site Speed, User Experience & Your Business [WebExpo 2025]
tammyeverts
11
1k
How to Align SEO within the Product Triangle To Get Buy-In & Support - #RIMC
aleyda
2
1.8k
Are puppies a ranking factor?
jonoalderson
2
3.9k
Information Architects: The Missing Link in Design Systems
soysaucechin
1
1.1k
Fireside Chat
paigeccino
42
4k
Transcript
Copyright (C) 2021 Toranoana Inc. All Rights Reserved. freshで普通のWebアプリを作る
虎の穴ラボ 藤原 佳顕 1
Copyright (C) 2021 Toranoana Inc. All Rights Reserved. アジェンダ •
概要 • freshでFormを扱う • freshでcookieセッションを扱う • freshでフロントエンドを扱う • DBを扱う 2
Copyright (C) 2021 Toranoana Inc. All Rights Reserved. 自己紹介 3
• 名前 ◦ 藤原佳顕 • 仕事 ◦ FantiaとかCreatiaとか社内アプリとか • 好み ◦ Clojure、Rust • 趣味 ◦ 格闘ゲーム ▪ Melty Blood、Guilty Gear ◦ STG(ダライアスとか)も好き
Copyright (C) 2021 Toranoana Inc. All Rights Reserved. 概要 4
• 新しい言語とかフレームワークを触るときにテンプレとしてWebアプリを作ってみてる のでDeno + Freshでもやってみます ◦ Clojureでやったときのもの ◦ https://gitlab.com/y-fujiwara/earth-clj • 基本的な機能としては以下 ◦ FormでのPOST ◦ ログイン処理及びセッション管理 ◦ フロントエンドで動く地図表示 ◦ (場合によってはWebSocket)
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> ); }
Copyright (C) 2021 Toranoana Inc. All Rights Reserved. freshでFormを扱う 6
• CSRF対策をどうするか? ◦ トークン埋め込み ▪ Formにトークンを埋め込み、Cookieやセッションの内容との一致をもってリクエスト成功と する方法 ▪ よく見られる一般的な対応 ◦ 独自ヘッダー ▪ X-Requested-With: XMLHttpRequestなどのカスタムヘッダーを埋め込むことで、preflight requestを強制し、かつ上記カスタムヘッダーがなければ応答しないようにする ▪ APIならこちらでもOK ◦ 今回は古典的な物を作りたかったのでよく使われるトークン埋め込みを採用
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で生成される安全なランダム文字列をセッションにセット ◦ 何度も生成しないようにセッションに存在すれば生成されない
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に埋め込むトークは毎回ランダムかつ、セッションの値との整合性をチェックできるもの でなければいけない
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))); }
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); }
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; }
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> ); }
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 }); } // 略 }, };
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トークンが異なります。"); } }
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の暗号化をしているだけ
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, }); } }, };
Copyright (C) 2021 Toranoana Inc. All Rights Reserved. フロントエンドで動く地図表示 17
• ログイン後の画面に地図を表示したい • 利用ライブラリ ◦ leaflet.js https://leafletjs.com/ • Deno用のライブラリはなさそう • 基本的にDOM関連ライブラリなのでクライアントサイドだけで動く • 実装どうするか ◦ islandsで全部やる(dynamic importをうまく使う) ◦ ライブラリは使わずに、cdn経由でファイル読み込んで使う ▪ グローバル変数に対して型定義が必要になる
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: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>', }).addTo(m); setMap(map); }); } }, []); return <div id="map" style={{ height: "180px" }} ref={mapContainer}></div>; }
Copyright (C) 2021 Toranoana Inc. All Rights Reserved. フロントエンドで動く地図表示
19 • dynamic importしたライブラリはfreshがよしなに制御してくれる
Copyright (C) 2021 Toranoana Inc. All Rights Reserved. まとめ •
denoで普通のWebアプリを作ってみました • セキュリティ関連の実装はできれば自作したくないので、FWに混同されていると嬉しいなと 思いました • DOM依存系のnpmモジュールは扱いに注意が必要だなと思いました ◦ islandsであってもエラーになるパターンがある 20