import { randomUUID, timingSafeEqual } from 'node:crypto'; import { and, eq, gt, isNull, inArray } from 'drizzle-orm'; import argon2 from 'argon2'; import type { Service } from './service.js'; import { PASSWORD_MIN_LENGTH, translate, type Account } from '@hop/shared'; import * as s from './db/schema.js'; import { fail, hash, randomToken } from './security.js'; export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string }; export type Mailer = (mail: Mail) => Promise; export type User = typeof s.users.$inferSelect; const passwordOptions = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 1 }; let dummy: Promise | undefined; export function normalizeEmail(value: string) { const trimmed = value.trim(); const split = trimmed.lastIndexOf('@'); if ( split < 1 || split === trimmed.length - 1 || trimmed.length > 254 || /[\s\x00-\x1f\x7f]/.test(trimmed) ) fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.'); const local = trimmed.slice(0, split), domain = trimmed.slice(split + 1).toLowerCase(); if ( !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local) || !/^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain) ) fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.'); return `${local}@${domain}`; } export class Auth { readonly sendMail: Mailer; readonly mailReady: boolean; constructor( readonly service: Service, mailer?: Mailer, ) { this.mailReady = Boolean(mailer); this.sendMail = mailer ?? (async () => fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.')); if (service.cfg.registrationMode === 'open' && !this.mailReady) throw Error('Mail provider must be configured before opening registration'); } serialize(user: User): Account { return { id: user.id, email: user.email ?? '', displayName: user.status === 'deleted' ? '탈퇴한 사용자' : user.displayName, role: user.role, status: user.status, verified: Boolean(user.verifiedAt && user.status === 'active'), }; } user(id: string) { const user = this.service.db.select().from(s.users).where(eq(s.users.id, id)).get(); if (!user) fail(404, 'ACCOUNT_NOT_FOUND', '계정을 찾을 수 없습니다.'); return user!; } session(token: string | undefined) { if (!token) return undefined; const row = this.service.db .select({ session: s.accountSessions, user: s.users }) .from(s.accountSessions) .innerJoin(s.users, eq(s.accountSessions.userId, s.users.id)) .where( and( eq(s.accountSessions.tokenHash, hash(token)), isNull(s.accountSessions.revokedAt), gt(s.accountSessions.expiresAt, Date.now()), inArray(s.users.status, ['pending', 'active']), ), ) .get(); return row; } require(token: string | undefined, verified = false) { const current = this.session(token); if (!current) fail(401, 'LOGIN_REQUIRED', '로그인이 필요합니다.'); if (verified && !this.serialize(current!.user).verified) fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.'); return current!; } async password(value: string) { const minimum = PASSWORD_MIN_LENGTH; if (Array.from(value).length < minimum || value.length > 1024) fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`); return argon2.hash(value, passwordOptions); } async token( user: User, purpose: 'verify' | 'reset' | 'email-change', value?: string, language = 'ko', ) { if (!this.mailReady) fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.'); const token = randomToken(), now = Date.now(); this.service.db.transaction(() => { this.service.db .update(s.accountTokens) .set({ usedAt: now }) .where( and( eq(s.accountTokens.userId, user.id), eq(s.accountTokens.purpose, purpose), isNull(s.accountTokens.usedAt), ), ) .run(); this.service.db .insert(s.accountTokens) .values({ id: randomUUID(), userId: user.id, purpose, tokenHash: hash(token), value, createdAt: now, expiresAt: now + (purpose === 'verify' ? 24 : 1) * 3600000, }) .run(); }); const action = purpose === 'verify' ? 'verify' : purpose === 'reset' ? 'reset-password' : 'verify-email-change'; await this.sendMail({ idempotencyKey: `auth-${purpose}-${hash(token)}`, to: value ?? user.email!, subject: translate( purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증', language, ), text: translate( '{p0} 다음 주소를 열어 주세요.\n{p1}/#{p2}={p3}\n이 링크는 한 번만 사용할 수 있습니다.', language, { p0: translate( purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면', language, ), p1: this.service.cfg.appUrl, p2: action, p3: token, }, ), }); } async register( email: string, password: string, displayName: string, ip: string, setupToken?: string, language = 'ko', ) { const { db, cfg, limiter } = this.service; limiter.hit(`register-ip:${ip}`, 5, 3600000); let admin = false; if (setupToken) { if ( !cfg.setupToken || hash(setupToken) !== hash(cfg.setupToken) || db.select().from(s.users).limit(1).get() ) fail(403, 'SETUP_DISABLED', '초기화를 진행할 수 없습니다.'); admin = true; } else if (cfg.registrationMode !== 'open') fail(403, 'REGISTRATION_CLOSED', '현재 공개 가입이 닫혀 있습니다.'); if (!this.mailReady) fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.'); const normalized = normalizeEmail(email), passwordHash = await this.password(password); if (db.select().from(s.users).where(eq(s.users.email, normalized)).get()) return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' }; const user = db.transaction(() => { if (admin && db.select().from(s.users).limit(1).get()) fail(409, 'SETUP_DISABLED', '초기화가 이미 완료되었습니다.'); const id = randomUUID(); db.insert(s.users) .values({ id, email: normalized, passwordHash, displayName, role: admin ? 'admin' : 'user', status: 'pending', createdAt: Date.now(), }) .run(); return this.user(id); }); await this.token(user, 'verify', undefined, language); return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' }; } consume( token: string, purpose: 'verify' | 'reset' | 'email-change', fn: (token: typeof s.accountTokens.$inferSelect, user: User) => void, ) { return this.service.db.transaction(() => { const row = this.service.db .select() .from(s.accountTokens) .where( and( eq(s.accountTokens.tokenHash, hash(token)), eq(s.accountTokens.purpose, purpose), isNull(s.accountTokens.usedAt), gt(s.accountTokens.expiresAt, Date.now()), ), ) .get(); if (!row) fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.'); const user = this.user(row!.userId); if (['suspended', 'deleted'].includes(user.status)) fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.'); fn(row!, user); this.service.db .update(s.accountTokens) .set({ usedAt: Date.now() }) .where(eq(s.accountTokens.id, row!.id)) .run(); return { ok: true }; }); } verify(token: string, oldToken?: string) { const previous = this.session(oldToken); let login!: ReturnType; const verified = this.consume(token, 'verify', (_t, user) => { this.service.db .update(s.users) .set({ status: 'active', verifiedAt: Date.now() }) .where(eq(s.users.id, user.id)) .run(); login = this.createSession(this.user(user.id), oldToken); }); if (previous) this.endSession(previous); return { ...verified, ...login }; } async login(email: string, password: string, ip: string, oldToken?: string) { const normalized = normalizeEmail(email); this.service.limiter.hit(`login-ip:${ip}`, 20); this.service.limiter.hit(`login-email:${hash(normalized)}`, 10); const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get(); dummy ??= argon2.hash(randomToken(), passwordOptions); const valid = await argon2 .verify(user?.passwordHash ?? (await dummy), password) .catch(() => false); const currentUser = user ? this.user(user.id) : null; if ( !valid || !currentUser || !['pending', 'active'].includes(currentUser.status) || currentUser.passwordHash !== user?.passwordHash ) fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.'); const previous = this.session(oldToken); const result = this.createSession(currentUser!, oldToken); if (previous) this.endSession(previous); return result; } private createSession(user: User, oldToken?: string) { const token = randomToken(), now = Date.now(); this.service.db.transaction(() => { if (oldToken) this.service.db .update(s.accountSessions) .set({ revokedAt: now }) .where(eq(s.accountSessions.tokenHash, hash(oldToken))) .run(); this.service.db .insert(s.accountSessions) .values({ id: randomUUID(), userId: user.id, tokenHash: hash(token), createdAt: now, lastSeen: now, expiresAt: now + 30 * 86400000, }) .run(); }); return { token, account: this.serialize(user), expiresAt: now + 30 * 86400000 }; } private endSession(current: NonNullable>) { this.service.storage.abortSession(current.session.id); for (const socket of this.service.io?.sockets.sockets.values() ?? []) { if (socket.data.accountSessionId !== current.session.id) continue; socket.leave(`account:${current.user.id}`); socket.leave(`account-session:${current.session.id}`); socket.data.accountId = undefined; socket.data.accountSessionId = undefined; socket.emit('account:revoked', {}); } } logout(token: string | undefined) { const current = this.session(token); if (!current) return; this.service.db .update(s.accountSessions) .set({ revokedAt: Date.now() }) .where(eq(s.accountSessions.id, current.session.id)) .run(); this.endSession(current); } revoke(userId: string) { this.service.storage.abortUser(userId); this.service.storage.abortUser(userId); this.service.db .update(s.accountSessions) .set({ revokedAt: Date.now() }) .where(eq(s.accountSessions.userId, userId)) .run(); this.service.io?.to(`account:${userId}`).emit('account:revoked', {}); this.service.io?.in(`account:${userId}`).socketsLeave(`account:${userId}`); } enforceSessions() { for (const socket of this.service.io?.sockets.sockets.values() ?? []) { const id = socket.data.accountSessionId; if (!id) continue; const session = this.service.db .select({ session: s.accountSessions, user: s.users }) .from(s.accountSessions) .innerJoin(s.users, eq(s.accountSessions.userId, s.users.id)) .where(eq(s.accountSessions.id, id)) .get(); if ( !session || session.session.revokedAt || session.session.expiresAt <= Date.now() || !['pending', 'active'].includes(session.user.status) ) { socket.leave(`account:${socket.data.accountId}`); socket.leave(`account-session:${id}`); socket.data.accountId = undefined; socket.data.accountSessionId = undefined; socket.emit('account:revoked', {}); } } } async resend(email: string, purpose: 'verify' | 'reset', ip: string, language = 'ko') { const normalized = normalizeEmail(email); this.service.limiter.hit(`mail-ip:${ip}`, 10, 3600000); this.service.limiter.hit(`mail-email:${hash(normalized)}`, 3, 3600000); const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get(); if ( user && ((purpose === 'verify' && user.status === 'pending') || (purpose === 'reset' && ['active', 'pending'].includes(user.status))) ) await this.token(user, purpose, undefined, language); return { message: '처리 가능한 주소라면 안내 메일이 발송됩니다.' }; } async resetPassword(token: string, password: string) { const passwordHash = await this.password(password); let userId = ''; const result = this.consume(token, 'reset', (_t, user) => { userId = user.id; this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run(); this.service.db .update(s.accountTokens) .set({ usedAt: Date.now() }) .where(eq(s.accountTokens.userId, user.id)) .run(); }); this.revoke(userId); return result; } async changePassword(user: User, currentPassword: string, password: string) { if (!user.passwordHash || !(await argon2.verify(user.passwordHash, currentPassword))) fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.'); const passwordHash = await this.password(password); this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run(); this.revoke(user.id); } async changeEmail(user: User, email: string, password: string) { if (!user.passwordHash || !(await argon2.verify(user.passwordHash, password))) fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.'); const normalized = normalizeEmail(email); if ( normalized === user.email || this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get() ) fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.'); await this.token(user, 'email-change', normalized); } async verifyEmailChange(token: string) { let previous = '', next = ''; const result = this.consume(token, 'email-change', (t, user) => { if ( !t.value || this.service.db.select().from(s.users).where(eq(s.users.email, t.value)).get() ) fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.'); previous = user.email!; next = t.value!; this.service.db .update(s.users) .set({ email: t.value, verifiedAt: Date.now(), status: 'active' }) .where(eq(s.users.id, user.id)) .run(); }); await this.sendMail({ to: previous, subject: 'hop. 이메일 주소 변경 안내', text: `계정 이메일이 ${next}로 변경되었습니다.`, }); return result; } }