Files
sori.studio/server/utils/login-attempt-limit.js
zenn de767095ba v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:05:40 +09:00

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)
}