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:
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user