diff --git a/.gitignore b/.gitignore index c5cb917..48246a4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ conversations/ .env .env.local .env.*.local +# 세션 서명용 자동 생성 시크릿. 절대 커밋 금지. +.session-secret diff --git a/src/server/app.ts b/src/server/app.ts index 014358e..a47b367 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,10 +1,12 @@ 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 } from '../shared/paths.js' import { ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition @@ -38,8 +40,28 @@ 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: { diff --git a/src/server/password.ts b/src/server/password.ts new file mode 100644 index 0000000..e9a5247 --- /dev/null +++ b/src/server/password.ts @@ -0,0 +1,48 @@ +import crypto from 'node:crypto' + +// 운영자 비밀번호 저장/검증. 외부 의존성 없이 Node 내장 scrypt 사용. +// 저장 형식: `scrypt$$`. 검증은 항상 상수시간 비교. +// 기존 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) +} diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts index 0e9caf3..48b450a 100644 --- a/src/server/routes/op.ts +++ b/src/server/routes/op.ts @@ -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' @@ -58,11 +60,20 @@ opRouter.post('/op', async (req, res, next) => { try { 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) { res.status(401).render('op/login', { error: t('login.wrongPassword') }) return } + // 평문으로 저장돼 있던 비밀번호는 로그인 성공 시 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) { diff --git a/src/shared/store.ts b/src/shared/store.ts index 3b76027..a795400 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -789,3 +789,7 @@ export async function readAccounts(): Promise { throw error } } + +export async function writeAccounts(accounts: AccountEntry[]): Promise { + await fsp.writeFile(accountFilePath, `${JSON.stringify(accounts, null, 2)}\n`, 'utf8') +}