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:
117
services/AuthService.ts
Normal file
117
services/AuthService.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user