import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import path from 'path'; import crypto from 'crypto'; import bcrypt from 'bcryptjs'; import { signToken, requireAuth } from '../middleware/auth.js'; import { nowIso } from '../utils.js'; import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js'; import { sendPasswordResetEmail, sendVerificationEmail } from '../services/emailService.js'; import { deleteUserAccount } from '../services/accountDeletion.js'; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const USERNAME_PATTERN = /^[\p{L}\p{N}_-]+$/u; const VERIFICATION_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; const PASSWORD_RESET_TOKEN_TTL_MS = 30 * 60 * 1000; const EMAIL_RESEND_COOLDOWN_MS = 60 * 1000; const MONTHLY_EMAIL_LIMIT = 3; const EMAIL_LIMIT_MESSAGE = '이 계정의 월간 이메일 발송 한도(3회)를 초과했습니다. 관리자 zenn@sori.studio로 직접 문의해 주세요.'; const EMAIL_LIMIT_CODE = 'MONTHLY_EMAIL_LIMIT_EXCEEDED'; /** 로그인 무차별 대입 방어 (IP 기준) */ const loginLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.' }, }); /** 계정·이메일 관련 쓰기 요청 남용 방어 (IP 기준) */ const authWriteLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.' }, }); /** * 한국 시간 기준 월 키 * @returns {string} */ function getKoreanMonthKey() { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', }).formatToParts(new Date()); const year = parts.find((part) => part.type === 'year')?.value; const month = parts.find((part) => part.type === 'month')?.value; return `${year}-${month}`; } /** * 월간 이메일 발송 횟수 예약 * @param {import('better-sqlite3').Database} db * @param {number} userId * @param {'verify_email'|'reset_password'} type * @returns {number|null} 예약 로그 ID. 한도 초과 시 null. */ function reserveEmailSend(db, userId, type) { const monthKey = getKoreanMonthKey(); const transaction = db.transaction(() => { db.prepare(` DELETE FROM email_send_logs WHERE status = 'reserved' AND created_at < datetime('now', '-10 minutes') `).run(); const row = db.prepare(` SELECT COUNT(*) AS count FROM email_send_logs WHERE user_id = ? AND month_key = ? AND status IN ('reserved', 'sent') `).get(userId, monthKey); if (row.count >= MONTHLY_EMAIL_LIMIT) return null; const result = db.prepare(` INSERT INTO email_send_logs (user_id, type, month_key, status) VALUES (?, ?, ?, 'reserved') `).run(userId, type, monthKey); return Number(result.lastInsertRowid); }); return transaction(); } /** * 예약한 이메일 발송을 성공 처리 * @param {import('better-sqlite3').Database} db * @param {number} logId */ function completeEmailSend(db, logId) { db.prepare(` UPDATE email_send_logs SET status = 'sent', sent_at = ? WHERE id = ? AND status = 'reserved' `).run(nowIso(), logId); } /** * 실패한 이메일 발송 예약 취소 * @param {import('better-sqlite3').Database} db * @param {number|null} logId */ function cancelEmailSend(db, logId) { if (!logId) return; db.prepare("DELETE FROM email_send_logs WHERE id = ? AND status = 'reserved'").run(logId); } /** * 인증용 원문 토큰과 DB 저장용 해시 생성 * @returns {{token: string, tokenHash: string}} */ function createOpaqueToken() { const token = crypto.randomBytes(32).toString('base64url'); return { token, tokenHash: crypto.createHash('sha256').update(token).digest('hex'), }; } /** * 원문 토큰 해시 * @param {string} token * @returns {string} */ function hashToken(token) { return crypto.createHash('sha256').update(token).digest('hex'); } /** * 사용자 인증 토큰 저장. 같은 목적의 기존 미사용 토큰은 폐기한다. * @param {import('better-sqlite3').Database} db * @param {number} userId * @param {'verify_email'|'reset_password'} type * @param {number} ttlMs * @returns {string} */ function issueAuthToken(db, userId, type, ttlMs) { const { token, tokenHash } = createOpaqueToken(); const expiresAt = new Date(Date.now() + ttlMs).toISOString(); const transaction = db.transaction(() => { db.prepare('DELETE FROM auth_tokens WHERE user_id = ? AND type = ? AND used_at IS NULL') .run(userId, type); db.prepare(` INSERT INTO auth_tokens (user_id, type, token_hash, expires_at) VALUES (?, ?, ?, ?) `).run(userId, type, tokenHash, expiresAt); }); transaction(); return token; } /** * 이메일 형식 검증 * @param {string} email * @returns {boolean} */ function isValidEmail(email) { return email.length <= 254 && EMAIL_PATTERN.test(email); } /** * 사용자명 형식 검증 * @param {string} username * @returns {boolean} */ function isValidUsername(username) { return username.length >= 2 && username.length <= 30 && USERNAME_PATTERN.test(username); } /** * DB 사용자 행을 API 응답 형식으로 변환 * @param {object} user * @returns {object} */ function toUserResponse(user) { if (!user) return null; return { ...user, displayName: user.username, avatarUrl: getAvatarUrl(user.id) || user.avatarUrl || null, defaultDeckPublic: (user.default_deck_public ?? user.defaultDeckPublic ?? 1) !== 0, skipDeckPublicPrompt: Boolean(user.skip_deck_public_prompt ?? user.skipDeckPublicPrompt), }; } /** * 인증 라우트 * @param {import('better-sqlite3').Database} db * @returns {import('express').Router} */ export function createAuthRouter(db) { const router = Router(); router.get('/avatar/:userId', (req, res) => { const filePath = getAvatarFilePath(req.params.userId); if (!filePath) { return res.status(404).json({ error: '아바타가 없습니다.' }); } res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', 'public, max-age=86400'); res.sendFile(path.resolve(filePath), (err) => { if (err && !res.headersSent) { res.status(404).json({ error: '아바타가 없습니다.' }); } }); }); router.post('/register', authWriteLimiter, async (req, res, next) => { const { email, username, password } = req.body || {}; if (!email || !username || !password) { return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' }); } const normalizedEmail = String(email).trim().toLowerCase(); const trimmedUsername = String(username).trim(); const rawPassword = String(password); if (!isValidEmail(normalizedEmail)) { return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' }); } if (!isValidUsername(trimmedUsername)) { return res.status(400).json({ error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.', }); } if (rawPassword.length < 8) { return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다.' }); } if (db.prepare('SELECT id FROM users WHERE email = ?').get(normalizedEmail)) { return res.status(409).json({ error: '이미 사용 중인 이메일입니다.' }); } if (db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(trimmedUsername)) { return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' }); } const hash = bcrypt.hashSync(rawPassword, 12); let userId; let emailLogId; try { const result = db.prepare(` INSERT INTO users (email, username, password_hash, display_name, email_verified_at) VALUES (?, ?, ?, ?, NULL) `).run(normalizedEmail, trimmedUsername, hash, trimmedUsername); userId = Number(result.lastInsertRowid); emailLogId = reserveEmailSend(db, userId, 'verify_email'); if (!emailLogId) { db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId); return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE, code: EMAIL_LIMIT_CODE, }); } const token = issueAuthToken(db, userId, 'verify_email', VERIFICATION_TOKEN_TTL_MS); await sendVerificationEmail({ email: normalizedEmail, username: trimmedUsername, token, }); completeEmailSend(db, emailLogId); res.status(201).json({ ok: true, email: normalizedEmail, message: '인증 메일을 보냈습니다. 이메일 인증 후 로그인해 주세요.', }); } catch (err) { cancelEmailSend(db, emailLogId); if (userId) { db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId); } if (String(err.message).includes('UNIQUE')) { return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' }); } next(err); } }); router.post('/resend-verification', authWriteLimiter, async (req, res, next) => { const normalizedEmail = String(req.body?.email || '').trim().toLowerCase(); if (!isValidEmail(normalizedEmail)) { return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' }); } const user = db.prepare(` SELECT id, email, username, email_verified_at FROM users WHERE email = ? AND is_active = 1 `).get(normalizedEmail); if (!user || user.email_verified_at) { return res.json({ ok: true, message: '인증이 필요한 계정이라면 이메일을 다시 보냈습니다.', }); } const recent = db.prepare(` SELECT created_at FROM auth_tokens WHERE user_id = ? AND type = 'verify_email' ORDER BY id DESC LIMIT 1 `).get(user.id); if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) { return res.status(429).json({ error: '인증 메일은 1분 후 다시 요청할 수 있습니다.', code: 'EMAIL_RESEND_COOLDOWN', }); } let emailLogId; try { emailLogId = reserveEmailSend(db, user.id, 'verify_email'); if (!emailLogId) { return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE, code: EMAIL_LIMIT_CODE, }); } const token = issueAuthToken(db, user.id, 'verify_email', VERIFICATION_TOKEN_TTL_MS); await sendVerificationEmail({ email: user.email, username: user.username, token }); completeEmailSend(db, emailLogId); res.json({ ok: true, message: '인증 메일을 다시 보냈습니다.' }); } catch (err) { cancelEmailSend(db, emailLogId); next(err); } }); router.post('/verify-email', authWriteLimiter, (req, res) => { const token = String(req.body?.token || ''); if (!token) { return res.status(400).json({ error: '인증 토큰이 없습니다.', code: 'VERIFICATION_TOKEN_MISSING', }); } const tokenHash = hashToken(token); const row = db.prepare(` SELECT t.id, t.user_id AS userId, t.expires_at AS expiresAt, t.used_at AS usedAt, u.email_verified_at AS emailVerifiedAt FROM auth_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ? AND t.type = 'verify_email' AND u.is_active = 1 `).get(tokenHash); if (!row || row.usedAt || new Date(row.expiresAt).getTime() <= Date.now()) { return res.status(400).json({ error: '인증 링크가 유효하지 않거나 만료되었습니다.', code: 'VERIFICATION_TOKEN_INVALID', }); } const transaction = db.transaction(() => { db.prepare(` UPDATE users SET email_verified_at = ?, updated_at = ? WHERE id = ? `).run(nowIso(), nowIso(), row.userId); db.prepare('UPDATE auth_tokens SET used_at = ? WHERE id = ?').run(nowIso(), row.id); }); transaction(); res.json({ ok: true, message: '이메일 인증이 완료되었습니다. 로그인해 주세요.' }); }); router.post('/login', loginLimiter, (req, res) => { const { email, password } = req.body || {}; if (!email || !password) { return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' }); } const normalizedEmail = String(email).trim().toLowerCase(); const rawPassword = String(password); const user = db.prepare(` SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active, email_verified_at FROM users WHERE email = ? `).get(normalizedEmail); if (!user || !user.is_active) { return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' }); } if (!bcrypt.compareSync(rawPassword, user.password_hash)) { return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' }); } if (!user.email_verified_at) { return res.status(403).json({ error: '이메일 인증이 필요합니다. 가입한 이메일의 인증 링크를 확인해 주세요.', code: 'EMAIL_NOT_VERIFIED', }); } db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id); const row = db.prepare(` SELECT id, email, username, avatar_url, role, updated_at AS updatedAt, last_login_at AS lastLoginAt FROM users WHERE id = ? `).get(user.id); const safeUser = toUserResponse(row); res.json({ token: signToken(safeUser), user: safeUser }); }); router.post('/forgot-password', authWriteLimiter, async (req, res, next) => { const normalizedEmail = String(req.body?.email || '').trim().toLowerCase(); if (!isValidEmail(normalizedEmail)) { return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' }); } const genericResponse = { ok: true, message: '가입된 이메일이라면 비밀번호 재설정 링크를 보냈습니다.', }; const user = db.prepare(` SELECT id, email, username FROM users WHERE email = ? AND is_active = 1 AND email_verified_at IS NOT NULL `).get(normalizedEmail); if (!user) { return res.json(genericResponse); } let emailLogId; try { const recent = db.prepare(` SELECT created_at FROM auth_tokens WHERE user_id = ? AND type = 'reset_password' ORDER BY id DESC LIMIT 1 `).get(user.id); if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) { return res.json(genericResponse); } emailLogId = reserveEmailSend(db, user.id, 'reset_password'); if (!emailLogId) { return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE, code: EMAIL_LIMIT_CODE, }); } const token = issueAuthToken(db, user.id, 'reset_password', PASSWORD_RESET_TOKEN_TTL_MS); await sendPasswordResetEmail({ email: user.email, username: user.username, token }); completeEmailSend(db, emailLogId); res.json(genericResponse); } catch (err) { cancelEmailSend(db, emailLogId); next(err); } }); router.post('/reset-password', authWriteLimiter, (req, res) => { const token = String(req.body?.token || ''); const newPassword = String(req.body?.newPassword || ''); if (!token) { return res.status(400).json({ error: '재설정 토큰이 없습니다.' }); } if (newPassword.length < 8) { return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' }); } const row = db.prepare(` SELECT t.id, t.user_id AS userId, t.expires_at AS expiresAt, t.used_at AS usedAt FROM auth_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ? AND t.type = 'reset_password' AND u.is_active = 1 `).get(hashToken(token)); if (!row || row.usedAt || new Date(row.expiresAt).getTime() <= Date.now()) { return res.status(400).json({ error: '재설정 링크가 유효하지 않거나 만료되었습니다.' }); } const passwordHash = bcrypt.hashSync(newPassword, 12); const transaction = db.transaction(() => { db.prepare(` UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ? AND is_active = 1 `).run(passwordHash, nowIso(), row.userId); db.prepare(` UPDATE auth_tokens SET used_at = ? WHERE user_id = ? AND type = 'reset_password' AND used_at IS NULL `).run(nowIso(), row.userId); }); transaction(); res.json({ ok: true, message: '비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.' }); }); router.get('/me', requireAuth, (req, res) => { db.prepare('UPDATE users SET last_login_at = ? WHERE id = ? AND is_active = 1').run( nowIso(), req.user.id, ); const user = db.prepare(` SELECT id, email, username, avatar_url AS avatarUrl, role, default_deck_public AS defaultDeckPublic, skip_deck_public_prompt AS skipDeckPublicPrompt, last_login_at AS lastLoginAt, created_at AS createdAt, updated_at AS updatedAt FROM users WHERE id = ? AND is_active = 1 `).get(req.user.id); if (!user) { return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' }); } res.json({ user: toUserResponse(user) }); }); router.put('/me', requireAuth, (req, res) => { const { email, username, avatarImage, removeAvatar, defaultDeckPublic, skipDeckPublicPrompt } = req.body || {}; const fields = ['updated_at = ?']; const values = [nowIso()]; const trimmedEmail = email?.trim().toLowerCase(); const trimmedUsername = username?.trim(); if (trimmedEmail) { const current = db.prepare('SELECT email FROM users WHERE id = ?').get(req.user.id); if (!isValidEmail(trimmedEmail)) { return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' }); } if (trimmedEmail !== current?.email) { return res.status(400).json({ error: '인증된 이메일 주소는 현재 직접 변경할 수 없습니다.', }); } } if (trimmedUsername) { if (!isValidUsername(trimmedUsername)) { return res.status(400).json({ error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.', }); } const duplicate = db.prepare(` SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ? `).get(trimmedUsername, req.user.id); if (duplicate) { return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' }); } fields.push('username = ?', 'display_name = ?'); values.push(trimmedUsername, trimmedUsername); } if (avatarImage) { const saved = saveAvatarImage(req.user.id, avatarImage); if (!saved) { return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' }); } const avatarUrl = getAvatarUrl(req.user.id); fields.push('avatar_url = ?'); values.push(avatarUrl); } else if (removeAvatar === true) { deleteAvatarImage(req.user.id); fields.push('avatar_url = ?'); values.push(null); } if (typeof defaultDeckPublic === 'boolean') { fields.push('default_deck_public = ?'); values.push(defaultDeckPublic ? 1 : 0); } if (typeof skipDeckPublicPrompt === 'boolean') { fields.push('skip_deck_public_prompt = ?'); values.push(skipDeckPublicPrompt ? 1 : 0); } try { values.push(req.user.id); db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values); } catch (err) { if (String(err.message).includes('UNIQUE')) { return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' }); } throw err; } const user = db.prepare(` SELECT id, email, username, avatar_url AS avatarUrl, role, default_deck_public AS defaultDeckPublic, skip_deck_public_prompt AS skipDeckPublicPrompt, created_at AS createdAt, updated_at AS updatedAt FROM users WHERE id = ? `).get(req.user.id); res.json({ user: toUserResponse(user) }); }); router.post('/me/password', requireAuth, (req, res) => { const { currentPassword, newPassword } = req.body || {}; if (!currentPassword || !newPassword) { return res.status(400).json({ error: '현재 비밀번호와 새 비밀번호를 입력하세요.' }); } if (newPassword.length < 8) { return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' }); } const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); if (!bcrypt.compareSync(currentPassword, row.password_hash)) { return res.status(401).json({ error: '현재 비밀번호가 올바르지 않습니다.' }); } const hash = bcrypt.hashSync(newPassword, 12); db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run( hash, nowIso(), req.user.id, ); res.json({ ok: true, message: '비밀번호가 변경되었습니다.' }); }); router.delete('/me', requireAuth, (req, res) => { const confirmation = String(req.body?.confirmation || ''); if (confirmation !== 'DELETE') { return res.status(400).json({ error: '탈퇴 확인 입력란에 DELETE를 정확히 입력해 주세요.', code: 'ACCOUNT_DELETE_CONFIRMATION_REQUIRED', }); } const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.user.id); if (!user) { return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' }); } if (user.role === 'admin') { return res.status(400).json({ error: '관리자 계정은 다른 관리자가 회원 관리 화면에서 탈퇴 처리해야 합니다.', }); } const result = deleteUserAccount(db, user.id); res.json({ ok: true, ...result, message: '회원 탈퇴가 완료되었습니다.', }); }); return router; }