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:
2026-07-11 12:31:34 +09:00
parent 00344084c7
commit f392842d7f
6 changed files with 73 additions and 14 deletions

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ conversations/
.env.*.local .env.*.local
# 세션 서명용 자동 생성 시크릿. 절대 커밋 금지. # 세션 서명용 자동 생성 시크릿. 절대 커밋 금지.
.session-secret .session-secret
# 운영 계정(해시 비밀번호) 파일. 추적되는 account.json 대신 이 파일을 사용. 커밋 금지.
account.local.json

View File

@@ -30,7 +30,8 @@
"title": "관리자 로그인", "title": "관리자 로그인",
"password": "비밀번호", "password": "비밀번호",
"submit": "로그인", "submit": "로그인",
"wrongPassword": "비밀번호가 올바르지 않습니다." "wrongPassword": "비밀번호가 올바르지 않습니다.",
"tooManyAttempts": "로그인 시도가 너무 많습니다. 약 {{minutes}}분 후 다시 시도해 주세요."
}, },
"dashboard": { "dashboard": {
"title": "음악퀴즈 목록", "title": "음악퀴즈 목록",

View File

@@ -67,6 +67,9 @@ app.use(session({
cookie: { cookie: {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
// HTTPS 전용 배포면 SESSION_COOKIE_SECURE=true 로 secure 쿠키 활성화.
// HTTP 접근이 섞이면 로그인 쿠키가 안 실리므로 기본값은 false.
secure: process.env.SESSION_COOKIE_SECURE === 'true',
maxAge: 1000 * 60 * 60 * 8 maxAge: 1000 * 60 * 60 * 8
} }
})) }))

View File

@@ -35,6 +35,40 @@ import { buildSongsMcfunction } from '../datapack.js'
export const opRouter = Router() 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 { function pickFirstValue(value: unknown): string {
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : '' if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
return typeof value === 'string' ? value : '' return typeof value === 'string' ? value : ''
@@ -58,20 +92,31 @@ opRouter.get('/op', (req, res) => {
opRouter.post('/op', async (req, res, next) => { opRouter.post('/op', async (req, res, next) => {
try { 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 password = pickFirstValue(req.body.password)
const accounts = await readAccounts() const accounts = await readAccounts()
const matched = accounts.find((entry) => verifyPassword(password, entry.password)) const matched = accounts.find((entry) => verifyPassword(password, entry.password))
if (!matched) { if (!matched) {
recordLoginFail(clientKey)
res.status(401).render('op/login', { error: t('login.wrongPassword') }) res.status(401).render('op/login', { error: t('login.wrongPassword') })
return return
} }
loginFails.delete(clientKey)
// 평문으로 저장돼 있던 비밀번호는 로그인 성공 시 scrypt 해시로 자동 업그레이드. // 평문으로 저장돼 있던 비밀번호는 로그인 성공 시 scrypt 해시로 자동 업그레이드.
if (!isHashed(matched.password)) { if (!isHashed(matched.password)) {
try { try {
matched.password = hashPassword(password) matched.password = hashPassword(password)
await writeAccounts(accounts) await writeAccounts(accounts)
} catch { } catch (err) {
// 업그레이드 실패는 로그인 자체에 영향 주지 않음. // 로그인 자체는 진행하되, 평문이 계속 남는 상황이므로 반드시 로그로 알린다.
console.error('[auth] 비밀번호 해시 자동 업그레이드 저장 실패(평문 유지됨):', (err as Error).message)
} }
} }
req.session.userId = matched.id req.session.userId = matched.id

View File

@@ -6,7 +6,10 @@ export const projectRoot = path.resolve(__dirname, '..', '..')
export const manifestRootPath = path.join(projectRoot, 'manifest.json') export const manifestRootPath = path.join(projectRoot, 'manifest.json')
export const manifestDirPath = path.join(projectRoot, 'manifest') export const manifestDirPath = path.join(projectRoot, 'manifest')
export const manifestTermsDirPath = path.join(manifestDirPath, 'terms') export const manifestTermsDirPath = path.join(manifestDirPath, 'terms')
// 추적되는 account.json(과거 평문 노출)을 대체할, gitignore 된 운영 계정 파일.
// readAccounts 는 이 파일을 우선 사용하고, 없을 때만 account.json 을 시드로 읽는다.
export const accountFilePath = path.join(projectRoot, 'account.json') export const accountFilePath = path.join(projectRoot, 'account.json')
export const accountLocalFilePath = path.join(projectRoot, 'account.local.json')
export const fileDirPath = path.join(projectRoot, 'file') export const fileDirPath = path.join(projectRoot, 'file')
export const fileListDirPath = path.join(fileDirPath, 'list') export const fileListDirPath = path.join(fileDirPath, 'list')
export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks') export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks')

View File

@@ -3,7 +3,7 @@ import fsp from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { import {
manifestRootPath, manifestDirPath, manifestTermsDirPath, manifestRootPath, manifestDirPath, manifestTermsDirPath,
accountFilePath, fileListDirPath accountFilePath, accountLocalFilePath, fileListDirPath
} from './paths.js' } from './paths.js'
import type { import type {
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType, Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
@@ -778,18 +778,23 @@ export function isPublicTermsFile(packKey: string, fileName: string): boolean {
} }
export async function readAccounts(): Promise<AccountEntry[]> { export async function readAccounts(): Promise<AccountEntry[]> {
try { // gitignore 된 account.local.json 우선. 없으면(ENOENT) 추적되는 account.json 을 시드로.
const raw = await fsp.readFile(accountFilePath, 'utf8') for (const filePath of [accountLocalFilePath, accountFilePath]) {
const parsed = JSON.parse(raw) try {
if (!Array.isArray(parsed)) return [] const raw = await fsp.readFile(filePath, 'utf8')
return parsed.filter((entry): entry is AccountEntry => const parsed = JSON.parse(raw)
typeof entry?.id === 'string' && typeof entry?.password === 'string') if (!Array.isArray(parsed)) return []
} catch (error) { return parsed.filter((entry): entry is AccountEntry =>
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] typeof entry?.id === 'string' && typeof entry?.password === 'string')
throw error } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
throw error
}
} }
return []
} }
// 운영 계정 저장은 항상 gitignore 된 account.local.json 에만 한다(추적 파일 오염 방지).
export async function writeAccounts(accounts: AccountEntry[]): Promise<void> { export async function writeAccounts(accounts: AccountEntry[]): Promise<void> {
await fsp.writeFile(accountFilePath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf8') await fsp.writeFile(accountLocalFilePath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf8')
} }