Extracted domain-specific sub-routers from monolithic `server/routes/admin.ts` and `server/routes/auth.ts` into isolated modules within `server/routes/admin/` and `server/routes/auth/` respectively. The original entry routers were updated to import and assemble these sub-routers without breaking their current HTTP interface or rate limiting/authorization middleware. Testing and linting were run ensuring perfect functionality and 100% test passing score. Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
237 lines
6.5 KiB
TypeScript
237 lines
6.5 KiB
TypeScript
import { Hono } from "jsr:@hono/hono@4";
|
|
import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
|
|
import { decodeBase64Url } from "jsr:@std/encoding@1/base64url";
|
|
import {
|
|
generateAuthenticationOptions,
|
|
verifyAuthenticationResponse,
|
|
} from "jsr:@simplewebauthn/server@13";
|
|
import type { AuthenticationResponseJSON } from "jsr:@simplewebauthn/server@13";
|
|
|
|
import { sqlWrapper } from "../../db.ts";
|
|
import { valkey } from "../../valkey.ts";
|
|
import { auditWrapper } from "../../audit.ts";
|
|
import { extractAllSessionIds } from "../../auth-session.ts";
|
|
import { getClientIp, publicRateLimiter } from "../../middleware.ts";
|
|
import { getCookieDomain } from "./utils.ts";
|
|
|
|
export const loginAuthRoutes = new Hono();
|
|
|
|
const rpID = Deno.env.get("RP_ID") ||
|
|
(import.meta.main ? undefined : "localhost");
|
|
const origin = Deno.env.get("ORIGIN") ||
|
|
(import.meta.main ? undefined : "http://localhost");
|
|
|
|
function generateSessionId() {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
loginAuthRoutes.use("/api/login/*", publicRateLimiter);
|
|
|
|
// Start a WebAuthn authentication ceremony
|
|
loginAuthRoutes.post("/api/login/challenge", async (c) => {
|
|
let body;
|
|
try {
|
|
body = await c.req.json();
|
|
} catch (_err) {
|
|
body = {};
|
|
}
|
|
const username = body.username;
|
|
let extensions: any = undefined;
|
|
let allowCredentials: any[] | undefined = undefined;
|
|
|
|
if (username) {
|
|
const user = await sqlWrapper
|
|
.sql`SELECT id FROM users WHERE username = ${username}`.then((res: any) =>
|
|
res[0]
|
|
);
|
|
if (user) {
|
|
const passkeys = await sqlWrapper
|
|
.sql`SELECT credential_id, prf_enabled, prf_salt FROM passkeys WHERE user_id = ${user.id}`;
|
|
|
|
if (passkeys.length > 0) {
|
|
allowCredentials = passkeys.map((pk: any) => ({
|
|
id: pk.credential_id,
|
|
type: "public-key",
|
|
}));
|
|
|
|
const prfPasskeys = passkeys.filter((pk: any) =>
|
|
pk.prf_enabled && pk.prf_salt
|
|
);
|
|
if (prfPasskeys.length > 0) {
|
|
extensions = {
|
|
["prf" as string]: { evalByCredential: {} },
|
|
};
|
|
for (const pk of prfPasskeys) {
|
|
const saltBytes = decodeBase64Url(pk.prf_salt);
|
|
extensions["prf"]["evalByCredential"][pk.credential_id] = {
|
|
first: saltBytes,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!rpID) throw new Error("rpID is missing");
|
|
|
|
const options = await generateAuthenticationOptions({
|
|
rpID,
|
|
userVerification: "preferred",
|
|
timeout: 60000,
|
|
allowCredentials,
|
|
extensions,
|
|
});
|
|
|
|
setCookie(c, "expected_authentication_challenge", options.challenge, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 300,
|
|
});
|
|
|
|
return c.json({ options });
|
|
});
|
|
|
|
// Verify login and issue session
|
|
loginAuthRoutes.post("/api/login/verify", async (c) => {
|
|
const { response } = await c.req.json();
|
|
|
|
const expectedChallenge = getCookie(c, "expected_authentication_challenge");
|
|
if (!expectedChallenge) {
|
|
return c.json(
|
|
{ error: "Missing or expired authentication challenge" },
|
|
400,
|
|
);
|
|
}
|
|
|
|
const base64CredentialID = response.id;
|
|
|
|
const passkey = await sqlWrapper
|
|
.sql`SELECT * FROM passkeys WHERE credential_id = ${base64CredentialID}`
|
|
.then((res: any) => res[0]);
|
|
if (!passkey) {
|
|
return c.json({
|
|
error: "Passkey not found. Please register your passkey first.",
|
|
}, 404);
|
|
}
|
|
|
|
const user = await sqlWrapper
|
|
.sql`SELECT id, username, account_status FROM users WHERE id = ${passkey.user_id}`
|
|
.then((res: any) => res[0]);
|
|
if (!user) {
|
|
return c.json({ error: "User not found" }, 404);
|
|
}
|
|
|
|
const userId = user.id;
|
|
|
|
if (user.account_status !== "active") {
|
|
auditWrapper.auditLog(
|
|
userId,
|
|
"login_failed",
|
|
null,
|
|
{ reason: `Account status is ${user.account_status}` },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({
|
|
error: "Account is not active. Please contact an administrator.",
|
|
}, 403);
|
|
}
|
|
|
|
const publicKeyBytes = decodeBase64Url(passkey.public_key);
|
|
|
|
if (!origin || !rpID) throw new Error("Missing origin or rpID");
|
|
|
|
let verification;
|
|
try {
|
|
verification = await verifyAuthenticationResponse({
|
|
response: response as AuthenticationResponseJSON,
|
|
expectedChallenge,
|
|
expectedOrigin: origin,
|
|
expectedRPID: rpID,
|
|
requireUserVerification: false,
|
|
credential: {
|
|
id: passkey.credential_id,
|
|
publicKey: publicKeyBytes,
|
|
counter: Number(passkey.counter),
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
return c.json({ error: error.message }, 400);
|
|
}
|
|
|
|
const { verified, authenticationInfo } = verification;
|
|
if (!verified || !authenticationInfo) {
|
|
auditWrapper.auditLog(
|
|
userId,
|
|
"login_failed",
|
|
null,
|
|
{ reason: "verification failed" },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ error: "Verification failed" }, 400);
|
|
}
|
|
|
|
await sqlWrapper
|
|
.sql`UPDATE passkeys SET counter = ${authenticationInfo.newCounter} WHERE id = ${passkey.id}`;
|
|
|
|
const sessionId = generateSessionId();
|
|
const expiresAt = new Date();
|
|
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
|
|
// Persistence in PostgreSQL
|
|
await sqlWrapper
|
|
.sql`INSERT INTO sessions (id, user_id, expires_at) VALUES (${sessionId}, ${user.id}, ${expiresAt})`;
|
|
|
|
// Write session to Valkey with TTL matching expiresAt
|
|
const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
|
|
try {
|
|
const sessionData = JSON.stringify({
|
|
uuid: user.id,
|
|
username: user.username,
|
|
});
|
|
await valkey.setex(sessionId, ttlSeconds, sessionData);
|
|
} catch (_err: unknown) {
|
|
// If Valkey fails, log and fail closed for security
|
|
auditWrapper.auditLog(
|
|
user.id,
|
|
"login_failed",
|
|
null,
|
|
{ reason: "Cache write failure" },
|
|
getClientIp(c),
|
|
);
|
|
return c.json({ error: "Internal server error" }, 500);
|
|
}
|
|
|
|
const oldSessionIds = extractAllSessionIds(c);
|
|
if (oldSessionIds.length > 0) {
|
|
for (const old of oldSessionIds) {
|
|
try {
|
|
await valkey.del(old);
|
|
await sqlWrapper.sql`DELETE FROM sessions WHERE id = ${old}`;
|
|
} catch (_e) {}
|
|
}
|
|
}
|
|
|
|
const cookieDomain = getCookieDomain(rpID);
|
|
|
|
setCookie(c, "session_id", sessionId, {
|
|
domain: cookieDomain,
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
expires: expiresAt,
|
|
});
|
|
|
|
setCookie(c, "expected_authentication_challenge", "", {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "Lax",
|
|
maxAge: 0,
|
|
});
|
|
|
|
auditWrapper.auditLog(userId, "login_success", null, null, getClientIp(c));
|
|
|
|
return c.json({ success: true });
|
|
});
|