Deno 向けの Web フレームワーク Fresh。
最近 1.0 がリリースされ、これから使ってみる人たちも増えるんだろうと思います。
だいたい、「ログイン機能を作る」ってのは、入口としてありそうなので、やってみます。
参考
Fresh 導入
ドキュメントに従い以下のように導入。
| 12
 3
 
 | $ deno -Vdeno 1.23.0
 $ deno run -A -r https://fresh.deno.dev my-app
 
 | 
今回やらないこと
- ユーザーの登録
- DBとの接続(なのでID/PASSWORDはハードコーディング、確認用だからいいよね)
- JWT の失効処理
実装
ディレクトリ構成
今回実装したもののディレクトリ構成は以下の通りです(必要部分を抜粋)。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 
 | $ tree.
 |-- deno.json
 |-- dev.ts
 |-- fresh.gen.ts
 |-- import_map.json
 |-- main.ts
 |-- routes
 |   |-- auth
 |   |   `-- index.tsx
 |   `-- index.tsx
 `-- util
 |-- csrf.ts
 `-- jwt.ts
 
 | 
実装の中身
import_map.json でいくつかモジュールを追加しています。
import_map.json| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 
 | {"imports": {
 "$fresh/": "https://deno.land/x/fresh@1.0.1/",
 "preact": "https://esm.sh/preact@10.8.2",
 "preact/": "https://esm.sh/preact@10.8.2/",
 "preact-render-to-string": "https://esm.sh/preact-render-to-string@5.2.0?deps=preact@10.8.2",
 
 "$std/": "https://deno.land/std@0.145.0/",
 "djwt/": "https://deno.land/x/djwt@v2.7/",
 "deno_csrf/": "https://deno.land/x/deno_csrf@0.0.4/"
 }
 }
 
 | 
 
/ にアクセスしたときに対応する routes/index.tsx 。
JWT を検証し、適切なものでは無ければ、/auth に飛ばす。
routes/index.tsx| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 
 | import { h } from "preact";
 import { Handlers, PageProps, Data } from "$fresh/server.ts";
 import { getCookies } from "$std/http/cookie.ts";
 import { getJwtPayload, inspectAlgorithm } from "../util/jwt.ts";
 
 export const handler: Handlers<Data> = {
 async GET(req, ctx) {
 try {
 const cookies = getCookies(req.headers);
 const jwtToken = cookies.token || "";
 
 if (!(await inspectAlgorithm(jwtToken))) throw new Error();
 const payload = await getJwtPayload(jwtToken);
 
 return ctx.render({ payload });
 } catch (e) {
 
 console.error(e);
 const response = new Response("", {
 status: 303,
 headers: { Location: "/auth" },
 });
 return response;
 }
 },
 };
 
 export default function Index(props: PageProps) {
 return <div>Hello {props.data.payload.name}:{props.data.payload.id}</div>;
 }
 
 | 
 
/auth に対応する routes/auth/index.tsx 。
ログインフォームの作成、検証処理をすべて受け持つ。
csrf対策しておく。
routes/auth/index.tsx| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 
 | import { h } from "preact";
 import { Handlers, PageProps } from "$fresh/server.ts";
 import { getCookies, setCookie } from "$std/http/cookie.ts";
 import { createJwt } from "../../util/jwt.ts";
 import { createTokenPair, verifyToken } from "../../util/csrf.ts";
 
 interface User {
 id: number;
 name: string;
 }
 
 async function pageLoad(ctx, message:string) {
 const pair = createTokenPair();
 const response = await ctx.render({ csrfToken: pair.tokenStr, message });
 
 setCookie(response.headers, {
 name: "csrf_cookie_token",
 value: pair.cookieStr,
 secure: true,
 httpOnly: true,
 });
 return response;
 }
 
 
 function verifyUser(email: string, password: string): User {
 
 if (!(email === "a@gmail.com" && password === "1234")) throw new Error();
 
 return { id: 1, name: "TANAKA TARO" };
 }
 
 export const handler: Handlers<Data> = {
 async GET(req, ctx) {
 return pageLoad(ctx);
 },
 async POST(req, ctx) {
 try {
 const form = await req.formData();
 
 const csrfToken = form.get("csrf_token") || "";
 const email = form.get("email") || "";
 const password = form.get("password") || "";
 
 const cookies = getCookies(req.headers);
 const csrfCookieToken = cookies.csrf_cookie_token || "";
 
 const verifyTokenResult = verifyToken(
 csrfToken,
 csrfCookieToken,
 );
 
 if (!verifyTokenResult) {
 return pageLoad(ctx, "認証エラー");
 }
 const user = verifyUser(email, password);
 
 const response = new Response("", {
 status: 303,
 headers: { Location: "/" },
 });
 
 setCookie(response.headers, {
 name: "token",
 value: await createJwt(user),
 secure: true,
 httpOnly: true,
 });
 
 return response;
 } catch (e) {
 console.error(e);
 return pageLoad(ctx, "認証エラー");
 }
 },
 };
 
 export default function Index(props: PageProps) {
 return (
 <div>
 { props.data.message != "" ? <p>{props.data.message}</p>: ""}
 <form method="post">
 <input type="email" name="email" placeholder="email" autocomplete="off"></input>
 <input type="password" name="password" placeholder="password"></input>
 <input type="hidden" name="csrf_token" value={props.data.csrfToken}>
 </input>
 <button type="submit">submit</button>
 </form>
 </div>
 );
 }
 
 | 
 
