인증 요청 안정화와 월간 메일 한도 적용

This commit is contained in:
2026-06-25 14:05:23 +09:00
parent 35a2976182
commit 5fbd58bd07
20 changed files with 350 additions and 30 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "uadeck-api",
"version": "4.0.12",
"version": "4.0.14",
"type": "module",
"main": "src/index.js",
"scripts": {

View File

@@ -78,6 +78,20 @@ export function initDb(dbPath) {
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_type
ON auth_tokens(user_id, type);
CREATE TABLE IF NOT EXISTS email_send_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
month_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'reserved',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
sent_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_email_send_logs_user_month
ON email_send_logs(user_id, month_key, status);
`);
const deckCols = db.prepare('PRAGMA table_info(decks)').all();

View File

@@ -29,7 +29,7 @@ app.use(cors({ origin: corsOrigins, credentials: true }));
app.use(express.json({ limit: '20mb' }));
app.get('/api/v1/health', (_req, res) => {
res.json({ ok: true, version: '4.0.12' });
res.json({ ok: true, version: '4.0.14' });
});
app.use('/api/v1/auth', createAuthRouter(db));
@@ -47,7 +47,7 @@ app.use((err, _req, res, _next) => {
});
app.listen(PORT, () => {
console.log(`UNIARE API v4.0.12 listening on :${PORT}`);
console.log(`UNIARE API v4.0.14 listening on :${PORT}`);
console.log(`DB: ${DB_PATH}`);
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
});

View File

@@ -12,6 +12,77 @@ 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로 직접 문의해 주세요.';
/**
* 한국 시간 기준 월 키
* @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 저장용 해시 생성
@@ -143,6 +214,7 @@ export function createAuthRouter(db) {
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)
@@ -150,18 +222,25 @@ export function createAuthRouter(db) {
`).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 });
}
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);
}
@@ -200,11 +279,18 @@ export function createAuthRouter(db) {
return res.status(429).json({ error: '인증 메일은 1분 후 다시 요청할 수 있습니다.' });
}
let emailLogId;
try {
emailLogId = reserveEmailSend(db, user.id, 'verify_email');
if (!emailLogId) {
return res.status(429).json({ error: EMAIL_LIMIT_MESSAGE });
}
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);
}
});
@@ -294,6 +380,7 @@ export function createAuthRouter(db) {
return res.json(genericResponse);
}
let emailLogId;
try {
const recent = db.prepare(`
SELECT created_at
@@ -305,10 +392,16 @@ export function createAuthRouter(db) {
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 });
}
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);
}
});

View File

@@ -66,7 +66,7 @@ async function sendEmail(message) {
* @returns {Promise<object>}
*/
export function sendVerificationEmail({ email, username, token }) {
const url = `${getAppBaseUrl()}/verify-email.html?token=${encodeURIComponent(token)}`;
const url = `${getAppBaseUrl()}/verify-email.html?v=4.0.14&token=${encodeURIComponent(token)}`;
const safeName = escapeHtml(username);
const safeUrl = escapeHtml(url);
return sendEmail({
@@ -91,7 +91,7 @@ export function sendVerificationEmail({ email, username, token }) {
* @returns {Promise<object>}
*/
export function sendPasswordResetEmail({ email, username, token }) {
const url = `${getAppBaseUrl()}/reset-password.html?token=${encodeURIComponent(token)}`;
const url = `${getAppBaseUrl()}/reset-password.html?v=4.0.14&token=${encodeURIComponent(token)}`;
const safeName = escapeHtml(username);
const safeUrl = escapeHtml(url);
return sendEmail({