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
Sponsored
·
SiteGround - Reliable hosting with speed, security, and support you can count on.
→
虎の穴ラボ株式会社
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 虎の穴ラボ株式会社
Tailwind CSSとAtomic Designで実現する効率的な Web 開発の事例
toranoana
1
670
Denoについて、同人誌記事を出しました+update
toranoana
0
230
【虎の穴ラボ Tech Talk #2】プロンプトエンジニアリング
toranoana
0
160
20241121_[TechTalk#2]虎の穴ラボでのLLMについて取り組み紹介
toranoana
0
160
社内チャットへRAG導入した話(Tech Talk #2)
toranoana
0
230
Deno Deploy で Web Cache API を 使えるようになったので試した知見
toranoana
1
730
【虎の穴ラボ Tech Talk】虎の穴ラボTech Talk説明資料
toranoana
0
510
虎の穴ラボ Tech Talk_CDKでFargate環境構築
toranoana
1
570
虎の穴ラボスキルアップ支援制度の利用例
toranoana
0
11k
Other Decks in Technology
See All in Technology
4人目のSREはAgent
tanimuyk
0
370
AI時代のエンジニアキャリアについて今一度考える
sakamoto_582
1
1k
SRE歴2ヶ月でも開発6年の知見を活かして、チームで止まっていた環境改善を前に進めた話
a_ono
0
180
プライバシー保護の理論と実践
lycorptech_jp
PRO
1
230
「軸足」は 固定しなくていい - 熱量と強みで描く、しなやかなキャリアの形
kakehashi
PRO
1
330
型は壁、Rustでもバグを直すな、表現できなくせよ
nwiizo
3
300
初めてのDatabricks勉強会
taka_aki
2
230
ご挨拶「10周年を迎える共創ラボのこれまでとこれから」
iotcomjpadmin
0
180
事業価値を⽣み出すSREへ SREが担うべき意思決定の5層
kenta_hi
0
130
AI駆動開発におけるQAエンジニアの役割事例 〜AI駆動開発の現場から〜
kobayashiyorimitsu
0
290
Multi-Agent並列開発を 安全に回すための技術 / Technology for Safely Multi-Agent Parallel Development
tooppoo
0
250
記録をかんたんに、提案をパーソナルに ── AIであすけんが目指すもの
oprstchn
0
130
Featured
See All Featured
Accessibility Awareness
sabderemane
1
150
Side Projects
sachag
455
43k
Designing Powerful Visuals for Engaging Learning
tmiket
1
430
SEO for Brand Visibility & Recognition
aleyda
0
4.6k
sira's awesome portfolio website redesign presentation
elsirapls
0
290
Embracing the Ebb and Flow
colly
88
5.1k
First, design no harm
axbom
PRO
2
1.2k
GraphQLとの向き合い方2022年版
quramy
50
15k
Dominate Local Search Results - an insider guide to GBP, reviews, and Local SEO
greggifford
PRO
0
200
実際に使うSQLの書き方 徹底解説 / pgcon21j-tutorial
soudai
PRO
201
75k
It's Worth the Effort
3n
188
29k
Leo the Paperboy
mayatellez
7
1.9k
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