deno-csrf を oak のミドルウェアにしてみる

前回 自分で作った deno-csrf を使ったアプリを作ってみる でも oak 拡張を作りたいと書いていました。
実際に作ったので、記録していきます。

参考

実装

ディレクトリ構成

1
2
3
4
5
6
7
8
9
10
11
.
|-- app.ts
|-- deps.ts
|-- docker-compose.yml
|-- dockerfile
|-- oak_csrf <= 今回のポイント
| |-- deps.ts
| |-- mod.ts
| `-- verify.ts
`-- page
`-- form.html

docker 関連

docker-compose.yml

docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
version: "3"
services:
app:
build:
context: .
dockerfile: Dockerfile
privileged: true
entrypoint:
- /sbin/init
ports:
- "8080:8080"
volumes:
- .:/usr/src/app:cached
tty: true
redis:
image: redis

dockerfile

1
2
3
4
5
6
FROM denoland/deno:centos-1.13.0

RUN mkdir /usr/src/app
WORKDIR /usr/src/app

EXPOSE 8080

拡張 oak_csrf 本体

oak_csrf/mod.ts

oak_csrf/mod.ts
1
export { CsrfVerify } from "./verify.ts";

oak_csrf/deps.ts

oak_csrf/deps.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
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";

export {
computeHmacTokenPair,
computeVerifyHmacTokenPair,
} from "https://deno.land/x/deno_csrf@0.0.4/mod.ts";

import Session from "https://deno.land/x/sessions@v1.5.4/src/Session.js";
export { Session };

export type {
Context,
Middleware,
} from "https://deno.land/x/oak@v9.0.0/mod.ts";

export {
MemoryStore,
SqliteStore,
RedisStore,
WebdisStore,
} from "https://deno.land/x/sessions@v1.5.4/mod.ts";

oak_csrf/verify.ts

oak_csrf/verify.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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import {
getCookies,
setCookie,
computeHmacTokenPair,
computeVerifyHmacTokenPair,
Session,
Context,
MemoryStore,
SqliteStore,
RedisStore,
WebdisStore,
Cookie,
Middleware,
} from "./deps.ts";

type Store = MemoryStore | SqliteStore | RedisStore | WebdisStore | null;

interface VerifyState {
state: boolean;
redirectPath: string;
message: string;
}

const getTask = async function (ctx: Context, key: string): Promise<void> {
const tokenPair = computeHmacTokenPair(key, 360);

await ctx.state.session.set("csrfToken", tokenPair.tokenStr);

const cookie: Cookie = {
name: "cookies_token",
value: tokenPair.cookieStr,
};
setCookie(ctx.response, cookie);
};

const postTask = async function (
ctx: Context,
key: string
): Promise<VerifyState> {
const value = await ctx.request.body({ type: "form" }).value;
const csrfToken = value.get("csrf_token");
const cookies = getCookies(ctx.request);

const referer = ctx.request.headers.get("referer");

let state = false,
redirectPath = "",
message = "";

// referer が無い
if (!referer) {
message = "Not huve referer!";
redirectPath = "/";
return { state, redirectPath, message };
}

// トークンが無い
if (!csrfToken) {
message = "Not found csrf token into form!";
ctx.state.session.flash("csrfUnVerify", true);
redirectPath = referer;
return { state, redirectPath, message };
}

// csrfトークン検証エラー
if (!computeVerifyHmacTokenPair(key, csrfToken, cookies.cookies_token)) {
message = "Not verify csrf token!";
ctx.state.session.flash("csrfUnVerify", true);
redirectPath = referer;
return { state, redirectPath, message };
}

state = true;
return { state, redirectPath, message };
};

export class CsrfVerify extends Session {
private key: string;

constructor(key: string, store: Store = null) {
super(store || null);
this.key = key;
}
verify(): Middleware {
const verifyFunc = async (
ctx: Context,
next: () => Promise<void>
): Promise<void> => {
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);
}

if (ctx.request.method === "GET") {
await getTask(ctx, this.key);
} else if (ctx.request.method === "POST") {
// トークン検証
const { state, redirectPath, message } = await postTask(ctx, this.key);

if (!state) {
console.error(message);
return ctx.response.redirect(redirectPath);
}

// トークン再設定
await getTask(ctx, this.key);
}
ctx.state.session.set("_flash", {});

await next();
};
return verifyFunc as Middleware;
}
}

アプリ本体

deps.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export { Application, Router } from "https://deno.land/x/oak@v9.0.0/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 {
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";
export { connect } from "https://deno.land/x/redis@v0.22.2/mod.ts";

export {
computeHmacTokenPair,
computeVerifyHmacTokenPair,
} from "https://deno.land/x/deno_csrf@0.0.4/mod.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
import "https://deno.land/x/dotenv/load.ts";
import { Application, Router, render, RedisStore } from "./deps.ts";

import { CsrfVerify } from "./oak_csrf/mod.ts";

const key = Deno.env.get("CSRF_KEY") as string;

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

// 自作モジュールのインスタンス化
const csrfVerify = new CsrfVerify(key, store);
const router = new Router();

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

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

const csrfToken = await context.state.session.get("csrfToken");

const body = render(Deno.readTextFileSync("./page/form.html"), {
token: csrfToken,
name,
csrfUnVerify,
});

context.response.body = body;
});

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

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

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

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

app.use(csrfVerify.verify()); // <= 自作モジュールの登録
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
15
16
17
<!DOCTYPE html>
<html>
<head> </head>
<body>
{{#csrfUnVerify}}
<div>CSRF検証に失敗しました。</div>
{{/csrfUnVerify}}
<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>

動作確認

今回、型定義をしっかり充てていったので、--no-check をつけずに実行できました。

1
$ deno run --allow-net --allow-env --allow-read --no-check app.ts

フォーム中のトークンと Cookie を削除か変更すると app.ts で定義した post ‘/‘ の処理をさせず、get ‘/‘ にリダイレクトがかかります。
併せて、セッションに csrf トークンの検証処理の結果を返すので、こちらを使いテンプレートで検証失敗のメッセージを出しています。

今回、deno_sessions をミドルウェアとして登録を使用したうえで、別のミドルウェアとしてセッション変数を扱ってみると、問題がありました。
なので、参考としている deno_sessions の処理のベースに付け加える形での実装として、登録方法は oak のルーターと同じ方法を取るようにしています。
結果として、このモジュールだけで セッションを取り扱いできています(これ自体は、上手くいかなかったポイント)。


今回は、自分で作った deno-csrf を oak のミドルウェアとして実装しなおして見ました。
近日、deno.land/x に登録します。

公開しました。(2021/09/20)

ではでは。