세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
136 lines
4.1 KiB
JavaScript
136 lines
4.1 KiB
JavaScript
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<string, { count: number, resetAt: number }>} */
|
|
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)
|
|
}
|