mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 22:26:33 +00:00
Each service plugin now independently initializes its own DB connection via shared/db/init.ts instead of depending on a central service-store plugin. This removes the inter-plugin dependency ordering and the plugin-registry singleton, making each service self-contained. - Move db-adapter, repository, mappers, migrations, adapters to shared/db/ - Create shared/db/config.ts (reusable dbConfigSchema) and init.ts - Delete service-store plugin and plugin-registry - Add db config block to each service's ConfigSchema + sec-config template - Move event_log purge timer into service-admin-http - Update all import paths across shared modules and plugins Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
311 lines
9.5 KiB
TypeScript
311 lines
9.5 KiB
TypeScript
/**
|
|
* Auth — shared module (not a BSB plugin).
|
|
*
|
|
* createAuth(repo, secrets, config) → AuthApi
|
|
*/
|
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
import argon2 from "argon2";
|
|
import { TOTP, Secret } from "otpauth";
|
|
|
|
import type { Repository } from "./db/repository.js";
|
|
import type { SecretsApi } from "./secrets.js";
|
|
import type { ApiKey, ApiKeyScope, Session, User } from "./types.js";
|
|
|
|
// ---- Public interface -------------------------------------------------------
|
|
|
|
export interface AuthConfig {
|
|
sessionIdleSeconds: number;
|
|
sessionMaxSeconds: number;
|
|
loginLockoutThreshold: number;
|
|
loginLockoutSeconds: number;
|
|
argon2Memory: number;
|
|
argon2TimeCost: number;
|
|
argon2Parallelism: number;
|
|
totpIssuer: string;
|
|
cookieName: string;
|
|
}
|
|
|
|
export interface AuthApi {
|
|
readonly config: AuthConfig;
|
|
hashPassword(plain: string): Promise<string>;
|
|
verifyPassword(plain: string, hash: string): Promise<boolean>;
|
|
needsRehash(hash: string): boolean;
|
|
generateTotpSecret(): string;
|
|
totpProvisioningUri(username: string, secretBase32: string): string;
|
|
verifyTotpCode(secretBase32: string, code: string): boolean;
|
|
encryptTotpSecret(secret: string): string;
|
|
decryptTotpSecret(ciphertext: string): string;
|
|
generateRecoveryCodes(): string[];
|
|
hashRecoveryCodes(codes: string[]): Promise<string[]>;
|
|
consumeRecoveryCode(code: string, hashedCodes: string[]): Promise<{ ok: boolean; remaining: string[] }>;
|
|
createSession(input: {
|
|
user: User;
|
|
userAgent: string | null;
|
|
ipAddress: string | null;
|
|
totpPending: boolean;
|
|
}): Promise<{ session: Session; cookieValue: string }>;
|
|
resolveSession(cookieValue: string): Promise<{ session: Session; user: User } | null>;
|
|
revokeSession(sid: string): Promise<void>;
|
|
createApiKey(input: {
|
|
name: string;
|
|
scopes: ApiKeyScope[];
|
|
expiresAt: string | null;
|
|
}): Promise<{ apiKey: ApiKey; plaintext: string }>;
|
|
verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null>;
|
|
verifyKioskKey(plaintext: string): Promise<{ id: number } | null>;
|
|
}
|
|
|
|
// ---- Constants --------------------------------------------------------------
|
|
|
|
const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
const RECOVERY_CODE_COUNT = 10;
|
|
const RECOVERY_CODE_LENGTH = 10;
|
|
|
|
// ---- Factory ----------------------------------------------------------------
|
|
|
|
export function createAuth(
|
|
repo: Repository,
|
|
secrets: SecretsApi,
|
|
config: AuthConfig,
|
|
): AuthApi {
|
|
|
|
// ---- Passwords ------------------------------------------------------------
|
|
|
|
async function hashPassword(plain: string): Promise<string> {
|
|
return argon2.hash(plain, {
|
|
type: argon2.argon2id,
|
|
memoryCost: config.argon2Memory,
|
|
timeCost: config.argon2TimeCost,
|
|
parallelism: config.argon2Parallelism,
|
|
});
|
|
}
|
|
|
|
async function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
|
try {
|
|
return await argon2.verify(hash, plain);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function needsRehash(hash: string): boolean {
|
|
return argon2.needsRehash(hash, {
|
|
memoryCost: config.argon2Memory,
|
|
timeCost: config.argon2TimeCost,
|
|
parallelism: config.argon2Parallelism,
|
|
});
|
|
}
|
|
|
|
// ---- TOTP -----------------------------------------------------------------
|
|
|
|
function generateTotpSecret(): string {
|
|
return new Secret({ size: 20 }).base32;
|
|
}
|
|
|
|
function totpProvisioningUri(username: string, secretBase32: string): string {
|
|
const totp = new TOTP({
|
|
issuer: config.totpIssuer,
|
|
label: username,
|
|
algorithm: "SHA1",
|
|
digits: 6,
|
|
period: 30,
|
|
secret: Secret.fromBase32(secretBase32),
|
|
});
|
|
return totp.toString();
|
|
}
|
|
|
|
function verifyTotpCode(secretBase32: string, code: string): boolean {
|
|
const totp = new TOTP({
|
|
issuer: config.totpIssuer,
|
|
algorithm: "SHA1",
|
|
digits: 6,
|
|
period: 30,
|
|
secret: Secret.fromBase32(secretBase32),
|
|
});
|
|
return totp.validate({ token: code, window: 1 }) !== null;
|
|
}
|
|
|
|
function encryptTotpSecret(secret: string): string {
|
|
return secrets.encryptString(secret, "totp");
|
|
}
|
|
|
|
function decryptTotpSecret(ciphertext: string): string {
|
|
return secrets.decryptString(ciphertext, "totp");
|
|
}
|
|
|
|
// ---- Recovery codes -------------------------------------------------------
|
|
|
|
function generateRecoveryCodes(): string[] {
|
|
const out: string[] = [];
|
|
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
|
const chars: string[] = [];
|
|
const buf = randomBytes(RECOVERY_CODE_LENGTH);
|
|
for (let j = 0; j < RECOVERY_CODE_LENGTH; j++) {
|
|
chars.push(RECOVERY_ALPHABET[buf[j]! % RECOVERY_ALPHABET.length]!);
|
|
}
|
|
out.push(chars.join(""));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function hashRecoveryCodes(codes: string[]): Promise<string[]> {
|
|
return Promise.all(codes.map((c) => hashPassword(c)));
|
|
}
|
|
|
|
async function consumeRecoveryCode(
|
|
code: string,
|
|
hashedCodes: string[],
|
|
): Promise<{ ok: boolean; remaining: string[] }> {
|
|
const remaining: string[] = [];
|
|
let consumed = false;
|
|
for (const h of hashedCodes) {
|
|
if (!consumed && (await verifyPassword(code, h))) {
|
|
consumed = true;
|
|
continue;
|
|
}
|
|
remaining.push(h);
|
|
}
|
|
return { ok: consumed, remaining };
|
|
}
|
|
|
|
// ---- Sessions -------------------------------------------------------------
|
|
|
|
const cookieKey = secrets.deriveKey("cookie");
|
|
|
|
function cookieMac(sid: string): string {
|
|
return createHmac("sha256", cookieKey).update(sid).digest("hex");
|
|
}
|
|
|
|
function signCookie(sid: string): string {
|
|
return `${sid}.${cookieMac(sid)}`;
|
|
}
|
|
|
|
function unsignCookie(cookieValue: string): string | null {
|
|
const dot = cookieValue.indexOf(".");
|
|
if (dot < 0) return null;
|
|
const sid = cookieValue.slice(0, dot);
|
|
const mac = cookieValue.slice(dot + 1);
|
|
const expected = cookieMac(sid);
|
|
const a = Buffer.from(mac, "hex");
|
|
const b = Buffer.from(expected, "hex");
|
|
if (a.length !== b.length) return null;
|
|
return timingSafeEqual(a, b) ? sid : null;
|
|
}
|
|
|
|
async function createSession(input: {
|
|
user: User;
|
|
userAgent: string | null;
|
|
ipAddress: string | null;
|
|
totpPending: boolean;
|
|
}): Promise<{ session: Session; cookieValue: string }> {
|
|
const id = randomBytes(32).toString("hex");
|
|
const csrfToken = randomBytes(32).toString("hex");
|
|
const expiresAt = new Date(
|
|
Date.now() + config.sessionMaxSeconds * 1000,
|
|
).toISOString();
|
|
const session = await repo.createSession({
|
|
id,
|
|
user_id: input.user.id,
|
|
csrf_token: csrfToken,
|
|
totp_pending: input.totpPending,
|
|
user_agent: input.userAgent,
|
|
ip_address: input.ipAddress,
|
|
expires_at: expiresAt,
|
|
});
|
|
return { session, cookieValue: signCookie(id) };
|
|
}
|
|
|
|
async function resolveSession(
|
|
cookieValue: string,
|
|
): Promise<{ session: Session; user: User } | null> {
|
|
const sid = unsignCookie(cookieValue);
|
|
if (!sid) return null;
|
|
const session = await repo.getSessionById(sid);
|
|
if (!session) return null;
|
|
if (session.revoked_at) return null;
|
|
const now = new Date();
|
|
if (new Date(session.expires_at) <= now) return null;
|
|
const idleMs = config.sessionIdleSeconds * 1000;
|
|
if (now.getTime() - new Date(session.last_seen_at).getTime() > idleMs) {
|
|
await repo.revokeSession(sid);
|
|
return null;
|
|
}
|
|
const user = await repo.getUserById(session.user_id);
|
|
if (!user || !user.is_active) return null;
|
|
await repo.touchSession(sid, now.toISOString());
|
|
return { session, user };
|
|
}
|
|
|
|
async function revokeSession(sid: string): Promise<void> {
|
|
await repo.revokeSession(sid);
|
|
}
|
|
|
|
// ---- API keys -------------------------------------------------------------
|
|
|
|
async function createApiKey(input: {
|
|
name: string;
|
|
scopes: ApiKeyScope[];
|
|
expiresAt: string | null;
|
|
}): Promise<{ apiKey: ApiKey; plaintext: string }> {
|
|
const plaintext = `bf-${randomBytes(24).toString("base64url")}`;
|
|
const keyHash = await hashPassword(plaintext);
|
|
const keyPrefix = plaintext.slice(0, 8);
|
|
const apiKey = await repo.createApiKey({
|
|
name: input.name,
|
|
key_hash: keyHash,
|
|
key_prefix: keyPrefix,
|
|
scopes: input.scopes,
|
|
expires_at: input.expiresAt,
|
|
});
|
|
return { apiKey, plaintext };
|
|
}
|
|
|
|
async function verifyApiKey(plaintext: string, ip: string | null): Promise<ApiKey | null> {
|
|
const prefix = plaintext.slice(0, 8);
|
|
const candidates = await repo.listApiKeysByPrefix(prefix);
|
|
for (const cand of candidates) {
|
|
if (cand.revoked_at) continue;
|
|
if (cand.expires_at && new Date(cand.expires_at) <= new Date()) continue;
|
|
if (await verifyPassword(plaintext, cand.key_hash)) {
|
|
await repo.touchApiKey(cand.id, ip);
|
|
return cand;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function verifyKioskKey(plaintext: string): Promise<{ id: number } | null> {
|
|
if (plaintext.length < 8) return null;
|
|
const prefix = plaintext.slice(0, 8);
|
|
const candidates = await repo.listKiosksByKeyPrefix(prefix);
|
|
for (const cand of candidates) {
|
|
if (await verifyPassword(plaintext, cand.key_hash)) {
|
|
return { id: cand.id };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---- Return ---------------------------------------------------------------
|
|
|
|
return {
|
|
config,
|
|
hashPassword,
|
|
verifyPassword,
|
|
needsRehash,
|
|
generateTotpSecret,
|
|
totpProvisioningUri,
|
|
verifyTotpCode,
|
|
encryptTotpSecret,
|
|
decryptTotpSecret,
|
|
generateRecoveryCodes,
|
|
hashRecoveryCodes,
|
|
consumeRecoveryCode,
|
|
createSession,
|
|
resolveSession,
|
|
revokeSession,
|
|
createApiKey,
|
|
verifyApiKey,
|
|
verifyKioskKey,
|
|
};
|
|
}
|