이메일 인증 재발송과 관리자 승인 개선

This commit is contained in:
2026-06-25 16:51:29 +09:00
parent 5fbd58bd07
commit a5b7d720d6
26 changed files with 688 additions and 91 deletions

View File

@@ -15,6 +15,7 @@ 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';
/**
* 한국 시간 기준 월 키
@@ -225,7 +226,10 @@ export function createAuthRouter(db) {
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 });
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({
@@ -276,14 +280,20 @@ export function createAuthRouter(db) {
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분 후 다시 요청할 수 있습니다.' });
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 });
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 });
@@ -298,7 +308,10 @@ export function createAuthRouter(db) {
router.post('/verify-email', (req, res) => {
const token = String(req.body?.token || '');
if (!token) {
return res.status(400).json({ error: '인증 토큰이 없습니다.' });
return res.status(400).json({
error: '인증 토큰이 없습니다.',
code: 'VERIFICATION_TOKEN_MISSING',
});
}
const tokenHash = hashToken(token);
const row = db.prepare(`
@@ -309,7 +322,10 @@ export function createAuthRouter(db) {
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: '인증 링크가 유효하지 않거나 만료되었습니다.' });
return res.status(400).json({
error: '인증 링크가 유효하지 않거나 만료되었습니다.',
code: 'VERIFICATION_TOKEN_INVALID',
});
}
const transaction = db.transaction(() => {
@@ -394,7 +410,10 @@ export function createAuthRouter(db) {
}
emailLogId = reserveEmailSend(db, user.id, 'reset_password');
if (!emailLogId) {
return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE });
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 });