import { createHash } from 'node:crypto' import { createError, setResponseHeader } from 'h3' const LOGIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000 const LOGIN_ACCOUNT_FAILURE_LIMIT = 5 const LOGIN_IP_FAILURE_LIMIT = 20 const CLEANUP_INTERVAL_MS = 5 * 60 * 1000 const MAX_ATTEMPT_BUCKET_COUNT = 10000 /** @type {Map} */ const attemptBuckets = new Map() let lastCleanupAt = 0 /** * 로그인 제한 키에 개인정보 원문이 남지 않도록 해시한다. * @param {string} value - 원본 키 * @returns {string} 해시 키 */ const createAttemptKey = (value) => createHash('sha256') .update(String(value || '')) .digest('base64url') /** * 만료된 로그인 제한 버킷을 정리한다. * @param {number} now - 현재 시각 * @returns {void} */ const cleanupExpiredBuckets = (now) => { if (now - lastCleanupAt < CLEANUP_INTERVAL_MS) { return } lastCleanupAt = now for (const [key, bucket] of attemptBuckets.entries()) { if (bucket.resetAt <= now) { attemptBuckets.delete(key) } } } /** * 현재 로그인 제한 버킷을 조회한다. * @param {string} key - 버킷 키 * @param {number} now - 현재 시각 * @returns {{ count: number, resetAt: number }} 버킷 */ const getAttemptBucket = (key, now) => { const current = attemptBuckets.get(key) if (current && current.resetAt > now) { return current } if (attemptBuckets.size >= MAX_ATTEMPT_BUCKET_COUNT) { const oldestKey = attemptBuckets.keys().next().value if (oldestKey) { attemptBuckets.delete(oldestKey) } } const next = { count: 0, resetAt: now + LOGIN_ATTEMPT_WINDOW_MS } attemptBuckets.set(key, next) return next } /** * 로그인 시도 제한용 계정·IP 키를 만든다. * @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값 * @returns {{ accountKey: string, ipKey: string }} 제한 키 */ const createLoginAttemptKeys = (input) => ({ accountKey: `account:${createAttemptKey(`${input.scope}:${String(input.email || '').trim().toLowerCase()}`)}`, ipKey: `ip:${createAttemptKey(`${input.scope}:${String(input.ip || '').trim()}`)}` }) /** * 로그인 요청이 현재 제한을 넘지 않았는지 확인한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값 * @returns {void} */ export const assertLoginAttemptAllowed = (event, input) => { const now = Date.now() cleanupExpiredBuckets(now) const keys = createLoginAttemptKeys(input) const accountBucket = attemptBuckets.get(keys.accountKey) const ipBucket = attemptBuckets.get(keys.ipKey) const accountBlocked = Boolean(accountBucket && accountBucket.resetAt > now && accountBucket.count >= LOGIN_ACCOUNT_FAILURE_LIMIT) const ipBlocked = Boolean(ipBucket && ipBucket.resetAt > now && ipBucket.count >= LOGIN_IP_FAILURE_LIMIT) if (!accountBlocked && !ipBlocked) { return } const retryAt = Math.max( accountBlocked ? accountBucket.resetAt : 0, ipBlocked ? ipBucket.resetAt : 0 ) const retryAfter = Math.max(1, Math.ceil((retryAt - now) / 1000)) setResponseHeader(event, 'Retry-After', String(retryAfter)) throw createError({ statusCode: 429, message: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.' }) } /** * 실패한 로그인 시도를 계정과 IP 기준으로 기록한다. * @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값 * @returns {void} */ export const recordLoginFailure = (input) => { const now = Date.now() cleanupExpiredBuckets(now) const keys = createLoginAttemptKeys(input) for (const key of [keys.accountKey, keys.ipKey]) { const bucket = getAttemptBucket(key, now) bucket.count += 1 } } /** * 로그인에 성공한 계정의 실패 기록을 제거한다. * @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값 * @returns {void} */ export const clearLoginAccountFailures = (input) => { const keys = createLoginAttemptKeys(input) attemptBuckets.delete(keys.accountKey) }