인증 요청 안정화와 월간 메일 한도 적용
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.12",
|
||||
"version": "4.0.14",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)'}`);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user