diff --git a/src/services/auth/auth.service.ts b/src/services/auth/auth.service.ts new file mode 100644 index 0000000..db5efed --- /dev/null +++ b/src/services/auth/auth.service.ts @@ -0,0 +1,304 @@ +import { eq, and, gt } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import type * as schema from '../../db/schema/index.js'; +import { users, sessions, emailVerificationCodes, passwordResetTokens } from '../../db/schema/index.js'; +import { hashPassword, verifyPassword } from '../../utils/password.js'; +import { + signAccessToken, + signRefreshToken, + verifyToken, + isRefreshPayload, + hashToken, +} from '../../utils/jwt.js'; +import { + AppError, + conflict, + unauthorized, + notFound, + ERROR_CODES, +} from '../../utils/errors.js'; +import { randomBytes, randomUUID } from 'node:crypto'; + +type Db = NodePgDatabase; + +export interface RegisterInput { + email: string; + password: string; + nickname: string; +} + +export interface LoginInput { + email: string; + password: string; + userAgent?: string; + ipAddress?: string; +} + +export interface LoginResult { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export interface RefreshInput { + refreshToken: string; + userAgent?: string; + ipAddress?: string; +} + +export interface ForgotPasswordResult { + token: string; + expiresAt: Date; +} + +const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const VERIFICATION_CODE_LENGTH = 6; +const VERIFICATION_CODE_TTL_MS = 15 * 60 * 1000; +const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; + +export class AuthService { + constructor(private readonly db: Db) {} + + async register(input: RegisterInput): Promise<{ userId: string; verificationCode: string }> { + const [existing] = await this.db + .select() + .from(users) + .where(eq(users.email, input.email.toLowerCase().trim())) + .limit(1); + + if (existing) { + throw conflict(ERROR_CODES.EMAIL_TAKEN, 'Email already registered'); + } + + const [nicknameConflict] = await this.db + .select() + .from(users) + .where(eq(users.nickname, input.nickname.trim())) + .limit(1); + + if (nicknameConflict) { + throw conflict(ERROR_CODES.NICKNAME_TAKEN, 'Nickname already taken'); + } + + const passwordHash = await hashPassword(input.password); + + const [user] = await this.db + .insert(users) + .values({ + email: input.email.toLowerCase().trim(), + passwordHash, + nickname: input.nickname.trim(), + }) + .returning({ id: users.id }); + + if (!user) { + throw new AppError(ERROR_CODES.INTERNAL_ERROR, 'Failed to create user', 500); + } + + const code = randomBytes(VERIFICATION_CODE_LENGTH) + .toString('hex') + .slice(0, VERIFICATION_CODE_LENGTH) + .toUpperCase(); + const expiresAt = new Date(Date.now() + VERIFICATION_CODE_TTL_MS); + + await this.db.insert(emailVerificationCodes).values({ + userId: user.id, + code, + expiresAt, + }); + + return { userId: user.id, verificationCode: code }; + } + + async login(input: LoginInput): Promise { + const [user] = await this.db + .select() + .from(users) + .where(eq(users.email, input.email.toLowerCase().trim())) + .limit(1); + + if (!user || !(await verifyPassword(user.passwordHash, input.password))) { + throw unauthorized('Invalid email or password'); + } + + const [accessToken, refreshToken] = await Promise.all([ + signAccessToken({ sub: user.id, email: user.email }), + signRefreshToken({ sub: user.id, sid: randomUUID() }), + ]); + + const refreshHash = hashToken(refreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TTL_MS); + + await this.db.insert(sessions).values({ + userId: user.id, + refreshTokenHash: refreshHash, + userAgent: input.userAgent ?? null, + ipAddress: input.ipAddress ?? null, + expiresAt, + }); + + return { + accessToken, + refreshToken, + expiresIn: Math.floor(REFRESH_TTL_MS / 1000), + }; + } + + async logout(refreshToken: string): Promise { + const hash = hashToken(refreshToken); + await this.db.delete(sessions).where(eq(sessions.refreshTokenHash, hash)); + } + + async refresh(input: RefreshInput): Promise { + const payload = await verifyToken(input.refreshToken); + + if (!isRefreshPayload(payload)) { + throw unauthorized('Invalid refresh token'); + } + + const hash = hashToken(input.refreshToken); + + const [session] = await this.db + .select() + .from(sessions) + .where(and(eq(sessions.refreshTokenHash, hash), gt(sessions.expiresAt, new Date()))) + .limit(1); + + if (!session) { + throw new AppError(ERROR_CODES.INVALID_REFRESH_TOKEN, 'Invalid or expired refresh token', 401); + } + + await this.db.delete(sessions).where(eq(sessions.id, session.id)); + + const [user] = await this.db.select().from(users).where(eq(users.id, session.userId)).limit(1); + + if (!user) { + throw notFound('User not found'); + } + + const [accessToken, newRefreshToken] = await Promise.all([ + signAccessToken({ sub: user.id, email: user.email }), + signRefreshToken({ sub: user.id, sid: randomUUID() }), + ]); + + const newHash = hashToken(newRefreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TTL_MS); + + await this.db.insert(sessions).values({ + userId: user.id, + refreshTokenHash: newHash, + userAgent: input.userAgent ?? session.userAgent, + ipAddress: input.ipAddress ?? session.ipAddress, + expiresAt, + }); + + return { + accessToken, + refreshToken: newRefreshToken, + expiresIn: Math.floor(REFRESH_TTL_MS / 1000), + }; + } + + async verifyEmail(userId: string, verificationCode: string): Promise { + const codeUpper = verificationCode.toUpperCase(); + const [record] = await this.db + .select() + .from(emailVerificationCodes) + .where( + and( + eq(emailVerificationCodes.userId, userId), + eq(emailVerificationCodes.code, codeUpper), + ), + ) + .limit(1); + + if (!record) { + throw new AppError(ERROR_CODES.INVALID_CODE, 'Invalid or expired verification code', 400); + } + + if (record.expiresAt < new Date()) { + await this.db + .delete(emailVerificationCodes) + .where(eq(emailVerificationCodes.id, record.id)); + throw new AppError(ERROR_CODES.INVALID_CODE, 'Verification code expired', 400); + } + + const [user] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1); + + if (!user) { + throw notFound('User not found'); + } + + if (user.emailVerifiedAt) { + throw new AppError(ERROR_CODES.ALREADY_VERIFIED, 'Email already verified', 400); + } + + await this.db + .update(users) + .set({ emailVerifiedAt: new Date(), updatedAt: new Date() }) + .where(eq(users.id, userId)); + + await this.db + .delete(emailVerificationCodes) + .where(eq(emailVerificationCodes.userId, userId)); + } + + async forgotPassword(email: string): Promise { + const [user] = await this.db + .select() + .from(users) + .where(eq(users.email, email.toLowerCase().trim())) + .limit(1); + + if (!user) { + return { + token: '', + expiresAt: new Date(Date.now() + RESET_TOKEN_TTL_MS), + }; + } + + const token = randomBytes(32).toString('hex'); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS); + + await this.db.insert(passwordResetTokens).values({ + userId: user.id, + tokenHash, + expiresAt, + }); + + return { token, expiresAt }; + } + + async resetPassword(token: string, newPassword: string): Promise { + const tokenHash = hashToken(token); + + const [record] = await this.db + .select() + .from(passwordResetTokens) + .where(eq(passwordResetTokens.tokenHash, tokenHash)) + .limit(1); + + if (!record) { + throw new AppError(ERROR_CODES.INVALID_RESET_TOKEN, 'Invalid or expired reset token', 400); + } + + if (record.expiresAt < new Date()) { + await this.db + .delete(passwordResetTokens) + .where(eq(passwordResetTokens.id, record.id)); + throw new AppError(ERROR_CODES.INVALID_RESET_TOKEN, 'Reset token expired', 400); + } + + const passwordHash = await hashPassword(newPassword); + + await this.db + .update(users) + .set({ passwordHash, updatedAt: new Date() }) + .where(eq(users.id, record.userId)); + + await this.db + .delete(passwordResetTokens) + .where(eq(passwordResetTokens.id, record.id)); + } +} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index da1479e..cb6a41e 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -1,6 +1,11 @@ +import { createHash } from 'node:crypto'; import * as jose from 'jose'; import { env } from '../config/env.js'; +export function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + export interface AccessPayload { sub: string; email: string;