이메일 인증 재발송과 관리자 승인 개선
This commit is contained in:
@@ -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)'}`);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user