Files
tier-maker/backend/src/routes/auth.js
2026-04-07 15:10:43 +09:00

437 lines
15 KiB
JavaScript

const express = require('express')
const bcrypt = require('bcryptjs')
const crypto = require('crypto')
const { z } = require('zod')
const { nanoid } = require('nanoid')
const multer = require('multer')
const {
countUsers,
findUserByEmail,
findUserByNickname,
findUserById,
createUser,
touchUserLastLoginAt,
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
function resolveNicknameChangeIntervalMs() {
const rawMs = String(process.env.NICKNAME_CHANGE_INTERVAL_MS || '').trim()
if (rawMs) {
const parsed = Number(rawMs)
if (Number.isFinite(parsed) && parsed >= 0) return parsed
}
const rawDays = String(process.env.NICKNAME_CHANGE_INTERVAL_DAYS || '').trim()
if (rawDays) {
const parsed = Number(rawDays)
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 24 * 60 * 60 * 1000
}
return 14 * 24 * 60 * 60 * 1000
}
function formatNicknameChangeIntervalLabel(intervalMs) {
if (!intervalMs || intervalMs <= 0) return '제한 없음'
const oneDayMs = 24 * 60 * 60 * 1000
const wholeDays = intervalMs / oneDayMs
if (Number.isInteger(wholeDays) && wholeDays >= 7 && wholeDays % 7 === 0) {
return `${wholeDays / 7}`
}
if (Number.isInteger(wholeDays)) {
return `${wholeDays}`
}
return `${Math.ceil(wholeDays)}`
}
const signupSchema = z.object({
email: z.string().email(),
nickname: z.string().trim().min(2).max(40),
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 changePasswordSchema = z.object({
currentPassword: z.string().min(6),
nextPassword: z.string().min(6),
})
const profileSchema = z.object({
nickname: z.string().trim().min(2).max(40),
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
})
function establishSession(req, user) {
return new Promise((resolve, reject) => {
req.session.regenerate((regenerateError) => {
if (regenerateError) return reject(regenerateError)
req.session.userId = user.id
req.session.isAdmin = !!user.isAdmin
req.session.save((saveError) => {
if (saveError) return reject(saveError)
resolve()
})
})
})
}
async function serializeUser(user) {
if (!user) return null
const primaryAdmin = await findPrimaryAdminUser()
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
const nicknameChangeIntervalMs = resolveNicknameChangeIntervalMs()
const nicknameChangeAvailableAt = nicknameChangeIntervalMs > 0 ? (user.nicknameUpdatedAt || 0) + nicknameChangeIntervalMs : 0
return {
id: user.id,
email: user.email,
nickname: user.nickname || '',
nicknameUpdatedAt: user.nicknameUpdatedAt || 0,
nicknameChangeAvailableAt,
nicknameChangeIntervalMs,
nicknameChangeIntervalLabel: formatNicknameChangeIntervalLabel(nicknameChangeIntervalMs),
isAdmin: !!user.isAdmin,
isPrimaryAdmin,
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' })
const { email, nickname, password } = parsed.data
const exists = await findUserByEmail(email)
if (exists) return res.status(409).json({ error: 'email_taken' })
if (isReservedNickname(nickname)) return res.status(400).json({ error: 'nickname_reserved' })
const nicknameExists = await findUserByNickname(nickname)
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
const passwordHash = await bcrypt.hash(password, 10)
const isAdmin = (await countUsers()) === 0
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)
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user), verificationRequired: false })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/login', async (req, res) => {
const parsed = z.object({
email: z.string().email(),
password: z.string().min(6),
}).safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const { email, password } = parsed.data
const user = await findUserByEmail(email)
if (!user) return res.status(401).json({ error: 'invalid_credentials' })
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)
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/logout', async (req, res) => {
if (!req.session) return res.json({ ok: true })
req.session.destroy(() => res.json({ ok: true }))
})
router.get('/me', async (req, res) => {
if (!req.session || !req.session.userId) return res.json({ user: null })
const user = await touchUserLastLoginAt(req.session.userId)
if (!user) return res.json({ user: null })
res.json({ user: await serializeUser(user) })
})
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)
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || 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)
const touchedUser = await touchUserLastLoginAt(verifiedUser.id)
res.json({ user: await serializeUser(touchedUser || verifiedUser) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
})
router.post('/password', requireAuth, async (req, res) => {
const parsed = changePasswordSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const authUser = await findUserByEmail(user.email)
if (!authUser) return res.status(404).json({ error: 'not_found' })
const passwordMatched = await bcrypt.compare(parsed.data.currentPassword, authUser.passwordHash)
if (!passwordMatched) return res.status(401).json({ error: 'invalid_current_password' })
const passwordHash = await bcrypt.hash(parsed.data.nextPassword, 10)
const updated = await updateUserPassword({ id: authUser.id, passwordHash })
res.json({ user: await serializeUser(updated) })
})
const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => {
const parsed = profileSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const normalizedNickname = parsed.data.nickname.trim()
const nicknameChanged = normalizedNickname !== (user.nickname || '').trim()
const nicknameChangeIntervalMs = resolveNicknameChangeIntervalMs()
if (isReservedNickname(normalizedNickname)) return res.status(400).json({ error: 'nickname_reserved' })
if (nicknameChanged && nicknameChangeIntervalMs > 0 && user.nicknameUpdatedAt && Date.now() < user.nicknameUpdatedAt + nicknameChangeIntervalMs) {
return res.status(429).json({
error: 'nickname_change_locked',
nicknameChangeAvailableAt: user.nicknameUpdatedAt + nicknameChangeIntervalMs,
nicknameChangeIntervalMs,
nicknameChangeIntervalLabel: formatNicknameChangeIntervalLabel(nicknameChangeIntervalMs),
})
}
const nicknameExists = await findUserByNickname(normalizedNickname, user.id)
if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' })
const optimized = req.file
? await writeOptimizedImage({
file: req.file,
directory: 'avatars',
width: 512,
height: 512,
fit: 'cover',
quality: 82,
})
: null
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || ''
const updated = await updateUserProfile({
id: user.id,
nickname: normalizedNickname,
avatarSrc: nextAvatarSrc,
touchNicknameUpdatedAt: nicknameChanged,
})
res.json({ user: await serializeUser(updated) })
})
module.exports = router