feat(m3): auth & user management with session, tests, and e2e

- Custom error classes (lib/errors.ts) and signed-cookie session
  (lib/auth/session.ts, HMAC-SHA256, httpOnly+sameSite=lax)
- UserRepository + AuthService (bcrypt hashing, role/status checks,
  register/login/logout/getCurrentUser/updateProfile)
- Auth helpers (getCurrentUser), user validator, API error handler
- API routes: register (auto-login), login, logout, me, PATCH users/me,
  GET admin/migrations (admin-only guard)
- middleware.ts redirects unauthenticated page access to /login
- Screens: login (login/register toggle), profile, dashboard skeleton,
  home redirect
- vitest: exclude e2e specs, setupFiles for SESSION_SECRET
- playwright: run migrate before dev server, set SESSION_SECRET
- Unit tests (UserRepository, AuthService, session), integration
  auth-flow, e2e auth.spec (register/login/logout/protected/401)
This commit is contained in:
Ken Yasue
2026-06-25 00:36:42 +02:00
parent 40ff4fb909
commit 21b9f03e9d
27 changed files with 1578 additions and 10 deletions

View File

@ -0,0 +1,27 @@
import path from 'node:path';
import { NextResponse } from 'next/server';
import { Migrator } from '@/lib/db/migrator';
import { getDb } from '@/lib/db/sqlite';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { UnauthorizedError, ForbiddenError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
if (user.role !== 'system_admin') {
return handleApiError(new ForbiddenError('管理者のみアクセス可能です'));
}
try {
const migrationsDir = path.join(process.cwd(), 'lib', 'db', 'migrations');
const migrator = new Migrator(getDb(), migrationsDir);
return NextResponse.json({ migrations: migrator.getAppliedMigrations() });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthService } from '@/services/AuthService';
import { UserRepository } from '@/repositories/UserRepository';
import { getDb } from '@/lib/db/sqlite';
import { toPublicUser } from '@/lib/auth/getCurrentUser';
import { setSessionCookie } from '@/lib/auth/session';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const authService = new AuthService(new UserRepository(getDb()));
try {
const { user, token } = authService.login(
String(body.email ?? ''),
String(body.password ?? '')
);
const response = NextResponse.json(
{ user: toPublicUser(user) },
{ status: 200 }
);
setSessionCookie(response, token);
return response;
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { clearSessionCookie } from '@/lib/auth/session';
export const runtime = 'nodejs';
export async function POST() {
const response = NextResponse.json({ ok: true });
clearSessionCookie(response);
return response;
}

14
app/api/auth/me/route.ts Normal file
View File

@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return handleApiError(new UnauthorizedError());
}
return NextResponse.json({ user: toPublicUser(user) });
}

View File

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthService } from '@/services/AuthService';
import { UserRepository } from '@/repositories/UserRepository';
import { getDb } from '@/lib/db/sqlite';
import { toPublicUser } from '@/lib/auth/getCurrentUser';
import { createSessionToken, setSessionCookie } from '@/lib/auth/session';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function POST(request: NextRequest) {
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const authService = new AuthService(new UserRepository(getDb()));
try {
const user = authService.register({
name: String(body.name ?? ''),
email: String(body.email ?? ''),
password: String(body.password ?? ''),
});
// 登録成功と同時にログイン(セッションCookieを設定)
const response = NextResponse.json(
{ user: toPublicUser(user) },
{ status: 201 }
);
setSessionCookie(response, createSessionToken(user.id));
return response;
} catch (error) {
return handleApiError(error);
}
}

36
app/api/users/me/route.ts Normal file
View File

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthService } from '@/services/AuthService';
import { UserRepository } from '@/repositories/UserRepository';
import { getDb } from '@/lib/db/sqlite';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function PATCH(request: NextRequest) {
const currentUser = await getCurrentUser();
if (!currentUser) {
return handleApiError(new UnauthorizedError());
}
let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
} catch {
return jsonError(400, 'リクエスト本文が不正です');
}
const authService = new AuthService(new UserRepository(getDb()));
try {
const updated = authService.updateProfile(currentUser.id, {
name: typeof body.name === 'string' ? body.name : undefined,
email: typeof body.email === 'string' ? body.email : undefined,
avatarUrl:
typeof body.avatarUrl === 'string' ? body.avatarUrl : undefined,
});
return NextResponse.json({ user: toPublicUser(updated) });
} catch (error) {
return handleApiError(error);
}
}

27
app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,27 @@
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return (
<main className="min-h-screen bg-gray-50 p-8">
<div className="mx-auto max-w-3xl">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<a href="/profile" className="text-sm text-blue-600 hover:underline">
{user.name}
</a>
</div>
<p className="mt-4 text-gray-600">
</p>
</div>
</main>
);
}

