Beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening
This commit is contained in:
+185
-3
@@ -1,6 +1,18 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
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
|
||||
@@ -22,7 +34,6 @@ 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.')
|
||||
}
|
||||
@@ -30,9 +41,180 @@ export function validateAdminPasswordSetup() {
|
||||
|
||||
export function isAdminPasswordValid(password) {
|
||||
if (!isAdminPasswordConfigured()) return false
|
||||
return sha256(password) === sha256(ADMIN_PASSWORD)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user