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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@ conversations/
|
|||||||
.env.*.local
|
.env.*.local
|
||||||
# 세션 서명용 자동 생성 시크릿. 절대 커밋 금지.
|
# 세션 서명용 자동 생성 시크릿. 절대 커밋 금지.
|
||||||
.session-secret
|
.session-secret
|
||||||
|
# 운영 계정(해시 비밀번호) 파일. 추적되는 account.json 대신 이 파일을 사용. 커밋 금지.
|
||||||
|
account.local.json
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
"title": "관리자 로그인",
|
"title": "관리자 로그인",
|
||||||
"password": "비밀번호",
|
"password": "비밀번호",
|
||||||
"submit": "로그인",
|
"submit": "로그인",
|
||||||
"wrongPassword": "비밀번호가 올바르지 않습니다."
|
"wrongPassword": "비밀번호가 올바르지 않습니다.",
|
||||||
|
"tooManyAttempts": "로그인 시도가 너무 많습니다. 약 {{minutes}}분 후 다시 시도해 주세요."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "음악퀴즈 목록",
|
"title": "음악퀴즈 목록",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
// gitignore 된 account.local.json 우선. 없으면(ENOENT) 추적되는 account.json 을 시드로.
|
||||||
|
for (const filePath of [accountLocalFilePath, accountFilePath]) {
|
||||||
try {
|
try {
|
||||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
const raw = await fsp.readFile(filePath, 'utf8')
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
if (!Array.isArray(parsed)) return []
|
if (!Array.isArray(parsed)) return []
|
||||||
return parsed.filter((entry): entry is AccountEntry =>
|
return parsed.filter((entry): entry is AccountEntry =>
|
||||||
typeof entry?.id === 'string' && typeof entry?.password === 'string')
|
typeof entry?.id === 'string' && typeof entry?.password === 'string')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return []
|
||||||
export async function writeAccounts(accounts: AccountEntry[]): Promise<void> {
|
}
|
||||||
await fsp.writeFile(accountFilePath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf8')
|
|
||||||
|
// 운영 계정 저장은 항상 gitignore 된 account.local.json 에만 한다(추적 파일 오염 방지).
|
||||||
|
export async function writeAccounts(accounts: AccountEntry[]): Promise<void> {
|
||||||
|
await fsp.writeFile(accountLocalFilePath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf8')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user