131
app/login/page.tsx Normal file
View File

@ -0,0 +1,131 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
type Mode = 'login' | 'register';
export default function LoginPage() {
const router = useRouter();
const [mode, setMode] = useState<Mode>('login');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const endpoint =
mode === 'login' ? '/api/auth/login' : '/api/auth/register';
const payload =
mode === 'login' ? { email, password } : { name, email, password };
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
router.push('/dashboard');
return;
}
const data = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(data?.error?.message ?? '処理に失敗しました');
setLoading(false);
}
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50 p-8">
<div className="w-full max-w-sm rounded-lg border bg-white p-8 shadow-sm">
<h1 className="text-2xl font-bold">
{mode === 'login' ? 'ログイン' : '新規登録'}
</h1>
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
{mode === 'register' && (
<div>
<label htmlFor="name" className="block text-sm font-medium">
</label>
<input
id="name"
name="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
/>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
</label>
<input
id="password"
name="password"
type="password"
autoComplete={
mode === 'login' ? 'current-password' : 'new-password'
}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
required
minLength={8}
/>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '処理中...' : mode === 'login' ? 'ログイン' : '登録する'}
</button>
</form>
<button
type="button"
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline"
onClick={() => {
setMode(mode === 'login' ? 'register' : 'login');
setError(null);
}}
>
{mode === 'login' ? '新規登録はこちら' : 'ログイン画面に戻る'}
</button>
</div>
</main>
);
}

View File

@ -1,10 +1,12 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function Home() { export default function Home() {
return ( const router = useRouter();
<main className="flex min-h-screen flex-col items-center justify-center p-8"> useEffect(() => {
<h1 className="text-3xl font-bold"></h1> router.replace('/dashboard');
<p className="mt-4 text-gray-600"> }, [router]);
return null;
</p>
</main>
);
} }

146
app/profile/page.tsx Normal file
View File

@ -0,0 +1,146 @@
'use client';
import { useEffect, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import type { PublicUser } from '@/lib/auth/getCurrentUser';
export default function ProfilePage() {
const router = useRouter();
const [user, setUser] = useState<PublicUser | null>(null);
const [loading, setLoading] = useState(true);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [avatarUrl, setAvatarUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
fetch('/api/auth/me')
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
.then((data: { user: PublicUser }) => {
setUser(data.user);
setName(data.user.name);
setEmail(data.user.email);
setAvatarUrl(data.user.avatarUrl ?? '');
})
.catch(() => router.push('/login'))
.finally(() => setLoading(false));
}, [router]);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
setSaved(false);
const res = await fetch('/api/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, avatarUrl }),
});
if (res.ok) {
const data = (await res.json()) as { user: PublicUser };
setUser(data.user);
setSaved(true);
} else {
const data = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
setError(data?.error?.message ?? '更新に失敗しました');
}
}
async function onLogout() {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
}
if (loading) {
return (
<main className="flex min-h-screen items-center justify-center">
<p>...</p>
</main>
);
}
return (
<main className="min-h-screen bg-gray-50 p-8">
<div className="mx-auto max-w-md rounded-lg border bg-white p-8 shadow-sm">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<button
type="button"
onClick={onLogout}
className="text-sm text-blue-600 hover:underline"
>
</button>
</div>
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
<div>
<label htmlFor="name" className="block text-sm font-medium">
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
<div>
<label htmlFor="avatarUrl" className="block text-sm font-medium">
URL
</label>
<input
id="avatarUrl"
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
/>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
{saved && (
<p className="text-sm text-green-600"></p>
)}
<button
type="submit"
className="w-full rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
>
</button>
</form>
<button
type="button"
onClick={() => router.push('/dashboard')}
className="mt-4 w-full text-center text-sm text-blue-600 hover:underline"
>
</button>
<p className="mt-2 text-center text-xs text-gray-500">
: {user?.role}
</p>
</div>
</main>
);
}

28
lib/api/handleError.ts Normal file
View File

@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { AppError, ValidationError } from '@/lib/errors';
/**
* Service層のエラーをHTTPエラーレスポンスに変換する。
* 期待されるエラー(AppError)は対応するステータスコードへ、
* 予期せぬエラーは500として内部情報を隠蔽する。
*/
export function handleApiError(error: unknown): NextResponse {
if (error instanceof AppError) {
const body: { error: { message: string; field?: string } } = {
error: { message: error.message },
};
if (error instanceof ValidationError && error.field) {
body.error.field = error.field;
}
return NextResponse.json(body, { status: error.status });
}
console.error('Unexpected error:', error);
return NextResponse.json(
{ error: { message: '内部エラーが発生しました' } },
{ status: 500 }
);
}
export function jsonError(status: number, message: string): NextResponse {
return NextResponse.json({ error: { message } }, { status });
}

