릴리스: v1.4.45 이메일 인증 및 비밀번호 재설정 추가

This commit is contained in:
2026-04-03 11:35:10 +09:00
parent 050ad04bc8
commit 2a39ee03e5
14 changed files with 827 additions and 52 deletions

View File

@@ -59,6 +59,7 @@ function mapUserRow(row) {
id: row.id,
email: row.email,
nickname: row.nickname || '',
emailVerified: row.email_verified == null ? true : !!row.email_verified,
isAdmin: !!row.is_admin,
avatarSrc: row.avatar_src || '',
createdAt: Number(row.created_at),
@@ -275,12 +276,47 @@ async function ensureSchema() {
email VARCHAR(255) NOT NULL UNIQUE,
nickname VARCHAR(80) NOT NULL DEFAULT '',
password_hash VARCHAR(255) NOT NULL,
email_verified TINYINT(1) NOT NULL DEFAULT 1,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
avatar_src VARCHAR(255) NOT NULL DEFAULT '',
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const userEmailVerifiedColumns = await query("SHOW COLUMNS FROM users LIKE 'email_verified'")
if (!userEmailVerifiedColumns.length) {
await query('ALTER TABLE users ADD COLUMN email_verified TINYINT(1) NOT NULL DEFAULT 1 AFTER password_hash')
await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL')
}
await query(`
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at BIGINT NOT NULL,
consumed_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
INDEX idx_email_verification_user (user_id, consumed_at, expires_at),
INDEX idx_email_verification_expires (expires_at),
CONSTRAINT fk_email_verification_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE,
expires_at BIGINT NOT NULL,
consumed_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
INDEX idx_password_reset_user (user_id, consumed_at, expires_at),
INDEX idx_password_reset_expires (expires_at),
CONSTRAINT fk_password_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS topics (
id VARCHAR(120) PRIMARY KEY,
@@ -567,7 +603,7 @@ async function countUsers() {
async function findUserByEmail(email) {
const rows = await query(
'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
[email]
)
const row = rows[0]
@@ -581,7 +617,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
const rows = excludeUserId
? await query(
`
SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
LIMIT 1
@@ -590,7 +626,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
)
: await query(
`
SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
LIMIT 1
@@ -604,24 +640,138 @@ async function findUserByNickname(nickname, excludeUserId = '') {
async function findUserById(id) {
const rows = await query(
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
[id]
)
return mapUserRow(rows[0])
}
async function createUser({ id, email, nickname, passwordHash, isAdmin }) {
async function createUser({ id, email, nickname, passwordHash, emailVerified = true, isAdmin }) {
const createdAt = now()
await query(
`
INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, email, nickname || '', passwordHash, isAdmin ? 1 : 0, '', createdAt]
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', createdAt]
)
return findUserById(id)
}
async function updateUserPassword({ id, passwordHash }) {
await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
return findUserById(id)
}
async function verifyUserEmail(userId) {
await query('UPDATE users SET email_verified = 1 WHERE id = ?', [userId])
return findUserById(userId)
}
async function createEmailVerificationToken({ id, userId, tokenHash, expiresAt }) {
const createdAt = now()
await query('UPDATE email_verification_tokens SET consumed_at = ? WHERE user_id = ? AND consumed_at = 0', [createdAt, userId])
await query(
`
INSERT INTO email_verification_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`,
[id, userId, tokenHash, expiresAt, 0, createdAt]
)
return {
id,
userId,
tokenHash,
expiresAt,
consumedAt: 0,
createdAt,
}
}
async function findEmailVerificationTokenByHash(tokenHash) {
const rows = await query(
`
SELECT
id,
user_id,
token_hash,
expires_at,
consumed_at,
created_at
FROM email_verification_tokens
WHERE token_hash = ?
LIMIT 1
`,
[tokenHash]
)
const row = rows[0]
if (!row) return null
return {
id: row.id,
userId: row.user_id,
tokenHash: row.token_hash,
expiresAt: Number(row.expires_at || 0),
consumedAt: Number(row.consumed_at || 0),
createdAt: Number(row.created_at || 0),
}
}
async function consumeEmailVerificationToken(tokenId) {
await query('UPDATE email_verification_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId])
}
async function createPasswordResetToken({ id, userId, tokenHash, expiresAt }) {
const createdAt = now()
await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE user_id = ? AND consumed_at = 0', [createdAt, userId])
await query(
`
INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`,
[id, userId, tokenHash, expiresAt, 0, createdAt]
)
return {
id,
userId,
tokenHash,
expiresAt,
consumedAt: 0,
createdAt,
}
}
async function findPasswordResetTokenByHash(tokenHash) {
const rows = await query(
`
SELECT
id,
user_id,
token_hash,
expires_at,
consumed_at,
created_at
FROM password_reset_tokens
WHERE token_hash = ?
LIMIT 1
`,
[tokenHash]
)
const row = rows[0]
if (!row) return null
return {
id: row.id,
userId: row.user_id,
tokenHash: row.token_hash,
expiresAt: Number(row.expires_at || 0),
consumedAt: Number(row.consumed_at || 0),
createdAt: Number(row.created_at || 0),
}
}
async function consumePasswordResetToken(tokenId) {
await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId])
}
async function updateUserProfile({ id, nickname, avatarSrc }) {
if (typeof avatarSrc === 'string') {
await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id])
@@ -633,7 +783,7 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
async function findPrimaryAdminUser() {
const rows = await query(
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
)
return mapUserRow(rows[0])
}
@@ -668,6 +818,7 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
u.id,
u.email,
u.nickname,
u.email_verified,
u.is_admin,
u.avatar_src,
u.created_at,
@@ -679,7 +830,7 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
FROM users u
LEFT JOIN tierlists t ON t.author_id = u.id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.created_at
ORDER BY ${orderBy}
`,
params
@@ -2501,6 +2652,14 @@ module.exports = {
findUserByNickname,
findUserById,
createUser,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,
findEmailVerificationTokenByHash,
consumeEmailVerificationToken,
createPasswordResetToken,
findPasswordResetTokenByHash,
consumePasswordResetToken,
updateUserProfile,
findPrimaryAdminUser,
listUsers,

113
backend/src/lib/mailer.js Normal file
View File

@@ -0,0 +1,113 @@
const nodemailer = require('nodemailer')
const SMTP_HOST = process.env.SMTP_HOST || 'smtp.gmail.com'
const SMTP_PORT = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 465
const SMTP_SECURE = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : SMTP_PORT === 465
const SMTP_USER = process.env.SMTP_USER || ''
const SMTP_PASS = process.env.SMTP_PASS || ''
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER
let transporterPromise = null
function isMailerConfigured() {
return !!SMTP_USER && !!SMTP_PASS && !!SMTP_FROM
}
async function getTransporter() {
if (!isMailerConfigured()) {
const error = new Error('mail_not_configured')
error.code = 'mail_not_configured'
throw error
}
if (!transporterPromise) {
transporterPromise = (async () => {
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
auth: {
user: SMTP_USER,
pass: SMTP_PASS,
},
})
await transporter.verify()
return transporter
})()
}
return transporterPromise
}
async function sendMail({ to, subject, text, html }) {
const transporter = await getTransporter()
await transporter.sendMail({
from: SMTP_FROM,
to,
subject,
text,
html,
})
}
async function sendEmailVerificationMail({ to, nickname, verificationUrl }) {
const displayName = nickname || to.split('@')[0] || '사용자'
await sendMail({
to,
subject: '[Tier Maker] 이메일 인증을 완료해주세요',
text: [
`${displayName}님, Tier Maker 가입을 완료하려면 아래 링크로 이메일 인증을 진행해주세요.`,
'',
verificationUrl,
'',
'이 링크는 24시간 동안 유효합니다.',
'직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.',
].join('\n'),
html: `
<div style="font-family:Arial,sans-serif;line-height:1.7;color:#111827">
<h1 style="font-size:20px;margin:0 0 16px">Tier Maker 이메일 인증</h1>
<p style="margin:0 0 16px">${displayName}님, Tier Maker 가입을 완료하려면 아래 버튼으로 이메일 인증을 진행해주세요.</p>
<p style="margin:0 0 20px">
<a href="${verificationUrl}" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#4c85f5;color:#ffffff;text-decoration:none;font-weight:700">이메일 인증하기</a>
</p>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280">버튼이 열리지 않으면 아래 주소를 브라우저에 직접 붙여넣어주세요.</p>
<p style="margin:0 0 20px;font-size:13px;word-break:break-all;color:#2563eb">${verificationUrl}</p>
<p style="margin:0;font-size:13px;color:#6b7280">이 링크는 24시간 동안 유효합니다. 직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.</p>
</div>
`,
})
}
async function sendPasswordResetMail({ to, nickname, resetUrl }) {
const displayName = nickname || to.split('@')[0] || '사용자'
await sendMail({
to,
subject: '[Tier Maker] 비밀번호 재설정 안내',
text: [
`${displayName}님, Tier Maker 비밀번호를 다시 설정하려면 아래 링크를 열어주세요.`,
'',
resetUrl,
'',
'이 링크는 1시간 동안 유효합니다.',
'직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.',
].join('\n'),
html: `
<div style="font-family:Arial,sans-serif;line-height:1.7;color:#111827">
<h1 style="font-size:20px;margin:0 0 16px">Tier Maker 비밀번호 재설정</h1>
<p style="margin:0 0 16px">${displayName}님, 비밀번호를 다시 설정하려면 아래 버튼을 눌러주세요.</p>
<p style="margin:0 0 20px">
<a href="${resetUrl}" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#4c85f5;color:#ffffff;text-decoration:none;font-weight:700">비밀번호 재설정</a>
</p>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280">버튼이 열리지 않으면 아래 주소를 브라우저에 직접 붙여넣어주세요.</p>
<p style="margin:0 0 20px;font-size:13px;word-break:break-all;color:#2563eb">${resetUrl}</p>
<p style="margin:0;font-size:13px;color:#6b7280">이 링크는 1시간 동안 유효합니다. 직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.</p>
</div>
`,
})
}
module.exports = {
isMailerConfigured,
sendEmailVerificationMail,
sendPasswordResetMail,
}

View File

@@ -1,5 +1,6 @@
const express = require('express')
const bcrypt = require('bcryptjs')
const crypto = require('crypto')
const { z } = require('zod')
const { nanoid } = require('nanoid')
const multer = require('multer')
@@ -9,14 +10,25 @@ const {
findUserByNickname,
findUserById,
createUser,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,
findEmailVerificationTokenByHash,
consumeEmailVerificationToken,
createPasswordResetToken,
findPasswordResetTokenByHash,
consumePasswordResetToken,
updateUserProfile,
findPrimaryAdminUser,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
const { isMailerConfigured, sendEmailVerificationMail, sendPasswordResetMail } = require('../lib/mailer')
const { isReservedNickname } = require('../lib/user-validation')
const router = express.Router()
const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000
const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000
const signupSchema = z.object({
email: z.string().email(),
@@ -24,6 +36,23 @@ const signupSchema = z.object({
password: z.string().min(6),
})
const verifyEmailSchema = z.object({
token: z.string().min(16).max(256),
})
const resendVerificationSchema = z.object({
email: z.string().email(),
})
const requestPasswordResetSchema = z.object({
email: z.string().email(),
})
const confirmPasswordResetSchema = z.object({
token: z.string().min(16).max(256),
password: z.string().min(6),
})
const profileSchema = z.object({
nickname: z.string().trim().min(1).max(40),
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
@@ -57,10 +86,77 @@ async function serializeUser(user) {
isOperator: !!user.isAdmin && !isPrimaryAdmin,
role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user',
avatarSrc: user.avatarSrc || '',
emailVerified: user.emailVerified !== false,
createdAt: user.createdAt,
}
}
function createRawToken() {
return crypto.randomBytes(32).toString('hex')
}
function hashToken(token) {
return crypto.createHash('sha256').update(String(token || '')).digest('hex')
}
function resolveAppOrigin(req) {
const envOrigin = String(process.env.APP_ORIGIN || process.env.PUBLIC_APP_ORIGIN || '').trim()
if (envOrigin) return envOrigin.replace(/\/+$/, '')
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim()
const protocol = forwardedProto || req.protocol || 'http'
const host = req.get('host')
return host ? `${protocol}://${host}` : ''
}
async function issueEmailVerificationMail(req, user) {
if (!isMailerConfigured()) {
const error = new Error('mail_not_configured')
error.code = 'mail_not_configured'
throw error
}
const rawToken = createRawToken()
await createEmailVerificationToken({
id: nanoid(),
userId: user.id,
tokenHash: hashToken(rawToken),
expiresAt: Date.now() + EMAIL_VERIFICATION_TTL_MS,
})
const appOrigin = resolveAppOrigin(req)
const verificationUrl = `${appOrigin}/login?verifyToken=${encodeURIComponent(rawToken)}`
await sendEmailVerificationMail({
to: user.email,
nickname: user.nickname,
verificationUrl,
})
}
async function issuePasswordResetMail(req, user) {
if (!isMailerConfigured()) {
const error = new Error('mail_not_configured')
error.code = 'mail_not_configured'
throw error
}
const rawToken = createRawToken()
await createPasswordResetToken({
id: nanoid(),
userId: user.id,
tokenHash: hashToken(rawToken),
expiresAt: Date.now() + PASSWORD_RESET_TTL_MS,
})
const appOrigin = resolveAppOrigin(req)
const resetUrl = `${appOrigin}/login?resetToken=${encodeURIComponent(rawToken)}`
await sendPasswordResetMail({
to: user.email,
nickname: user.nickname,
resetUrl,
})
}
router.post('/signup', async (req, res) => {
const parsed = signupSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -74,11 +170,36 @@ router.post('/signup', async (req, res) => {
const passwordHash = await bcrypt.hash(password, 10)
const isAdmin = (await countUsers()) === 0
const user = await createUser({ id: nanoid(), email, nickname, passwordHash, isAdmin })
if (!isAdmin && !isMailerConfigured()) {
return res.status(503).json({ error: 'mail_not_configured' })
}
const user = await createUser({
id: nanoid(),
email,
nickname,
passwordHash,
emailVerified: isAdmin,
isAdmin,
})
if (!isAdmin) {
try {
await issueEmailVerificationMail(req, user)
return res.json({
user: null,
verificationRequired: true,
email: user.email,
})
} catch (err) {
const statusCode = err?.code === 'mail_not_configured' ? 503 : 502
return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' })
}
}
try {
await establishSession(req, user)
res.json(await serializeUser(user))
res.json({ user: await serializeUser(user), verificationRequired: false })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -97,10 +218,11 @@ router.post('/login', async (req, res) => {
const ok = await bcrypt.compare(password, user.passwordHash)
if (!ok) return res.status(401).json({ error: 'invalid_credentials' })
if (!user.emailVerified) return res.status(403).json({ error: 'email_unverified', email: user.email })
try {
await establishSession(req, user)
res.json(await serializeUser(user))
res.json({ user: await serializeUser(user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -122,6 +244,84 @@ router.get('/meta', async (req, res) => {
res.json({ hasUsers: (await countUsers()) > 0 })
})
router.post('/email/verify', async (req, res) => {
const parsed = verifyEmailSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tokenRow = await findEmailVerificationTokenByHash(hashToken(parsed.data.token))
if (!tokenRow || tokenRow.consumedAt || tokenRow.expiresAt < Date.now()) {
return res.status(400).json({ error: 'invalid_or_expired_token' })
}
const user = await verifyUserEmail(tokenRow.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
await consumeEmailVerificationToken(tokenRow.id)
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/email/resend', async (req, res) => {
const parsed = resendVerificationSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserByEmail(parsed.data.email)
if (!user || user.emailVerified) return res.json({ ok: true })
try {
await issueEmailVerificationMail(req, user)
res.json({ ok: true })
} catch (err) {
const statusCode = err?.code === 'mail_not_configured' ? 503 : 502
return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' })
}
})
router.post('/password-reset/request', async (req, res) => {
const parsed = requestPasswordResetSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserByEmail(parsed.data.email)
if (!user) return res.json({ ok: true })
try {
await issuePasswordResetMail(req, user)
res.json({ ok: true })
} catch (err) {
const statusCode = err?.code === 'mail_not_configured' ? 503 : 502
return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' })
}
})
router.post('/password-reset/confirm', async (req, res) => {
const parsed = confirmPasswordResetSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tokenRow = await findPasswordResetTokenByHash(hashToken(parsed.data.token))
if (!tokenRow || tokenRow.consumedAt || tokenRow.expiresAt < Date.now()) {
return res.status(400).json({ error: 'invalid_or_expired_token' })
}
const passwordHash = await bcrypt.hash(parsed.data.password, 10)
const updatedUser = await updateUserPassword({ id: tokenRow.userId, passwordHash })
if (!updatedUser) return res.status(404).json({ error: 'not_found' })
const verifiedUser = updatedUser.emailVerified ? updatedUser : await verifyUserEmail(updatedUser.id)
await consumePasswordResetToken(tokenRow.id)
try {
await establishSession(req, verifiedUser)
res.json({ user: await serializeUser(verifiedUser) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => {