const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const APP_PASSWORD = process.env.APP_PASSWORD; const COOKIE_NAME = 'cc_session'; const COOKIE_DAYS = 30; const COOKIE_MAX_AGE_S = 60 * 60 * 24 * COOKIE_DAYS; const STATE_DIR = path.join(process.env.HOME || '/home/node', '.cc-web'); const SECRET_FILE = path.join(STATE_DIR, 'session-secret'); const AUTH_FILE = path.join(STATE_DIR, 'auth.json'); // Without a SESSION_SECRET secret one is generated once and kept in HOME, so // a restart (or a Space waking up) no longer logs every device out. function loadSecret() { if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET; try { const saved = fs.readFileSync(SECRET_FILE, 'utf8').trim(); if (saved.length >= 32) return saved; } catch { // ilk calisma } const secret = crypto.randomBytes(32).toString('hex'); try { fs.mkdirSync(STATE_DIR, { recursive: true }); fs.writeFileSync(SECRET_FILE, `${secret}\n`, { mode: 0o600 }); } catch (err) { console.warn(`[auth] UYARI: oturum anahtari kaydedilemedi (${err.message}); yeniden baslatmada girisler sifirlanir.`); } return secret; } const SESSION_SECRET = loadSecret(); // "Log out everywhere" bumps the epoch: every cookie signed before is void. function loadEpoch() { try { const saved = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')); return Number.isInteger(saved.epoch) && saved.epoch >= 0 ? saved.epoch : 0; } catch { return 0; } } let epoch = loadEpoch(); function hmac(value) { return crypto.createHmac('sha256', SESSION_SECRET).update(value).digest('hex'); } // v2... function makeToken(now = Date.now()) { const value = `v2.${Math.floor(now / 1000)}.${epoch}`; return `${value}.${hmac(value)}`; } function verifyCookieValue(signed, now = Date.now()) { if (typeof signed !== 'string' || !signed) return false; const idx = signed.lastIndexOf('.'); if (idx < 0) return false; const value = signed.slice(0, idx); const sig = signed.slice(idx + 1); if (!/^[0-9a-f]{64}$/.test(sig)) return false; const sigBuf = Buffer.from(sig, 'hex'); const expBuf = Buffer.from(hmac(value), 'hex'); if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return false; const parts = value.split('.'); if (parts.length !== 3 || parts[0] !== 'v2') return false; const issued = Number(parts[1]); const tokenEpoch = Number(parts[2]); if (!Number.isFinite(issued) || tokenEpoch !== epoch) return false; const age = now / 1000 - issued; return age > -300 && age < COOKIE_MAX_AGE_S; } function parseCookies(header) { const out = {}; (header || '').split(';').forEach((part) => { const idx = part.indexOf('='); if (idx < 0) return; const k = part.slice(0, idx).trim(); const v = part.slice(idx + 1).trim(); if (!k) return; try { out[k] = decodeURIComponent(v); } catch { out[k] = v; } }); return out; } function checkPassword(candidate) { if (!APP_PASSWORD) return false; // Hashing first makes the comparison constant-time whatever the lengths. const a = crypto.createHash('sha256').update(String(candidate ?? '')).digest(); const b = crypto.createHash('sha256').update(APP_PASSWORD).digest(); return crypto.timingSafeEqual(a, b); } function isAuthenticated(req) { const cookies = parseCookies(req.headers.cookie); return verifyCookieValue(cookies[COOKIE_NAME]); } function setAuthCookie(res) { res.setHeader('Set-Cookie', `${COOKIE_NAME}=${makeToken()}; HttpOnly; Secure; Path=/; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}`); } function clearAuthCookie(res) { res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Secure; Path=/; SameSite=Strict; Max-Age=0`); } // Every device has to log in again. function logoutEverywhere() { epoch += 1; try { fs.mkdirSync(STATE_DIR, { recursive: true }); fs.writeFileSync(AUTH_FILE, JSON.stringify({ epoch, changedAt: Date.now() }), { mode: 0o600 }); } catch (err) { console.error('[auth] cikis kaydedilemedi:', err.message); } } function requireAuth(req, res, next) { if (isAuthenticated(req)) return next(); res.status(401).json({ error: 'unauthorized' }); } // ---- login rate limiting ---- // Per client address: 5 wrong passwords are free, then each one doubles a // lockout (30 s, 1 min, 2 min ... at most 30 min). A global cap slows down // guessing spread over many addresses. const FREE_FAILURES = 5; const BASE_LOCK_MS = 30 * 1000; const MAX_LOCK_MS = 30 * 60 * 1000; const FORGET_MS = 6 * 60 * 60 * 1000; const GLOBAL_WINDOW_MS = 60 * 1000; const GLOBAL_MAX_FAILURES = 40; const failures = new Map(); let globalFailures = []; const PRIVATE_IP = /^(?:10\.|127\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|169\.254\.|::1$|f[cd][0-9a-f]{2}:|fe80:)/i; // HF's proxy appends the real address to X-Forwarded-For; a value the client // sent itself stays in front of it, so the right-most public entry is used. function clientIp(req) { const forwarded = String(req.headers['x-forwarded-for'] || '') .split(',') .map((s) => s.trim().replace(/^::ffff:/, '')) .filter(Boolean); for (let i = forwarded.length - 1; i >= 0; i -= 1) { if (!PRIVATE_IP.test(forwarded[i])) return forwarded[i]; } return String((req.socket && req.socket.remoteAddress) || 'unknown').replace(/^::ffff:/, ''); } function prune(now) { for (const [ip, entry] of failures) { if (now - entry.last > FORGET_MS) failures.delete(ip); } globalFailures = globalFailures.filter((t) => now - t < GLOBAL_WINDOW_MS); } // Milliseconds until this address may try again (0: now). function loginWait(req, now = Date.now()) { prune(now); const entry = failures.get(clientIp(req)); const own = entry && entry.until > now ? entry.until - now : 0; const global = globalFailures.length >= GLOBAL_MAX_FAILURES ? GLOBAL_WINDOW_MS - (now - globalFailures[0]) : 0; return Math.max(own, global, 0); } function recordFailure(req, now = Date.now()) { const ip = clientIp(req); const entry = failures.get(ip) || { count: 0, until: 0, last: 0 }; entry.count += 1; entry.last = now; if (entry.count > FREE_FAILURES) { entry.until = now + Math.min(MAX_LOCK_MS, BASE_LOCK_MS * 2 ** (entry.count - FREE_FAILURES - 1)); } failures.set(ip, entry); globalFailures.push(now); if (entry.count === FREE_FAILURES + 1) console.warn(`[auth] ${ip} adresinden cok sayida hatali giris; gecici olarak engellendi.`); } function recordSuccess(req) { failures.delete(clientIp(req)); } module.exports = { APP_PASSWORD, checkPassword, isAuthenticated, setAuthCookie, clearAuthCookie, logoutEverywhere, requireAuth, parseCookies, verifyCookieValue, makeToken, loginWait, recordFailure, recordSuccess, clientIp, COOKIE_NAME, };