Compare commits
7 Commits
v0.3.23
...
00aa47ed17
| Author | SHA1 | Date | |
|---|---|---|---|
| 00aa47ed17 | |||
| 9c2a92c101 | |||
| 662c3c7b23 | |||
| face70b3e9 | |||
| 651c63faa6 | |||
| f392842d7f | |||
| 00344084c7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,3 +7,7 @@ conversations/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
# 세션 서명용 자동 생성 시크릿. 절대 커밋 금지.
|
||||
.session-secret
|
||||
# 운영 계정(해시 비밀번호) 파일. 추적되는 account.json 대신 이 파일을 사용. 커밋 금지.
|
||||
account.local.json
|
||||
|
||||
3
account.example.json
Normal file
3
account.example.json
Normal file
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{ "id": "admin", "password": "여기에-비밀번호를-넣으세요" }
|
||||
]
|
||||
@@ -50,7 +50,9 @@ npm start # 기본 포트 3000.
|
||||
|
||||
## 계정
|
||||
|
||||
`account.json` 에 정의합니다(루트 디렉터리). **외부 HTTP 로 절대 노출되지 않도록 라우팅에서 제외돼 있습니다.**
|
||||
운영 계정은 **gitignore 된 `account.local.json`**(루트 디렉터리, 0600, scrypt 해시)에 저장됩니다. 추적되는 `account.json` 은 서버에 `account.local.json` 이 없을 때만 읽는 **시드 소스**로, 서버 시작 시 자동으로 `account.local.json`(0600) 으로 복사됩니다. 두 파일 모두 **외부 HTTP 로 절대 노출되지 않도록 라우팅에서 제외돼 있습니다.**
|
||||
|
||||
시드 포맷(`account.json`):
|
||||
|
||||
```json
|
||||
[
|
||||
@@ -58,7 +60,7 @@ npm start # 기본 포트 3000.
|
||||
]
|
||||
```
|
||||
|
||||
> 운영 환경에서는 평문 비밀번호 대신 해시를 쓰도록 추후 보강할 여지가 있습니다.
|
||||
평문 비밀번호로 시드해도 로그인 성공 시 자동으로 scrypt 해시(`scrypt$<salt>$<hash>`)로 업그레이드되어 `account.local.json` 에만 저장됩니다. 자세한 마이그레이션 절차는 아래 "운영 계정 파일 마이그레이션" 을 참고하세요.
|
||||
|
||||
## 대시보드 (`/op/dashboard`)
|
||||
|
||||
@@ -138,4 +140,14 @@ say [musicquiz] 데이터팩 초기화
|
||||
|
||||
- `account.json` 은 라우팅에서 차단되어 있으나, 디스크 권한도 운영자만 접근 가능하게 두는 것이 안전합니다.
|
||||
- 관리자 비밀번호는 충분히 강하게 설정.
|
||||
|
||||
### 운영 계정 파일 마이그레이션 (미완결 — 재배포 게이트)
|
||||
|
||||
운영 계정은 이제 gitignore 된 `account.local.json`(0o600, scrypt 해시)에만 저장됩니다. 추적되는 `account.json` 은 서버에 `account.local.json` 이 없을 때만 읽는 **시드 소스**로 남겨 둔 상태입니다. 남은 위생 작업이 하나 있습니다:
|
||||
|
||||
1. **[운영] 최신 main 재배포** → 서버가 `account.local.json`(0o600) 을 자동 생성하는지 확인.
|
||||
2. **[운영] 비밀번호 로테이션** — git 히스토리에 평문 비밀번호가 남아 있어 추적 해제로는 지워지지 않으므로, 이것이 실질적 최우선 보안 조치입니다.
|
||||
3. **[후속 커밋] `account.json` 추적 해제** — 위 1번(서버에 `account.local.json` 존재) 확인 **후에만** `git rm --cached account.json` 를 별도 커밋으로 진행. 같은 커밋에서 하면 시드 소스가 사라져 로그인이 막힙니다.
|
||||
|
||||
> 코드 상 앵커: `src/shared/paths.ts` 의 `TODO(untrack-after-redeploy)` 주석.
|
||||
- 모든 `/op/*` 라우트는 세션 기반 인증 미들웨어를 거칩니다. 세션 만료 시 자동으로 로그인 페이지로 리다이렉트.
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"title": "관리자 로그인",
|
||||
"password": "비밀번호",
|
||||
"submit": "로그인",
|
||||
"wrongPassword": "비밀번호가 올바르지 않습니다."
|
||||
"wrongPassword": "비밀번호가 올바르지 않습니다.",
|
||||
"tooManyAttempts": "로그인 시도가 너무 많습니다. 약 {{minutes}}분 후 다시 시도해 주세요."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "음악퀴즈 목록",
|
||||
|
||||
@@ -656,7 +656,12 @@ ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; htm
|
||||
|
||||
ipcMain.handle('server:acceptEula', async (_event, installPath: string) => {
|
||||
const target = path.join(installPath, 'eula.txt')
|
||||
await fsp.writeFile(target, `# Generated by music quiz installer\neula=true\n`, 'utf8')
|
||||
const acceptedAt = new Date()
|
||||
await fsp.writeFile(
|
||||
target,
|
||||
`# Generated by music quiz installer\n# EULA accepted at: ${acceptedAt.toISOString()}\neula=true\n`,
|
||||
'utf8',
|
||||
)
|
||||
sendLog(t('log.eulaAccepted'))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import express from 'express'
|
||||
import session from 'express-session'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import crypto from 'node:crypto'
|
||||
import {
|
||||
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
||||
fileDirPath, viewsDirPath, publicDirPath
|
||||
fileDirPath, viewsDirPath, publicDirPath, projectRoot,
|
||||
accountFilePath, accountLocalFilePath
|
||||
} from '../shared/paths.js'
|
||||
import {
|
||||
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
|
||||
@@ -25,7 +28,24 @@ const app = express()
|
||||
|
||||
app.set('view engine', 'ejs')
|
||||
app.set('views', viewsDirPath)
|
||||
app.set('trust proxy', 1)
|
||||
// 리버스 프록시 뒤일 때만 켠다. 항상 켜두면 직접 노출 시 X-Forwarded-For 조작으로
|
||||
// req.ip 를 위조해 로그인 rate limit 을 우회할 수 있다. 프록시 뒤라면 TRUST_PROXY=true.
|
||||
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : false)
|
||||
|
||||
// 추적되는 account.json(과거 평문 노출)을 gitignore 된 account.local.json 으로 시드한다.
|
||||
// 로컬 파일이 이미 있으면 건드리지 않음. 이후 계정 쓰기/자동 해시 업그레이드는 로컬
|
||||
// 파일에만 반영되어, 재배포로 account.json 을 추적 해제해도 로그인이 유지된다.
|
||||
function seedLocalAccounts(): void {
|
||||
try {
|
||||
if (fs.existsSync(accountLocalFilePath)) return
|
||||
if (!fs.existsSync(accountFilePath)) return
|
||||
fs.copyFileSync(accountFilePath, accountLocalFilePath)
|
||||
fs.chmodSync(accountLocalFilePath, 0o600)
|
||||
} catch {
|
||||
// 실패해도 readAccounts 가 account.json 으로 폴백하므로 치명적이지 않음.
|
||||
}
|
||||
}
|
||||
seedLocalAccounts()
|
||||
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.json())
|
||||
@@ -38,13 +58,36 @@ app.use((_req, res, next) => {
|
||||
next()
|
||||
})
|
||||
|
||||
// 세션 시크릿: 환경변수 우선, 없으면 하드코딩(위조 위험) 대신 영구 랜덤 시크릿을
|
||||
// 파일로 생성/보관한다(재시작해도 세션 유지). 파일 접근 불가 시엔 프로세스 수명 동안만
|
||||
// 유효한 랜덤값으로 폴백(그래도 하드코딩보다 안전).
|
||||
function resolveSessionSecret(): string {
|
||||
const fromEnv = process.env.SESSION_SECRET
|
||||
if (fromEnv && fromEnv.length >= 16) return fromEnv
|
||||
const secretPath = path.join(projectRoot, '.session-secret')
|
||||
try {
|
||||
if (fs.existsSync(secretPath)) {
|
||||
const existing = fs.readFileSync(secretPath, 'utf8').trim()
|
||||
if (existing.length >= 16) return existing
|
||||
}
|
||||
const generated = crypto.randomBytes(32).toString('hex')
|
||||
fs.writeFileSync(secretPath, generated, { mode: 0o600 })
|
||||
return generated
|
||||
} catch {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
}
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
|
||||
secret: resolveSessionSecret(),
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
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
|
||||
}
|
||||
}))
|
||||
|
||||
48
src/server/password.ts
Normal file
48
src/server/password.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
// 운영자 비밀번호 저장/검증. 외부 의존성 없이 Node 내장 scrypt 사용.
|
||||
// 저장 형식: `scrypt$<saltHex>$<hashHex>`. 검증은 항상 상수시간 비교.
|
||||
// 기존 account.json 의 평문 비밀번호는 verifyPassword 가 그대로 검증할 수 있고,
|
||||
// 로그인 성공 시 호출측에서 hashPassword 로 재저장(자동 업그레이드)한다.
|
||||
|
||||
const SCHEME = 'scrypt'
|
||||
const KEY_LEN = 32
|
||||
const SALT_LEN = 16
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
const salt = crypto.randomBytes(SALT_LEN)
|
||||
const hash = crypto.scryptSync(plain, salt, KEY_LEN)
|
||||
return `${SCHEME}$${salt.toString('hex')}$${hash.toString('hex')}`
|
||||
}
|
||||
|
||||
export function isHashed(stored: string): boolean {
|
||||
return typeof stored === 'string' && stored.startsWith(`${SCHEME}$`)
|
||||
}
|
||||
|
||||
export function verifyPassword(plain: string, stored: string): boolean {
|
||||
if (typeof stored !== 'string' || stored.length === 0) return false
|
||||
if (isHashed(stored)) {
|
||||
const parts = stored.split('$')
|
||||
if (parts.length !== 3) return false
|
||||
let salt: Buffer
|
||||
let expected: Buffer
|
||||
try {
|
||||
salt = Buffer.from(parts[1], 'hex')
|
||||
expected = Buffer.from(parts[2], 'hex')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (expected.length === 0) return false
|
||||
let derived: Buffer
|
||||
try {
|
||||
derived = crypto.scryptSync(plain, salt, expected.length)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return derived.length === expected.length && crypto.timingSafeEqual(derived, expected)
|
||||
}
|
||||
// 레거시 평문: 길이 노출을 피하려 양쪽을 sha256 으로 고정 길이화한 뒤 상수시간 비교.
|
||||
const a = crypto.createHash('sha256').update(plain, 'utf8').digest()
|
||||
const b = crypto.createHash('sha256').update(stored, 'utf8').digest()
|
||||
return crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
@@ -22,8 +22,10 @@ import {
|
||||
saveTerm,
|
||||
savePackList,
|
||||
setPackPublic,
|
||||
setTermVisibility
|
||||
setTermVisibility,
|
||||
writeAccounts
|
||||
} from '../../shared/store.js'
|
||||
import { hashPassword, isHashed, verifyPassword } from '../password.js'
|
||||
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
||||
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
@@ -33,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 : ''
|
||||
@@ -56,13 +92,32 @@ 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) => entry.password === password)
|
||||
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 {
|
||||
// 업그레이드 실패는 로그인 자체에 영향 주지 않음.
|
||||
}
|
||||
}
|
||||
req.session.userId = matched.id
|
||||
res.redirect('/op/dashboard')
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,7 +6,17 @@ export const projectRoot = path.resolve(__dirname, '..', '..')
|
||||
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
|
||||
export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||
export const manifestTermsDirPath = path.join(manifestDirPath, 'terms')
|
||||
// 추적되는 account.json(과거 평문 노출)을 대체할, gitignore 된 운영 계정 파일.
|
||||
// readAccounts 는 이 파일을 우선 사용하고, 없을 때만 account.json 을 시드로 읽는다.
|
||||
//
|
||||
// TODO(untrack-after-redeploy): account.json 은 아직 git 추적 상태다. 절대 지금
|
||||
// 같은 커밋에서 `git rm --cached account.json` 하지 말 것 — 서버에 account.local.json
|
||||
// 이 아직 없을 때 시드 소스가 사라져 로그인이 막힌다(chicken-and-egg).
|
||||
// 안전한 순서: (1) 이 커밋 배포 → 서버가 account.local.json(0o600) 자동 생성 확인 →
|
||||
// (2) 그 다음 후속 커밋에서 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 fileListDirPath = path.join(fileDirPath, 'list')
|
||||
export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks')
|
||||
|
||||
@@ -3,7 +3,7 @@ import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
||||
accountFilePath, fileListDirPath
|
||||
accountFilePath, accountLocalFilePath, fileListDirPath
|
||||
} from './paths.js'
|
||||
import type {
|
||||
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
|
||||
@@ -778,14 +778,24 @@ export function isPublicTermsFile(packKey: string, fileName: string): boolean {
|
||||
}
|
||||
|
||||
export async function readAccounts(): Promise<AccountEntry[]> {
|
||||
// gitignore 된 account.local.json 우선. 없으면(ENOENT) 추적되는 account.json 을 시드로.
|
||||
for (const filePath of [accountLocalFilePath, accountFilePath]) {
|
||||
try {
|
||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||
const raw = await fsp.readFile(filePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((entry): entry is AccountEntry =>
|
||||
typeof entry?.id === 'string' && typeof entry?.password === 'string')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 운영 계정 저장은 항상 gitignore 된 account.local.json 에만 한다(추적 파일 오염 방지).
|
||||
// 비밀번호(해시) 파일이므로 소유자만 읽기/쓰기(0o600).
|
||||
export async function writeAccounts(accounts: AccountEntry[]): Promise<void> {
|
||||
await fsp.writeFile(accountLocalFilePath, `${JSON.stringify(accounts, null, 2)}\n`, { mode: 0o600 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user