feat(auth): add local login and CSRF-protected sessions
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { AuthenticationConfigProvider } from "./types.js";
|
||||
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
|
||||
import type { AuthSessionStore } from "./session-store.js";
|
||||
import { rolesToPermissions } from "./config.js";
|
||||
import { getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
|
||||
import { requirePermission, isPrincipalContext } from "./authorization.js";
|
||||
import { deriveCsrfToken } from "./csrf.js";
|
||||
|
||||
const TEN_MINUTES_MS = 10 * 60 * 1000;
|
||||
const REMEMBER_COOKIE_SECONDS = 2_592_000;
|
||||
const MAX_USERNAME_LENGTH = 64;
|
||||
const MAX_PASSWORD_LENGTH = 1024;
|
||||
const MAX_LIMIT_ENTRIES = 10_000;
|
||||
|
||||
export interface AuthRouteDependencies {
|
||||
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
|
||||
authentication?: AuthenticationConfigProvider;
|
||||
sessionStore?: AuthSessionStore;
|
||||
localUserRegistry?: LocalUserRegistry;
|
||||
}
|
||||
|
||||
interface LoginPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
class LoginFailureLimiter {
|
||||
private readonly usernames = new Map<string, number[]>();
|
||||
private readonly addresses = new Map<string, number[]>();
|
||||
|
||||
isLimited(username: string, address: string, now = Date.now()): boolean {
|
||||
return this.active(this.usernames, username, now).length >= 10
|
||||
|| this.active(this.addresses, address, now).length >= 20;
|
||||
}
|
||||
|
||||
recordFailure(username: string, address: string, now = Date.now()): void {
|
||||
this.active(this.usernames, username, now).push(now);
|
||||
this.active(this.addresses, address, now).push(now);
|
||||
}
|
||||
|
||||
private active(bucket: Map<string, number[]>, key: string, now: number): number[] {
|
||||
const prior = bucket.get(key) ?? [];
|
||||
const current = prior.filter((timestamp) => timestamp > now - TEN_MINUTES_MS);
|
||||
if (current.length === 0) bucket.delete(key); else bucket.set(key, current);
|
||||
if (!bucket.has(key) && bucket.size >= MAX_LIMIT_ENTRIES) {
|
||||
const oldest = bucket.keys().next().value;
|
||||
if (typeof oldest === "string") bucket.delete(oldest);
|
||||
}
|
||||
if (!bucket.has(key)) bucket.set(key, current);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
class VerificationGate {
|
||||
private active = 0;
|
||||
|
||||
async run(operation: () => Promise<boolean>): Promise<boolean | undefined> {
|
||||
if (this.active >= 2) return undefined;
|
||||
this.active += 1;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
this.active -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void {
|
||||
const limiter = new LoginFailureLimiter();
|
||||
const verificationGate = new VerificationGate();
|
||||
|
||||
app.get("/auth/config", async (_request, reply) => {
|
||||
try {
|
||||
const mode = deps.authentication?.current().value.mode ?? deps.authMode;
|
||||
return reply.send({ mode, localLogin: mode === "local", oidcLogin: false });
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/auth/local/login", async (request, reply) => {
|
||||
const configured = currentLocalConfig(deps);
|
||||
if (!configured || !deps.localUserRegistry || !deps.sessionStore) return unavailable(reply);
|
||||
const originCheck = requireExactOrigin(request, reply, configured.origin);
|
||||
if (originCheck !== true) return originCheck;
|
||||
|
||||
const payload = loginPayload(request);
|
||||
const normalizedUsername = payload.username.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
||||
const sourceAddress = boundedAddress(request.ip);
|
||||
if (limiter.isLimited(normalizedUsername, sourceAddress)) return loginLimited(reply);
|
||||
|
||||
let user: LocalUserRecord | undefined;
|
||||
try {
|
||||
if (payload.username.length > 0) user = await deps.localUserRegistry.findByUsername(payload.username);
|
||||
} catch {
|
||||
user = undefined;
|
||||
}
|
||||
let verified: boolean | undefined;
|
||||
try {
|
||||
verified = await verificationGate.run(async () =>
|
||||
deps.localUserRegistry!.verify(user, argon2SafePassword(payload.password)));
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
if (verified === undefined) return loginLimited(reply);
|
||||
if (!verified || !user || !user.enabled) {
|
||||
limiter.recordFailure(normalizedUsername, sourceAddress);
|
||||
return invalidCredentials(reply);
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await deps.sessionStore.create({
|
||||
principal: {
|
||||
issuer: "local",
|
||||
subject: user.id,
|
||||
displayName: user.displayName ?? user.username,
|
||||
roles: user.roles,
|
||||
permissions: rolesToPermissions(user.roles),
|
||||
isAdmin: user.roles.includes("admin"),
|
||||
},
|
||||
method: "local",
|
||||
remembered: payload.remember,
|
||||
userAuthRevision: user.authRevision,
|
||||
authConfigRevision: configured.revision,
|
||||
idleTtlMs: (payload.remember ? configured.session.rememberIdleSeconds : configured.session.regularIdleSeconds) * 1000,
|
||||
absoluteTtlMs: (payload.remember ? configured.session.rememberTtlSeconds : configured.session.regularTtlSeconds) * 1000,
|
||||
});
|
||||
reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.secure, payload.remember));
|
||||
return reply.send({});
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/auth/oidc/login", async (_request, reply) => notImplemented(reply));
|
||||
app.get("/auth/oidc/callback", async (_request, reply) => notImplemented(reply));
|
||||
|
||||
app.post("/auth/logout", async (request, reply) => {
|
||||
const token = request.authSessionToken;
|
||||
if (!token || !deps.sessionStore) return unavailable(reply);
|
||||
try {
|
||||
await deps.sessionStore.revoke(token);
|
||||
reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(deps), false));
|
||||
return reply.code(204).send();
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/me", async (request, reply) => {
|
||||
const principal = requirePermission(request, reply, "session.use");
|
||||
if (!isPrincipalContext(principal)) return principal;
|
||||
const session = request.authSession;
|
||||
const token = request.authSessionToken;
|
||||
if (!session || !token) return unavailable(reply);
|
||||
try {
|
||||
return {
|
||||
issuer: principal.issuer,
|
||||
subject: principal.subject,
|
||||
...(principal.displayName === undefined ? {} : { displayName: principal.displayName }),
|
||||
roles: principal.roles,
|
||||
permissions: principal.permissions,
|
||||
isAdmin: principal.isAdmin,
|
||||
csrfToken: deriveCsrfToken(token),
|
||||
session: {
|
||||
method: session.method,
|
||||
remembered: session.remembered,
|
||||
idleExpiresAt: session.idleExpiresAt,
|
||||
absoluteExpiresAt: session.absoluteExpiresAt,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return unavailable(reply);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function currentLocalConfig(deps: AuthRouteDependencies): {
|
||||
revision: string;
|
||||
origin: string;
|
||||
secure: boolean;
|
||||
session: { regularTtlSeconds: number; regularIdleSeconds: number; rememberTtlSeconds: number; rememberIdleSeconds: number };
|
||||
} | undefined {
|
||||
try {
|
||||
const loaded = deps.authentication?.current();
|
||||
if (!loaded || loaded.value.mode !== "local") return undefined;
|
||||
const url = new URL(loaded.value.publicUrl);
|
||||
return {
|
||||
revision: loaded.revision,
|
||||
origin: url.origin,
|
||||
secure: url.protocol === "https:",
|
||||
session: loaded.value.session,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function currentSecure(deps: AuthRouteDependencies): boolean {
|
||||
try { return new URL(deps.authentication?.current().value.publicUrl ?? "").protocol === "https:"; } catch { return false; }
|
||||
}
|
||||
|
||||
function cookieOptions(secure: boolean, remembered: boolean) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure,
|
||||
...(remembered ? { maxAge: REMEMBER_COOKIE_SECONDS } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function loginPayload(request: FastifyRequest): LoginPayload {
|
||||
const body = request.body;
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return { username: "", password: "", remember: false };
|
||||
const input = body as Record<string, unknown>;
|
||||
return {
|
||||
username: typeof input.username === "string" && input.username.length <= MAX_USERNAME_LENGTH ? input.username : "",
|
||||
password: typeof input.password === "string" && input.password.length <= MAX_PASSWORD_LENGTH ? input.password : "",
|
||||
remember: input.remember === true,
|
||||
};
|
||||
}
|
||||
|
||||
function boundedAddress(address: string): string {
|
||||
return typeof address === "string" && address.length > 0 && address.length <= 128 ? address : "unknown";
|
||||
}
|
||||
|
||||
function argon2SafePassword(value: string): string {
|
||||
const typed = value as string & { isWellFormed?: () => boolean };
|
||||
const wellFormed = typeof typed.isWellFormed === "function"
|
||||
? typed.isWellFormed()
|
||||
: !/[\uD800-\uDFFF]/.test(value);
|
||||
const bytes = Buffer.byteLength(value, "utf8");
|
||||
if (wellFormed && bytes >= 12 && bytes <= MAX_PASSWORD_LENGTH) return value;
|
||||
// A per-attempt random value preserves the Argon2 work without turning an invalid input into
|
||||
// a reusable password that could happen to match a user's configured secret.
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function invalidCredentials(reply: FastifyReply): FastifyReply {
|
||||
return reply.code(401).send({ code: "invalid_credentials", error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
function loginLimited(reply: FastifyReply): FastifyReply {
|
||||
return reply.code(429).send({ code: "login_rate_limited", error: "Too many login attempts" });
|
||||
}
|
||||
|
||||
function unavailable(reply: FastifyReply): FastifyReply {
|
||||
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
|
||||
}
|
||||
|
||||
function notImplemented(reply: FastifyReply): FastifyReply {
|
||||
return reply.code(501).send({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
|
||||
}
|
||||
Reference in New Issue
Block a user