Files
opengroupware/services/AuthService.ts
Ken Yasue 21b9f03e9d 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)
2026-06-25 00:36:42 +02:00

118 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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