feat(auth): add enrollment check, role-based sessions, and player access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-02 13:23:35 +02:00
parent db10ebe220
commit 37fa5fc3c8
2 changed files with 36 additions and 10 deletions

View File

@@ -6,12 +6,16 @@
import type { RouteContext, RouteModule } from "./types"; import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse } from "./utils"; import { jsonResponse, errorResponse } from "./utils";
import { logger } from "@shared/lib/logger"; import { logger } from "@shared/lib/logger";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users } from "@shared/db/schema";
import { eq } from "drizzle-orm";
// In-memory session store: token → { discordId, username, avatar, expiresAt } // In-memory session store: token → { discordId, username, avatar, role, expiresAt }
export interface Session { export interface Session {
discordId: string; discordId: string;
username: string; username: string;
avatar: string | null; avatar: string | null;
role: "admin" | "player";
expiresAt: number; expiresAt: number;
} }
@@ -144,26 +148,34 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const user = await userRes.json() as { id: string; username: string; avatar: string | null }; const user = await userRes.json() as { id: string; username: string; avatar: string | null };
// Check allowlist // Check enrollment — user must exist in the users table
const adminIds = getAdminIds(); const dbUser = await DrizzleClient.query.users.findFirst({
if (adminIds.length > 0 && !adminIds.includes(user.id)) { where: eq(users.id, BigInt(user.id)),
logger.warn("auth", `Unauthorized login attempt by ${user.username} (${user.id})`); });
if (!dbUser) {
logger.info("auth", `Non-enrolled login attempt by ${user.username} (${user.id})`);
return new Response( return new Response(
`<html><body><h1>Access Denied</h1><p>Your Discord account is not authorized.</p></body></html>`, `<html><body><h1>Not Enrolled</h1><p>You need to use the Aurora bot in Discord before you can access this panel.</p><a href="/">Go back</a></body></html>`,
{ status: 403, headers: { "Content-Type": "text/html" } } { status: 403, headers: { "Content-Type": "text/html" } }
); );
} }
// Determine role
const adminIds = getAdminIds();
const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";
// Create session // Create session
const token = generateToken(); const token = generateToken();
sessions.set(token, { sessions.set(token, {
discordId: user.id, discordId: user.id,
username: user.username, username: user.username,
avatar: user.avatar, avatar: user.avatar,
role,
expiresAt: Date.now() + SESSION_MAX_AGE, expiresAt: Date.now() + SESSION_MAX_AGE,
}); });
logger.info("auth", `Admin login: ${user.username} (${user.id})`); logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
// Get return_to URL from redirect token cookie // Get return_to URL from redirect token cookie
const cookies = parseCookies(ctx.req.headers.get("cookie")); const cookies = parseCookies(ctx.req.headers.get("cookie"));
@@ -213,13 +225,15 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
// GET /auth/me — return current session info // GET /auth/me — return current session info
if (pathname === "/auth/me" && method === "GET") { if (pathname === "/auth/me" && method === "GET") {
const session = getSession(ctx.req); const session = getSession(ctx.req);
if (!session) return jsonResponse({ authenticated: false }, 401); if (!session) return jsonResponse({ authenticated: false, enrolled: false });
return jsonResponse({ return jsonResponse({
authenticated: true, authenticated: true,
enrolled: true,
user: { user: {
discordId: session.discordId, discordId: session.discordId,
username: session.username, username: session.username,
avatar: session.avatar, avatar: session.avatar,
role: session.role,
}, },
}); });
} }

View File

@@ -4,7 +4,7 @@
*/ */
import type { RouteContext, RouteModule } from "./types"; import type { RouteContext, RouteModule } from "./types";
import { authRoutes, isAuthenticated } from "./auth.routes"; import { authRoutes, isAuthenticated, getSession } from "./auth.routes";
import { healthRoutes } from "./health.routes"; import { healthRoutes } from "./health.routes";
import { statsRoutes } from "./stats.routes"; import { statsRoutes } from "./stats.routes";
import { actionsRoutes } from "./actions.routes"; import { actionsRoutes } from "./actions.routes";
@@ -70,9 +70,21 @@ export async function handleRequest(req: Request, url: URL): Promise<Response |
// For API routes, enforce authentication // For API routes, enforce authentication
if (ctx.pathname.startsWith("/api/")) { if (ctx.pathname.startsWith("/api/")) {
if (!isAuthenticated(req)) { const session = getSession(req);
if (!session) {
return errorResponse("Unauthorized", 401); return errorResponse("Unauthorized", 401);
} }
// Admin-only routes: everything except stats and own user data
const playerAllowedPrefixes = ["/api/stats", "/api/health"];
const isPlayerAllowed = playerAllowedPrefixes.some(p => ctx.pathname.startsWith(p));
// Players can access their own user data
const isOwnUserRoute = ctx.pathname.match(/^\/api\/users\/\d+/) && session.role === "player";
if (session.role === "player" && !isPlayerAllowed && !isOwnUserRoute) {
return errorResponse("Admin access required", 403);
}
} }
// Try protected routes // Try protected routes