Guide/guides
メール確認ガイド
Guren はトークン生成、検証、有効期限を備えたセキュアなメール確認システムを提供します。トークンはセキュリティのため、保存前にハッシュ化されます。
メール確認ガイド
Guren はトークン生成、検証、有効期限を備えたセキュアなメール確認システムを提供します。トークンはセキュリティのため、保存前にハッシュ化されます。
コアコンセプト
- EmailVerificationTokenStore – メール確認トークンを保存するためのインターフェース。
- トークンハッシュ化 – トークンは保存前にSHA-256でハッシュ化される。
- 平文は保存しない – トークンはハッシュのみ保存。
- 一度きりの使用 – トークンは確認成功後に削除される。
- 有効期限 – トークンは設定可能な時間後に期限切れになる(デフォルト: 24時間)。
基本的な使い方
確認トークンの作成
import { createEmailVerificationToken, MemoryEmailVerificationStore } from '@guren/core'
const store = new MemoryEmailVerificationStore() // 本番環境ではデータベースを使用
// 確認トークンを作成
const { token, expiresAt } = await createEmailVerificationToken(
'user@example.com',
store
)
// 確認メールを送信
await sendVerificationEmail(email, token)
トークンの検証(読み取り専用)
import { verifyEmailToken } from '@guren/core'
// トークンを消費せずに有効性を確認
const email = await verifyEmailToken(token, store)
if (!email) {
return ctx.json({ error: '無効または期限切れのトークン' }, 400)
}
// トークンは有効だが、まだ消費されていない
return ctx.json({ email, valid: true })
確認の完了
import { completeEmailVerification } from '@guren/core'
const user = await completeEmailVerification(
token,
store,
async (email) => {
// ユーザーのメールを確認済みとしてマーク
await User.update(
{ email },
{ emailVerifiedAt: new Date() }
)
return User.findByEmail(email)
}
)
if (!user) {
return ctx.json({ error: '無効または期限切れのトークン' }, 400)
}
return ctx.redirect('/dashboard')
完全な実装例
ルート
import { Router } from '@guren/core'
import { VerificationController } from '@/app/Controllers/VerificationController'
export function registerWebRoutes(router: Router): void {
router.middleware('auth').group((auth) => {
auth.get('/email/verify', [VerificationController, 'notice'])
auth.post('/email/resend', [VerificationController, 'resend'])
})
router.get('/email/verify/:token', [VerificationController, 'verify'])
}
コントローラー
import { Controller } from '@guren/core'
import {
createEmailVerificationToken,
completeEmailVerification,
buildVerificationUrl,
isEmailVerified,
} from '@guren/core'
import { User } from '@/app/Models/User'
import { pages } from '@/.guren/pages.gen'
export class VerificationController extends Controller {
private store = new DatabaseEmailVerificationStore()
async notice() {
const user = await this.auth.user()
if (isEmailVerified(user)) {
return this.redirect('/dashboard')
}
return this.inertia(pages.auth.VerifyEmail, {
email: user.email,
})
}
async resend() {
const user = await this.auth.user()
if (isEmailVerified(user)) {
return this.json({ message: 'すでにメール確認済みです' })
}
const { token } = await createEmailVerificationToken(
user.email,
this.store,
{ expiresIn: 24 * 60 * 60 * 1000 } // 24時間
)
const verifyUrl = buildVerificationUrl(
`${process.env.APP_URL}/email/verify`,
token
)
await this.sendVerificationEmail(user, verifyUrl)
return this.json({ message: '確認メールを送信しました' })
}
async verify() {
const token = this.request.param('token')
const user = await completeEmailVerification(
token,
this.store,
async (email) => {
await User.where('email', email).update({
emailVerifiedAt: new Date(),
})
return User.where('email', email).first()
}
)
if (!user) {
return this.inertia(pages.auth.VerifyEmail, {
error: '無効または期限切れの確認リンク',
})
}
return this.redirect('/dashboard?verified=1')
}
private async sendVerificationEmail(user: User, verifyUrl: string) {
await mail.send({
to: user.email,
subject: 'メールアドレスを確認してください',
html: `
<h1>メール確認</h1>
<p>以下のボタンをクリックしてメールアドレスを確認してください:</p>
<a href="${verifyUrl}" style="...">メールを確認</a>
<p>このリンクは24時間で期限切れになります。</p>
<p>アカウントを作成した覚えがない場合は、何もする必要はありません。</p>
`,
})
}
}
ヘルパー関数
メール確認状態のチェック
import { isEmailVerified } from '@guren/core'
// ユーザーのメールが確認済みかチェック
if (isEmailVerified(user)) {
// ユーザーのメールは確認済み
}
// nullableなユーザーでも動作
if (!isEmailVerified(null)) {
// nullの場合はfalseを返す
}
メール確認必須ミドルウェア
import { Router, requireVerifiedEmail } from '@guren/core'
const router = new Router()
// メール未確認ユーザーをリダイレクト
router.get('/dashboard', [DashboardController, 'index']).middleware(
requireVerifiedEmail({ redirectTo: '/email/verify' })
)
// カスタムユーザー取得
router.get('/profile', [ProfileController, 'show']).middleware(
requireVerifiedEmail({
redirectTo: '/verify-email',
getUser: async (ctx) => {
return ctx.get('user')
},
})
)
URLヘルパー
確認URLの構築
import { buildVerificationUrl } from '@guren/core'
// トークン付きの基本URL
const url = buildVerificationUrl('https://example.com/verify', token)
// 結果: https://example.com/verify?token=abc123...
// メールパラメータ付き
const urlWithEmail = buildVerificationUrl(
'https://example.com/verify',
token,
'user@example.com'
)
// 結果: https://example.com/verify?token=abc123...&email=user%40example.com
確認URLの解析
import { parseVerificationUrl } from '@guren/core'
const { token, email } = parseVerificationUrl(
'https://example.com/verify?token=abc123&email=user%40example.com'
)
console.log(token) // 'abc123'
console.log(email) // 'user@example.com'
データベースストレージ
EmailVerificationTokenStoreの実装
import type { EmailVerificationTokenStore, EmailVerificationToken } from '@guren/core'
import { emailVerifications } from '@/db/schema'
import { eq } from 'drizzle-orm'
export class DatabaseEmailVerificationStore implements EmailVerificationTokenStore {
async store(token: EmailVerificationToken): Promise<void> {
await db.insert(emailVerifications).values({
hashedToken: token.hashedToken,
email: token.email,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
})
}
async findByHashedToken(hashedToken: string): Promise<EmailVerificationToken | null> {
const result = await db.select()
.from(emailVerifications)
.where(eq(emailVerifications.hashedToken, hashedToken))
.limit(1)
if (!result[0]) return null
return {
email: result[0].email,
hashedToken: result[0].hashedToken,
expiresAt: result[0].expiresAt,
createdAt: result[0].createdAt,
}
}
async delete(hashedToken: string): Promise<void> {
await db.delete(emailVerifications)
.where(eq(emailVerifications.hashedToken, hashedToken))
}
async deleteForEmail(email: string): Promise<void> {
await db.delete(emailVerifications)
.where(eq(emailVerifications.email, email.toLowerCase()))
}
}
データベーススキーマ
// db/schema.ts
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const emailVerifications = pgTable('email_verifications', {
hashedToken: text('hashed_token').primaryKey(),
email: text('email').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
// usersテーブルにはemailVerifiedAtを含める
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
emailVerifiedAt: timestamp('email_verified_at'),
// ... その他のフィールド
})
設定
トークンオプション
interface EmailVerificationConfig {
/** トークン有効期限(ミリ秒、デフォルト: 24時間) */
expiresIn?: number
/** hex エンコード前のトークンバイト長(デフォルト: 32) */
tokenLength?: number
}
// カスタム設定の例
const { token } = await createEmailVerificationToken(email, store, {
expiresIn: 48 * 60 * 60 * 1000, // 48時間
tokenLength: 64,
})
テスト
import { describe, test, expect, beforeEach } from 'bun:test'
import {
createEmailVerificationToken,
verifyEmailToken,
completeEmailVerification,
isEmailVerified,
MemoryEmailVerificationStore,
} from '@guren/core'
describe('メール確認', () => {
let store: MemoryEmailVerificationStore
beforeEach(() => {
store = new MemoryEmailVerificationStore()
})
test('トークンを作成し検証する', async () => {
const { token } = await createEmailVerificationToken('user@example.com', store)
const email = await verifyEmailToken(token, store)
expect(email).toBe('user@example.com')
})
test('メールを小文字に正規化する', async () => {
const { token } = await createEmailVerificationToken('User@Example.COM', store)
const email = await verifyEmailToken(token, store)
expect(email).toBe('user@example.com')
})
test('期限切れトークンを拒否する', async () => {
const { token } = await createEmailVerificationToken('user@example.com', store, {
expiresIn: -1000, // すでに期限切れ
})
const email = await verifyEmailToken(token, store)
expect(email).toBeNull()
})
test('確認を完了しトークンを消費する', async () => {
const { token } = await createEmailVerificationToken('user@example.com', store)
const result = await completeEmailVerification(
token,
store,
async (email) => ({ email, verified: true })
)
expect(result).toEqual({ email: 'user@example.com', verified: true })
// トークンは消費されているはず
const secondAttempt = await verifyEmailToken(token, store)
expect(secondAttempt).toBeNull()
})
test('isEmailVerifiedヘルパーが正しく動作する', () => {
expect(isEmailVerified({ emailVerifiedAt: new Date() })).toBe(true)
expect(isEmailVerified({ emailVerifiedAt: null })).toBe(false)
expect(isEmailVerified(null)).toBe(false)
})
})
登録フローの例
// UserController.ts
async register() {
const { name, email, password } = await this.validate({
name: z.string().min(2),
email: z.email(),
password: z.string().min(8),
})
// ユーザーを作成
const user = await User.create({
name,
email,
password: await Bun.password.hash(password),
emailVerifiedAt: null,
})
// 確認トークンを作成
const { token } = await createEmailVerificationToken(email, this.store)
// 確認メールを送信
const verifyUrl = buildVerificationUrl(
`${process.env.APP_URL}/email/verify`,
token
)
await this.sendVerificationEmail(user, verifyUrl)
// ユーザーをログイン
await this.auth.login(user)
// メール確認通知ページへリダイレクト
return this.redirect('/email/verify')
}
ベストプラクティス
長めの有効期限を使用: メール確認トークンは24〜72時間で安全に期限切れにできる。
メールを正規化: 大文字小文字の問題を防ぐため、メールは小文字で保存。
再送信を許可: ユーザーが必要に応じて新しい確認メールをリクエストできるようにする。
古いトークンをクリア: 新しいトークン作成時に同じメールの古いトークンが自動的に削除される。
ルートを保護: メール確認済みユーザーが必要なルートには
requireVerifiedEmailミドルウェアを使用。確認済みユーザーの処理: 新しいトークンを送信する前に
isEmailVerified()をチェック。データベースストレージを使用:
MemoryEmailVerificationStoreはテスト用のみ。登録時に送信: ユーザー登録時に自動的に確認メールを送信。