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

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

@@ -1,11 +1,12 @@
{
"name": "uadeck-api",
"version": "4.0.14",
"version": "4.0.15",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
"dev": "node --watch src/index.js",
"test": "node --test test/*.test.js"
},
"dependencies": {
"bcryptjs": "^3.0.2",

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.14' });
res.json({ ok: true, version: '4.0.15' });
});
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.14 listening on :${PORT}`);
console.log(`UNIARE API v4.0.15 listening on :${PORT}`);
console.log(`DB: ${DB_PATH}`);
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
});

View File

@@ -69,7 +69,8 @@ export function createAdminRouter(db) {
`).get(like, like, like).c;
rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, last_login_at AS lastLoginAt,
role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
@@ -79,7 +80,8 @@ export function createAdminRouter(db) {
total = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, last_login_at AS lastLoginAt,
role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(Number(limit), offset);
@@ -102,7 +104,8 @@ export function createAdminRouter(db) {
router.get('/users/:id', (req, res) => {
const user = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, last_login_at AS lastLoginAt,
role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(req.params.id);
@@ -210,7 +213,8 @@ export function createAdminRouter(db) {
const updated = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, last_login_at AS lastLoginAt,
role, is_active AS isActive, email_verified_at AS emailVerifiedAt,
last_login_at AS lastLoginAt,
created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(targetId);
@@ -219,6 +223,38 @@ export function createAdminRouter(db) {
res.json({ user: updated });
});
router.post('/users/:id/verify-email', (req, res) => {
const targetId = Number(req.params.id);
const user = db.prepare(`
SELECT id, email_verified_at AS emailVerifiedAt
FROM users WHERE id = ?
`).get(targetId);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const verifiedAt = user.emailVerifiedAt || nowIso();
const transaction = db.transaction(() => {
db.prepare(`
UPDATE users
SET email_verified_at = ?, updated_at = ?
WHERE id = ?
`).run(verifiedAt, nowIso(), targetId);
db.prepare(`
UPDATE auth_tokens
SET used_at = ?
WHERE user_id = ? AND type = 'verify_email' AND used_at IS NULL
`).run(nowIso(), targetId);
});
transaction();
res.json({
ok: true,
emailVerifiedAt: verifiedAt,
message: '이메일 인증을 승인했습니다.',
});
});
router.post('/users/:id/reset-password', (req, res) => {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) {

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 });

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?v=4.0.14&token=${encodeURIComponent(token)}`;
const url = `${getAppBaseUrl()}/verify-email.html?v=4.0.15&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?v=4.0.14&token=${encodeURIComponent(token)}`;
const url = `${getAppBaseUrl()}/reset-password.html?v=4.0.15&token=${encodeURIComponent(token)}`;
const safeName = escapeHtml(username);
const safeUrl = escapeHtml(url);
return sendEmail({

View File

@@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import bcrypt from 'bcryptjs';
import express from 'express';
import { initDb } from '../src/db.js';
import { createAdminRouter } from '../src/routes/admin.js';
import { createAuthRouter } from '../src/routes/auth.js';
test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정보가 연결되지 않는다', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-account-delete-'));
const dbPath = path.join(tempDir, 'test.db');
const db = initDb(dbPath);
try {
const oldUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run('old@example.com', 'test', 'old-password-hash', 'test').lastInsertRowid,
);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES ('OLDDECK1', 'user', ?, 'ua01', '테스트', '기존 덱', '[]', 1)
`).run(oldUserId);
db.prepare(`
INSERT INTO deck_favorites (user_id, share_code)
VALUES (?, 'OLDDECK1')
`).run(oldUserId);
db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'old-token', datetime('now', '+1 day'))
`).run(oldUserId);
db.prepare(`
INSERT INTO email_send_logs (user_id, type, month_key, status)
VALUES (?, 'verify_email', '2026-06', 'sent')
`).run(oldUserId);
db.prepare('DELETE FROM users WHERE id = ?').run(oldUserId);
const newUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run('new@example.com', 'test', 'new-password-hash', 'test').lastInsertRowid,
);
const oldDeck = db.prepare(`
SELECT user_id AS userId FROM decks WHERE share_code = 'OLDDECK1'
`).get();
assert.ok(newUserId > oldUserId);
assert.equal(oldDeck.userId, null);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM deck_favorites WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM auth_tokens WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM email_send_logs WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count FROM decks
WHERE share_code = 'OLDDECK1' AND user_id = ?
`).get(newUserId).count,
0,
);
} finally {
db.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('관리자는 미인증 회원의 이메일 인증을 수동 승인할 수 있다', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-admin-verify-'));
const dbPath = path.join(tempDir, 'test.db');
const db = initDb(dbPath);
process.env.JWT_SECRET = 'test-secret-for-admin-email-verification';
const adminPassword = 'admin-password';
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, role, email_verified_at
)
VALUES (?, ?, ?, ?, 'admin', datetime('now'))
`).run(
'admin@example.com',
'admin',
bcrypt.hashSync(adminPassword, 4),
'admin',
);
const targetUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, NULL)
`).run(
'pending@example.com',
'pending',
bcrypt.hashSync('pending-password', 4),
'pending',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'pending-token', datetime('now', '+1 day'))
`).run(targetUserId);
const app = express();
app.use(express.json());
app.use('/api/v1/auth', createAuthRouter(db));
app.use('/api/v1/admin', createAdminRouter(db));
const server = app.listen(0);
try {
await new Promise((resolve) => server.once('listening', resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}/api/v1`;
const loginResponse = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'admin@example.com',
password: adminPassword,
}),
});
assert.equal(loginResponse.status, 200);
const { token } = await loginResponse.json();
const approveResponse = await fetch(
`${baseUrl}/admin/users/${targetUserId}/verify-email`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
},
);
assert.equal(approveResponse.status, 200);
const target = db.prepare(`
SELECT email_verified_at AS emailVerifiedAt
FROM users WHERE id = ?
`).get(targetUserId);
const pendingToken = db.prepare(`
SELECT used_at AS usedAt
FROM auth_tokens WHERE user_id = ? AND type = 'verify_email'
`).get(targetUserId);
assert.ok(target.emailVerifiedAt);
assert.ok(pendingToken.usedAt);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
db.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});