Refactor server/auth-session.ts and ui/mod.ts to decouple domain utilities, database queries, and admin authorization checks into separate files (server/forward_auth.ts, server/session_resolver.ts, ui/db_queries.ts, ui/auth_checks.ts).
Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
This commit is contained in:
parent
f2310503f4
commit
99bb8d794f
@ -1,410 +1,39 @@
|
||||
import type { Context } from "jsr:@hono/hono@4";
|
||||
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import { sqlWrapper } from "./db.ts";
|
||||
import { valkey } from "./valkey.ts";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
label?: string;
|
||||
isAgent?: boolean;
|
||||
customScopes?: string[];
|
||||
}
|
||||
import {
|
||||
getClientIp,
|
||||
getCookieDomain,
|
||||
isIpAllowed,
|
||||
isPathBypassed,
|
||||
isSafeRedirectUrl,
|
||||
} from "./forward_auth.ts";
|
||||
|
||||
export interface AppRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
domain?: string;
|
||||
is_public?: boolean;
|
||||
bypass_paths?: string[];
|
||||
allowed_cidrs?: string[];
|
||||
}
|
||||
import {
|
||||
type AppRecord,
|
||||
type AuthenticatedUser,
|
||||
extractAllSessionIds,
|
||||
getAppByHost,
|
||||
getAuthenticatedUser,
|
||||
getUserGrant,
|
||||
isGlobalAdmin,
|
||||
} from "./session_resolver.ts";
|
||||
|
||||
/**
|
||||
* Calculates the wildcard parent cookie domain (e.g. auth.atyg.org -> .atyg.org)
|
||||
* to ensure cookies are sent to all subdomains (ed-droid.atyg.org, grafana.atyg.org, etc.).
|
||||
*/
|
||||
export function getCookieDomain(customRpId?: string): string | undefined {
|
||||
const envDomain = Deno.env.get("COOKIE_DOMAIN");
|
||||
if (envDomain) {
|
||||
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
|
||||
}
|
||||
const targetId = customRpId || Deno.env.get("RP_ID") || "";
|
||||
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
|
||||
return undefined;
|
||||
}
|
||||
const parts = targetId.split(".").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `.${parts.slice(-2).join(".")}`;
|
||||
}
|
||||
return `.${targetId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get authenticated user from session cookie or Authorization header.
|
||||
* Checks Valkey cache first, with automatic PostgreSQL sessions table fallback.
|
||||
* Iterates through all session_id cookies to prevent Android Chrome cookie shadowing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts all session_id tokens from the Cookie and Authorization headers.
|
||||
*/
|
||||
export function extractAllSessionIds(c: Context): string[] {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// 1. Check Authorization: Bearer <token>
|
||||
const authHeader = c.req.header("authorization") || "";
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
const bearerToken = authHeader.substring(7).trim();
|
||||
if (bearerToken) candidates.push(bearerToken);
|
||||
}
|
||||
|
||||
// 2. Check Cookie header
|
||||
const cookieHeader = c.req.header("cookie") || "";
|
||||
if (cookieHeader) {
|
||||
const cookieMatches = [
|
||||
...cookieHeader.matchAll(/(?:^|;\s*)session_id=([^;]+)/g),
|
||||
]
|
||||
.map((m) => decodeURIComponent(m[1].trim()))
|
||||
.filter(Boolean);
|
||||
candidates.push(...cookieMatches);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export async function getAuthenticatedUser(
|
||||
c: Context,
|
||||
): Promise<AuthenticatedUser | null> {
|
||||
const sessionMatches = extractAllSessionIds(c);
|
||||
if (sessionMatches.length === 0) return null;
|
||||
|
||||
// Iterate over each candidate session ID
|
||||
for (let i = 0; i < sessionMatches.length; i++) {
|
||||
const candidateId = sessionMatches[i];
|
||||
|
||||
// 1. Try Valkey cache
|
||||
try {
|
||||
const sessionDataStr = await valkey.get(candidateId);
|
||||
if (sessionDataStr) {
|
||||
const sessionData = JSON.parse(sessionDataStr);
|
||||
if (sessionData && sessionData.uuid) {
|
||||
if (i > 0) {
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
}
|
||||
|
||||
return {
|
||||
userId: sessionData.uuid,
|
||||
sessionId: candidateId,
|
||||
username: sessionData.username || "",
|
||||
label: sessionData.label,
|
||||
isAgent: sessionData.isAgent,
|
||||
customScopes: sessionData.customScopes,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Valkey cache miss or connection hiccup - fallback to DB
|
||||
}
|
||||
|
||||
// 2. Fallback to PostgreSQL sessions table
|
||||
try {
|
||||
const nowIso = new Date().toISOString();
|
||||
const session = await sqlWrapper.sql`
|
||||
SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (session) {
|
||||
const username = session.username || "";
|
||||
// Repopulate Valkey in background
|
||||
try {
|
||||
const ttlSeconds = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(new Date(session.expires_at).getTime() - Date.now()) / 1000,
|
||||
),
|
||||
);
|
||||
await valkey.setex(
|
||||
candidateId,
|
||||
ttlSeconds,
|
||||
JSON.stringify({
|
||||
uuid: session.user_id,
|
||||
username,
|
||||
label: session.label,
|
||||
isAgent: session.is_agent,
|
||||
customScopes: session.custom_scopes,
|
||||
}),
|
||||
);
|
||||
} catch (_e) {}
|
||||
|
||||
if (i > 0) {
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
}
|
||||
|
||||
return {
|
||||
userId: session.user_id,
|
||||
sessionId: candidateId,
|
||||
username,
|
||||
label: session.label,
|
||||
isAgent: session.is_agent,
|
||||
customScopes: session.custom_scopes,
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue to next candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get an application by host.
|
||||
* Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey.
|
||||
*/
|
||||
export async function getAppByHost(
|
||||
host: string,
|
||||
): Promise<AppRecord | null> {
|
||||
const cacheKey = `auth:app_by_host:${host}`;
|
||||
|
||||
// 1. Try Valkey cache
|
||||
try {
|
||||
const cachedStr = await valkey.get(cacheKey);
|
||||
if (cachedStr) {
|
||||
return JSON.parse(cachedStr);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Valkey cache miss or connection error
|
||||
}
|
||||
|
||||
// 2. Fallback to PostgreSQL
|
||||
try {
|
||||
// Search by domain first
|
||||
let app = await sqlWrapper.sql`
|
||||
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
|
||||
FROM apps WHERE domain = ${host}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
// Fallback: match name against the first subdomain segment
|
||||
if (!app) {
|
||||
const subdomain = host.split(".")[0];
|
||||
if (subdomain) {
|
||||
app = await sqlWrapper.sql`
|
||||
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
|
||||
FROM apps WHERE name = ${subdomain}
|
||||
`.then((res: any) => res[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (app) {
|
||||
// Repopulate Valkey
|
||||
try {
|
||||
await valkey.setex(cacheKey, 3600, JSON.stringify(app)); // Cache for 1 hour
|
||||
} catch (_e) {}
|
||||
return app as AppRecord;
|
||||
}
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a user's role grant for a specific app.
|
||||
* Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey.
|
||||
*/
|
||||
export async function getUserGrant(
|
||||
userId: string,
|
||||
appId: string,
|
||||
): Promise<string | null> {
|
||||
const cacheKey = `auth:grants:${userId}:${appId}`;
|
||||
|
||||
// 1. Try Valkey cache
|
||||
try {
|
||||
const cachedRole = await valkey.get(cacheKey);
|
||||
if (cachedRole) {
|
||||
return cachedRole;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Valkey cache miss or connection error
|
||||
}
|
||||
|
||||
// 2. Fallback to PostgreSQL
|
||||
try {
|
||||
const grant = await sqlWrapper.sql`
|
||||
SELECT role FROM grants WHERE user_id = ${userId} AND app_id = ${appId}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (grant && grant.role) {
|
||||
// Repopulate Valkey
|
||||
try {
|
||||
await valkey.setex(cacheKey, 3600, grant.role); // Cache for 1 hour
|
||||
} catch (_e) {}
|
||||
return grant.role;
|
||||
}
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if user has global admin privileges.
|
||||
* Strict check: Requires an explicit 'admin' grant on the Management Console
|
||||
* or global role, or is the bootstrap root user.
|
||||
*/
|
||||
export async function isGlobalAdmin(userId: string): Promise<boolean> {
|
||||
try {
|
||||
// Check 1: User has an explicit 'admin' grant for the Auth-Yes Management Console or global app
|
||||
const adminGrant = await sqlWrapper.sql`
|
||||
SELECT g.id
|
||||
FROM grants g
|
||||
LEFT JOIN apps a ON g.app_id = a.id
|
||||
WHERE g.user_id = ${userId}
|
||||
AND g.role = 'admin'
|
||||
AND (
|
||||
a.spiffe_id = 'spiffe://system.local/auth-yes-management'
|
||||
OR a.name = 'Auth-Yes Management Console'
|
||||
OR g.app_id IS NULL
|
||||
)
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (adminGrant) return true;
|
||||
|
||||
// Check 2: First registered user in system fallback
|
||||
const firstUser = await sqlWrapper.sql`
|
||||
SELECT id FROM users ORDER BY created_at ASC NULLS LAST, username ASC LIMIT 1
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (firstUser && firstUser.id === userId) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth API] isGlobalAdmin error:", err);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic fast path prefix matcher for dynamic bypasses
|
||||
*/
|
||||
export function isPathBypassed(
|
||||
requestPath: string,
|
||||
bypassPaths?: string[],
|
||||
): boolean {
|
||||
if (!bypassPaths || bypassPaths.length === 0) return false;
|
||||
|
||||
for (const pattern of bypassPaths) {
|
||||
if (pattern === requestPath) return true;
|
||||
if (pattern.endsWith("/*")) {
|
||||
const prefix = pattern.slice(0, -2); // remove /*
|
||||
if (requestPath === prefix || requestPath.startsWith(prefix + "/")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure native Deno bitwise CIDR matcher
|
||||
*/
|
||||
export function isIpAllowed(
|
||||
clientIp: string,
|
||||
allowedCidrs?: string[],
|
||||
): boolean {
|
||||
if (!allowedCidrs || allowedCidrs.length === 0) return false;
|
||||
|
||||
// Basic IP parsing (v4 only for simplicity and speed, or basic v6 check)
|
||||
const parseIp4 = (ip: string) => {
|
||||
const parts = ip.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>>
|
||||
0;
|
||||
// Re-export for backward compatibility
|
||||
export {
|
||||
type AppRecord,
|
||||
type AuthenticatedUser,
|
||||
extractAllSessionIds,
|
||||
getAppByHost,
|
||||
getAuthenticatedUser,
|
||||
getClientIp,
|
||||
getCookieDomain,
|
||||
getUserGrant,
|
||||
isGlobalAdmin,
|
||||
isIpAllowed,
|
||||
isPathBypassed,
|
||||
isSafeRedirectUrl,
|
||||
};
|
||||
|
||||
// X-Forwarded-For can contain multiple IPs if chained (e.g., "client, proxy1, proxy2")
|
||||
// We extract the first IP (the original client)
|
||||
const primaryIp = clientIp.split(",")[0].trim();
|
||||
const ipNum = parseIp4(primaryIp);
|
||||
|
||||
for (const cidr of allowedCidrs) {
|
||||
const [subnet, maskStr] = cidr.split("/");
|
||||
if (!maskStr) {
|
||||
if (subnet === primaryIp) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// IPv4 CIDR matching
|
||||
if (ipNum !== null && subnet.includes(".")) {
|
||||
const subnetNum = parseIp4(subnet);
|
||||
if (subnetNum !== null) {
|
||||
const maskBits = parseInt(maskStr, 10);
|
||||
// Fix for /0 masks to avoid JS bitwise shift 32 overflow masking
|
||||
const mask = maskBits === 0
|
||||
? 0
|
||||
: ((0xffffffff << (32 - maskBits)) >>> 0);
|
||||
if ((ipNum & mask) === (subnetNum & mask)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} // Note: To remain zero-dependency and ultra-fast, we are supporting IPv4 CIDR.
|
||||
// Full IPv6 CIDR math would be added here if needed, but string equality
|
||||
// works for exact IPv6 matches.
|
||||
else if (subnet === clientIp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a given URL to ensure it is safe to redirect to.
|
||||
* Allows relative paths, localhost, and *.atyg.org (or custom RP_ID)
|
||||
*/
|
||||
export function isSafeRedirectUrl(
|
||||
rawUrl: string,
|
||||
customDomain?: string,
|
||||
): boolean {
|
||||
if (!rawUrl) return false;
|
||||
// 1. Relative paths within the same origin are safe
|
||||
if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const host = parsed.hostname;
|
||||
const root = customDomain || Deno.env.get("RP_ID") || "atyg.org";
|
||||
const cleanRoot = root.replace(/^\./, "");
|
||||
|
||||
// 2. Allow localhost, exact root match, or subdomains (*.atyg.org)
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host === "atyg.org" ||
|
||||
host.endsWith(".atyg.org") ||
|
||||
host === cleanRoot ||
|
||||
host.endsWith(`.${cleanRoot}`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the real client IP from X-Real-IP or X-Forwarded-For headers.
|
||||
*/
|
||||
/**
|
||||
* Evaluates if the current user's capabilities satisfy the required scope.
|
||||
* If !auth.isAgent, primary sessions inherit full capabilities (returns true).
|
||||
@ -473,21 +102,3 @@ export async function requireAdmin(c: Context, next: () => Promise<void>) {
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
export function getClientIp(c: Context): string {
|
||||
const realIp = c.req.header("x-real-ip");
|
||||
if (realIp) {
|
||||
return realIp.trim();
|
||||
}
|
||||
|
||||
let forwardedFor = c.req.header("x-forwarded-for");
|
||||
if (forwardedFor) {
|
||||
if (forwardedFor.length > 256) {
|
||||
forwardedFor = forwardedFor.substring(0, 256);
|
||||
}
|
||||
const parts = forwardedFor.split(",");
|
||||
return parts[parts.length - 1].trim();
|
||||
}
|
||||
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
152
server/forward_auth.ts
Normal file
152
server/forward_auth.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import type { Context } from "jsr:@hono/hono@4";
|
||||
|
||||
/**
|
||||
* Calculates the wildcard parent cookie domain (e.g. auth.atyg.org -> .atyg.org)
|
||||
* to ensure cookies are sent to all subdomains (ed-droid.atyg.org, grafana.atyg.org, etc.).
|
||||
*/
|
||||
export function getCookieDomain(customRpId?: string): string | undefined {
|
||||
const envDomain = Deno.env.get("COOKIE_DOMAIN");
|
||||
if (envDomain) {
|
||||
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
|
||||
}
|
||||
const targetId = customRpId || Deno.env.get("RP_ID") || "";
|
||||
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
|
||||
return undefined;
|
||||
}
|
||||
const parts = targetId.split(".").filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return `.${parts.slice(-2).join(".")}`;
|
||||
}
|
||||
return `.${targetId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic fast path prefix matcher for dynamic bypasses
|
||||
*/
|
||||
export function isPathBypassed(
|
||||
requestPath: string,
|
||||
bypassPaths?: string[],
|
||||
): boolean {
|
||||
if (!bypassPaths || bypassPaths.length === 0) return false;
|
||||
|
||||
for (const pattern of bypassPaths) {
|
||||
if (pattern === requestPath) return true;
|
||||
if (pattern.endsWith("/*")) {
|
||||
const prefix = pattern.slice(0, -2); // remove /*
|
||||
if (requestPath === prefix || requestPath.startsWith(prefix + "/")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure native Deno bitwise CIDR matcher
|
||||
*/
|
||||
export function isIpAllowed(
|
||||
clientIp: string,
|
||||
allowedCidrs?: string[],
|
||||
): boolean {
|
||||
if (!allowedCidrs || allowedCidrs.length === 0) return false;
|
||||
|
||||
// Basic IP parsing (v4 only for simplicity and speed, or basic v6 check)
|
||||
const parseIp4 = (ip: string) => {
|
||||
const parts = ip.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
return parts.reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>>
|
||||
0;
|
||||
};
|
||||
|
||||
// X-Forwarded-For can contain multiple IPs if chained (e.g., "client, proxy1, proxy2")
|
||||
// We extract the first IP (the original client)
|
||||
const primaryIp = clientIp.split(",")[0].trim();
|
||||
const ipNum = parseIp4(primaryIp);
|
||||
|
||||
for (const cidr of allowedCidrs) {
|
||||
const [subnet, maskStr] = cidr.split("/");
|
||||
if (!maskStr) {
|
||||
if (subnet === primaryIp) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// IPv4 CIDR matching
|
||||
if (ipNum !== null && subnet.includes(".")) {
|
||||
const subnetNum = parseIp4(subnet);
|
||||
if (subnetNum !== null) {
|
||||
const maskBits = parseInt(maskStr, 10);
|
||||
// Fix for /0 masks to avoid JS bitwise shift 32 overflow masking
|
||||
const mask = maskBits === 0
|
||||
? 0
|
||||
: ((0xffffffff << (32 - maskBits)) >>> 0);
|
||||
if ((ipNum & mask) === (subnetNum & mask)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} // Note: To remain zero-dependency and ultra-fast, we are supporting IPv4 CIDR.
|
||||
// Full IPv6 CIDR math would be added here if needed, but string equality
|
||||
// works for exact IPv6 matches.
|
||||
else if (subnet === clientIp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a given URL to ensure it is safe to redirect to.
|
||||
* Allows relative paths, localhost, and *.atyg.org (or custom RP_ID)
|
||||
*/
|
||||
export function isSafeRedirectUrl(
|
||||
rawUrl: string,
|
||||
customDomain?: string,
|
||||
): boolean {
|
||||
if (!rawUrl) return false;
|
||||
// 1. Relative paths within the same origin are safe
|
||||
if (rawUrl.startsWith("/") && !rawUrl.startsWith("//")) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const host = parsed.hostname;
|
||||
const root = customDomain || Deno.env.get("RP_ID") || "atyg.org";
|
||||
const cleanRoot = root.replace(/^\./, "");
|
||||
|
||||
// 2. Allow localhost, exact root match, or subdomains (*.atyg.org)
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host === "atyg.org" ||
|
||||
host.endsWith(".atyg.org") ||
|
||||
host === cleanRoot ||
|
||||
host.endsWith(`.${cleanRoot}`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the real client IP from X-Real-IP or X-Forwarded-For headers.
|
||||
*/
|
||||
export function getClientIp(c: Context): string {
|
||||
const realIp = c.req.header("x-real-ip");
|
||||
if (realIp) {
|
||||
return realIp.trim();
|
||||
}
|
||||
|
||||
let forwardedFor = c.req.header("x-forwarded-for");
|
||||
if (forwardedFor) {
|
||||
if (forwardedFor.length > 256) {
|
||||
forwardedFor = forwardedFor.substring(0, 256);
|
||||
}
|
||||
const parts = forwardedFor.split(",");
|
||||
return parts[parts.length - 1].trim();
|
||||
}
|
||||
|
||||
return "127.0.0.1";
|
||||
}
|
||||
267
server/session_resolver.ts
Normal file
267
server/session_resolver.ts
Normal file
@ -0,0 +1,267 @@
|
||||
import type { Context } from "jsr:@hono/hono@4";
|
||||
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import { sqlWrapper } from "./db.ts";
|
||||
import { valkey } from "./valkey.ts";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
username: string;
|
||||
label?: string;
|
||||
isAgent?: boolean;
|
||||
customScopes?: string[];
|
||||
}
|
||||
|
||||
export interface AppRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
domain?: string;
|
||||
is_public?: boolean;
|
||||
bypass_paths?: string[];
|
||||
allowed_cidrs?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all session_id tokens from the Cookie and Authorization headers.
|
||||
*/
|
||||
export function extractAllSessionIds(c: Context): string[] {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// 1. Check Authorization: Bearer <token>
|
||||
const authHeader = c.req.header("authorization") || "";
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
const bearerToken = authHeader.substring(7).trim();
|
||||
if (bearerToken) candidates.push(bearerToken);
|
||||
}
|
||||
|
||||
// 2. Check Cookie header
|
||||
const cookieHeader = c.req.header("cookie") || "";
|
||||
if (cookieHeader) {
|
||||
const cookieMatches = [
|
||||
...cookieHeader.matchAll(/(?:^|;\s*)session_id=([^;]+)/g),
|
||||
]
|
||||
.map((m) => decodeURIComponent(m[1].trim()))
|
||||
.filter(Boolean);
|
||||
candidates.push(...cookieMatches);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export async function getAuthenticatedUser(
|
||||
c: Context,
|
||||
): Promise<AuthenticatedUser | null> {
|
||||
const sessionMatches = extractAllSessionIds(c);
|
||||
if (sessionMatches.length === 0) return null;
|
||||
|
||||
// Iterate over each candidate session ID
|
||||
for (let i = 0; i < sessionMatches.length; i++) {
|
||||
const candidateId = sessionMatches[i];
|
||||
|
||||
// 1. Try Valkey cache
|
||||
try {
|
||||
const sessionDataStr = await valkey.get(candidateId);
|
||||
if (sessionDataStr) {
|
||||
const sessionData = JSON.parse(sessionDataStr);
|
||||
if (sessionData && sessionData.uuid) {
|
||||
if (i > 0) {
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
}
|
||||
|
||||
return {
|
||||
userId: sessionData.uuid,
|
||||
sessionId: candidateId,
|
||||
username: sessionData.username || "",
|
||||
label: sessionData.label,
|
||||
isAgent: sessionData.isAgent,
|
||||
customScopes: sessionData.customScopes,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Valkey cache miss or connection hiccup - fallback to DB
|
||||
}
|
||||
|
||||
// 2. Fallback to PostgreSQL sessions table
|
||||
try {
|
||||
const nowIso = new Date().toISOString();
|
||||
const session = await sqlWrapper.sql`
|
||||
SELECT s.user_id, s.expires_at, s.label, s.is_agent, s.custom_scopes, u.username
|
||||
FROM sessions s
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = ${candidateId} AND s.expires_at > ${nowIso}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (session) {
|
||||
const username = session.username || "";
|
||||
// Repopulate Valkey in background
|
||||
try {
|
||||
const ttlSeconds = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(new Date(session.expires_at).getTime() - Date.now()) / 1000,
|
||||
),
|
||||
);
|
||||
await valkey.setex(
|
||||
candidateId,
|
||||
ttlSeconds,
|
||||
JSON.stringify({
|
||||
uuid: session.user_id,
|
||||
username,
|
||||
label: session.label,
|
||||
isAgent: session.is_agent,
|
||||
customScopes: session.custom_scopes,
|
||||
}),
|
||||
);
|
||||
} catch (_e) {}
|
||||
|
||||
if (i > 0) {
|
||||
deleteCookie(c, "session_id", { path: "/" });
|
||||
}
|
||||
|
||||
return {
|
||||
userId: session.user_id,
|
||||
sessionId: candidateId,
|
||||
username,
|
||||
label: session.label,
|
||||
isAgent: session.is_agent,
|
||||
customScopes: session.custom_scopes,
|
||||
};
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue to next candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get an application by host.
|
||||
* Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey.
|
||||
*/
|
||||
export async function getAppByHost(
|
||||
host: string,
|
||||
): Promise<AppRecord | null> {
|
||||
const cacheKey = `auth:app_by_host:${host}`;
|
||||
|
||||
// 1. Try Valkey cache
|
||||
try {
|
||||
const cachedStr = await valkey.get(cacheKey);
|
||||
if (cachedStr) {
|
||||
return JSON.parse(cachedStr);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Valkey cache miss or connection error
|
||||
}
|
||||
|
||||
// 2. Fallback to PostgreSQL
|
||||
try {
|
||||
// Search by domain first
|
||||
let app = await sqlWrapper.sql`
|
||||
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
|
||||
FROM apps WHERE domain = ${host}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
// Fallback: match name against the first subdomain segment
|
||||
if (!app) {
|
||||
const subdomain = host.split(".")[0];
|
||||
if (subdomain) {
|
||||
app = await sqlWrapper.sql`
|
||||
SELECT id, name, domain, is_public, bypass_paths, allowed_cidrs
|
||||
FROM apps WHERE name = ${subdomain}
|
||||
`.then((res: any) => res[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (app) {
|
||||
// Repopulate Valkey
|
||||
try {
|
||||
await valkey.setex(cacheKey, 3600, JSON.stringify(app)); // Cache for 1 hour
|
||||
} catch (_e) {}
|
||||
return app as AppRecord;
|
||||
}
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get a user's role grant for a specific app.
|
||||
* Checks Valkey cache first, falls back to PostgreSQL, and populates Valkey.
|
||||
*/
|
||||
export async function getUserGrant(
|
||||
userId: string,
|
||||
appId: string,
|
||||
): Promise<string | null> {
|
||||
const cacheKey = `auth:grants:${userId}:${appId}`;
|
||||
|
||||
// 1. Try Valkey cache
|
||||
try {
|
||||
const cachedRole = await valkey.get(cacheKey);
|
||||
if (cachedRole) {
|
||||
return cachedRole;
|
||||
}
|
||||
} catch (_err) {
|
||||
// Valkey cache miss or connection error
|
||||
}
|
||||
|
||||
// 2. Fallback to PostgreSQL
|
||||
try {
|
||||
const grant = await sqlWrapper.sql`
|
||||
SELECT role FROM grants WHERE user_id = ${userId} AND app_id = ${appId}
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (grant && grant.role) {
|
||||
// Repopulate Valkey
|
||||
try {
|
||||
await valkey.setex(cacheKey, 3600, grant.role); // Cache for 1 hour
|
||||
} catch (_e) {}
|
||||
return grant.role;
|
||||
}
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if user has global admin privileges.
|
||||
* Strict check: Requires an explicit 'admin' grant on the Management Console
|
||||
* or global role, or is the bootstrap root user.
|
||||
*/
|
||||
export async function isGlobalAdmin(userId: string): Promise<boolean> {
|
||||
try {
|
||||
// Check 1: User has an explicit 'admin' grant for the Auth-Yes Management Console or global app
|
||||
const adminGrant = await sqlWrapper.sql`
|
||||
SELECT g.id
|
||||
FROM grants g
|
||||
LEFT JOIN apps a ON g.app_id = a.id
|
||||
WHERE g.user_id = ${userId}
|
||||
AND g.role = 'admin'
|
||||
AND (
|
||||
a.spiffe_id = 'spiffe://system.local/auth-yes-management'
|
||||
OR a.name = 'Auth-Yes Management Console'
|
||||
OR g.app_id IS NULL
|
||||
)
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (adminGrant) return true;
|
||||
|
||||
// Check 2: First registered user in system fallback
|
||||
const firstUser = await sqlWrapper.sql`
|
||||
SELECT id FROM users ORDER BY created_at ASC NULLS LAST, username ASC LIMIT 1
|
||||
`.then((res: any) => res[0]);
|
||||
|
||||
if (firstUser && firstUser.id === userId) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth API] isGlobalAdmin error:", err);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
42
ui/auth_checks.ts
Normal file
42
ui/auth_checks.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { Context } from "jsr:@hono/hono@4";
|
||||
import { deleteCookie, getCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import {
|
||||
type AuthenticatedUser,
|
||||
getAuthenticatedUser,
|
||||
getCookieDomain,
|
||||
isGlobalAdmin,
|
||||
isSessionAdmin,
|
||||
} from "../server/auth-session.ts";
|
||||
|
||||
export interface UiAuthResult {
|
||||
auth: AuthenticatedUser;
|
||||
isAdmin: boolean;
|
||||
isSessionAdminRole: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to ensure a user is authenticated for UI routes.
|
||||
* If not authenticated, clears cookies and redirects to /login.
|
||||
* Returns the AuthenticatedUser and admin status if successful, or a Hono Response (redirect).
|
||||
*/
|
||||
export async function requireUiAuth(
|
||||
c: Context,
|
||||
): Promise<UiAuthResult | Response> {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||
|
||||
return { auth, isAdmin, isSessionAdminRole };
|
||||
}
|
||||
137
ui/db_queries.ts
Normal file
137
ui/db_queries.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { sql } from "../server/db.ts";
|
||||
|
||||
export async function getDashboardApps(userId: string, isAdmin: boolean) {
|
||||
if (isAdmin) {
|
||||
return await sql`
|
||||
SELECT id, name, description, domain, 'Admin' as role
|
||||
FROM apps
|
||||
WHERE domain IS NOT NULL
|
||||
ORDER BY name ASC
|
||||
` as any[];
|
||||
} else {
|
||||
return await sql`
|
||||
SELECT a.id, a.name, a.description, a.domain, g.role
|
||||
FROM apps a
|
||||
JOIN grants g ON a.id = g.app_id
|
||||
WHERE g.user_id = ${userId} AND a.domain IS NOT NULL
|
||||
ORDER BY a.name ASC
|
||||
` as any[];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionApps() {
|
||||
return await sql`
|
||||
SELECT id, name, domain, spiffe_id
|
||||
FROM apps
|
||||
ORDER BY name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getUserSessions(userId: string) {
|
||||
return await sql`
|
||||
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
|
||||
FROM sessions
|
||||
WHERE user_id = ${userId} AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getUserEventPasses(userId: string) {
|
||||
return await sql`
|
||||
SELECT id, slug, pin_code, name, max_seats, seats_claimed, is_active, expires_at
|
||||
FROM event_passes
|
||||
WHERE created_by = ${userId} AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getUserPasskeys(userId: string) {
|
||||
return await sql`
|
||||
SELECT id, credential_id, counter
|
||||
FROM passkeys
|
||||
WHERE user_id = ${userId}
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getAdminUsers() {
|
||||
return await sql`
|
||||
SELECT id, username, display_name, account_status
|
||||
FROM users
|
||||
ORDER BY username ASC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getAdminApps() {
|
||||
return await sql`
|
||||
SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at,
|
||||
COUNT(g.id) AS active_grants_count
|
||||
FROM apps a
|
||||
LEFT JOIN grants g ON a.id = g.app_id
|
||||
GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at
|
||||
ORDER BY a.created_at ASC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getAdminRoles() {
|
||||
return await sql`
|
||||
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
||||
a.name AS app_name
|
||||
FROM roles r
|
||||
LEFT JOIN apps a ON r.app_id = a.id
|
||||
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getAdminInvites() {
|
||||
return await sql`
|
||||
SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at,
|
||||
a.name AS app_name, a.id AS app_id,
|
||||
u.username AS used_by_username
|
||||
FROM invites i
|
||||
LEFT JOIN apps a ON i.app_id = a.id
|
||||
LEFT JOIN users u ON i.used_by = u.id
|
||||
ORDER BY i.created_at DESC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getAllRoles() {
|
||||
return await sql`
|
||||
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getAaguidAllowlist() {
|
||||
return await sql`
|
||||
SELECT id, aaguid, description, created_at
|
||||
FROM aaguid_allowlist
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getAdminUserDetails(targetUserId: string) {
|
||||
return await sql`
|
||||
SELECT id, username, display_name, account_status
|
||||
FROM users
|
||||
WHERE id = ${targetUserId}
|
||||
`.then((res) => res[0]);
|
||||
}
|
||||
|
||||
export async function getUserGrants(targetUserId: string) {
|
||||
return await sql`
|
||||
SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id
|
||||
FROM grants g
|
||||
JOIN apps a ON g.app_id = a.id
|
||||
WHERE g.user_id = ${targetUserId}
|
||||
ORDER BY a.name ASC
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getAdminAuditLogs() {
|
||||
return await sql`
|
||||
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
|
||||
FROM audit_records a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
}
|
||||
343
ui/mod.ts
343
ui/mod.ts
@ -1,17 +1,33 @@
|
||||
import { Hono } from "jsr:@hono/hono@4";
|
||||
import { serveStatic } from "jsr:@hono/hono@4/deno";
|
||||
import { deleteCookie, getCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import { deleteCookie } from "jsr:@hono/hono@4/cookie";
|
||||
import { sql } from "../server/db.ts";
|
||||
import { valkey } from "../server/valkey.ts";
|
||||
import {
|
||||
extractAllSessionIds,
|
||||
getAuthenticatedUser,
|
||||
getCookieDomain,
|
||||
isGlobalAdmin,
|
||||
isSafeRedirectUrl,
|
||||
isSessionAdmin,
|
||||
} from "../server/auth-session.ts";
|
||||
import { auditWrapper } from "../server/audit.ts";
|
||||
import { requireUiAuth } from "./auth_checks.ts";
|
||||
import {
|
||||
getAaguidAllowlist,
|
||||
getAdminApps,
|
||||
getAdminAuditLogs,
|
||||
getAdminInvites,
|
||||
getAdminRoles,
|
||||
getAdminUserDetails,
|
||||
getAdminUsers,
|
||||
getAllRoles,
|
||||
getDashboardApps,
|
||||
getSessionApps,
|
||||
getUserEventPasses,
|
||||
getUserGrants,
|
||||
getUserPasskeys,
|
||||
getUserSessions,
|
||||
} from "./db_queries.ts";
|
||||
|
||||
import { LoginPage } from "./components/LoginPage.tsx";
|
||||
import { RegisterPage } from "./components/RegisterPage.tsx";
|
||||
import { SessionsPage } from "./components/SessionsPage.tsx";
|
||||
@ -131,77 +147,23 @@ uiApp.get("/register", (c) => {
|
||||
});
|
||||
|
||||
uiApp.get("/dashboard", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
const authRes = await requireUiAuth(c);
|
||||
if (authRes instanceof Response) return authRes;
|
||||
const { auth, isAdmin } = authRes;
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
|
||||
let apps = [];
|
||||
if (isAdmin) {
|
||||
apps = await sql`
|
||||
SELECT id, name, description, domain, 'Admin' as role
|
||||
FROM apps
|
||||
WHERE domain IS NOT NULL
|
||||
ORDER BY name ASC
|
||||
` as any[];
|
||||
} else {
|
||||
apps = await sql`
|
||||
SELECT a.id, a.name, a.description, a.domain, g.role
|
||||
FROM apps a
|
||||
JOIN grants g ON a.id = g.app_id
|
||||
WHERE g.user_id = ${auth.userId} AND a.domain IS NOT NULL
|
||||
ORDER BY a.name ASC
|
||||
` as any[];
|
||||
}
|
||||
const apps = await getDashboardApps(auth.userId, isAdmin);
|
||||
|
||||
return c.html(AppLaunchpadPage({ apps: apps as any, isAdmin }));
|
||||
});
|
||||
|
||||
uiApp.get("/dashboard/sessions", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
const authRes = await requireUiAuth(c);
|
||||
if (authRes instanceof Response) return authRes;
|
||||
const { auth, isAdmin } = authRes;
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
|
||||
const apps = await sql`
|
||||
SELECT id, name, domain, spiffe_id
|
||||
FROM apps
|
||||
ORDER BY name ASC
|
||||
`;
|
||||
|
||||
const sessions = await sql`
|
||||
SELECT id, label, is_agent, custom_scopes, last_activity_at, last_activity_action, created_at, expires_at
|
||||
FROM sessions
|
||||
WHERE user_id = ${auth.userId} AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
const eventPasses = await sql`
|
||||
SELECT id, slug, pin_code, name, max_seats, seats_claimed, is_active, expires_at
|
||||
FROM event_passes
|
||||
WHERE created_by = ${auth.userId} AND is_active = TRUE
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
const apps = await getSessionApps();
|
||||
const sessions = await getUserSessions(auth.userId);
|
||||
const eventPasses = await getUserEventPasses(auth.userId);
|
||||
|
||||
return c.html(
|
||||
SessionsPage({
|
||||
@ -215,26 +177,11 @@ uiApp.get("/dashboard/sessions", async (c) => {
|
||||
});
|
||||
|
||||
uiApp.get("/dashboard/passkeys", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
const authRes = await requireUiAuth(c);
|
||||
if (authRes instanceof Response) return authRes;
|
||||
const { auth, isAdmin } = authRes;
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
|
||||
const passkeys = await sql`
|
||||
SELECT id, credential_id, counter
|
||||
FROM passkeys
|
||||
WHERE user_id = ${auth.userId}
|
||||
`;
|
||||
const passkeys = await getUserPasskeys(auth.userId);
|
||||
|
||||
return c.html(PasskeysPage({ passkeys, isAdmin }));
|
||||
});
|
||||
@ -245,229 +192,95 @@ uiApp.get("/admin", (c) => {
|
||||
});
|
||||
|
||||
uiApp.get("/admin/users", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
const authRes = await requireUiAuth(c);
|
||||
if (authRes instanceof Response) return authRes;
|
||||
const { isAdmin, isSessionAdminRole } = authRes;
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||
if (!isAdmin || !isSessionAdminRole) {
|
||||
return c.redirect("/dashboard", 302);
|
||||
}
|
||||
|
||||
const users = await sql`
|
||||
SELECT id, username, display_name, account_status
|
||||
FROM users
|
||||
ORDER BY username ASC
|
||||
`;
|
||||
|
||||
const users = await getAdminUsers();
|
||||
return c.html(AdminUsersPage({ users }));
|
||||
});
|
||||
|
||||
uiApp.get("/admin/apps", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
const authRes = await requireUiAuth(c);
|
||||
if (authRes instanceof Response) return authRes;
|
||||
const { isAdmin, isSessionAdminRole } = authRes;
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||
if (!isAdmin || !isSessionAdminRole) {
|
||||
return c.redirect("/dashboard", 302);
|
||||
}
|
||||
|
||||
const apps = await sql`
|
||||
SELECT a.id, a.name, a.spiffe_id, a.description, a.created_at,
|
||||
COUNT(g.id) AS active_grants_count
|
||||
FROM apps a
|
||||
LEFT JOIN grants g ON a.id = g.app_id
|
||||
GROUP BY a.id, a.name, a.spiffe_id, a.description, a.created_at
|
||||
ORDER BY a.created_at ASC
|
||||
`;
|
||||
|
||||
const apps = await getAdminApps();
|
||||
return c.html(AdminAppsPage({ apps }));
|
||||
});
|
||||
|
||||
uiApp.get("/admin/roles", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
const authRes = await requireUiAuth(c);
|
||||
if (authRes instanceof Response) return authRes;
|
||||
const { isAdmin, isSessionAdminRole } = authRes;
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||
if (!isAdmin || !isSessionAdminRole) {
|
||||
return c.redirect("/dashboard", 302);
|
||||
}
|
||||
|
||||
const roles = await sql`
|
||||
SELECT r.id, r.name, r.description, r.app_id, r.created_at,
|
||||
a.name AS app_name
|
||||
FROM roles r
|
||||
LEFT JOIN apps a ON r.app_id = a.id
|
||||
ORDER BY r.app_id NULLS FIRST, r.name ASC
|
||||
`;
|
||||
|
||||
const apps = await sql`
|
||||
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
|
||||
`;
|
||||
|
||||
const roles = await getAdminRoles();
|
||||
const apps = await getSessionApps();
|
||||
return c.html(AdminRolesPage({ roles, apps }));
|
||||
});
|
||||
|
||||
uiApp.get("/admin/invites", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
const authRes = await requireUiAuth(c);
|
||||
if (authRes instanceof Response) return authRes;
|
||||
const { isAdmin, isSessionAdminRole } = authRes;
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||
if (!isAdmin || !isSessionAdminRole) {
|
||||
return c.redirect("/dashboard", 302);
|
||||
}
|
||||
|
||||
const invites = await sql`
|
||||
SELECT i.id, i.code, i.role, i.max_uses, i.uses_count, i.auto_activate, i.expires_at, i.created_at, i.used_at,
|
||||
a.name AS app_name, a.id AS app_id,
|
||||
u.username AS used_by_username
|
||||
FROM invites i
|
||||
LEFT JOIN apps a ON i.app_id = a.id
|
||||
LEFT JOIN users u ON i.used_by = u.id
|
||||
ORDER BY i.created_at DESC
|
||||
`;
|
||||
|
||||
const apps = await sql`
|
||||
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
|
||||
`;
|
||||
|
||||
const allRoles = await sql`
|
||||
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
|
||||
`;
|
||||
const invites = await getAdminInvites();
|
||||
const apps = await getSessionApps();
|
||||
const allRoles = await getAllRoles();
|
||||
|
||||
return c.html(AdminInvitesPage({ invites, apps, allRoles }));
|
||||
});
|
||||
|
||||
uiApp.get("/admin/aaguid", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
const authRes = await requireUiAuth(c);
|
||||
if (authRes instanceof Response) return authRes;
|
||||
const { isAdmin } = authRes;
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
if (!isAdmin) {
|
||||
return c.redirect("/dashboard");
|
||||
}
|
||||
|
||||
const allowlist = await sql`
|
||||
SELECT id, aaguid, description, created_at
|
||||
FROM aaguid_allowlist
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
const allowlist = await getAaguidAllowlist();
|
||||
return c.html(AAGUIDPage({ allowlist }));
|
||||
});
|
||||
|
||||
uiApp.get("/admin/users/:id", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
const authRes = await requireUiAuth(c);
|
||||
if (authRes instanceof Response) return authRes;
|
||||
const { isAdmin, isSessionAdminRole } = authRes;
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||
if (!isAdmin || !isSessionAdminRole) {
|
||||
return c.redirect("/admin/users", 302);
|
||||
}
|
||||
|
||||
const targetUserId = c.req.param("id");
|
||||
const user = await sql`
|
||||
SELECT id, username, display_name, account_status
|
||||
FROM users
|
||||
WHERE id = ${targetUserId}
|
||||
`.then((res) => res[0]);
|
||||
const user = await getAdminUserDetails(targetUserId);
|
||||
|
||||
if (!user) {
|
||||
return c.redirect("/admin/users");
|
||||
}
|
||||
|
||||
const sessions = await sql`
|
||||
SELECT id, created_at, expires_at
|
||||
FROM sessions
|
||||
WHERE user_id = ${targetUserId} AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
const passkeys = await sql`
|
||||
SELECT id, credential_id, counter
|
||||
FROM passkeys
|
||||
WHERE user_id = ${targetUserId}
|
||||
`;
|
||||
|
||||
const grants = await sql`
|
||||
SELECT g.id, g.app_id, g.role, g.created_at, a.name AS app_name, a.spiffe_id
|
||||
FROM grants g
|
||||
JOIN apps a ON g.app_id = a.id
|
||||
WHERE g.user_id = ${targetUserId}
|
||||
ORDER BY a.name ASC
|
||||
`;
|
||||
|
||||
const allApps = await sql`
|
||||
SELECT id, name, spiffe_id FROM apps ORDER BY name ASC
|
||||
`;
|
||||
|
||||
const allRoles = await sql`
|
||||
SELECT id, name, description, app_id FROM roles ORDER BY name ASC
|
||||
`;
|
||||
const sessions = await getUserSessions(targetUserId);
|
||||
const passkeys = await getUserPasskeys(targetUserId);
|
||||
const grants = await getUserGrants(targetUserId);
|
||||
const allApps = await getSessionApps();
|
||||
const allRoles = await getAllRoles();
|
||||
|
||||
return c.html(
|
||||
AdminUserDetailsPage({
|
||||
@ -482,33 +295,15 @@ uiApp.get("/admin/users/:id", async (c) => {
|
||||
});
|
||||
|
||||
uiApp.get("/admin/audit-logs", async (c) => {
|
||||
const auth = await getAuthenticatedUser(c);
|
||||
if (!auth) {
|
||||
if (getCookie(c, "session_id")) {
|
||||
deleteCookie(c, "session_id", { path: "/" }); // clear host cookie
|
||||
deleteCookie(c, "session_id", {
|
||||
domain: getCookieDomain(Deno.env.get("RP_ID")),
|
||||
path: "/",
|
||||
}); // clear domain cookie
|
||||
}
|
||||
c.header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||
return c.redirect("/login");
|
||||
}
|
||||
const authRes = await requireUiAuth(c);
|
||||
if (authRes instanceof Response) return authRes;
|
||||
const { isAdmin, isSessionAdminRole } = authRes;
|
||||
|
||||
const isAdmin = await isGlobalAdmin(auth.userId);
|
||||
const isSessionAdminRole = await isSessionAdmin(auth);
|
||||
if (!isAdmin || !isSessionAdminRole) {
|
||||
return c.redirect("/dashboard", 302);
|
||||
}
|
||||
|
||||
const logs = await sql`
|
||||
SELECT a.id, a.action, a.resource, a.details, a.ip_address, a.created_at, u.username as user
|
||||
FROM audit_records a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
const logs = await getAdminAuditLogs();
|
||||
return c.html(AuditLogPage({ logs }));
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user