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

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,
});
}
}