헤더 검색 중앙 정렬·Resend 이메일 OTP·비밀번호 찾기 (v0.0.99)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
16
server/api/auth/bootstrap-status.get.js
vendored
16
server/api/auth/bootstrap-status.get.js
vendored
@@ -1,7 +1,17 @@
|
||||
import { getMemberBootstrapState } from '../../repositories/member-repository'
|
||||
import { isResendConfigured } from '../../utils/resend-mail'
|
||||
|
||||
/**
|
||||
* 최초 관리자 등록 필요 여부를 조회한다.
|
||||
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
|
||||
* 최초 관리자 등록 필요 여부·이메일 OTP(Resend) 사용 가능 여부를 조회한다.
|
||||
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean, emailOtpConfigured: boolean }>} 부트스트랩 상태
|
||||
*/
|
||||
export default defineEventHandler(async () => getMemberBootstrapState())
|
||||
export default defineEventHandler(async () => {
|
||||
const base = await getMemberBootstrapState()
|
||||
const config = useRuntimeConfig()
|
||||
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
|
||||
const emailOtpConfigured = isResendConfigured(config) && hasPepper
|
||||
return {
|
||||
...base,
|
||||
emailOtpConfigured
|
||||
}
|
||||
})
|
||||
|
||||
164
server/api/auth/email-otp/request.post.js
Normal file
164
server/api/auth/email-otp/request.post.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import { getPostgresClient } from '../../../repositories/postgres-client'
|
||||
import { getMemberBootstrapState, getUserByEmail } from '../../../repositories/member-repository'
|
||||
import {
|
||||
countOtpSendsLastHour,
|
||||
hasRecentOtpSend,
|
||||
insertOtpChallenge,
|
||||
invalidatePendingOtpChallenges
|
||||
} from '../../../repositories/email-otp-repository'
|
||||
import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp'
|
||||
import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail'
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
purpose: z.enum(['signup', 'password_reset'])
|
||||
})
|
||||
|
||||
const OTP_TTL_MS = 15 * 60 * 1000
|
||||
const MAX_SENDS_PER_HOUR = 5
|
||||
|
||||
/**
|
||||
* OTP 이메일 발송용 pepper
|
||||
* @param {import('nuxt/schema').RuntimeConfig} config - 런타임 설정
|
||||
* @returns {string}
|
||||
*/
|
||||
const resolveOtpPepper = (config) => {
|
||||
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
|
||||
if (!pepper) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '이메일 인증을 사용하려면 MEMBER_SESSION_SECRET 또는 EMAIL_OTP_PEPPER를 설정해 주세요.'
|
||||
})
|
||||
}
|
||||
return pepper
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증번호 이메일 발송 요청
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: boolean, message: string }>}
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (!isResendConfigured(config)) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
message: '이메일 발송(Resend)이 서버에 설정되지 않았습니다. RESEND_API_KEY와 RESEND_FROM_EMAIL을 확인해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(await readBody(event))
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const email = normalizeOtpEmail(parsed.data.email)
|
||||
const purpose = parsed.data.purpose
|
||||
const sql = getPostgresClient()
|
||||
if (!sql) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '데이터베이스 설정이 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const genericOk = {
|
||||
ok: true,
|
||||
message: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
|
||||
}
|
||||
|
||||
if (purpose === 'signup') {
|
||||
const bootstrap = await getMemberBootstrapState()
|
||||
if (bootstrap.needsAdminSetup) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '최초 관리자 등록 단계에서는 이메일 인증이 필요하지 않습니다.'
|
||||
})
|
||||
}
|
||||
const existing = await getUserByEmail(email)
|
||||
if (existing) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 이메일입니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (await hasRecentOtpSend(sql, email, purpose)) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: '잠시 후 다시 인증번호를 요청해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if ((await countOtpSendsLastHour(sql, email, purpose)) >= MAX_SENDS_PER_HOUR) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: '인증번호 요청 횟수가 너무 많습니다. 1시간 후 다시 시도해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if (purpose === 'password_reset') {
|
||||
const user = await getUserByEmail(email)
|
||||
if (!user) {
|
||||
const dummyHash = randomBytes(32).toString('hex')
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
|
||||
const createdIp = String(getRequestIP(event) || '')
|
||||
await invalidatePendingOtpChallenges(sql, email, purpose)
|
||||
await insertOtpChallenge(sql, {
|
||||
email,
|
||||
purpose,
|
||||
codeHash: dummyHash,
|
||||
expiresAt,
|
||||
createdIp
|
||||
})
|
||||
return genericOk
|
||||
}
|
||||
}
|
||||
|
||||
const pepper = resolveOtpPepper(config)
|
||||
const code = generateSixDigitOtp()
|
||||
const codeHash = hashOtpCode({ pepper, email, purpose, code })
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
|
||||
const createdIp = String(getRequestIP(event) || '')
|
||||
|
||||
await invalidatePendingOtpChallenges(sql, email, purpose)
|
||||
await insertOtpChallenge(sql, {
|
||||
email,
|
||||
purpose,
|
||||
codeHash,
|
||||
expiresAt,
|
||||
createdIp
|
||||
})
|
||||
|
||||
const siteTitle = String(config.public?.siteTitle || 'sori.studio')
|
||||
const subject = purpose === 'signup'
|
||||
? `[${siteTitle}] 회원가입 인증번호`
|
||||
: `[${siteTitle}] 비밀번호 재설정 인증번호`
|
||||
|
||||
const html = purpose === 'signup'
|
||||
? `<p>회원가입을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
|
||||
: `<p>비밀번호 재설정을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
|
||||
|
||||
await sendResendEmail({
|
||||
apiKey: String(config.resendApiKey).trim(),
|
||||
from: String(config.resendFromEmail).trim(),
|
||||
to: email,
|
||||
subject,
|
||||
html
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: purpose === 'signup'
|
||||
? '인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
|
||||
: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
|
||||
}
|
||||
})
|
||||
@@ -25,7 +25,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const user = await getUserByEmail(body.email)
|
||||
const emailNorm = body.email.trim().toLowerCase()
|
||||
const user = await getUserByEmail(emailNorm)
|
||||
|
||||
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
|
||||
throw createError({
|
||||
|
||||
65
server/api/auth/password-reset/confirm.post.js
Normal file
65
server/api/auth/password-reset/confirm.post.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { z } from 'zod'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { updateMemberPasswordByEmail } from '../../../repositories/member-repository'
|
||||
import { verifyAndConsumeEmailOtp } from '../../../repositories/email-otp-repository'
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
code: z.string().regex(/^\d{6}$/),
|
||||
newPassword: z.string().min(8).max(32)
|
||||
})
|
||||
|
||||
/**
|
||||
* 이메일 OTP로 비밀번호를 재설정한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: boolean }>}
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsed = bodySchema.safeParse(await readBody(event))
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
|
||||
if (!pepper) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '서버 인증 설정이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const email = parsed.data.email.trim().toLowerCase()
|
||||
const verify = await verifyAndConsumeEmailOtp({
|
||||
email,
|
||||
purpose: 'password_reset',
|
||||
code: parsed.data.code,
|
||||
pepper
|
||||
})
|
||||
|
||||
if (!verify.ok) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '인증번호가 올바르지 않거나 만료되었습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const nextHash = await bcrypt.hash(parsed.data.newPassword, 12)
|
||||
const updated = await updateMemberPasswordByEmail({
|
||||
email,
|
||||
passwordHash: nextHash
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '계정을 찾을 수 없습니다. 다시 인증번호를 요청해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -1,16 +1,33 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { z } from 'zod'
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import { createUser, getUserByEmail, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { createUser, getUserByEmail, getMemberBootstrapState, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { verifyAndConsumeEmailOtp } from '../../repositories/email-otp-repository'
|
||||
import { setMemberSession } from '../../utils/member-auth'
|
||||
import { setAdminSession } from '../../utils/admin-auth'
|
||||
import { isResendConfigured } from '../../utils/resend-mail'
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().trim().min(1),
|
||||
email: z.string().trim().email(),
|
||||
password: z.string().min(8).max(32)
|
||||
password: z.string().min(8).max(32),
|
||||
emailOtp: z.string().regex(/^\d{6}$/).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* 이메일 OTP가 회원가입에 필요한지
|
||||
* @param {import('nuxt/schema').RuntimeConfig} config - 런타임 설정
|
||||
* @param {{ needsAdminSetup: boolean }} bootstrap - 부트스트랩 상태
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isSignupOtpRequired = (config, bootstrap) => {
|
||||
if (bootstrap.needsAdminSetup) {
|
||||
return false
|
||||
}
|
||||
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
|
||||
return isResendConfigured(config) && hasPepper
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 가입 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
@@ -27,6 +44,11 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const config = useRuntimeConfig()
|
||||
const bootstrap = await getMemberBootstrapState()
|
||||
const otpRequired = isSignupOtpRequired(config, bootstrap)
|
||||
const emailNorm = body.email.trim().toLowerCase()
|
||||
|
||||
const usernameTaken = await isUsernameTaken({
|
||||
username: body.username
|
||||
})
|
||||
@@ -38,7 +60,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const existingUser = await getUserByEmail(body.email)
|
||||
const existingUser = await getUserByEmail(emailNorm)
|
||||
|
||||
if (existingUser) {
|
||||
throw createError({
|
||||
@@ -47,10 +69,33 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (otpRequired) {
|
||||
const otp = body.emailOtp
|
||||
if (!otp) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이메일 인증번호를 입력해 주세요.'
|
||||
})
|
||||
}
|
||||
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
|
||||
const verify = await verifyAndConsumeEmailOtp({
|
||||
email: emailNorm,
|
||||
purpose: 'signup',
|
||||
code: otp,
|
||||
pepper
|
||||
})
|
||||
if (!verify.ok) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이메일 인증번호가 올바르지 않거나 만료되었습니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||
const created = await createUser({
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
username: body.username.trim(),
|
||||
email: emailNorm,
|
||||
passwordHash
|
||||
})
|
||||
|
||||
@@ -74,4 +119,3 @@ export default defineEventHandler(async (event) => {
|
||||
isAdmin: Boolean(created.isAdmin)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
164
server/repositories/email-otp-repository.js
Normal file
164
server/repositories/email-otp-repository.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import { createError } from 'h3'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
import { hashOtpCode, normalizeOtpEmail, timingSafeEqualHex } from '../utils/email-otp'
|
||||
|
||||
/** 최대 검증 시도 횟수(초과 시 해당 챌린지는 더 이상 사용 불가) */
|
||||
const MAX_OTP_VERIFY_ATTEMPTS = 8
|
||||
|
||||
/**
|
||||
* DB 클라이언트 조회 (필수)
|
||||
* @returns {ReturnType<typeof import('postgres').default>} postgres sql
|
||||
*/
|
||||
const requireSql = () => {
|
||||
const sql = getPostgresClient()
|
||||
if (!sql) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '데이터베이스 설정이 필요합니다.'
|
||||
})
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
/**
|
||||
* 동일 이메일·용도의 미소진 OTP를 무효화한다.
|
||||
* @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션
|
||||
* @param {string} email - 정규화된 이메일
|
||||
* @param {string} purpose - signup | password_reset
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const invalidatePendingOtpChallenges = async (sql, email, purpose) => {
|
||||
await sql`
|
||||
UPDATE email_otp_challenges
|
||||
SET consumed_at = now()
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND purpose = ${purpose}
|
||||
AND consumed_at IS NULL
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 짧은 시간 내 동일 이메일·용도 발송이 있는지 확인한다.
|
||||
* @param {import('postgres').Sql} sql - sql
|
||||
* @param {string} email - 이메일
|
||||
* @param {string} purpose - 용도
|
||||
* @returns {Promise<boolean>} true면 재요청 쿨다운 중
|
||||
*/
|
||||
export const hasRecentOtpSend = async (sql, email, purpose) => {
|
||||
const rows = await sql`
|
||||
SELECT 1 AS "x"
|
||||
FROM email_otp_challenges
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND purpose = ${purpose}
|
||||
AND created_at > now() - interval '55 seconds'
|
||||
LIMIT 1
|
||||
`
|
||||
return Boolean(rows?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 1시간 내 발송 횟수
|
||||
* @param {import('postgres').Sql} sql - sql
|
||||
* @param {string} email - 이메일
|
||||
* @param {string} purpose - 용도
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export const countOtpSendsLastHour = async (sql, email, purpose) => {
|
||||
const rows = await sql`
|
||||
SELECT COUNT(*)::int AS "c"
|
||||
FROM email_otp_challenges
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND purpose = ${purpose}
|
||||
AND created_at > now() - interval '1 hour'
|
||||
`
|
||||
return Number(rows?.[0]?.c || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* OTP 챌린지 행을 삽입한다.
|
||||
* @param {import('postgres').Sql} sql - sql
|
||||
* @param {{ email: string, purpose: string, codeHash: string, expiresAt: Date, createdIp: string }} input - 입력
|
||||
* @returns {Promise<string>} 삽입된 id
|
||||
*/
|
||||
export const insertOtpChallenge = async (sql, input) => {
|
||||
const rows = await sql`
|
||||
INSERT INTO email_otp_challenges (email, purpose, code_hash, expires_at, created_ip)
|
||||
VALUES (${input.email}, ${input.purpose}, ${input.codeHash}, ${input.expiresAt}, ${input.createdIp})
|
||||
RETURNING id
|
||||
`
|
||||
const id = rows?.[0]?.id
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '인증 정보 저장에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
return String(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 OTP를 검증하고 소진 처리한다.
|
||||
* @param {{ email: string, purpose: string, code: string, pepper: string }} input - 입력
|
||||
* @returns {Promise<{ ok: boolean, reason?: 'none' | 'expired' | 'locked' | 'mismatch' }>}
|
||||
*/
|
||||
export const verifyAndConsumeEmailOtp = async (input) => {
|
||||
const sql = requireSql()
|
||||
const email = normalizeOtpEmail(input.email)
|
||||
const purpose = String(input.purpose || '').trim()
|
||||
const code = String(input.code || '').trim()
|
||||
const pepper = String(input.pepper || '')
|
||||
|
||||
if (!email || !purpose || !/^\d{6}$/.test(code) || !pepper) {
|
||||
return { ok: false, reason: 'mismatch' }
|
||||
}
|
||||
|
||||
return await sql.begin(async (tx) => {
|
||||
const rows = await tx`
|
||||
SELECT id, code_hash AS "codeHash", verify_attempt_count AS "verifyAttemptCount", expires_at AS "expiresAt"
|
||||
FROM email_otp_challenges
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND purpose = ${purpose}
|
||||
AND consumed_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`
|
||||
|
||||
const row = rows?.[0]
|
||||
if (!row) {
|
||||
return { ok: false, reason: 'none' }
|
||||
}
|
||||
|
||||
const expiresAt = new Date(row.expiresAt)
|
||||
if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() < Date.now()) {
|
||||
await tx`
|
||||
UPDATE email_otp_challenges
|
||||
SET consumed_at = now()
|
||||
WHERE id = ${row.id}
|
||||
`
|
||||
return { ok: false, reason: 'expired' }
|
||||
}
|
||||
|
||||
if (Number(row.verifyAttemptCount || 0) >= MAX_OTP_VERIFY_ATTEMPTS) {
|
||||
return { ok: false, reason: 'locked' }
|
||||
}
|
||||
|
||||
const expected = hashOtpCode({ pepper, email, purpose, code })
|
||||
if (!timingSafeEqualHex(expected, row.codeHash)) {
|
||||
await tx`
|
||||
UPDATE email_otp_challenges
|
||||
SET verify_attempt_count = verify_attempt_count + 1
|
||||
WHERE id = ${row.id}
|
||||
`
|
||||
return { ok: false, reason: 'mismatch' }
|
||||
}
|
||||
|
||||
await tx`
|
||||
UPDATE email_otp_challenges
|
||||
SET consumed_at = now()
|
||||
WHERE id = ${row.id}
|
||||
`
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export const getUserByEmail = async (email) => {
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
FROM users
|
||||
WHERE email = ${email}
|
||||
WHERE lower(email) = lower(${email})
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
@@ -214,6 +214,25 @@ export const updateMemberProfile = async (input) => {
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일(대소문자 무시)로 비밀번호 해시를 갱신한다.
|
||||
* @param {{ email: string, passwordHash: string }} input - 입력
|
||||
* @returns {Promise<boolean>} 갱신된 행이 있으면 true
|
||||
*/
|
||||
export const updateMemberPasswordByEmail = async (input) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
password_hash = ${input.passwordHash},
|
||||
updated_at = now()
|
||||
WHERE lower(email) = lower(${input.email})
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
return Boolean(rows?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 비밀번호 변경
|
||||
* @param {{ userId: string, passwordHash: string }} input - 수정 값
|
||||
|
||||
43
server/utils/email-otp.js
Normal file
43
server/utils/email-otp.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createHash, randomInt, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* OTP용 이메일 정규화(소문자·trim)
|
||||
* @param {string} email - 원본
|
||||
* @returns {string}
|
||||
*/
|
||||
export const normalizeOtpEmail = (email) => String(email || '').trim().toLowerCase()
|
||||
|
||||
/**
|
||||
* 6자리 숫자 인증 코드 생성
|
||||
* @returns {string}
|
||||
*/
|
||||
export const generateSixDigitOtp = () => String(randomInt(0, 1_000_000)).padStart(6, '0')
|
||||
|
||||
/**
|
||||
* OTP 코드 해시(hex)
|
||||
* @param {{ pepper: string, email: string, purpose: string, code: string }} input - 입력
|
||||
* @returns {string} sha256 hex
|
||||
*/
|
||||
export const hashOtpCode = (input) => {
|
||||
const payload = `${String(input.pepper || '')}|${normalizeOtpEmail(input.email)}|${String(input.purpose || '')}|${String(input.code || '')}`
|
||||
return createHash('sha256').update(payload, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 sha256 hex 문자열을 상수 시간으로 비교한다.
|
||||
* @param {string} a - hex
|
||||
* @param {string} b - hex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const timingSafeEqualHex = (a, b) => {
|
||||
try {
|
||||
const ba = Buffer.from(String(a || ''), 'hex')
|
||||
const bb = Buffer.from(String(b || ''), 'hex')
|
||||
if (ba.length !== bb.length || ba.length === 0) {
|
||||
return false
|
||||
}
|
||||
return timingSafeEqual(ba, bb)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
42
server/utils/resend-mail.js
Normal file
42
server/utils/resend-mail.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createError } from 'h3'
|
||||
|
||||
/**
|
||||
* Resend가 서버 설정으로 사용 가능한지
|
||||
* @param {{ resendApiKey?: string, resendFromEmail?: string }} config - 런타임 설정
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isResendConfigured = (config) => {
|
||||
const key = String(config?.resendApiKey || '').trim()
|
||||
const from = String(config?.resendFromEmail || '').trim()
|
||||
return Boolean(key && from)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend REST API로 이메일을 발송한다.
|
||||
* @param {{ apiKey: string, from: string, to: string, subject: string, html: string }} input - 발송 입력
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const sendResendEmail = async (input) => {
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: input.from,
|
||||
to: [input.to],
|
||||
subject: input.subject,
|
||||
html: input.html
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
message: '이메일 발송에 실패했습니다. Resend 발신 주소·도메인 설정을 확인해 주세요.',
|
||||
data: process.env.NODE_ENV === 'development' && detail ? { detail } : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user