CSRF 対策用のモジュール deno_csrf を使用し、トークンペアの発行と検証をひとまとめ。
util/csrf.ts| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 
 | import {computeAesGcmTokenPair,
 computeVerifyAesGcmTokenPair,
 } from "deno_csrf/mod.ts";
 
 const key = Deno.env.get("CSRF_KEY") || "";
 
 export function createTokenPair() {
 return computeAesGcmTokenPair(key, 5 * 60);
 }
 
 export function verifyToken(csrfToken: string, csrfCookieToken: string) {
 return computeVerifyAesGcmTokenPair(
 key,
 csrfToken,
 csrfCookieToken,
 );
 }
 
 | 
 
deno 向けのJWT発行のモジュール djwtを使用し、発行と検証を行う。
util/jwt.ts| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 
 | import { decode } from "$std/encoding/base64.ts";
 import * as djwt from "djwt/mod.ts";
 
 const encodedKey = Deno.env.get("JWT_CRYPTO_KEY") || "";
 const decodedKey = decode(encodedKey);
 
 const key = await crypto.subtle.importKey(
 "raw",
 decodedKey,
 { name: "HMAC", hash: "SHA-512" },
 true,
 ["sign", "verify"],
 );
 
 export async function createJwt(src: Object) {
 
 const assignedObject = Object.assign(src, {
 jti: crypto.randomUUID(),
 exp: djwt.getNumericDate(10),
 });
 return await djwt.create({ alg: "HS512", typ: "JWT" }, assignedObject, key);
 }
 
 export async function inspectAlgorithm(token: string) {
 
 
 const [header] = await djwt.decode(token, key);
 return header.alg === "HS512" && header.typ === "JWT";
 }
 
 export async function getJwtPayload(token: string) {
 return await djwt.verify(token, key);
 }
 
 | 
 
djwt のサンプルでは、暗号化鍵の発行処理は、次のように記載がある。
| 12
 3
 4
 5
 
 | const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-512" },
 true,
 ["sign", "verify"],
 );
 
 | 
この方法だと、起動都度キーが変わる。エッジワーカーの起動タイミングで発行されると検証がロクにできない可能性が高い。
なので、crypto.subtle.generateKey で発行したキーをエクスポートして文字列化し、環境変数で持つようにした。
アプリ側では、先の実装のように crypto.subtle.importKey を使用しキーを復元して暗号化に使用した。
キーの発行/エクスポート処理だけ、ざっと作って公開した。
generate_crypto_key
以下の方法で発行したキーを環境変数で持っておけばいい。
| 12
 
 | $ deno run https://raw.githubusercontent.com/Octo8080/generate_crypto_key/master/cli.ts// => GENERATED KEY: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=
 
 | 
動作確認
と、ここまで実装したところで、deno deploy にデプロイして環境変数の設定を行う。

アクセスしてみて動作確認すると、次のように動きます。(2.5倍速)

- https://expensive-robin-80.deno.devにアクセス- 
- https://expensive-robin-80.deno.dev/authにリダイレクト
 
- パスワードを間違う(4文字のところを3文字入力)
- パスワードを(4文字のところを3文字入力)
- https://expensive-robin-80.deno.devにリダイレクト
 
- JWT に保存した内容を表示
- 10秒経ったらトークンの有効期限切れで、https://expensive-robin-80.deno.dev/authにリダイレクト
Fresh でログイン機能を実装、Deno Deploy で動作確認してみました。
今回は Fresh の特徴ともいえる Islands Architecture を全く使用せずいわゆる MPA 的な動作だけで用意しました。
トークンは、Cookie に持たせたので  Islands Architecture でのSPA的部分もあまり手間無く取り扱いできそうです。
ネタもないけど Fresh で何か作って公開したいなぁ。
ではでは。