6
lib/auth/constants.ts Normal file
View File

@ -0,0 +1,6 @@
/**
* セッションCookie関連の定数。
* Edge Runtime(middleware)からも安全に参照できるよう、依存を持たない定数のみ配置する。
*/
export const SESSION_COOKIE = 'session';
export const SESSION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;

View File

@ -0,0 +1,33 @@
import { UserRepository } from '@/repositories/UserRepository';
import { getDb } from '@/lib/db/sqlite';
import { getSessionUserId } from '@/lib/auth/session';
import type { User } from '@/lib/types';
/**
* APIレスポンス等で外部に公開するユーザー情報passwordHashを除く
*/
export type PublicUser = Omit<User, 'passwordHash'>;
export function toPublicUser(user: User): PublicUser {
return {
id: user.id,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
role: user.role,
status: user.status,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
/**
* 現在のリクエストからログインユーザーを解決する。
* 未認証の場合は null を返す。
*/
export async function getCurrentUser(): Promise<User | null> {
const userId = await getSessionUserId();
if (userId === null) return null;
const userRepository = new UserRepository(getDb());
return userRepository.findById(userId);
}

93
lib/auth/session.ts Normal file
View File

@ -0,0 +1,93 @@
import crypto from 'node:crypto';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { SESSION_COOKIE, SESSION_MAX_AGE_SECONDS } from './constants';
/**
* 署名付きCookieによるステートレスセッション。
* トークン形式: base64url(payloadJson).base64url(hmacSha256(secret, encodedPayload))
* payload = { uid, iat }
*/
function getSecret(): string {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error('SESSION_SECRET is not configured');
}
return secret;
}
function sign(encodedPayload: string): string {
return crypto
.createHmac('sha256', getSecret())
.update(encodedPayload)
.digest('base64url');
}
/**
* ユーザーIDからセッショントークンを生成する
*/
export function createSessionToken(userId: number): string {
const payload = JSON.stringify({ uid: userId, iat: Date.now() });
const encoded = Buffer.from(payload, 'utf-8').toString('base64url');
return `${encoded}.${sign(encoded)}`;
}
/**
* セッショントークンを検証し、ユーザーIDを返す不正時はnull
*/
export function verifySessionToken(token: string): number | null {
const parts = token.split('.');
if (parts.length !== 2) return null;
const [encoded, signature] = parts;
const expected = sign(encoded);
const received = Buffer.from(signature);
const wanted = Buffer.from(expected);
if (received.length !== wanted.length) return null;
if (!crypto.timingSafeEqual(received, wanted)) return null;
try {
const payload = JSON.parse(
Buffer.from(encoded, 'base64url').toString('utf-8')
) as { uid?: unknown };
if (typeof payload.uid !== 'number') return null;
return payload.uid;
} catch {
return null;
}
}
/**
* 現在のリクエスト(Server Component / Route Handler)からセッションユーザーIDを取得する
*/
export async function getSessionUserId(): Promise<number | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
return verifySessionToken(token);
}
/**
* レスポンスにセッションCookieを設定する
*/
export function setSessionCookie(res: NextResponse, token: string): void {
res.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
maxAge: SESSION_MAX_AGE_SECONDS,
});
}
/**
* レスポンスのセッションCookieを削除するログアウト
*/
export function clearSessionCookie(res: NextResponse): void {
res.cookies.set(SESSION_COOKIE, '', {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 0,
});
}

55
lib/errors.ts Normal file
View File

@ -0,0 +1,55 @@
/**
* アプリケーション全体で使用するカスタムエラー。
* 各エラーは HTTP ステータスコードを保持し、Route Handler でレスポンスへの変換が容易。
*/
export class AppError extends Error {
constructor(
message: string,
public readonly status: number
) {
super(message);
this.name = this.constructor.name;
}
}
export class ValidationError extends AppError {
constructor(
message: string,
public readonly field?: string
) {
super(message, 400);
this.name = 'ValidationError';
}
}
export class UnauthorizedError extends AppError {
constructor(message: string = '認証が必要です') {
super(message, 401);
this.name = 'UnauthorizedError';
}
}
export class ForbiddenError extends AppError {
constructor(message: string = 'この操作を行う権限がありません') {
super(message, 403);
this.name = 'ForbiddenError';
}
}
export class NotFoundError extends AppError {
constructor(
public readonly resource: string,
public readonly id: number | string
) {
super(`${resource} not found: ${id}`, 404);
this.name = 'NotFoundError';
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409);
this.name = 'ConflictError';
}
}

