Claude / server /auth.js
Cnass's picture
Agent Web 2.0: token tasarrufu, kalici MCP kurulumu, guvenlik, yan menu araclari, 3B avatar ve ses
765fe0a verified
Raw History Blame Contribute Delete
6.84 kB
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.<issued at, seconds>.<epoch>.<signature>
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,
};