릴리스: v1.4.45 이메일 인증 및 비밀번호 재설정 추가
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user