View File

@ -0,0 +1,95 @@
import { ValidationError } from '@/lib/errors';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_NAME_LENGTH = 100;
const MIN_PASSWORD_LENGTH = 8;
const MAX_AVATAR_URL_LENGTH = 500;
export interface RegisterInput {
name: string;
email: string;
password: string;
}
export interface LoginInput {
email: string;
password: string;
}
export interface ProfileUpdateInput {
name?: string;
email?: string;
avatarUrl?: string;
}
export function validateRegister(input: RegisterInput): void {
validateName(input.name);
validateEmail(input.email);
validatePassword(input.password);
}
export function validateLogin(input: LoginInput): void {
if (!input.email) {
throw new ValidationError('メールアドレスを入力してください', 'email');
}
if (!input.password) {
throw new ValidationError('パスワードを入力してください', 'password');
}
}
export function validateProfileUpdate(input: ProfileUpdateInput): void {
if (
input.name === undefined &&
input.email === undefined &&
input.avatarUrl === undefined
) {
throw new ValidationError('更新対象のフィールドを指定してください');
}
if (input.name !== undefined) validateName(input.name);
if (input.email !== undefined) validateEmail(input.email);
if (
input.avatarUrl !== undefined &&
input.avatarUrl.length > MAX_AVATAR_URL_LENGTH
) {
throw new ValidationError(
`アイコン画像URLは${MAX_AVATAR_URL_LENGTH}文字以内で入力してください`,
'avatarUrl'
);
}
}
function validateName(name: string): void {
if (!name || !name.trim()) {
throw new ValidationError('表示名を入力してください', 'name');
}
if (name.length > MAX_NAME_LENGTH) {
throw new ValidationError(
`表示名は${MAX_NAME_LENGTH}文字以内で入力してください`,
'name'
);
}
}
function validateEmail(email: string): void {
if (!email) {
throw new ValidationError('メールアドレスを入力してください', 'email');
}
if (!EMAIL_RE.test(email)) {
throw new ValidationError(
'メールアドレスの形式が正しくありません',
'email'
);
}
}
function validatePassword(password: string): void {
if (!password) {
throw new ValidationError('パスワードを入力してください', 'password');
}
if (password.length < MIN_PASSWORD_LENGTH) {
throw new ValidationError(
`パスワードは${MIN_PASSWORD_LENGTH}文字以上で入力してください`,
'password'
);
}
}

25
middleware.ts Normal file
View File

@ -0,0 +1,25 @@
import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE } from '@/lib/auth/constants';
/**
* 認証ミドルウェアEdge Runtime
* セッションCookieの「存在」のみを確認し、未所持なら保護画面を /login へリダイレクトする。
* HMAC検証・DB参照はNode.js RuntimeのgetCurrentUserで行うここでは行わない
* APIルートは各Route Handlerで401を返すため、本ミドルウェアの対象外とする。
*/
export function middleware(request: NextRequest) {
const token = request.cookies.get(SESSION_COOKIE)?.value;
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
// /login, /api, _next 静的 assets, favicon を除外
'/((?!login|api|_next/static|_next/image|favicon.ico).*)',
],
};

View File

@ -18,9 +18,10 @@ export default defineConfig({
}, },
], ],
webServer: { webServer: {
command: 'npm run dev', command: 'npm run migrate && npm run dev',
url: 'http://localhost:3000', url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI, reuseExistingServer: false,
timeout: 120 * 1000, timeout: 120 * 1000,
env: { SESSION_SECRET: 'e2e-secret' },
}, },
}); });

View File

@ -0,0 +1,124 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { User, UserRole, UserStatus } from '@/lib/types';
interface UserRow {
id: number;
name: string;
email: string;
password_hash: string | null;
avatar_url: string | null;
role: string;
status: string;
created_at: string;
updated_at: string;
}
function mapUser(row: UserRow): User {
return {
id: row.id,
name: row.name,
email: row.email,
passwordHash: row.password_hash,
avatarUrl: row.avatar_url,
role: row.role as UserRole,
status: row.status as UserStatus,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export interface CreateUserInput {
name: string;
email: string;
passwordHash: string;
role?: UserRole;
}
export interface UpdateUserInput {
name?: string;
email?: string;
avatarUrl?: string | null;
role?: UserRole;
status?: UserStatus;
}
/**
* usersテーブルへのデータアクセスを担うRepository。
* 直接SQLiteライブラリを触らず、必ずSqliteDatabase経由でSQLを実行する。
*/
export class UserRepository {
constructor(private readonly db: SqliteDatabase) {}
findById(id: number): User | null {
const row = this.db.get<UserRow>('SELECT * FROM users WHERE id = @id', {
id,
});
return row ? mapUser(row) : null;
}
findByEmail(email: string): User | null {
const row = this.db.get<UserRow>(
'SELECT * FROM users WHERE email = @email',
{ email }
);
return row ? mapUser(row) : null;
}
create(input: CreateUserInput): User {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO users (name, email, password_hash, avatar_url, role, status, created_at, updated_at)
VALUES (@name, @email, @passwordHash, @avatarUrl, @role, @status, @createdAt, @updatedAt)`,
{
name: input.name,
email: input.email,
passwordHash: input.passwordHash,
avatarUrl: null,
role: input.role ?? 'member',
status: 'active',
createdAt: now,
updatedAt: now,
}
);
const created = this.findById(Number(result.lastInsertRowid));
if (!created) {
throw new Error('Failed to create user');
}
return created;
}
update(id: number, input: UpdateUserInput): User | null {
const fields: string[] = ['updated_at = @updatedAt'];
const params: Record<string, unknown> = {
updatedAt: new Date().toISOString(),
id,
};
if (input.name !== undefined) {
fields.push('name = @name');
params.name = input.name;
}
if (input.email !== undefined) {
fields.push('email = @email');
params.email = input.email;
}
if (input.avatarUrl !== undefined) {
fields.push('avatar_url = @avatarUrl');
params.avatarUrl = input.avatarUrl;
}
if (input.role !== undefined) {
fields.push('role = @role');
params.role = input.role;
}
if (input.status !== undefined) {
fields.push('status = @status');
params.status = input.status;
}
this.db.execute(
`UPDATE users SET ${fields.join(', ')} WHERE id = @id`,
params
);
return this.findById(id);
}
}

117
services/AuthService.ts Normal file
View File

@ -0,0 +1,117 @@
import bcrypt from 'bcrypt';
import { UserRepository } from '@/repositories/UserRepository';
import { createSessionToken } from '@/lib/auth/session';
import {
validateRegister,
validateLogin,
validateProfileUpdate,
type ProfileUpdateInput,
} from '@/lib/validators/userValidator';
import { ConflictError, UnauthorizedError, NotFoundError } from '@/lib/errors';
import type { User, UserRole } from '@/lib/types';
const BCRYPT_ROUNDS = 10;
export interface RegisterInput {
name: string;
email: string;
password: string;
}
export interface LoginResult {
user: User;
token: string;
}
/**
* 認証・ユーザー管理の業務ロジックを担うService。
* パスワードはbcryptでハッシュ化し、平文保存しない。
* セッションCookieの設定はRoute Handler層の責務AuthServiceはトークン生成まで
*/
export class AuthService {
constructor(private readonly userRepository: UserRepository) {}
register(input: RegisterInput): User {
validateRegister(input);
const existing = this.userRepository.findByEmail(input.email);
if (existing) {
throw new ConflictError('このメールアドレスは既に使用されています');
}
const passwordHash = bcrypt.hashSync(input.password, BCRYPT_ROUNDS);
return this.userRepository.create({
name: input.name,
email: input.email,
passwordHash,
role: 'member',
});
}
login(email: string, password: string): LoginResult {
validateLogin({ email, password });
const user = this.userRepository.findByEmail(email);
if (!user) {
throw new UnauthorizedError(
'メールアドレスまたはパスワードが正しくありません'
);
}
if (user.status === 'inactive') {
throw new UnauthorizedError('このアカウントは無効です');
}
if (
!user.passwordHash ||
!bcrypt.compareSync(password, user.passwordHash)
) {
throw new UnauthorizedError(
'メールアドレスまたはパスワードが正しくありません'
);
}
return { user, token: createSessionToken(user.id) };
}
logout(): void {
// セッションCookieの削除はRoute Handler層で行う
}
getCurrentUser(userId: number): User | null {
return this.userRepository.findById(userId);
}
updateProfile(userId: number, input: ProfileUpdateInput): User {
validateProfileUpdate(input);
const user = this.userRepository.findById(userId);
if (!user) {
throw new NotFoundError('User', userId);
}
if (input.email && input.email !== user.email) {
const existing = this.userRepository.findByEmail(input.email);
if (existing) {
throw new ConflictError('このメールアドレスは既に使用されています');
}
}
const updated = this.userRepository.update(userId, {
name: input.name,
email: input.email,
avatarUrl: input.avatarUrl,
});
if (!updated) {
throw new NotFoundError('User', userId);
}
return updated;
}
/** テスト/初期データ用途: ロールを直接指定してユーザー作成 */
createWithRole(input: RegisterInput, role: UserRole): User {
validateRegister(input);
const existing = this.userRepository.findByEmail(input.email);
if (existing) {
throw new ConflictError('このメールアドレスは既に使用されています');
}
const passwordHash = bcrypt.hashSync(input.password, BCRYPT_ROUNDS);
return this.userRepository.create({
name: input.name,
email: input.email,
passwordHash,
role,
});
}
}

81
tests/e2e/auth.spec.ts Normal file
View File

@ -0,0 +1,81 @@
import { test, expect } from '@playwright/test';
function uniqueEmail(): string {
return `e2e-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}@example.com`;
}
test.describe('authentication', () => {
test('register lands on the dashboard, then profile and logout', async ({
page,
}) => {
const email = uniqueEmail();
await page.goto('/login');
await page.getByRole('button', { name: '新規登録はこちら' }).click();
await page.getByLabel('表示名').fill('E2E User');
await page.getByLabel('メールアドレス').fill(email);
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: '登録する' }).click();
// 登録と同時にログイン → ダッシュボードへ
await expect(page).toHaveURL(/\/dashboard/);
await expect(
page.getByRole('heading', { name: 'ダッシュボード' })
).toBeVisible();
// プロフィールへ移動しログアウト
await page.getByRole('link', { name: /さん/ }).click();
await expect(page).toHaveURL(/\/profile/);
await page.getByRole('button', { name: 'ログアウト' }).click();
await expect(page).toHaveURL(/\/login/);
});
test('login with a pre-registered account', async ({ page, request }) => {
const email = uniqueEmail();
await request.post('/api/auth/register', {
data: { name: 'Pre User', email, password: 'password123' },
});
await page.goto('/login');
await page.getByLabel('メールアドレス').fill(email);
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
test('wrong password shows an error and stays on login', async ({
page,
request,
}) => {
const email = uniqueEmail();
await request.post('/api/auth/register', {
data: { name: 'Pre User', email, password: 'password123' },
});
await page.goto('/login');
await page.getByLabel('メールアドレス').fill(email);
await page.getByLabel('パスワード').fill('wrong-password');
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page.getByRole('alert')).toBeVisible();
await expect(page).toHaveURL(/\/login/);
});
test('unauthenticated access to a protected page redirects to login', async ({
browser,
}) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
await context.close();
});
test('unauthenticated API request returns 401', async ({ request }) => {
const res = await request.get('/api/auth/me');
expect(res.status()).toBe(401);
});
});

View File

@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { AuthService } from '@/services/AuthService';
import { verifySessionToken } from '@/lib/auth/session';
import { UnauthorizedError } from '@/lib/errors';
describe('authentication flow (integration)', () => {
let db: SqliteDatabase;
let repo: UserRepository;
let authService: AuthService;
beforeEach(() => {
db = createMigratedTestDb();
repo = new UserRepository(db);
authService = new AuthService(repo);
});
afterEach(() => {
db.close();
});
it('registers, logs in, resolves the session, and reads the user', () => {
// 1. 登録
const registered = authService.register({
name: 'Flow User',
email: 'flow@example.com',
password: 'password123',
});
expect(registered.id).toBeGreaterThan(0);
// 2. ログイン → トークン発行
const { user, token } = authService.login(
'flow@example.com',
'password123'
);
expect(user.id).toBe(registered.id);
// 3. トークンからユーザーIDを解決(セッション復元)
const resolvedUserId = verifySessionToken(token);
expect(resolvedUserId).toBe(user.id);
// 4. ユーザーIDから現在ユーザーを取得(getCurrentUser相当)
const current = repo.findById(resolvedUserId!);
expect(current?.email).toBe('flow@example.com');
});
it('rejects login after the account is deactivated', () => {
authService.register({
name: 'Flow User',
email: 'flow@example.com',
password: 'password123',
});
// 一度ログイン成功を確認
expect(() =>
authService.login('flow@example.com', 'password123')
).not.toThrow();
// 無効化
const user = repo.findByEmail('flow@example.com')!;
repo.update(user.id, { status: 'inactive' });
// 無効アカウントではログイン不可
expect(() => authService.login('flow@example.com', 'password123')).toThrow(
UnauthorizedError
);
});
it('prevents a session token from resolving after profile email changes', () => {
const created = authService.register({
name: 'Flow User',
email: 'flow@example.com',
password: 'password123',
});
const { token } = authService.login('flow@example.com', 'password123');
expect(verifySessionToken(token)).toBe(created.id);
// プロフィール更新(メール変更)後もトークンはuidベースで有効
authService.updateProfile(created.id, {
email: 'changed@example.com',
name: 'New Name',
});
const resolved = repo.findById(verifySessionToken(token)!);
expect(resolved?.email).toBe('changed@example.com');
});
});

2
tests/setup.ts Normal file
View File

@ -0,0 +1,2 @@
// テスト実行時に必要な環境変数のデフォルトを設定する
process.env.SESSION_SECRET = process.env.SESSION_SECRET ?? 'test-secret';

View File

@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { createSessionToken, verifySessionToken } from '@/lib/auth/session';
describe('session token', () => {
it('round-trips a user id through create and verify', () => {
const token = createSessionToken(42);
expect(verifySessionToken(token)).toBe(42);
});
it('returns null for a tampered signature', () => {
const token = createSessionToken(42);
const [encoded] = token.split('.');
const tampered = `${encoded}.aW52YWxpZHNpZ25hdHVyZQ`;
expect(verifySessionToken(tampered)).toBeNull();
});
it('returns null for a tampered payload', () => {
const token = createSessionToken(42);
const [, signature] = token.split('.');
// payload を別ユーザーIDに書き換えたトークン署名は元のまま
const forgedPayload = Buffer.from(
JSON.stringify({ uid: 99, iat: Date.now() })
).toString('base64url');
const forged = `${forgedPayload}.${signature}`;
expect(verifySessionToken(forged)).toBeNull();
});
it('returns null for a malformed token', () => {
expect(verifySessionToken('not-a-valid-token')).toBeNull();
expect(verifySessionToken('only.one.too.many')).toBeNull();
expect(verifySessionToken('')).toBeNull();
});
it('returns null when the payload uid is not a number', () => {
const encoded = Buffer.from(
JSON.stringify({ uid: 'not-a-number', iat: Date.now() })
).toString('base64url');
// 署名は無意味でも構造は整える
expect(verifySessionToken(`${encoded}.${encoded}`)).toBeNull();
});
});

View File

@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
describe('UserRepository', () => {
let db: SqliteDatabase;
let repo: UserRepository;
beforeEach(() => {
db = createMigratedTestDb();
repo = new UserRepository(db);
});
afterEach(() => {
db.close();
});
describe('create', () => {
it('creates a user and returns it with an id and active status', () => {
const user = repo.create({
name: 'Alice',
email: 'alice@example.com',
passwordHash: 'hashed',
});
expect(user.id).toBeGreaterThan(0);
expect(user.name).toBe('Alice');
expect(user.email).toBe('alice@example.com');
expect(user.passwordHash).toBe('hashed');
expect(user.role).toBe('member');
expect(user.status).toBe('active');
expect(user.createdAt).toBeTruthy();
});
it('rejects a duplicate email (UNIQUE constraint)', () => {
repo.create({
name: 'Alice',
email: 'dup@example.com',
passwordHash: 'h',
});
expect(() =>
repo.create({
name: 'Bob',
email: 'dup@example.com',
passwordHash: 'h',
})
).toThrow();
});
});
describe('findById', () => {
it('returns the user when found', () => {
const created = repo.create({
name: 'Alice',
email: 'a@example.com',
passwordHash: 'h',
});
const found = repo.findById(created.id);
expect(found?.id).toBe(created.id);
expect(found?.name).toBe('Alice');
});
it('returns null when no user exists for the id', () => {
expect(repo.findById(99999)).toBeNull();
});
});
describe('findByEmail', () => {
it('returns the user matching the email', () => {
repo.create({ name: 'Alice', email: 'a@example.com', passwordHash: 'h' });
const found = repo.findByEmail('a@example.com');
expect(found?.name).toBe('Alice');
});
it('returns null when no user matches the email', () => {
expect(repo.findByEmail('nobody@example.com')).toBeNull();
});
});
describe('update', () => {
it('updates name, email, avatarUrl, role and status', () => {
const created = repo.create({
name: 'Alice',
email: 'a@example.com',
passwordHash: 'h',
});
const updated = repo.update(created.id, {
name: 'Alice2',
email: 'a2@example.com',
avatarUrl: 'https://example.com/avatar.png',
role: 'system_admin',
status: 'inactive',
});
expect(updated?.name).toBe('Alice2');
expect(updated?.email).toBe('a2@example.com');
expect(updated?.avatarUrl).toBe('https://example.com/avatar.png');
expect(updated?.role).toBe('system_admin');
expect(updated?.status).toBe('inactive');
});
it('only updates provided fields', () => {
const created = repo.create({
name: 'Alice',
email: 'a@example.com',
passwordHash: 'h',
});
const updated = repo.update(created.id, { name: 'NewName' });
expect(updated?.name).toBe('NewName');
expect(updated?.email).toBe('a@example.com');
});
it('returns null when updating a non-existent user', () => {
const result = repo.update(99999, { name: 'X' });
// UPDATE affects 0 rows; findById returns null
expect(result).toBeNull();
});
});
});

View File

@ -0,0 +1,177 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { AuthService } from '@/services/AuthService';
import { ConflictError, UnauthorizedError, NotFoundError } from '@/lib/errors';
import { verifySessionToken } from '@/lib/auth/session';
import bcrypt from 'bcrypt';
describe('AuthService', () => {
let db: SqliteDatabase;
let repo: UserRepository;
let authService: AuthService;
beforeEach(() => {
db = createMigratedTestDb();
repo = new UserRepository(db);
authService = new AuthService(repo);
});
afterEach(() => {
db.close();
});
describe('register', () => {
it('creates a user with a bcrypt-hashed password', () => {
const user = authService.register({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
expect(user.id).toBeGreaterThan(0);
expect(user.passwordHash).not.toBe('password123');
expect(bcrypt.compareSync('password123', user.passwordHash!)).toBe(true);
expect(user.role).toBe('member');
expect(user.status).toBe('active');
});
it('throws ConflictError when the email is already registered', () => {
authService.register({
name: 'Alice',
email: 'dup@example.com',
password: 'password123',
});
expect(() =>
authService.register({
name: 'Bob',
email: 'dup@example.com',
password: 'password123',
})
).toThrow(ConflictError);
});
it('throws ValidationError for a weak password', () => {
expect(() =>
authService.register({
name: 'Alice',
email: 'a@example.com',
password: 'short',
})
).toThrow();
});
});
describe('login', () => {
beforeEach(() => {
authService.register({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
});
it('returns the user and a verifiable session token on success', () => {
const { user, token } = authService.login(
'alice@example.com',
'password123'
);
expect(user.email).toBe('alice@example.com');
expect(verifySessionToken(token)).toBe(user.id);
});
it('throws UnauthorizedError when the password is wrong', () => {
expect(() =>
authService.login('alice@example.com', 'wrong-password')
).toThrow(UnauthorizedError);
});
it('throws UnauthorizedError when the email does not exist', () => {
expect(() =>
authService.login('nobody@example.com', 'password123')
).toThrow(UnauthorizedError);
});
it('throws UnauthorizedError when the account is inactive', () => {
const user = repo.findByEmail('alice@example.com')!;
repo.update(user.id, { status: 'inactive' });
expect(() =>
authService.login('alice@example.com', 'password123')
).toThrow(UnauthorizedError);
});
});
describe('getCurrentUser', () => {
it('returns the user for a valid id', () => {
const created = authService.register({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
expect(authService.getCurrentUser(created.id)?.email).toBe(
'alice@example.com'
);
});
it('returns null for an unknown id', () => {
expect(authService.getCurrentUser(99999)).toBeNull();
});
});
describe('updateProfile', () => {
it('updates name and email', () => {
const created = authService.register({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
const updated = authService.updateProfile(created.id, {
name: 'Alice New',
email: 'alice2@example.com',
});
expect(updated.name).toBe('Alice New');
expect(updated.email).toBe('alice2@example.com');
});
it('throws ConflictError when changing email to one already in use', () => {
authService.register({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
const bob = authService.register({
name: 'Bob',
email: 'bob@example.com',
password: 'password123',
});
expect(() =>
authService.updateProfile(bob.id, { email: 'alice@example.com' })
).toThrow(ConflictError);
});
it('throws NotFoundError when the user does not exist', () => {
expect(() => authService.updateProfile(99999, { name: 'X' })).toThrow(
NotFoundError
);
});
});
describe('createWithRole', () => {
it('creates a user with the specified role', () => {
const admin = authService.createWithRole(
{ name: 'Admin', email: 'admin@example.com', password: 'password123' },
'system_admin'
);
expect(admin.role).toBe('system_admin');
});
});
});

View File

@ -11,6 +11,14 @@ export default defineConfig({
'repositories/**/*.{test,spec}.{ts,tsx}', 'repositories/**/*.{test,spec}.{ts,tsx}',
'services/**/*.{test,spec}.{ts,tsx}', 'services/**/*.{test,spec}.{ts,tsx}',
], ],
exclude: [
'tests/e2e/**',
'node_modules/**',
'dist/**',
'.next/**',
'**/node_modules/**',
],
setupFiles: ['tests/setup.ts'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'json', 'html'], reporter: ['text', 'json', 'html'],