Fresh でログイン機能を作る

Deno 向けの Web フレームワーク Fresh
最近 1.0 がリリースされ、これから使ってみる人たちも増えるんだろうと思います。

だいたい、「ログイン機能を作る」ってのは、入口としてありそうなので、やってみます。

参考

Fresh 導入

ドキュメントに従い以下のように導入。

1
2
3
$ deno -V
deno 1.23.0
$ deno run -A -r https://fresh.deno.dev my-app

今回やらないこと

  • ユーザーの登録
  • DBとの接続(なのでID/PASSWORDはハードコーディング、確認用だからいいよね)
  • JWT の失効処理

実装

ディレクトリ構成

今回実装したもののディレクトリ構成は以下の通りです(必要部分を抜粋)。

1
2
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
1
2
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
1
2
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
/** @jsx h */
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) {
// JWTが適切に取り扱いできなければログイン画面にリダイレクト
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
1
2
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
/** @jsx h */
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
1
2
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
1
2
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), // 確認用なのでトークンの有効期間は10秒
});
return await djwt.create({ alg: "HS512", typ: "JWT" }, assignedObject, key);
}

export async function inspectAlgorithm(token: string) {
// ヘッダー内容が想定通りのものか検証する
// alg を none にする署名回避を防御しておく
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 のサンプルでは、暗号化鍵の発行処理は、次のように記載がある。

1
2
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

以下の方法で発行したキーを環境変数で持っておけばいい。

1
2
$ 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 で何か作って公開したいなぁ。

ではでは。