이메일 인증과 비밀번호 재설정 추가

This commit is contained in:
2026-06-25 12:02:44 +09:00
parent edf48e53a5
commit 35a2976182
26 changed files with 770 additions and 43 deletions

View File

@@ -1,9 +1,79 @@
import { Router } from 'express';
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';
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;
/**
* 인증용 원문 토큰과 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 응답 형식으로 변환
@@ -43,59 +113,158 @@ export function createAuthRouter(db) {
});
});
router.post('/register', (req, res) => {
router.post('/register', async (req, res, next) => {
const { email, username, password } = req.body || {};
if (!email || !username || !password) {
return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' });
}
if (password.length < 8) {
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자 이상이어야 합니다.' });
}
const hash = bcrypt.hashSync(password, 12);
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;
try {
const trimmedUsername = username.trim();
const result = db.prepare(`
INSERT INTO users (email, username, password_hash, display_name)
VALUES (?, ?, ?, ?)
`).run(email.trim().toLowerCase(), trimmedUsername, hash, trimmedUsername);
INSERT INTO users (email, username, password_hash, display_name, email_verified_at)
VALUES (?, ?, ?, ?, NULL)
`).run(normalizedEmail, trimmedUsername, hash, trimmedUsername);
userId = Number(result.lastInsertRowid);
const user = db.prepare(`
SELECT id, email, username, role, created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(result.lastInsertRowid);
const safeUser = toUserResponse(user);
const token = signToken(safeUser);
res.status(201).json({ token, user: safeUser });
const token = issueAuthToken(db, userId, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
await sendVerificationEmail({
email: normalizedEmail,
username: trimmedUsername,
token,
});
res.status(201).json({
ok: true,
email: normalizedEmail,
message: '인증 메일을 보냈습니다. 이메일 인증 후 로그인해 주세요.',
});
} catch (err) {
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: '이미 사용 중인 이메일 또는 사용자명입니다.' });
}
throw err;
next(err);
}
});
router.post('/resend-verification', 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분 후 다시 요청할 수 있습니다.' });
}
try {
const token = issueAuthToken(db, user.id, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
await sendVerificationEmail({ email: user.email, username: user.username, token });
res.json({ ok: true, message: '인증 메일을 다시 보냈습니다.' });
} catch (err) {
next(err);
}
});
router.post('/verify-email', (req, res) => {
const token = String(req.body?.token || '');
if (!token) {
return res.status(400).json({ error: '인증 토큰이 없습니다.' });
}
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: '인증 링크가 유효하지 않거나 만료되었습니다.' });
}
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', (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
SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active,
email_verified_at
FROM users WHERE email = ?
`).get(email.trim().toLowerCase());
`).get(normalizedEmail);
if (!user || !user.is_active) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
}
if (!bcrypt.compareSync(password, user.password_hash)) {
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(`
@@ -107,6 +276,80 @@ export function createAuthRouter(db) {
res.json({ token: signToken(safeUser), user: safeUser });
});
router.post('/forgot-password', 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);
}
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);
}
const token = issueAuthToken(db, user.id, 'reset_password', PASSWORD_RESET_TOKEN_TTL_MS);
await sendPasswordResetEmail({ email: user.email, username: user.username, token });
res.json(genericResponse);
} catch (err) {
next(err);
}
});
router.post('/reset-password', (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(),
@@ -139,10 +382,29 @@ export function createAuthRouter(db) {
const trimmedUsername = username?.trim();
if (trimmedEmail) {
fields.push('email = ?');
values.push(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);
}