- 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)
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
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,
|
||
});
|
||
}
|
||
}
|