security: login rate-limit, gitignored account store, secure cookie opt, upgrade-fail log
리뷰 지적 4건 반영(서버 전용, exe 영향 없음). - 로그인 IP 기준 실패 제한(15분 창 10회 → 15분 차단). 브루트포스 + scrypt CPU 남용 방지. 인메모리, 무한 성장 가드 포함. - 운영 계정을 gitignore 된 account.local.json 으로 이전. readAccounts 는 local 우선, 없으면 추적되는 account.json 을 시드로 읽음. writeAccounts 는 local 에만 기록 → 첫 로그인 자동 해시 업그레이드부터는 추적 평문 파일을 더 쓰지 않음. (account.json 을 git rm --cached 하면 서버 pull 시 삭제되는 위험이 있어 추적 자체는 건드리지 않고, 실질 사용 파일만 분리.) - 세션 쿠키 secure 를 SESSION_COOKIE_SECURE=true 로 켤 수 있게(HTTPS 배포용). - 평문→해시 자동 업그레이드 저장 실패를 조용히 무시하지 않고 console.error 로 기록. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,9 @@ app.use(session({
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
// HTTPS 전용 배포면 SESSION_COOKIE_SECURE=true 로 secure 쿠키 활성화.
|
||||
// HTTP 접근이 섞이면 로그인 쿠키가 안 실리므로 기본값은 false.
|
||||
secure: process.env.SESSION_COOKIE_SECURE === 'true',
|
||||
maxAge: 1000 * 60 * 60 * 8
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -35,6 +35,40 @@ import { buildSongsMcfunction } from '../datapack.js'
|
||||
|
||||
export const opRouter = Router()
|
||||
|
||||
// 로그인 브루트포스 + scrypt CPU 남용 방지용 IP 기준 인메모리 실패 제한.
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000
|
||||
const LOGIN_MAX_FAILS = 10
|
||||
const loginFails = new Map<string, { count: number; first: number; blockedUntil: number }>()
|
||||
|
||||
function loginClientKey(req: { ip?: string; socket?: { remoteAddress?: string } }): string {
|
||||
return req.ip || req.socket?.remoteAddress || 'unknown'
|
||||
}
|
||||
|
||||
/** 차단 중이면 남은 ms, 아니면 0. 접근 시 만료된 항목은 정리. */
|
||||
function loginBlockedMs(key: string): number {
|
||||
const now = Date.now()
|
||||
const entry = loginFails.get(key)
|
||||
if (!entry) return 0
|
||||
if (entry.blockedUntil > now) return entry.blockedUntil - now
|
||||
if (now - entry.first > LOGIN_WINDOW_MS) loginFails.delete(key)
|
||||
return 0
|
||||
}
|
||||
|
||||
function recordLoginFail(key: string): void {
|
||||
const now = Date.now()
|
||||
let entry = loginFails.get(key)
|
||||
if (!entry || now - entry.first > LOGIN_WINDOW_MS) entry = { count: 0, first: now, blockedUntil: 0 }
|
||||
entry.count += 1
|
||||
if (entry.count >= LOGIN_MAX_FAILS) entry.blockedUntil = now + LOGIN_WINDOW_MS
|
||||
loginFails.set(key, entry)
|
||||
// 맵 무한 성장 방지(분산 시도 대비): 상한 초과 시 만료 항목 정리.
|
||||
if (loginFails.size > 5000) {
|
||||
for (const [k, v] of loginFails) {
|
||||
if (v.blockedUntil <= now && now - v.first > LOGIN_WINDOW_MS) loginFails.delete(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pickFirstValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
|
||||
return typeof value === 'string' ? value : ''
|
||||
@@ -58,20 +92,31 @@ opRouter.get('/op', (req, res) => {
|
||||
|
||||
opRouter.post('/op', async (req, res, next) => {
|
||||
try {
|
||||
const clientKey = loginClientKey(req)
|
||||
const blockedMs = loginBlockedMs(clientKey)
|
||||
if (blockedMs > 0) {
|
||||
res.status(429).render('op/login', {
|
||||
error: t('login.tooManyAttempts', { minutes: Math.ceil(blockedMs / 60000) })
|
||||
})
|
||||
return
|
||||
}
|
||||
const password = pickFirstValue(req.body.password)
|
||||
const accounts = await readAccounts()
|
||||
const matched = accounts.find((entry) => verifyPassword(password, entry.password))
|
||||
if (!matched) {
|
||||
recordLoginFail(clientKey)
|
||||
res.status(401).render('op/login', { error: t('login.wrongPassword') })
|
||||
return
|
||||
}
|
||||
loginFails.delete(clientKey)
|
||||
// 평문으로 저장돼 있던 비밀번호는 로그인 성공 시 scrypt 해시로 자동 업그레이드.
|
||||
if (!isHashed(matched.password)) {
|
||||
try {
|
||||
matched.password = hashPassword(password)
|
||||
await writeAccounts(accounts)
|
||||
} catch {
|
||||
// 업그레이드 실패는 로그인 자체에 영향 주지 않음.
|
||||
} catch (err) {
|
||||
// 로그인 자체는 진행하되, 평문이 계속 남는 상황이므로 반드시 로그로 알린다.
|
||||
console.error('[auth] 비밀번호 해시 자동 업그레이드 저장 실패(평문 유지됨):', (err as Error).message)
|
||||
}
|
||||
}
|
||||
req.session.userId = matched.id
|
||||
|
||||
Reference in New Issue
Block a user