268 lines
8.0 KiB
JavaScript
268 lines
8.0 KiB
JavaScript
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
|
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { parseCookies } from './helpers.js'
|
|
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
const __dirname = path.dirname(__filename)
|
|
const TOTP_SECRET_FILE = path.join(__dirname, '..', 'data', 'totp-secret.json')
|
|
|
|
// Pending sessions: password verified, waiting for TOTP code
|
|
// Map<pendingToken, { expiresAt }>
|
|
const TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
|
const totpPendingSessions = new Map()
|
|
|
|
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
|
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
|
const adminSessions = new Map()
|
|
|
|
function cookieFlags() {
|
|
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
|
}
|
|
|
|
export function sha256(value) {
|
|
return createHash('sha256').update(String(value)).digest('hex')
|
|
}
|
|
|
|
export function isAdminPasswordConfigured() {
|
|
return Boolean(ADMIN_PASSWORD)
|
|
}
|
|
|
|
export function validateAdminPasswordSetup() {
|
|
if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') {
|
|
throw new Error('ADMIN_PASSWORD is required in production.')
|
|
}
|
|
if (!isAdminPasswordConfigured()) {
|
|
console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.')
|
|
}
|
|
}
|
|
|
|
export function isAdminPasswordValid(password) {
|
|
if (!isAdminPasswordConfigured()) return false
|
|
const a = Buffer.from(sha256(password), 'utf8')
|
|
const b = Buffer.from(sha256(ADMIN_PASSWORD), 'utf8')
|
|
if (a.length !== b.length) return false
|
|
return timingSafeEqual(a, b)
|
|
}
|
|
|
|
// ── TOTP (RFC 6238) — implemented with Node built-in crypto ─────────────────
|
|
|
|
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
|
|
|
function base32Decode(str) {
|
|
const s = str.toUpperCase().replace(/=+$/, '')
|
|
let bits = 0
|
|
let value = 0
|
|
const output = []
|
|
for (const char of s) {
|
|
const idx = BASE32_CHARS.indexOf(char)
|
|
if (idx === -1) continue
|
|
value = (value << 5) | idx
|
|
bits += 5
|
|
if (bits >= 8) {
|
|
output.push((value >>> (bits - 8)) & 0xff)
|
|
bits -= 8
|
|
}
|
|
}
|
|
return Buffer.from(output)
|
|
}
|
|
|
|
function base32Encode(buf) {
|
|
let bits = 0
|
|
let value = 0
|
|
let output = ''
|
|
for (const byte of buf) {
|
|
value = (value << 8) | byte
|
|
bits += 8
|
|
while (bits >= 5) {
|
|
output += BASE32_CHARS[(value >>> (bits - 5)) & 0x1f]
|
|
bits -= 5
|
|
}
|
|
}
|
|
if (bits > 0) output += BASE32_CHARS[(value << (5 - bits)) & 0x1f]
|
|
return output
|
|
}
|
|
|
|
function totpToken(secret, counter) {
|
|
const key = base32Decode(secret)
|
|
const msg = Buffer.alloc(8)
|
|
// Write 64-bit big-endian counter
|
|
const hi = Math.floor(counter / 0x100000000)
|
|
const lo = counter >>> 0
|
|
msg.writeUInt32BE(hi, 0)
|
|
msg.writeUInt32BE(lo, 4)
|
|
const hmac = createHmac('sha1', key).update(msg).digest()
|
|
const offset = hmac[hmac.length - 1] & 0x0f
|
|
const code = ((hmac[offset] & 0x7f) << 24)
|
|
| (hmac[offset + 1] << 16)
|
|
| (hmac[offset + 2] << 8)
|
|
| hmac[offset + 3]
|
|
return String(code % 1000000).padStart(6, '0')
|
|
}
|
|
|
|
export function generateTotpSecret() {
|
|
const buf = Buffer.allocUnsafe(20)
|
|
randomFillSync(buf)
|
|
return base32Encode(buf)
|
|
}
|
|
|
|
export async function loadTotpState() {
|
|
try {
|
|
const raw = await readFile(TOTP_SECRET_FILE, 'utf8')
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function saveTotpState(state) {
|
|
await writeFile(TOTP_SECRET_FILE, JSON.stringify(state, null, 2), 'utf8')
|
|
}
|
|
|
|
export async function isTotpEnabled() {
|
|
const state = await loadTotpState()
|
|
return Boolean(state?.secret && state?.verified)
|
|
}
|
|
|
|
function randomBytesForRecovery(n) {
|
|
const buf = Buffer.allocUnsafe(n)
|
|
randomFillSync(buf)
|
|
return buf
|
|
}
|
|
|
|
export function getTotpUri(secret, label = 'Siteforge Admin') {
|
|
const issuer = 'Siteforge'
|
|
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`
|
|
}
|
|
|
|
export function verifyTotpCode(secret, code) {
|
|
try {
|
|
const token = String(code).replace(/\s/g, '')
|
|
const step = Math.floor(Date.now() / 1000 / 30)
|
|
// Accept current step and one step either side (±30 seconds clock skew)
|
|
for (const offset of [-1, 0, 1]) {
|
|
if (totpToken(secret, step + offset) === token) return true
|
|
}
|
|
return false
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ── Recovery Codes ──────────────────────────────────────────────────────────
|
|
|
|
const RECOVERY_CODE_COUNT = 8
|
|
|
|
function generateRecoveryCode() {
|
|
// Format: XXXX-XXXX-XXXX (uppercase alphanumeric, no ambiguous chars)
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
const randBytes = randomBytesForRecovery(12)
|
|
let code = ''
|
|
for (let i = 0; i < 12; i++) {
|
|
if (i > 0 && i % 4 === 0) code += '-'
|
|
code += chars[randBytes[i] % chars.length]
|
|
}
|
|
return code
|
|
}
|
|
|
|
export function generateRecoveryCodes() {
|
|
const codes = []
|
|
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
|
codes.push(generateRecoveryCode())
|
|
}
|
|
return codes
|
|
}
|
|
|
|
export function hashRecoveryCode(code) {
|
|
return sha256(code.replace(/-/g, '').toUpperCase())
|
|
}
|
|
|
|
// Returns the matched code if valid, null otherwise. Mutates state.hashedRecoveryCodes.
|
|
export function consumeRecoveryCode(state, inputCode) {
|
|
if (!Array.isArray(state.hashedRecoveryCodes) || state.hashedRecoveryCodes.length === 0) return false
|
|
const normalized = inputCode.replace(/[-\s]/g, '').toUpperCase()
|
|
const inputHash = sha256(normalized)
|
|
const idx = state.hashedRecoveryCodes.findIndex(h => {
|
|
const a = Buffer.from(h, 'utf8')
|
|
const b = Buffer.from(inputHash, 'utf8')
|
|
return a.length === b.length && timingSafeEqual(a, b)
|
|
})
|
|
if (idx === -1) return false
|
|
state.hashedRecoveryCodes.splice(idx, 1)
|
|
return true
|
|
}
|
|
|
|
// ── Pending (password-ok, awaiting TOTP) sessions ───────────────────────────
|
|
|
|
export function createPendingSession() {
|
|
const token = randomUUID()
|
|
totpPendingSessions.set(token, { expiresAt: Date.now() + TOTP_PENDING_TTL_MS })
|
|
return token
|
|
}
|
|
|
|
export function consumePendingSession(token) {
|
|
if (!token) return false
|
|
const entry = totpPendingSessions.get(token)
|
|
if (!entry || entry.expiresAt <= Date.now()) {
|
|
totpPendingSessions.delete(token)
|
|
return false
|
|
}
|
|
totpPendingSessions.delete(token)
|
|
return true
|
|
}
|
|
|
|
// ── Admin Sessions ───────────────────────────────────────────────────────────
|
|
|
|
export function createAdminSession() {
|
|
const token = randomUUID()
|
|
adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS)
|
|
return token
|
|
}
|
|
|
|
export function deleteAdminSession(token) {
|
|
if (token) {
|
|
adminSessions.delete(token)
|
|
}
|
|
}
|
|
|
|
export function isValidAdminSession(req) {
|
|
if (!isAdminPasswordConfigured()) return false
|
|
|
|
const cookies = parseCookies(req.headers.cookie)
|
|
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
|
|
if (!sessionToken) return false
|
|
|
|
const expiresAt = adminSessions.get(sessionToken)
|
|
if (!expiresAt || expiresAt <= Date.now()) {
|
|
adminSessions.delete(sessionToken)
|
|
return false
|
|
}
|
|
|
|
adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS)
|
|
return true
|
|
}
|
|
|
|
export function setAdminSessionCookie(res, token) {
|
|
res.append(
|
|
'Set-Cookie',
|
|
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
|
)
|
|
}
|
|
|
|
export function clearAdminSessionCookie(res) {
|
|
res.append(
|
|
'Set-Cookie',
|
|
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
|
)
|
|
}
|
|
|
|
export function requireAdminAuth(req, res, next) {
|
|
if (!isValidAdminSession(req)) {
|
|
res.status(401).json({ message: 'Unauthorized' })
|
|
return
|
|
}
|
|
next()
|
|
}
|