v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
135
server/utils/login-attempt-limit.js
Normal file
135
server/utils/login-attempt-limit.js
Normal file
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user