自分で作った deno-csrf を使ったアプリを作ってみる

Deno 用の CSRF トークンの検証モジュールを作ったのが約 1 カ月前。
「アプリで実際に使うことほとんど試していなかったな」と気が付いたので、作ってみます。

簡単に、POST するだけのアプリです。

参考

実装 - その前に -

今回使うdeno.land/x/sessions@v1.5.4、実は上手く動かないのを確認しています。
(今度プルリクを送っておきたい。プルリクは承認されたんですが、deno.land/x のモジュールはまだ更新されず。[2021/09/03])

なので、モンキーパッチ(という言い方で正しいのか?)的な方法で対応します。

deno.land/x/sessions@v1.5.4/src/frameworks/OakSession.js を見ると以下のようになっています。

deno.land/x/sessions@v1.5.4/src/frameworks/OakSession.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import Session from "../Session.js";

export default class OakSession extends Session {
constructor(oakApp, store = null) {
super(store || null);

oakApp.use(async (ctx, next) => {
const sid = ctx.cookies.get("sid"); // <= ここ await 足りない

if (sid && (await this.sessionExists(sid))) {
ctx.state.session = this.getSession(sid);
} else {
ctx.state.session = await this.createSession();
ctx.cookies.set("sid", ctx.state.session.id);
}

ctx.state.session.set("_flash", {});

await next();
});
}
}

書き足したコメントの通り、await が足りていないです,
なので、次のようにして使います。

./PatchedOakSession.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import Session from "https://deno.land/x/sessions@v1.5.4/src/Session.js";

export class PatchedOakSession extends Session {
constructor(oakApp, store = null) {
super(store || null);

oakApp.use(async (ctx, next) => {
const sid = await ctx.cookies.get("sid");

if (sid && (await this.sessionExists(sid))) {
ctx.state.session = this.getSession(sid);
} else {
ctx.state.session = await this.createSession();
ctx.cookies.set("sid", ctx.state.session.id);
}

ctx.state.session.set("_flash", {});

await next();
});
}
}

以降のアプリ本体では、この ./PatchedOakSession.js を使用します。

実装

deps.ts

./deps.ts
1
2
3
4
5
6
7
8
9
10
11
12
export { Application, Router } from "https://deno.land/x/oak/mod.ts";
export { RedisStore } from "https://deno.land/x/sessions@v1.5.4/mod.ts"
export { render } from "https://deno.land/x/mustache/mod.ts";
export {
computeHmacTokenPair,
computeVerifyHmacTokenPair,
} from "https://deno.land/x/deno_csrf@0.0.4/mod.ts";
export {
getCookies,
setCookie,
} from "https://deno.land/std@0.106.0/http/cookie.ts";
export type { Cookie } from "https://deno.land/std@0.106.0/http/cookie.ts";

app.ts

ベタ書きですが、動作確認なので許してください。

./app.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
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
import "https://deno.land/x/dotenv/load.ts";
import {
Application,
Router,
render,
computeHmacTokenPair,
computeVerifyHmacTokenPair,
getCookies,
setCookie,
RedisStore,
} from "./deps.ts";
import { Cookie } from "./deps.ts";
// 修正を行った OakSession
import { PatchedOakSession } from "./PatchedOakSession.js";

const app = new Application();
const store = new RedisStore({
host: "redis",
port: 6379,
});

await store.init();

new PatchedOakSession(app, store);

const router = new Router();

// csrf モジュールで使用する32文字のキー
const key = Deno.env.get("CSRF_KEY") as string;

router.get("/", async (context) => {
console.log("get /");
const tokenPair = computeHmacTokenPair(key, 360);

const flash = await context.state.session.get("_flash");
const name = !flash.name ? "" : flash.name;

const body = render(Deno.readTextFileSync("./page/form.html"), {
token: tokenPair.tokenStr,
name,
});
const cookie: Cookie = { name: "cookies_token", value: tokenPair.cookieStr };
setCookie(context.response, cookie);

context.response.body = body;
});

router.post("/", async (context) => {
console.log("post /");

const value = await context.request.body({ type: "form" }).value;
const csrf_token = value.get("csrf_token");
const name = value.get("name");
const cookies = getCookies(context.request);

// トークンが無い
if (!csrf_token) {
const body = render(Deno.readTextFileSync("./page/error.html"), {});
context.response.body = body;
return;
}

// csrfトークン検証エラー
if (!computeVerifyHmacTokenPair(key, csrf_token, cookies.cookies_token)) {
const body = render(Deno.readTextFileSync("./page/error.html"), {});
context.response.body = body;
return;
}

await context.state.session.flash("name", name);

context.response.redirect("/");
});

app.use(router.routes());
app.use(router.allowedMethods());
app.listen({ port: 8080 });

page/form.html

page/form.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head> </head>
<body>
<form action="/" method="POST">
<input type="hidden" name="csrf_token" value="{{token}}" />
<input type="text" name="name" />
<button type="submit">送信</button>
</form>
{{#name}}
<div>入力されたのは、{{name}}</div>
{{/name}}
</body>
</html>

page/

page/error.html
1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>

</head>
<body>
<h1>Error</h1>
</body>
</html>

起動

以下コマンドで起動します。

1
deno run --allow-net --allow-env --allow-read app.ts

アクセスするとフォームが表示されるの入力/送信。フォームの下に入力結果が表示されます。

開発者ツールでCookieを削除したり、フォーム中のトークンを書き換えなどしてフォーム送信すると、「ERROR」の表示に移ります。


作ったツールを使ったアプリの動作確認をしました。
動作確認したものの、可能なら oak 用の拡張として公開してみたいので、近いうちにチャレンジする予定です。

ではでは。