Gitea - lien « Code source » hardcodé (REPO_SOURCE_URL) vers le dépôt, toujours visible - retrait complet du réglage configurable (api.ts, route+store settings, SettingsView, i18n, help, tests) Découverte des dépôts - aucune racine de scan par défaut → pas de scan au premier démarrage (clean install) - corrige la découverte des dépôts à l'INTÉRIEUR d'une racine qui est elle-même un repo (depth 0 = conteneur de scan, on descend ; depth > 0 = feuille) Sécurité « enterprise-deployable » - en-têtes HTTP durcis (CSP, X-Frame-Options, nosniff, Referrer-Policy, HSTS conditionnel, no-store API), header Server retiré - permissions DB 0o600 / dossier de données 0o700 ; error handler sanitisé ; garde Content-Type sur les mutations - chiffrement au repos AES-256-GCM des secrets (server_secret, clé privée VAPID) via SecretBox - journal d'audit (migration #7) + endpoint /audit-logs + RGPD export/effacement + UI Réglages - SECURITY.md, docs/ENTERPRISE_DEPLOYMENT.md, sections README EN/FR, SBOM CycloneDX en CI CI - pack-smoke packe depuis le contexte du package (cd packages/server) au lieu de « npm pack -w » : corrige l'embarquement de @arboretum/shared dans le tarball Purge des données personnelles du dépôt public - suppression de spikes/s4-discovery/result.json, reformulation du VERDICT - chemin de test générique, anonymisation des 5 fixtures de dialogues
188 lines
7.0 KiB
TypeScript
188 lines
7.0 KiB
TypeScript
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
import { type Db, getSetting, setSetting } from '../db/index.js';
|
|
import type { SecretBox } from '../core/secret-box.js';
|
|
import { recordAudit } from '../core/audit-log.js';
|
|
|
|
const COOKIE_NAME = 'arb_session';
|
|
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
|
|
|
|
export interface AuthContext {
|
|
tokenId: string;
|
|
label: string;
|
|
}
|
|
|
|
export class AuthService {
|
|
private readonly secret: Buffer;
|
|
|
|
constructor(
|
|
private readonly db: Db,
|
|
box?: SecretBox,
|
|
) {
|
|
// server_secret chiffré au repos quand un SecretBox est fourni (prod). Migration douce :
|
|
// une valeur en clair pré-existante est re-chiffrée à la lecture.
|
|
const stored = getSetting(db, 'server_secret');
|
|
let secretHex: string;
|
|
if (!stored) {
|
|
secretHex = randomBytes(32).toString('hex');
|
|
setSetting(db, 'server_secret', box ? box.encrypt(secretHex) : secretHex);
|
|
recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'server_secret' });
|
|
} else {
|
|
secretHex = box ? box.decrypt(stored) : stored;
|
|
if (box && !box.isEncrypted(stored)) setSetting(db, 'server_secret', box.encrypt(secretHex));
|
|
}
|
|
this.secret = Buffer.from(secretHex, 'hex');
|
|
}
|
|
|
|
get cookieName(): string {
|
|
return COOKIE_NAME;
|
|
}
|
|
|
|
/** Crée le token initial au premier démarrage et renvoie sa valeur en clair (affichée une seule fois). */
|
|
ensureBootstrapToken(): string | null {
|
|
const row = this.db
|
|
.prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL')
|
|
.get() as { n: number };
|
|
if (row.n > 0) return null;
|
|
return this.createToken('initial');
|
|
}
|
|
|
|
createToken(label: string): string {
|
|
return this.createTokenRecord(label).token;
|
|
}
|
|
|
|
/** Comme createToken mais renvoie aussi l'id (pour l'API de gestion des tokens). */
|
|
createTokenRecord(label: string): { id: string; token: string } {
|
|
const id = randomUUID();
|
|
const raw = `arb_${randomBytes(24).toString('hex')}`;
|
|
this.db
|
|
.prepare('INSERT INTO auth_tokens (id, label, token_hash, created_at) VALUES (?, ?, ?, ?)')
|
|
.run(id, label, sha256(raw), new Date().toISOString());
|
|
return { id, token: raw };
|
|
}
|
|
|
|
/** Tokens actifs (non révoqués), du plus ancien au plus récent. Ne renvoie JAMAIS le hash. */
|
|
listTokens(): Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null }> {
|
|
return this.db
|
|
.prepare(
|
|
'SELECT id, label, created_at AS createdAt, last_used_at AS lastUsedAt FROM auth_tokens WHERE revoked_at IS NULL ORDER BY created_at',
|
|
)
|
|
.all() as Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null }>;
|
|
}
|
|
|
|
/** Nombre de tokens actifs (non révoqués). */
|
|
countActiveTokens(): number {
|
|
return (this.db.prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL').get() as { n: number }).n;
|
|
}
|
|
|
|
/**
|
|
* Révoque un token. Refuse de révoquer le DERNIER token actif (sinon lock-out total) → 'last'.
|
|
* 'ok' = révoqué ; 'not_found' = id inconnu ou déjà révoqué.
|
|
*/
|
|
revokeToken(id: string): 'ok' | 'last' | 'not_found' {
|
|
// Transaction : le check « dernier token » et l'UPDATE doivent être atomiques (garde
|
|
// anti lock-out robuste, même si un refactor futur introduisait de la concurrence).
|
|
this.db.exec('BEGIN IMMEDIATE');
|
|
try {
|
|
const row = this.db.prepare('SELECT id FROM auth_tokens WHERE id = ? AND revoked_at IS NULL').get(id) as
|
|
| { id: string }
|
|
| undefined;
|
|
let result: 'ok' | 'last' | 'not_found';
|
|
if (!row) {
|
|
result = 'not_found';
|
|
} else if (this.countActiveTokens() <= 1) {
|
|
result = 'last';
|
|
} else {
|
|
this.db.prepare('UPDATE auth_tokens SET revoked_at = ? WHERE id = ?').run(new Date().toISOString(), id);
|
|
result = 'ok';
|
|
}
|
|
this.db.exec('COMMIT');
|
|
return result;
|
|
} catch (err) {
|
|
this.db.exec('ROLLBACK');
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
verifyRawToken(raw: string): AuthContext | null {
|
|
if (typeof raw !== 'string' || raw.length < 8 || raw.length > 128) return null;
|
|
const row = this.db
|
|
.prepare('SELECT id, label, token_hash FROM auth_tokens WHERE revoked_at IS NULL AND token_hash = ?')
|
|
.get(sha256(raw)) as { id: string; label: string; token_hash: string } | undefined;
|
|
if (!row) return null;
|
|
// double vérification en temps constant (le lookup par hash est déjà non-oracle, ceinture+bretelles)
|
|
if (!timingSafeEqual(Buffer.from(row.token_hash), Buffer.from(sha256(raw)))) return null;
|
|
this.db.prepare('UPDATE auth_tokens SET last_used_at = ? WHERE id = ?').run(new Date().toISOString(), row.id);
|
|
return { tokenId: row.id, label: row.label };
|
|
}
|
|
|
|
issueCookie(ctx: AuthContext): string {
|
|
const expires = Date.now() + COOKIE_TTL_MS;
|
|
const payload = `${ctx.tokenId}.${expires}`;
|
|
return `${payload}.${this.sign(payload)}`;
|
|
}
|
|
|
|
verifyCookie(value: string | undefined): AuthContext | null {
|
|
if (!value) return null;
|
|
const lastDot = value.lastIndexOf('.');
|
|
if (lastDot <= 0) return null;
|
|
const payload = value.slice(0, lastDot);
|
|
const sig = value.slice(lastDot + 1);
|
|
const expected = this.sign(payload);
|
|
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
|
const [tokenId, expiresStr] = payload.split('.');
|
|
if (!tokenId || !expiresStr) return null;
|
|
const expires = Number(expiresStr);
|
|
if (!Number.isFinite(expires) || expires < Date.now()) return null;
|
|
const row = this.db
|
|
.prepare('SELECT id, label FROM auth_tokens WHERE id = ? AND revoked_at IS NULL')
|
|
.get(tokenId) as { id: string; label: string } | undefined;
|
|
return row ? { tokenId: row.id, label: row.label } : null;
|
|
}
|
|
|
|
private sign(payload: string): string {
|
|
return createHmac('sha256', this.secret).update(payload).digest('hex');
|
|
}
|
|
}
|
|
|
|
function sha256(s: string): string {
|
|
return createHash('sha256').update(s).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Rate limit GLOBAL du login (pas par IP : derrière Tailscale Serve toutes les
|
|
* requêtes arrivent de 127.0.0.1) + backoff exponentiel sur échecs consécutifs.
|
|
*/
|
|
export class LoginRateLimiter {
|
|
private attempts: number[] = [];
|
|
private consecutiveFailures = 0;
|
|
private lockedUntil = 0;
|
|
|
|
constructor(
|
|
private readonly maxPerMinute = 5,
|
|
private readonly now: () => number = Date.now,
|
|
) {}
|
|
|
|
/** null = autorisé ; sinon délai d'attente en ms */
|
|
check(): number | null {
|
|
const t = this.now();
|
|
if (t < this.lockedUntil) return this.lockedUntil - t;
|
|
this.attempts = this.attempts.filter((a) => t - a < 60_000);
|
|
if (this.attempts.length >= this.maxPerMinute) return 60_000 - (t - (this.attempts[0] ?? t));
|
|
this.attempts.push(t);
|
|
return null;
|
|
}
|
|
|
|
recordFailure(): void {
|
|
this.consecutiveFailures += 1;
|
|
if (this.consecutiveFailures >= 3) {
|
|
const backoff = Math.min(2 ** (this.consecutiveFailures - 3) * 30_000, 30 * 60_000);
|
|
this.lockedUntil = this.now() + backoff;
|
|
}
|
|
}
|
|
|
|
recordSuccess(): void {
|
|
this.consecutiveFailures = 0;
|
|
this.lockedUntil = 0;
|
|
}
|
|
}
|