2026-05-09 23:09:13 +00:00
|
|
|
/**
|
|
|
|
|
* Authentication routes: login, TOTP, recovery, logout.
|
|
|
|
|
*/
|
2026-05-10 00:53:06 +00:00
|
|
|
import { type H3, readBody, getCookie, getQuery, getRequestHeader } from "h3";
|
|
|
|
|
import { htmlPage, redirectWithCookie, redirectClearCookie } from "./html-response.js";
|
2026-05-09 23:09:13 +00:00
|
|
|
import type { AdminDeps } from "./index.js";
|
|
|
|
|
import { LoginPage, TotpPage, RecoveryPage } from "../../web-templates/auth-pages.js";
|
2026-05-14 05:38:18 +00:00
|
|
|
import { audit } from "../../shared/audit.js";
|
2026-05-14 05:40:22 +00:00
|
|
|
import { createRateLimiter } from "../../shared/rate-limit.js";
|
|
|
|
|
|
|
|
|
|
// 8 attempts per 60s per IP — paired with the user-account lockout already in
|
|
|
|
|
// place via deps.auth.config.loginLockoutThreshold to defeat enumeration.
|
|
|
|
|
const loginGuard = createRateLimiter({ windowMs: 60_000, max: 8 });
|
2026-05-09 23:09:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
export function registerAuthRoutes(app: H3, deps: AdminDeps): void {
|
|
|
|
|
// ---- Login ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
app.get("/auth/login", (event) => {
|
|
|
|
|
const q = getQuery(event) as Record<string, string | undefined>;
|
|
|
|
|
const welcome = q["welcome"] === "1";
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(LoginPage({ welcome }));
|
2026-05-09 23:09:13 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post("/auth/login", async (event) => {
|
2026-05-14 05:40:22 +00:00
|
|
|
const ip = getRequestHeader(event, "x-real-ip")
|
|
|
|
|
?? getRequestHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
|
|
|
|
|
?? "anon";
|
|
|
|
|
if (!loginGuard.take(`login:${ip}`)) {
|
|
|
|
|
audit(deps.repo, event as any, "user.login", {
|
|
|
|
|
result: "failed",
|
|
|
|
|
metadata: { reason: "rate_limited", ip },
|
|
|
|
|
});
|
|
|
|
|
return new Response("Too many login attempts. Try again in a minute.", {
|
|
|
|
|
status: 429,
|
|
|
|
|
headers: { "retry-after": "60", "content-type": "text/plain" },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 23:09:13 +00:00
|
|
|
const body = await readBody<{ username?: string; password?: string }>(event);
|
|
|
|
|
const username = (body?.username ?? "").trim();
|
|
|
|
|
const password = body?.password ?? "";
|
|
|
|
|
|
|
|
|
|
if (!username || !password) {
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(LoginPage({ error: "Username and password required.", username }));
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
|
|
|
|
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
const user = deps.repo.getUserByUsername(username);
|
2026-05-09 23:09:13 +00:00
|
|
|
if (!user || !user.is_active) {
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (user.locked_until) {
|
|
|
|
|
const lockEnd = new Date(user.locked_until);
|
|
|
|
|
if (lockEnd > new Date()) {
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(LoginPage({ error: "Account locked. Try again later.", username }));
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const valid = await deps.auth.verifyPassword(password, user.password_hash);
|
|
|
|
|
if (!valid) {
|
|
|
|
|
const count = user.failed_login_count + 1;
|
|
|
|
|
const patch: Record<string, unknown> = { failed_login_count: count };
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
if (count >= deps.auth.config.loginLockoutThreshold) {
|
|
|
|
|
patch["locked_until"] = new Date(Date.now() + deps.auth.config.loginLockoutSeconds * 1000).toISOString();
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
deps.repo.updateUser(user.id, patch);
|
2026-05-14 05:38:18 +00:00
|
|
|
audit(deps.repo, event as any, "user.login", {
|
|
|
|
|
result: "failed",
|
|
|
|
|
actor_type: "system",
|
|
|
|
|
actor_label: username,
|
|
|
|
|
metadata: { failed_count: count, locked: count >= deps.auth.config.loginLockoutThreshold },
|
|
|
|
|
});
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(LoginPage({ error: "Invalid credentials.", username }));
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
|
|
|
|
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
deps.repo.updateUser(user.id, {
|
2026-05-09 23:09:13 +00:00
|
|
|
failed_login_count: 0,
|
|
|
|
|
locked_until: null,
|
|
|
|
|
last_login_at: new Date().toISOString(),
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-14 05:38:18 +00:00
|
|
|
audit(deps.repo, event as any, "user.login", {
|
|
|
|
|
actor_type: "user",
|
|
|
|
|
actor_id: user.id,
|
|
|
|
|
actor_label: user.username,
|
|
|
|
|
metadata: { totp_pending: user.totp_enabled },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 23:09:13 +00:00
|
|
|
const totpPending = user.totp_enabled;
|
|
|
|
|
const { cookieValue } = await deps.auth.createSession({
|
|
|
|
|
user,
|
|
|
|
|
userAgent: getRequestHeader(event, "user-agent") ?? null,
|
|
|
|
|
ipAddress: getRequestHeader(event, "x-forwarded-for")
|
|
|
|
|
?? getRequestHeader(event, "x-real-ip")
|
|
|
|
|
?? null,
|
|
|
|
|
totpPending,
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-10 00:53:06 +00:00
|
|
|
const cookie = { name: deps.cookieName, value: cookieValue, maxAge: deps.auth.config.sessionMaxSeconds };
|
2026-05-09 23:09:13 +00:00
|
|
|
if (totpPending) {
|
2026-05-10 00:53:06 +00:00
|
|
|
return redirectWithCookie("/auth/totp", cookie);
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
2026-05-10 00:53:06 +00:00
|
|
|
return redirectWithCookie("/admin/", cookie);
|
2026-05-09 23:09:13 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---- TOTP -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
app.get("/auth/totp", (event) => {
|
|
|
|
|
const cookie = getCookie(event, deps.cookieName);
|
|
|
|
|
if (!cookie) {
|
|
|
|
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
|
|
|
|
}
|
|
|
|
|
const resolved = deps.auth.resolveSession(cookie);
|
|
|
|
|
if (!resolved || !resolved.session.totp_pending) {
|
|
|
|
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
|
|
|
|
}
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(TotpPage({}));
|
2026-05-09 23:09:13 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post("/auth/totp", async (event) => {
|
|
|
|
|
const cookie = getCookie(event, deps.cookieName);
|
|
|
|
|
if (!cookie) {
|
|
|
|
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
|
|
|
|
}
|
|
|
|
|
const resolved = deps.auth.resolveSession(cookie);
|
|
|
|
|
if (!resolved || !resolved.session.totp_pending) {
|
|
|
|
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const body = await readBody<{ code?: string }>(event);
|
|
|
|
|
const code = (body?.code ?? "").trim().replace(/\s/g, "");
|
|
|
|
|
|
|
|
|
|
if (!code || code.length !== 6) {
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(TotpPage({ error: "Enter a 6-digit code." }));
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { user, session } = resolved;
|
|
|
|
|
if (!user.totp_secret_encrypted) {
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(TotpPage({ error: "TOTP not configured for this account." }));
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const secret = deps.auth.decryptTotpSecret(user.totp_secret_encrypted);
|
|
|
|
|
const valid = deps.auth.verifyTotpCode(secret, code);
|
|
|
|
|
if (!valid) {
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(TotpPage({ error: "Invalid code. Try again." }));
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
|
|
|
|
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
deps.repo.setSessionTotpPending(session.id, false);
|
2026-05-09 23:09:13 +00:00
|
|
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---- Recovery code --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
app.get("/auth/recovery", (event) => {
|
|
|
|
|
const cookie = getCookie(event, deps.cookieName);
|
|
|
|
|
if (!cookie) {
|
|
|
|
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
|
|
|
|
}
|
|
|
|
|
const resolved = deps.auth.resolveSession(cookie);
|
|
|
|
|
if (!resolved || !resolved.session.totp_pending) {
|
|
|
|
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
|
|
|
|
}
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(RecoveryPage({}));
|
2026-05-09 23:09:13 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post("/auth/recovery", async (event) => {
|
|
|
|
|
const cookie = getCookie(event, deps.cookieName);
|
|
|
|
|
if (!cookie) {
|
|
|
|
|
return new Response(null, { status: 302, headers: { location: "/auth/login" } });
|
|
|
|
|
}
|
|
|
|
|
const resolved = deps.auth.resolveSession(cookie);
|
|
|
|
|
if (!resolved || !resolved.session.totp_pending) {
|
|
|
|
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const body = await readBody<{ code?: string }>(event);
|
|
|
|
|
const code = (body?.code ?? "").trim().toUpperCase().replace(/\s/g, "");
|
|
|
|
|
|
|
|
|
|
if (!code) {
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(RecoveryPage({ error: "Enter a recovery code." }));
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { user, session } = resolved;
|
|
|
|
|
const hashedCodes: string[] = user.recovery_codes_hashed ?? [];
|
|
|
|
|
|
|
|
|
|
const result = await deps.auth.consumeRecoveryCode(code, hashedCodes);
|
|
|
|
|
if (!result.ok) {
|
2026-05-10 00:50:16 +00:00
|
|
|
return htmlPage(RecoveryPage({ error: "Invalid recovery code." }));
|
2026-05-09 23:09:13 +00:00
|
|
|
}
|
|
|
|
|
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
deps.repo.updateUser(user.id, {
|
2026-05-09 23:09:13 +00:00
|
|
|
recovery_codes_hashed: result.remaining,
|
|
|
|
|
});
|
|
|
|
|
|
refactor: collapse 6 non-service plugins into shared modules
BSB plugins should be actual services (own port, lifecycle, resource
ownership). Moved secrets, auth, pairing, bundle, nodered-bridge, and
cec-relay from plugin folders to shared modules under server/src/shared/.
4 BSB plugins remain: service-store, service-admin-http,
service-api-http, service-coordinator-ws.
service-admin-http now initializes secrets + auth as plain modules in
init() using the store repo from the plugin-registry singleton. No
more setSiblings() hack or inter-plugin wiring.
sec-config.yaml updated: secrets/auth config moved into
service-admin-http, pairing config into service-api-http, nodered
config into service-coordinator-ws.
2026-05-10 00:29:25 +00:00
|
|
|
deps.repo.setSessionTotpPending(session.id, false);
|
2026-05-09 23:09:13 +00:00
|
|
|
return new Response(null, { status: 302, headers: { location: "/admin/" } });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---- Logout ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
app.post("/auth/logout", (event) => {
|
|
|
|
|
const cookie = getCookie(event, deps.cookieName);
|
|
|
|
|
if (cookie) {
|
|
|
|
|
const resolved = deps.auth.resolveSession(cookie);
|
|
|
|
|
if (resolved) {
|
|
|
|
|
deps.auth.revokeSession(resolved.session.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-10 00:53:06 +00:00
|
|
|
return redirectClearCookie("/auth/login", deps.cookieName);
|
2026-05-09 23:09:13 +00:00
|
|
|
});
|
|
|
|
|
}
|