SvelteKit でのログイン機能の実装 (Amazon DynamoDB、Google OpenID Connect 連携利用)
はじめに
SvelteKit でのログイン機能の実装方法を見ていきます。サンプル全体は https://github.com/keshihoriuchi/samples/tree/master/ts/svelte-login に置いてあります。
まず、ログイン機能と一口にいってもある程度要件にバリエーションがありますがここでは以下の通りとします。
セッション管理方式
セッション情報を管理する KVS として Amazon DynamoDB を使います。他のパターンとして Redis や Cloudflare Workers KV のような別の KVS を使うパターンや、 KVS 使わないで JWT とかで署名しとけば OK というパターンもあると思いますが、この辺りは所属する組織によって変わってくると思います。
ちなみにユーザ管理の RDB にセッション管理テーブルを足して機能要件を満たすってのもできなくはないですが、最初期こそ楽なものの利用年月や利用者の増加に耐えうる実装にしていくのが難しく、時限爆弾的にパフォーマンス不良を起こす例をいくつか見てきたので個人的にはお勧めできません。
認証方式
Google の OpenID Connect で sub と email が取得できれば認証できたとします。ここも自サービスでの E メールの検証は必須とか自社サービス統合 IdP にアクセスとか大口顧客の AzureAD と SAML 連携とかいろいろなパターンがあるとは思います。
ログイン前後の画面遷移
ほとんどのコンテンツはログイン後でしか閲覧操作できず、ログイン前のコンテンツはログインや新規登録画面や利用規約くらいを想定します。PaaS/SaaS の管理画面などエンプラ系のシステムでよくあるパターンです。
別のパターンだと EC サイトや SNS(CGM)であれば、ログインしないとカートに商品を入れたりコンテンツの投稿はできないが、ログイン前でもコンテンツの閲覧はできる、というような画面遷移になると思います。
作成するサンプルの画面
/
がログイン画面で/console
がログイン後のトップページとします。ログイン後のコンテンツは/console
以下のパスに追加されていくものとします。
/の画面
/consoleの画面
セッション管理
まず DynamoDB に格納するセッション情報のデータ構造を定義します。src/lib/server/session.ts
に書いていきます。SvelteKit では src/lib/server 以下のコードはクライアント側のコードで読み込まれないことが保証されます。 (参考: Server-only modules • Docs • SvelteKit)
また、DynamoDB にアクセスするためのデータマッパーとしてDynamooseを使います。Dynamoose で DynamoDB のデータを扱う上での概念について簡単に説明しておきます。
- スキーマ
- テーブルに含まれる属性を定義します
- モデル
- クエリやトランザクションなどのテーブルを操作するメソッドを持ちます
- スキーマを所有します。スキーマは複数所有できます
- 1 つのテーブルに複数のモデルが属することができます。DynamoDB ではよいプラクティスとされている、 1 つのテーブルで複数のモデルを表す状態を実現するためと思われます
- アイテム
- 1 インスタンスが DynamoDB の 1 アイテムに対応しています。アイテム自体の保存や削除のメソッドを持ちます
- TypeScript で扱う場合はスキーマとは別に TypeScript の型を定義する必要があります
- テーブル
- 1 インスタンスが DynamoDB の 1 テーブルに対応します
- モデルを所有します
以下がセッション管理用にアイテムの型、スキーマ、モデル、テーブルを定義するコードです。
import dynamoose from 'dynamoose';
import type { Item } from 'dynamoose/dist/Item';
// dynamodb localを使う設定。本番では別設定になるように分岐する必要がある
dynamoose.aws.ddb.local();
// アイテムの型の定義
export interface PreloginSession extends Item {
user_id?: undefined;
session_id: string;
ttl: number;
oauth_state?: string;
after_login_path?: string;
}
export interface LoginSession extends Item {
user_id: string;
session_id: string;
ttl: number;
email: string;
}
export type Session = PreloginSession | LoginSession;
// スキーマの定義
const preloginSessionSchema = new dynamoose.Schema({
session_id: {
type: String,
hashKey: true
},
ttl: Number,
oauth_state: String,
after_login_path: String
});
const loginSessionSchema = new dynamoose.Schema({
session_id: {
type: String,
hashKey: true
},
ttl: Number,
user_id: {
type: String,
index: {
type: 'global'
}
},
email: String
});
// モデルの定義
const Session = dynamoose.model<Session>('Session', [loginSessionSchema, preloginSessionSchema]);
// テーブルを作成する
new dynamoose.Table('login_sample_session', [Session], {
expires: {
ttl: 10 * 1000, // 作成から10秒後に期限切れ
attribute: 'ttl',
items: {
returnExpired: false
}
}
});
モデルや属性の意味や用途は以下の通りになります。
- PreloginSession
- ログイン前のセッション情報を表します。慣れてない人だと直観に反するかもしれませんがログイン前でもセッション情報管理は何かと必要です
- LoginSession
- ログイン後のセッション情報を表します
- Session
- PreloginSession と LoginSession を合わせてセッション情報を表します
- session_id
- セッションの ID です。セッション所有者以外に知られてはいけないランダムな値です。ブラウザの Cookie に付与されます
- ハッシュキー(DynamoDB 用語でプライマリインデックスのこと)として使用します
- user_id
- ユーザの ID です。このサンプルでは Google の ID Token に含まれる sub 属性を使います
- 知ってる人には当たり前の話ですが、一般にはユーザ管理 DB を別に持っていてその DB 上の ID になると思います。また、メールアドレスのようなあるユーザエンティティの生存期間中に変わりうる ID ではなく、内部管理上ユーザエンティティの生存期間中は変更できない ID が必要です
- 型は LoginSession では string、PreloginSession では undefined になります。コード中では Session 型で受け取った Item を user_id が undefined かどうかで分岐して、LoginSession もしくは PreloginSession にナローイングさせます
- GSI を設定します。要するに user_id をインデックスとしてクエリできるようにします。ユーザにあるセッションを無効にする機能を提供するなど、あるユーザのあるセッションを無効化したい場合(実際は大体のケースが該当すると思います)に必要です
- ユーザの ID です。このサンプルでは Google の ID Token に含まれる sub 属性を使います
- ttl
- セッションの有効期限を表します。DynamoDB の TTL の仕組みの利用に使います (参考: 仕組み: DynamoDB の有効期限 (TTL) - Amazon DynamoDB)
- テーブルの初期化時に expires オプションを与えて有効期限としての各種設定をします。それぞれ以下の意味を持ちます (参考: Table | Dynamoose)
- ttl: アイテムの保存から何ミリ秒後に期限切れになるかを設定します。このサンプルでは 10 秒という動作確認はしやすいものの実用上はあり得ない期間を設定しています。実際は 1 日くらいで設定するかと思います
- attributes: テーブル中のどの属性で DynamoDB に有効期限の管理をさせるかを設定します。ここでは ttl 属性を使用したいので ttl としています
- items.returnExpired: DynamoDB は有効期限が切れても即座に削除してくれるわけではなく、それなりにタイムラグがあります (公式ドキュメント上は最大 48 時間)。このオプションを false にしておくと、Dynamoose で有効期限を見て有効期限が切れたアイテムはクエリ結果から除外してくれます
- このサンプルではログインから 10 秒後に有効期限が切れるように実装していきますが、最終アクセスから n 時間後に有効期限が切れる、という要件を実現したい場合は、アクセスごとに有効期限を更新する操作が必要になります。それなりに注意深くキャッシュ等の設計をしないと料金がかさむかもしれません
- oauth_state
- OAuth2 や OpenID Connect の state を格納します。OAuth2 や OpenID Connect の解説は世間に大量にあるので state の具体的な用途の解説は割愛します
- after_login_path
- ログイン後用の URL にアクセスしたときにセッションの有効期限が切れていた場合、ログイン画面にリダイレクトさせますが、そこでログインが完了したときにデフォルトのログイン後画面ではなく、アクセスした URL にリダイレクトさせるために使います
- email
- ユーザの E メールアドレスです。このサンプルではいわゆる Display Name として使用します
HTTP リクエストごとのセッション初期化
HTTP リクエストごとにセッションに対応するデータを取得、もしくは新規セッションとして初期データを作成する処理を書いていきます。
src/lib/server/session.ts
まず、src/lib/server/session.ts に、セッションに対応するデータを取得、および新規セッションとして初期データを作成する関数を記述します。
@@ -1,3 +1,4 @@
import dynamoose from 'dynamoose';
+import crypto from 'crypto';
import type { Item } from 'dynamoose/dist/Item';
@@ -62,2 +63,14 @@
}
});
+
+export async function initSession(): Promise<PreloginSession> {
+ const session_id = crypto.randomBytes(16).toString('hex');
+ const session = new Session({ session_id });
+ await session.save({ overwrite: false, return: 'item' });
+ return session as PreloginSession;
+}
+
+export async function findSession(session_id: string): Promise<Session | undefined> {
+ const res = await Session.query('session_id').eq(session_id).exec();
+ return res[0];
+}
session_id の生成にはいわゆる暗号論的疑似乱数が必要なので、 crypto を使います。
src/hooks.server.ts
次に、src/hooks.server.ts に HTTP リクエストごとのセッションを初期化する処理を記述します。SvelteKit では src/hooks.server.ts にすべての HTTP リクエストに対する共通処理を記述できます (参考: Server Hooks: Hooks • Docs • SvelteKit)。つまり多くの HTTP サーバフレームワークでミドルウェアと言われるような機能を有しています。
import { COOKIE_NAME } from '$lib/server/constants';
import { findSession, initSession } from '$lib/server/session';
import type { Handle } from '@sveltejs/kit';
export const handle = (async ({ event, resolve }) => {
const sessionID = event.cookies.get(COOKIE_NAME);
let session = undefined;
if (sessionID === undefined) {
session = await initSession();
event.cookies.set(COOKIE_NAME, session.session_id, { path: '/' });
} else {
session = await findSession(sessionID);
// 有効期限が切れていて消滅したか、何らかの不正な設定がされていた場合、undefined
if (session === undefined) {
session = await initSession();
event.cookies.set(COOKIE_NAME, session.session_id, { path: '/' });
}
}
event.locals.session = session;
return await resolve(event);
}) satisfies Handle;
event.cookies.get でリクエストから COOKIE_NAME に対応する値を取得します。COOKIE_NAME の中身はなんでもいいですがここでは"session_id"にしています。また、event.cookies.set で値を更新します。つまり HTTP レスポンスに Set-Cookie ヘッダが含まれるようになります。
httpOnly や samesite などの Cookie の歴史的経緯で必要な実質必須の Set-Cookie の属性は、SvelteKit で補ってくれます。
- 参考:
- Cookies: Types • Docs • SvelteKit
- Cookie の送信先の定義: HTTP Cookie の使用 - HTTP | MDN
取得または初期化したセッションは event.locals.session に代入します。これで resolve(event)で呼ばれる各ルートの locals パラメータで session が取得できるようになります。
src/app.d.ts
また、locals が session を有していることを型定義上で表明するために、src/app.d.ts を以下の通り更新します。 (参考: Locals: Types • Docs • SvelteKit)
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
import type { Session } from '$lib/server/session';
declare global {
namespace App {
// interface Error {}
interface Locals {
session: Session;
}
// interface PageData {}
// interface Platform {}
}
}
export {};
以上でセッション管理の基本的な部分が記述できました。
ログイン処理
ここからログイン機能のユースケース記述に相当する部分を実装します。ただし、本来は OpenID Connect で取得する email と sub は、ひとまず固定値をハードコーディングする実装にします。OpenID Connect に関わるコードはそれだけで長くなるためです。
src/lib/server/session.ts
まず、src/lib/server/session.ts にログイン処理のための関数を追加します。
@@ -75,2 +75,15 @@
return res[0];
}
+
+export async function login(
+ session: Session,
+ email: string,
+ user_id: string
+): Promise<LoginSession> {
+ // セッション固定攻撃対策のためセッションIDを振りなおす
+ const session_id = crypto.randomBytes(16).toString('hex');
+ await session.delete();
+ const newSession = new Session({ session_id, email, user_id });
+ await newSession.save();
+ return newSession as LoginSession;
+}
ポイントはログイン前セッションの ID 使いまわすのではなく、新規のセッション ID を発行する点です。これはセッション固定攻撃と呼ばれる攻撃の対策に必要です。 (参考: 安全なウェブサイトの作り方 - 1.4 セッション管理の不備 | 情報セキュリティ | IPA 独立行政法人 情報処理推進機構)
src/routes/+page.svelte
次に src/routes/+page.svelte にログイン画面を記述します。
<h1>ログイン</h1>
<a href="/login/google">
<img src="/btn_google_signin_dark_normal_web.png" alt="Sign in with Google" />
</a>
src/routes/+page.svelte に書いた内容は/
にアクセスされた際に表示される画面になります。 (参考: ルーティング • Docs • SvelteKit)
いわゆる Google でログイン機能を公開するためには、ログインボタンは Google が提示しているガイドラインに準拠する必要があります。今回はガイドラインで公開されている画像を使うことで準拠させています。 (参考: ログインにおけるブランドの取り扱いガイドライン | Google Identity Platform | Google Developers)
src/routes/login/google/+server.ts
ログインボタンをクリックしたときに呼び出される処理として、src/routes/login/google/+server.ts を以下のように書きます。Svelte Kit では+server.ts でフロントを持たないサーバの HTTP 処理を記述することができます (参考: +server: ルーティング • Docs • SvelteKit)。一般にはフロントからの API リクエストを受けるために使う機能です。
import { COOKIE_NAME } from '$lib/server/constants';
import { login } from '$lib/server/session';
import type { RequestHandler } from './$types';
import { error, redirect } from '@sveltejs/kit';
export const GET = (async ({ locals, cookies }) => {
const session = locals.session;
if (session.user_id !== undefined) {
throw error(400, 'session state is invalid');
}
// 後でOpenID Connectに置き換え
const claims = {
email: 'someone@example.com',
sub: 'abcd'
};
// ログイン前セッションを解除してログイン後セッションを設定する
const newSession = await login(session, claims.email, claims.sub);
locals.session = newSession;
cookies.set(COOKIE_NAME, newSession.session_id, { path: '/' });
throw redirect(303, '/console');
}) satisfies RequestHandler;
email と user_id (=sub)を取得して DynamoDB に保存し、新しい session_id を cookies.set で Cookie に設定します。セクション冒頭にも書きましたが email と sub は一旦ハードコードしておきます。
セッションの設定が完了したらログイン後のトップページである/console
にリダイレクトさせます。SvelteKit では 3xx 系レスポンスを発生させたい場合は throw redirect、4xx 系や 5xx 系レスポンスを発生させたい場合は throw error で例外を投げよ、ということになっています (参考: Errors, Redirects: Loading data • Docs • SvelteKit)。異常系列ではない代替系列で例外を投げるのは個人的にはちょっと違和感があります。
src/routes/console/+page(.svelte|.server.ts)
/console で表示される画面として、src/routes/console/+page.svelte と src/routes/console/+page.server.ts を記述します。+page.svelte がフロントの画面と処理、+page.server.ts がサーバで実行される処理を表します (参考: Page Data: Loading data • Docs • SvelteKit)。
<script lang="ts">
import type { PageData } from "./$types";
export let data: PageData;
</script>
<div>Email: {data.email}</div>
<div><a href="/logout">ログアウト</a></div>
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals }) => {
if (locals.session.user_id === undefined) {
throw error(400, 'session state is invalid');
}
return {
user_id: locals.session.user_id,
email: locals.session.email
};
}) satisfies PageServerLoad;
+page.server.ts の load 関数で locals.session の値を返却しているので、+page.svelte ではこれを export let data として受け取ることができます。
npm run dev を実行していると+page.svelte が参照している"./$types"の PageData 型は+page.server.ts の返却値から自動で生成されます。SvelteKit を使ってみて感動する機能の一つです。どうやって実現されているかは、ルートの tsconfig.json と.svelte-kit/tsconfig.json を読むとある程度仕組みがわかるようになっています。
OpenID Connect
前のセクションで飛ばした OpenID Connect の処理を書いていきます。OpenID Connect 自体の解説は割愛します。
src/routes/login/google/+server.ts
まず、 src/routes/login/google/+server.ts を以下のように書き換えます。Authorization Endpoint にリダイレクトさせる処理です。
import { CLIENT_ID, REDIRECT_URI } from '$lib/server/constants';
import type { RequestHandler } from './$types';
import { error, redirect } from '@sveltejs/kit';
import crypto from 'crypto';
import querystring from 'querystring';
// OpenID ConnectのAuthorization Endpointにリダイレクト
// 参考: https://developers.google.com/identity/openid-connect/openid-connect?hl=ja#authenticationuriparameters
export const GET = (async ({ locals }) => {
if (locals.session.user_id !== undefined) {
throw error(400, 'session state is invalid');
}
const state = crypto.randomBytes(8).toString('hex');
const nonce = crypto.randomBytes(8).toString('hex');
const q = querystring.encode({
client_id: CLIENT_ID,
scope: 'openid profile email',
response_type: 'code',
state,
nonce,
redirect_uri: REDIRECT_URI
});
const location = `https://accounts.google.com/o/oauth2/v2/auth?${q}`;
locals.session.oauth_state = state;
await locals.session.save();
throw redirect(303, location);
}) satisfies RequestHandler;
src/routes/login/google/callback/+server.ts
次に、Authorization Endpoint からのコールバック処理を src/routes/login/google/callback/+server.ts に記述します。
import { CLIENT_ID, CLIENT_SECRET, COOKIE_NAME, REDIRECT_URI } from '$lib/server/constants';
import { login } from '$lib/server/session';
import type { RequestHandler } from './$types';
import { error, redirect } from '@sveltejs/kit';
import querystring from 'querystring';
import { z } from 'zod';
const Claims = z.object({
sub: z.string(),
email: z.string(),
email_verified: z.boolean()
});
type Claims = z.infer<typeof Claims>;
export const GET = (async ({ locals, url, cookies }) => {
const session = locals.session;
if (session.user_id !== undefined) {
throw error(400, 'session state is invalid');
}
// パラメータの検証
const code = url.searchParams.get('code');
if (code === null) {
throw error(400, 'code is required');
}
const state = url.searchParams.get('state');
if (state === null || state !== session.oauth_state) {
throw error(400, 'state is invalid');
}
// OpenID ConnectのToken Endpointにリクエスト
const res = await fetch('https://www.googleapis.com/oauth2/v4/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body: querystring.encode({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code'
})
});
if (res.status !== 200) {
throw error(400, 'failed to request token endpoint');
}
// レスポンスのJSONをパースしてIDトークンを取り出し、
// さらにIDトークンのJWTをパースして認証情報(claims)を取得する。
const jsonData = await res.json();
const idToken = jsonData.id_token;
const base64Str = idToken.split('.')[1];
const decodedStr = Buffer.from(base64Str, 'base64').toString('utf-8');
const claims = Claims.parse(JSON.parse(decodedStr));
if (!claims.email_verified) {
throw error(400, 'email is not verified');
}
// 以下もとのsrc/routes/login/google/+server.tsと同じ
// ログイン前セッションを解除してログイン後セッションを設定する
const newSession = await login(session, claims.email, claims.sub);
locals.session = newSession;
cookies.set(COOKIE_NAME, newSession.session_id, { path: '/' });
throw redirect(303, '/console');
}) satisfies RequestHandler;
受け取った state を検証して受け取った code を TokenEndpoint にリクエストして ID トークン を取得し、ID トークン から email と sub を取得します。基本的には普通の OpenID Connect の処理ですが以下 2 点はちょっとトリッキーかと思います。
- 署名を検証せずに ID トークンからデータを取り出しています。今回記載したコードでは Google の TokenEndpoint から直接取得した ID トークンであることが明らかなので、検証はスキップできます
- 参考: ID トークンの検証 OpenID Connect | Authentication | Google Developers
-
Google から直接取得した ID トークンでない限り、サーバー上のすべての ID トークンを検証する必要があります。
-
- 参考: ID トークンの検証 OpenID Connect | Authentication | Google Developers
- ID トークンに sub、email、email_verified が含まれていることをバリデーションするにあたり、何でバリデーションするでもいいのですがZodを使っています。悪意がないとやらないと思いますが、Authorization Endpoint へのリクエストの scope から email が取り除かれていると、ID トークンには email 属性が含まれなくなります
ログアウト
ログアウト処理を src/routes/logout/+server.ts に記述します。
import type { RequestHandler } from './$types';
import { initSession } from '$lib/server/session';
import { redirect } from '@sveltejs/kit';
import { COOKIE_NAME } from '$lib/server/constants';
export const GET = (async ({ cookies, locals }) => {
await locals.session.delete();
locals.session = await initSession();
cookies.set(COOKIE_NAME, locals.session.session_id, { path: '/' });
throw redirect(303, '/');
}) satisfies RequestHandler;
ログイン後セッションを削除してログイン前セッションを新規発行することでログアウトさせたことになります。
セッション状態に合わせたリダイレクト
大抵のサイトで実装されている機能ですが、ユーザビリティの向上のため以下のようにセッション状態に合わせてリダイレクトさせるようにします。
- ログイン後用の URL にアクセスしたときにセッションの有効期限が切れていた場合、ログイン画面にリダイレクトさせ、ログイン完了したらもともとアクセスした URL にリダイレクトさせます
- 逆にログイン画面などログイン前専用の URL にログイン済みのセッションでアクセスした場合、ログイン後のページにリダイレクトさせます
src/hooks.server.ts
src/hooks.server.ts に次のような処理を追記します。
@@ -1,6 +1,6 @@
import { COOKIE_NAME } from '$lib/server/constants';
import { findSession, initSession } from '$lib/server/session';
-import type { Handle } from '@sveltejs/kit';
+import { redirect, type Handle } from '@sveltejs/kit';
export const handle = (async ({ event, resolve }) => {
const sessionID = event.cookies.get(COOKIE_NAME);
@@ -18,5 +18,20 @@
}
event.locals.session = session;
+ const pathname = event.url.pathname;
+ if (session.user_id !== undefined) {
+ // 認証に成功しており認証時はアクセスする必要がないパスの場合リダイレクトする
+ if (pathname === '/' || pathname.startsWith('/login')) {
+ throw redirect(303, '/console');
+ }
+ } else {
+ // 認証が必要なパスで認証情報が存在しない場合リダイレクトする
+ if (pathname.startsWith('/console')) {
+ session.after_login_path = pathname;
+ await session.save();
+ throw redirect(303, '/');
+ }
+ }
+
return await resolve(event);
}) satisfies Handle;
認証が必要なパスで認証情報が存在しない場合は after_login_path に現在のパスを保存してからリダイレクトさせます。
src/routes/login/google/callback/+server.ts
src/routes/login/google/callback/+server.ts でログイン完了したときに after_login_path を見てリダイレクト先を変更させます。
@@ -60,10 +60,14 @@
// 以下もとのsrc/routes/login/google/+server.tsと同じ
+ // ログイン後にリダイレクトさせるURLの設定
+ const afterLoginPath = session.after_login_path;
+ const location = afterLoginPath !== undefined ? afterLoginPath : '/console';
+
// ログイン前セッションを解除してログイン後セッションを設定する
const newSession = await login(session, claims.email, claims.sub);
locals.session = newSession;
cookies.set(COOKIE_NAME, newSession.session_id, { path: '/' });
- throw redirect(303, '/console');
+ throw redirect(303, location);
}) satisfies RequestHandler;
おわりに
結構長い記事になってしまいました。ログイン機能は必須級機能のわりに複雑な機能だと改めて感じます。
このサンプルではログイン機能用の OSS である sk-auth や @auth/sveltekit は使わないで SvelteKit 上にログイン機能をべた書きしました。ログイン回りの内容はセキュリティ監査などで細かい仕様の調整や説明が求められるので、これらのライブラリは使わずべた書きしたほうが良いと個人的には感じています。
この記事の内容は全体的にSvelteKit Authentication Using Cookiesを大きく参考にしました。
Svelte&SvelteKit 素晴らしく開発者体験がよいと思うので個人的には React を越えて流行ってほしいなと思います。
Discussion