先日、Fresh 2 のミドルウェアを作る方法 について確認した。
ExpressやHonoに似たことで移植もしやすいことを見込んでいた。 なので移植する形で、実装しプルリクを出した。 結果、CORSミドルウェアとCSRFミドルウェアをFresh本体のリポジトリに入れ、CORSミドルウェアはマージとなった。
今回は、Fresh 2 の拡張として、プラグイン的なもの作ってみる。
Fresh 2 では、Fresh1のようなプラグイン機能は移植されていない。 しかし、Fresh2 のミドルウェアなら、ある機能のために複数の拡張をまとめて行うプラグイン的なものを、実装できるだろうことを見込んだ。
実際に試してみる。
Octo8080X/fresh2-password-auth-sample にもアップしたので、全体を見たい場合はそちらを参照してほしい。
参考
実装 結論 一旦「どのように動くか」を紹介する。
未ログイン状態で、アクセスするとサインイン用のリンクが表示される。
アカウント作成する。
サインインすると、登録したメールアドレスが表示されており、ログイン状態であることがわかる。
という具合のものを、以下の設定だけで作れるようにした。
utils\auth.ts 1 2 3 4 5 6 7 8 import * as auth from "./auth/plugin.ts" ;export const passwordAuth = auth.passwordAuth ({ signinPath : "/session" , signupPath : "/signup" , signinAfterPath : "/" , }); export type { SignInSessionState } from "./auth/middleware.ts" ;
utils.ts 1 2 3 4 5 6 7 8 9 import { createDefine } from "fresh" ;import { SignInSessionState } from "./utils/auth.ts" ;export interface BaseState {}export interface State extends BaseState , SignInSessionState {}export const define = createDefine<State >();
main.ts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import { App , fsRoutes, staticFiles } from "fresh" ;import { define, type State } from "./utils.ts" ;import { passwordAuth } from "./utils/auth.ts" ;export const app = new App <State >();app.use (staticFiles ()); passwordAuth.setup (app, define); await fsRoutes (app, { loadIsland : (path ) => import (`./islands/${path} ` ), loadRoute : (path ) => import (`./routes/${path} ` ), }); if (import .meta .main ) { app.listen (); }
のように一行で完結させた。
1 passwordAuth.setup (app, define);
以下詳細の実装を紹介する。 なお、パスワードでの認証とセッションにフォーカスを置いたので、本来であればCSRFやCORSなどのセキュリティ対策も必要だが、今回は省略する。 (公式がミドルウェアを公開するようになっているので、そちらを使うとよい。)
簡単なResult型 早々に主題から離れるが、簡単なResult型を用意しておく。 これを用意しておくことで、実行後の結果管理が型でフォローされるので楽になる。
utils\result.ts 1 2 3 4 5 6 7 8 9 10 type SimpleResultFailure = { ok : false ; error : string ; }; type SimpleResultSuccess <T> = T extends null ? { ok : true } : { ok : true } & { [K in keyof T]: T[K] }; export type SimpleResult <T> = SimpleResultSuccess <T>|SimpleResultFailure ;
プラグイン本体 以下がプラグイン本体になる。 Fresh のコアと、define を引数にとる。
utils\auth\plugin.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 import { App , Define } from "fresh" ;import { signInSessionMiddleware } from "./middleware.ts" ;import { getSigninHandler, getSigninPageHandler, getSignupHandler, getSignupPageHandler, } from "./routes.tsx" ; import type { SignInSessionState } from "./middleware.ts" ;import { signinLinkComponent, signupLinkComponent } from "./component.tsx" ;function setupPasswordAuth<State extends SignInSessionState >( app : App <State >, define : Define <State >, params : { signinPath : string ; signupPath : string ; signinAfterPath : string ; }, ) { app.use (define.middleware (signInSessionMiddleware)); app.post ( params.signinPath , getSigninHandler (params.signinPath , params.signinAfterPath ), ); app.get (params.signinPath , getSigninPageHandler (params.signinPath )); app.post ( params.signupPath , getSignupHandler (params.signupPath , params.signinAfterPath ), ); app.get (params.signupPath , getSignupPageHandler (params.signupPath )); } export function passwordAuth (params: { signinPath: string ; signupPath: string ; signinAfterPath: string ; } ) { return { setup<State extends SignInSessionState >( app : App <State >, define : Define <State >, ) { setupPasswordAuth (app, define, params); }, SigninLinkComponent : signinLinkComponent (params.signinPath ), SignupLinkComponent : signupLinkComponent (params.signupPath ), }; }
セットアップする関数は型引数としてState(及びモジュールが使うState) を引数として受とっておくと便利になる。 main.ts で実際にセットアップする際にStateがプラグインで使う型が含まれることを保証できる。
routes routes.tsx では、サインイン、サインアップのページとハンドラを定義する。 特別何か工夫はないが、今回の機能程度であれば1つのファイルにまとめてしまってもよいだろう。 もっと規模が大きい機能(エンドポイント数)であれば、分割が望ましい可能性はある。
utils\auth\routes.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 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 119 120 121 122 123 124 125 126 127 128 129 130 131 132 import { FreshContext } from "fresh" ;import { signIn, signUp } from "./logic.ts" ;export function getSigninPageHandler (signinPath: string ) { return (ctx: FreshContext ) => { const error = ctx.url .searchParams .get ("error" ); const html = ( <html > <head > <title > Signin</title > </head > <body > <h1 > Signin</h1 > <p > {error ? error : ""}</p > <form method ="POST" action ={signinPath} > <label for ="email" > Email:</label > <input type ="email" id ="email" name ="email" required /> <br /> <label for ="password" > Password:</label > <input type ="password" id ="password" name ="password" required /> <br /> <button type ="submit" > Login</button > </form > </body > </html > ); return ctx.render (html); }; } export function getSigninHandler (signinPath: string , signinAfterPath: string ) { return async (ctx : FreshContext ) => { const form = await ctx.req .formData (); const email = form.get ("email" ); const password = form.get ("password" ); if ( !email || !password ) { console .error (`Invalid signin attempt with email: ${email} ` ); return Response .redirect ( `${ctx.url.protocol} //${ctx.url.host} ${signinPath} ?error=Invalid credentials` , ); } const result = await signIn (email.toString (), password.toString ()); if (!result.ok ) { console .error ( `Signin failed for email: ${email} , error: ${result.error} ` , ); return Response .redirect ( `${ctx.url.protocol} //${ctx.url.host} ${signinPath} ?error=${ encodeURIComponent (result.error) } ` , ); } const response = new Response (null , { status : 302 , headers : { "Location" : `${ctx.url.protocol} //${ctx.url.host} ${signinAfterPath} ` , "Set-Cookie" : `session=${result.token} ; HttpOnly; Path=/; Max-Age=3600` , }, }); return response; }; } export function getSignupPageHandler (signupPath: string ) { return (ctx: FreshContext ) => { const html = ( <html > <head > <title > Signup</title > </head > <body > <h1 > Signup</h1 > <form method ="POST" action ={signupPath} > <label for ="email" > Email:</label > <input type ="email" id ="email" name ="email" required /> <br /> <label for ="password" > Password:</label > <input type ="password" id ="password" name ="password" required /> <br /> <button type ="submit" > Signup</button > </form > </body > </html > ); return ctx.render (html); }; } export function getSignupHandler (signupPath: string , signinAfterPath: string ) { return async (ctx : FreshContext ) => { const form = await ctx.req .formData (); const email = form.get ("email" ); const password = form.get ("password" ); if (!email || !password) { console .error (`Invalid signup attempt with email: ${email} ` ); return Response .redirect ( `${ctx.url.protocol} //${ctx.url.host} ${signupPath} ?error=Signup not implemented` , ); } const result = await signUp (email.toString (), password.toString ()); if (!result.ok ) { console .error ( `Signup failed for email: ${email} , error: ${result.error} ` , ); return Response .redirect ( `${ctx.url.protocol} //${ctx.url.host} ${signupPath} ?error=${ encodeURIComponent (result.error) } ` , ); } const response = new Response (null , { status : 302 , headers : { "Location" : `${ctx.url.protocol} //${ctx.url.host} ${signinAfterPath} ` , "Set-Cookie" : `session=${result.token} ; HttpOnly; Path=/; Max-Age=3600` , }, }); return response; }; }
ミドルウェア 認証する、セッションを持つ入り口は上記のroutesにある。 ミドルウェアでは、セッションを持っているのかを検証し、後続の処理が使う情報をStateとして追加する。 前回記事でも紹介したミドルウェアで使用する部分のStateの拡張は、ここに記述している。
utils\auth\middleware.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 import { FreshContext } from "fresh" ;import { getUserBySession } from "./logic.ts" ;export interface SignInSessionState { session : { id : number ; email : string ; isLogin : true ; } | { isLogin : false ; }; } export async function signInSessionMiddleware<State extends SignInSessionState >( ctx : FreshContext <State >, ) { const session = ctx.req .headers .get ("cookie" )?.split ("; " ).find ((c ) => c.startsWith ("session=" ) )?.split ("=" )[1 ]; if (!session) { ctx.state .session = { isLogin : false }; return ctx.next (); } const result = await getUserBySession (session); if (!result.ok ) { ctx.state .session = { isLogin : false }; } else { ctx.state .session = { id : result.id , email : result.email , isLogin : true , }; } return ctx.next (); }
メインロジック メインロジックは、サインアップするユーザーの作成、サインインするユーザーの検証。 そして、セッションのトークンを生成と検証をする入り口になる。
先のresult.tsで定義したSimpleResult
を使うことで、結果の型を明確にし、エラー処理を簡潔にする。
utils\auth\logic.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 import { createSession, createUser, getSessionByToken, getUserByEmailAndPassword, getUserById, } from "../../utils/db.ts" ; import { SimpleResult } from "../../utils/result.ts" ;async function getTextHash (src: string , salt: string ): Promise <string > { const encoder = new TextEncoder (); const data = encoder.encode (salt + src + salt); const hashBuffer = await crypto.subtle .digest ("SHA-256" , data); return Array .from (new Uint8Array (hashBuffer)) .map ((b ) => b.toString (16 ).padStart (2 , "0" )) .join ("" ); } export async function signUp ( email: string , password: string , ): Promise <SimpleResult <{ token : string }>> { const passwordHash = await getTextHash (password, Deno .env .get ("AUTH_SALT" )!); const createUserResult = await createUser (email, passwordHash); if (!createUserResult.ok ) { console .error (createUserResult.error ); return { ok : false , error : createUserResult.error }; } const token = await crypto.randomUUID (); const tokenHash = await getTextHash (token, Deno .env .get ("AUTH_SALT" )!); const expire = Date .now () + 10 * 60 * 1000 ; const createSessionResult = await createSession ( createUserResult.id , tokenHash, expire, ); if (!createSessionResult.ok ) { console .error (createSessionResult.error ); return { ok : false , error : createSessionResult.error }; } return { ok : true , token }; } export async function signIn ( email: string , password: string , ): Promise <SimpleResult <{ token : string }>> { const passwordHash = await getTextHash (password, Deno .env .get ("AUTH_SALT" )!); const userResult = await getUserByEmailAndPassword (email, passwordHash); if (!userResult.ok ) { console .error (userResult.error ); return { ok : false , error : userResult.error }; } const token = await crypto.randomUUID (); const tokenHash = await getTextHash (token, Deno .env .get ("AUTH_SALT" )!); const expire = Date .now () + 10 * 60 * 1000 ; const createSessionResult = await createSession ( userResult.id , tokenHash, expire, ); if (!createSessionResult.ok ) { console .error (createSessionResult.error ); return { ok : false , error : createSessionResult.error }; } return { ok : true , token }; } export async function getUserBySession ( token: string , ): Promise <SimpleResult <{ id : number ; email : string }>> { const tokenHash = await getTextHash (token, Deno .env .get ("AUTH_SALT" )!); const result = await getSessionByToken (tokenHash, Date .now ()); if (!result.ok ) { console .error (result.error ); return { ok : false , error : result.error }; } const userResult = await getUserById (result.user_id ); if (!userResult.ok ) { console .error (userResult.error ); return { ok : false , error : userResult.error }; } return { ok : true , id : result.user_id , email : userResult.email }; }
SimpleResultの型を使うことでシンプルに記述ができているだろうことが見える。 実験的なもののため、安全性の厳密な検証はしていないが各種ハッシュについてはSALTを入れてSHA256でハッシュ化している。 一定程度の強度があるのではなかろうか。
コンポーネント あまりコアのロジックとは関わりないが、結局コアのロジックに渡すパスと連動するのでコンポーネントも提供している。
utils\auth\component.tsx 1 2 3 4 5 6 7 8 9 10 11 export interface Props { text : string ; } export const signinLinkComponent = (signinPath: string ) => { return (props: Props ) => <a href ={signinPath} > {props.text}</a > ; }; export const signupLinkComponent = (signupPath: string ) => { return (props: Props ) => <a href ={signupPath} > {props.text}</a > ; };
データベース @db/sqlite を使い、SQLiteデータベースを操作するための処理を提供する。 @db/sqlite を使ってのデータベースアクセスは、SQL文を直接記述のようになる。 初期化については、起動に合わせてなければ作成というところで、手軽なものにしているが本来は分離するだろう。
utils\db.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 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 import { Database } from "@db/sqlite" ;import { SimpleResult } from "./result.ts" ;const db = new Database (Deno .env .get ("DB_URL" )!);const init = async ( ) => { await db.exec (` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL ); ` ); await db.exec (` CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, secret_hash BLOB NOT NULL UNIQUE, expires_at INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); ` );}; console .log ("Initializing database..." );await init ();export function getUserByEmailAndPassword ( email: string , passwordHash: string , ): SimpleResult <{ id : number }> { const stmt = db.prepare ( "SELECT * FROM users WHERE email = ? AND password_hash = ?" , ); try { const result = stmt.get (email, passwordHash); if (!result || !("id" in result) || typeof result.id !== "number" ) { return { ok : false , error : "User not found or invalid credentials" }; } return { ok : true , id : result.id }; } catch (error) { console .error ("Error getting user by email and password:" , error); return { ok : false , error : "Error getting user by email and password" }; } } export function createUser ( email: string , passwordHash: string , ): SimpleResult <{ id : number }> { const stmt = db.prepare ( "INSERT INTO users (email, password_hash) VALUES (?, ?) RETURNING id" , ); try { const result = stmt.get (email, passwordHash); if (!result || !("id" in result) || typeof result.id !== "number" ) { return { ok : false , error : "Failed to create user or user already exists" , }; } return { ok : true , id : result.id }; } catch (error) { console .error ("Error creating user:" , error); return { ok : false , error : "Error creating user" }; } } export function createSession ( userId: number , secretHash: string , expire: number , ): SimpleResult <null > { const stmt = db.prepare ( "INSERT INTO sessions (user_id, secret_hash, expires_at) VALUES (?, ?, ?) RETURNING id" , ); try { const result = stmt.get (userId, secretHash, expire); if (!result || !("id" in result) || typeof result.id !== "number" ) { return { ok : false , error : "Failed to create session" , }; } return { ok : true }; } catch (error) { console .error ("Error creating session:" , error); return { ok : false , error : "Error creating session" }; } } export function getSessionByToken ( token: string , now: number , ): SimpleResult <{ id : number ; user_id : number }> { const stmt = db.prepare ( "SELECT * FROM sessions WHERE secret_hash = ? AND expires_at > ? ORDER BY created_at DESC LIMIT 1" , ); try { const result = stmt.get (token, now); if ( !result || !("id" in result) || typeof result.id !== "number" || !("user_id" in result) || typeof result.user_id !== "number" ) { return { ok : false , error : "Invalid session token" }; } return { ok : true , id : result.id , user_id : result.user_id }; } catch (error) { console .error ("Error getting session by token:" , error); return { ok : false , error : "Error getting session by token" }; } } export function getUserById ( id: number , ): SimpleResult <{ id : number ; email : string }> { const stmt = db.prepare ("SELECT * FROM users WHERE id = ?" ); try { const result = stmt.get (id); if ( !result || !("id" in result) || typeof result.id !== "number" || !("email" in result) || typeof result.email !== "string" ) { return { ok : false , error : "User not found" }; } return { ok : true , id : result.id , email : result.email }; } catch (error) { console .error ("Error getting user by id:" , error); return { ok : false , error : "Error getting user by id" }; } }
拡張したうえで、routes はどうなるか 先の通り、Stateを拡張し、ctx.state.session
以下が生えているのでそれを活用できる。
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 import { useSignal } from "@preact/signals" ;import { define } from "../utils.ts" ;import Counter from "../islands/Counter.tsx" ;import { passwordAuth } from "./../utils/auth.ts" ;export default define.page (function Home (ctx ) { const count = useSignal (3 ); return ( <div class ="px-4 py-8 mx-auto fresh-gradient" > <div class ="max-w-screen-md mx-auto flex flex-col items-center justify-center" > {/* 省略 */} <div > {ctx.state.session.isLogin // ctx.state.session があることを前提に型ですべてフォローされる ? <p > Welcome back, {ctx.state.session.email}!</p > : ( <p > You are not signed in. Please{" "} <passwordAuth.SignupLinkComponent text ="sign up" /> or{" "} <passwordAuth.SigninLinkComponent text ="sign in" /> . </p > )} </div > </div > </div > ); });
とこのような具合利用できる。
つらつらと対象箇所のソースコードを紹介した。 繰り返すようだが、ポイントを絞っているので別途CORSやCSRFなどのセキュリティ対策はあってしかるべきである。
今回、特定ミドルウェアだけでなく、エンドポイントの拡張も含めた統合された「プラグイン的なもの」として実装した。 今回作ったようなものがプラグインであるとFreshは公式に述べてはいない。 そのうえで、プラグインめいた一定の機能を拡張するまとまった規模の拡張を入れるならば、Fresh2のミドルウェアはかなりわかりやすい。Octo8080X/fresh_garden を作っていた時と感覚的に、楽になっている。
今回の作ったものは、Octo8080X/fresh2-password-auth-sample にもアップした。
実装自体だと、プラグインに define を渡す必要はあったのかは考えてもよいと感じる。 見直しすれば必要ではない可能性がある。
では。