Remix+Hono+JWTで簡単な認証を実装する
概要
-
/admin
配下のルートで認証を行う - RemixのサーバーにHonoを使う
- Remixのactionでパスワード認証を行いJWTを発行する
- HonoのミドルウェアでJWTの検証による認証を行う(メイン)
環境構築
Cloudflare Workers用のテンプレートを使用して作成した環境をベースに進めます。
npx create-remix@latest --template remix-run/remix/templates/cloudflare-workers
Honoをインストールします。
npm add hono
パスワード認証によるログイン
Remixのactionでパスワード認証を行い、クッキーに認証用のトークンをセットします。
クッキーの準備
トークンをセットするクッキーを準備します。
import { createCookie } from "@remix-run/cloudflare";
// 管理画面認証用トークン
export const adminToken = createCookie("admin-token", {
httpOnly: true,
path: "/admin",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
});
ログイン処理
パスワード認証に成功したらトークンを作成してクッキーにセットします。
JWTの署名にはHonoのJWTヘルパーを利用します。別途インストールしなくて良いのが嬉しいです。
import { ActionFunctionArgs, json, redirect } from "@remix-run/cloudflare";
import { Form, useActionData } from "@remix-run/react";
import { sign } from "hono/jwt";
import { adminToken } from "~/cookie.server";
export async function action({ request, context }: ActionFunctionArgs) {
const id = "admin";
const password = "admin";
const body = await request.formData();
if (body.get("id") === id && body.get("password") === password) {
const token = await sign(
{ exp: Math.round(Date.now() / 1000 + 60 * 60), data: { id } },
context.env.ADMIN_JWT_SECRET,
"HS256"
);
return redirect("/admin", {
headers: {
"Set-Cookie": await adminToken.serialize(token, {
// 1h
maxAge: 60 * 60,
}),
},
});
}
return json(
{ errors: {} },
{
status: 422,
}
);
}
export default function AdminLogin() {
const data = useActionData<typeof action>();
return (
<Form method="post">
<input type="text" name="id" placeholder="ID" />
<input type="password" name="password" placeholder="Password" />
<button type="submit">Login</button>
{data?.errors && <p>Invalid ID or Password</p>}
</Form>
);
}
HonoのミドルウェアでJWTの検証による認証を行う
本題です。
Remixで認証のサンプルを探しているとloaderの先頭で認証し、NGならリダイレクトするようなものをよく見るのですが、すべてのloader、actionに仕込むのはあまり現実的ではありません。
そこで今回はHonoのミドルウェアの仕組みを使って1箇所で処理してみようと思います。
ありがたいことにHonoにはJWTを検証してくれるミドルウェアがあるのですが、検証に失敗すると401エラーを返してしまうので署名の時と同じくヘルパーの方を使って実装します。
クッキーを取得する
クッキーはRemixのクッキーオブジェクトを使ってシリアライズされた値がセットされているので、同じくRemixのクッキーオブジェクトを使って取得します。
app.use("/admin/*", async (c, next) => {
const token = await adminToken.parse(c.req.header("Cookie") ?? "");
// ...
}
トークンを検証する
トークンを検証します。
検証が成功したかどうかがわかるように変数に保存しておきます。
app.use("/admin/*", async (c, next) => {
// ...
let admin;
if (token) {
try {
const payload = await verify(token, c.env.ADMIN_JWT_SECRET, "HS256");
admin = payload.data;
} catch (e) {
//
}
}
// ...
}
検証に失敗した場合はリダイレクトする
検証に失敗した場合はリダイレクトするのですが、Remixならではの注意点があります。
Remixからのリクエストには画面遷移によるリクエストとfetchでのリクエストがあり、後者の場合はLocationヘッダーを返しても、そのリクエストがリダイレクトされるだけで元の画面をリダイレクトさせることはできません。
Remixからのfetchリクエストにたいして元の画面をリダイレクトさせるには X-Remix-Redirect
ヘッダーにリダイレクト先のパスをセットして返します。
画面遷移かfetchかの判定にはAcceptヘッダーに text/html
が含まれるかどうかで判断します。
app.use("/admin/*", async (c, next) => {
// ...
// 認証エラーの場合
if (!c.req.path.startsWith("/admin/login") && !admin) {
const loginUrl = "/admin/login";
if (c.req.header("Accept")?.includes("text/html")) {
// 画面遷移してきた場合はリダイレクト
return c.redirect(loginUrl);
} else {
// APIリクエストの場合は `X-Remix-Redirect` ヘッダーを返す
return new Response(null, {
status: 204,
statusText: "No Content",
headers: {
"X-Remix-Redirect": loginUrl,
"X-Remix-Status": "302",
},
});
}
}
await next();
}
ミドルウェアの全コード
app.use("/admin/*", async (c, next) => {
const token = await adminToken.parse(c.req.header("Cookie") ?? "");
let admin;
if (token) {
try {
const payload = await verify(token, c.env.ADMIN_JWT_SECRET, "HS256");
admin = payload.data;
} catch (e) {
//
}
}
c.set("admin", admin);
// 認証エラーの場合
if (!c.req.path.startsWith("/admin/login") && !admin) {
const loginUrl = "/admin/login";
if (c.req.header("Accept")?.includes("text/html")) {
// 画面遷移してきた場合はリダイレクト
return c.redirect(loginUrl);
} else {
// APIリクエストの場合は `X-Remix-Redirect` ヘッダーを返す
return new Response(null, {
status: 204,
statusText: "No Content",
headers: {
"X-Remix-Redirect": loginUrl,
"X-Remix-Status": "302",
},
});
}
}
await next();
});
要点
- ミドルウェアの仕組みを使って1箇所で認証とそのエラー処理を行います。
- Remixのfetchリクエスト(の元の画面)をリダイレクトするには
X-Remix-Redirect
ヘッダーを使います。 - 認証処理やJWTのペイロードについては(本題ではないので)少し適当になっているのでご留意ください。
Discussion