헤더 검색 중앙 정렬·Resend 이메일 OTP·비밀번호 찾기 (v0.0.99)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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 - 수정 값
|
||||
|
||||
Reference in New Issue
Block a user