Add local user authentication and admin capabilities
Implement local user authentication with password hashing, add admin roles for user management and audit log viewing, and introduce audit logging for critical actions. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 832a44ff-12ae-4096-8a0d-666ec083d536 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/1p7jhzu Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@workspace/api-zod": "workspace:*",
|
||||
"@workspace/db": "workspace:*",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
@@ -23,6 +24,7 @@
|
||||
"pino-http": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import app from "./app";
|
||||
import { logger } from "./lib/logger";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db, usersTable } from "@workspace/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const rawPort = process.env["PORT"];
|
||||
|
||||
@@ -15,6 +18,30 @@ if (Number.isNaN(port) || port <= 0) {
|
||||
throw new Error(`Invalid PORT value: "${rawPort}"`);
|
||||
}
|
||||
|
||||
async function seedAdminUser(): Promise<void> {
|
||||
try {
|
||||
const [row] = await db.select({ count: sql<number>`count(*)::int` }).from(usersTable);
|
||||
if ((row?.count ?? 0) > 0) return;
|
||||
|
||||
const adminUsername = process.env.LOCAL_ADMIN_USERNAME || "admin";
|
||||
const adminPassword = process.env.LOCAL_ADMIN_PASSWORD;
|
||||
if (!adminPassword) {
|
||||
logger.warn("LOCAL_ADMIN_PASSWORD is not set — skipping admin seed. Set it to enable local login.");
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(adminPassword, 10);
|
||||
await db.insert(usersTable).values({
|
||||
username: adminUsername,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
});
|
||||
logger.info({ username: adminUsername }, "Admin user created");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to seed admin user");
|
||||
}
|
||||
}
|
||||
|
||||
app.listen(port, (err) => {
|
||||
if (err) {
|
||||
logger.error({ err }, "Error listening on port");
|
||||
@@ -22,4 +49,5 @@ app.listen(port, (err) => {
|
||||
}
|
||||
|
||||
logger.info({ port }, "Server listening");
|
||||
seedAdminUser();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { db, auditLogsTable } from "@workspace/db";
|
||||
import { type Request } from "express";
|
||||
|
||||
export async function writeAuditLog(
|
||||
req: Request,
|
||||
entityType: string,
|
||||
entityId: number | null,
|
||||
action: string,
|
||||
changes?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const user = req.session.user;
|
||||
if (!user) return;
|
||||
const username = user.preferred_username || user.name || user.sub;
|
||||
await db.insert(auditLogsTable).values({
|
||||
entityType,
|
||||
entityId,
|
||||
action,
|
||||
userId: user.sub,
|
||||
username,
|
||||
changes: changes ? JSON.stringify(changes) : null,
|
||||
});
|
||||
}
|
||||
@@ -7,3 +7,15 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
if (req.session.user.role !== "admin") {
|
||||
res.status(403).json({ error: "Admin access required" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, desc, and } from "drizzle-orm";
|
||||
import { db, auditLogsTable } from "@workspace/db";
|
||||
import { requireAdmin } from "../middleware/auth";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.get("/audit-logs", requireAdmin, async (req, res): Promise<void> => {
|
||||
const { entityType, entityId, limit } = req.query;
|
||||
const maxLimit = Math.min(parseInt(limit as string) || 100, 500);
|
||||
|
||||
const conditions: ReturnType<typeof eq>[] = [];
|
||||
if (entityType && typeof entityType === "string") {
|
||||
conditions.push(eq(auditLogsTable.entityType, entityType));
|
||||
}
|
||||
if (entityId) {
|
||||
const id = parseInt(entityId as string, 10);
|
||||
if (!isNaN(id)) {
|
||||
conditions.push(eq(auditLogsTable.entityId, id));
|
||||
}
|
||||
}
|
||||
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(auditLogsTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(auditLogsTable.createdAt))
|
||||
.limit(maxLimit);
|
||||
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,11 +1,23 @@
|
||||
import { Router, type IRouter, type Request } from "express";
|
||||
import { Issuer, generators, type Client } from "openid-client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, usersTable } from "@workspace/db";
|
||||
import { logger } from "../lib/logger";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
let cachedClient: Client | null = null;
|
||||
|
||||
function isOidcConfigured(): boolean {
|
||||
return !!(
|
||||
process.env.KEYCLOAK_URL &&
|
||||
process.env.KEYCLOAK_REALM &&
|
||||
process.env.KEYCLOAK_CLIENT_ID &&
|
||||
process.env.KEYCLOAK_CLIENT_SECRET
|
||||
);
|
||||
}
|
||||
|
||||
function getBaseUrl(req: Request): string {
|
||||
if (process.env.APP_URL) return process.env.APP_URL;
|
||||
const host = req.get("x-forwarded-host") || req.get("host") || "localhost";
|
||||
@@ -40,10 +52,62 @@ async function getClient(): Promise<Client | null> {
|
||||
}
|
||||
}
|
||||
|
||||
router.get("/auth/mode", (_req, res): void => {
|
||||
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
||||
});
|
||||
|
||||
router.post("/auth/login", async (req, res): Promise<void> => {
|
||||
if (isOidcConfigured()) {
|
||||
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: "username and password are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.username, String(username)))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ error: "Invalid username or password" });
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(String(password), user.passwordHash);
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: "Invalid username or password" });
|
||||
return;
|
||||
}
|
||||
|
||||
req.session.user = {
|
||||
sub: String(user.id),
|
||||
name: user.username,
|
||||
preferred_username: user.username,
|
||||
email: user.email ?? undefined,
|
||||
role: (user.role as "admin" | "user") ?? "user",
|
||||
isLocal: true,
|
||||
};
|
||||
|
||||
res.json({
|
||||
sub: String(user.id),
|
||||
email: user.email ?? null,
|
||||
name: user.username,
|
||||
preferredUsername: user.username,
|
||||
role: user.role,
|
||||
isLocal: true,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/auth/login", async (req, res): Promise<void> => {
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
res.status(503).json({ error: "Keycloak is not configured. Set KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET." });
|
||||
res.status(503).json({ error: "Keycloak is not configured." });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +158,8 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
|
||||
email: typeof userinfo.email === "string" ? userinfo.email : undefined,
|
||||
name: typeof userinfo.name === "string" ? userinfo.name : undefined,
|
||||
preferred_username: typeof userinfo.preferred_username === "string" ? userinfo.preferred_username : undefined,
|
||||
role: "user",
|
||||
isLocal: false,
|
||||
};
|
||||
delete req.session.codeVerifier;
|
||||
|
||||
@@ -108,7 +174,6 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
|
||||
});
|
||||
|
||||
router.get("/auth/logout", async (req, res): Promise<void> => {
|
||||
const user = req.session.user;
|
||||
req.session.destroy(() => {});
|
||||
|
||||
const client = await getClient();
|
||||
@@ -132,6 +197,8 @@ router.get("/auth/me", async (req, res): Promise<void> => {
|
||||
email: u.email ?? null,
|
||||
name: u.name ?? null,
|
||||
preferredUsername: u.preferred_username ?? null,
|
||||
role: u.role ?? "user",
|
||||
isLocal: u.isLocal ?? false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import toolsRouter from "./tools";
|
||||
import ratingsRouter from "./ratings";
|
||||
import analyticsRouter from "./analytics";
|
||||
import authRouter from "./auth";
|
||||
import usersRouter from "./users";
|
||||
import auditRouter from "./audit";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -12,5 +14,7 @@ router.use(healthRouter);
|
||||
router.use(toolsRouter);
|
||||
router.use(ratingsRouter);
|
||||
router.use(analyticsRouter);
|
||||
router.use(usersRouter);
|
||||
router.use(auditRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DeleteToolParams,
|
||||
} from "@workspace/api-zod";
|
||||
import { requireAuth } from "../middleware/auth";
|
||||
import { writeAuditLog } from "../lib/audit";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -27,6 +28,13 @@ function buildToolWithStats(tool: typeof toolsTable.$inferSelect, ratings: { use
|
||||
return { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined };
|
||||
}
|
||||
|
||||
function canEditTool(req: import("express").Request, tool: { createdBy: string | null }): boolean {
|
||||
const user = req.session.user;
|
||||
if (!user) return false;
|
||||
if (user.role === "admin") return true;
|
||||
return tool.createdBy === user.sub || tool.createdBy === user.preferred_username;
|
||||
}
|
||||
|
||||
router.get("/tools", async (req, res): Promise<void> => {
|
||||
const parsed = ListToolsQueryParams.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
@@ -79,16 +87,21 @@ router.post("/tools", requireAuth, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = req.session.user!;
|
||||
const createdBy = user.preferred_username || user.name || user.sub;
|
||||
|
||||
const [tool] = await db.insert(toolsTable).values({
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
category: parsed.data.category,
|
||||
websiteUrl: parsed.data.websiteUrl ?? null,
|
||||
iconUrl: parsed.data.iconUrl ?? null,
|
||||
createdBy,
|
||||
features: parsed.data.features ?? [],
|
||||
tags: parsed.data.tags ?? [],
|
||||
}).returning();
|
||||
|
||||
await writeAuditLog(req, "tool", tool.id, "create", { name: tool.name, category: tool.category });
|
||||
res.status(201).json(tool);
|
||||
});
|
||||
|
||||
@@ -120,6 +133,21 @@ router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(toolsTable)
|
||||
.where(eq(toolsTable.id, params.data.id));
|
||||
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Tool not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canEditTool(req, existing)) {
|
||||
res.status(403).json({ error: "Not allowed to edit this tool" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = UpdateToolBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
@@ -127,13 +155,28 @@ router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (parsed.data.name !== undefined) updateData.name = parsed.data.name;
|
||||
if (parsed.data.description !== undefined) updateData.description = parsed.data.description;
|
||||
if (parsed.data.category !== undefined) updateData.category = parsed.data.category;
|
||||
if (parsed.data.websiteUrl !== undefined) updateData.websiteUrl = parsed.data.websiteUrl;
|
||||
if (parsed.data.iconUrl !== undefined) updateData.iconUrl = parsed.data.iconUrl;
|
||||
if (parsed.data.features !== undefined) updateData.features = parsed.data.features;
|
||||
if (parsed.data.tags !== undefined) updateData.tags = parsed.data.tags;
|
||||
const changes: Record<string, { from: unknown; to: unknown }> = {};
|
||||
|
||||
function track(key: keyof typeof existing, value: unknown) {
|
||||
if (value !== undefined && value !== existing[key]) {
|
||||
changes[key] = { from: existing[key], to: value };
|
||||
(updateData as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
track("name", parsed.data.name);
|
||||
track("description", parsed.data.description);
|
||||
track("category", parsed.data.category);
|
||||
track("websiteUrl", parsed.data.websiteUrl);
|
||||
track("iconUrl", parsed.data.iconUrl);
|
||||
if (parsed.data.features !== undefined) {
|
||||
changes["features"] = { from: existing.features, to: parsed.data.features };
|
||||
updateData.features = parsed.data.features;
|
||||
}
|
||||
if (parsed.data.tags !== undefined) {
|
||||
changes["tags"] = { from: existing.tags, to: parsed.data.tags };
|
||||
updateData.tags = parsed.data.tags;
|
||||
}
|
||||
|
||||
const [tool] = await db
|
||||
.update(toolsTable)
|
||||
@@ -146,6 +189,10 @@ router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await writeAuditLog(req, "tool", tool.id, "update", changes);
|
||||
}
|
||||
|
||||
res.json(tool);
|
||||
});
|
||||
|
||||
@@ -156,12 +203,23 @@ router.delete("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const [tool] = await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id)).returning();
|
||||
if (!tool) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(toolsTable)
|
||||
.where(eq(toolsTable.id, params.data.id));
|
||||
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Tool not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canEditTool(req, existing)) {
|
||||
res.status(403).json({ error: "Not allowed to delete this tool" });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeAuditLog(req, "tool", existing.id, "delete", { name: existing.name });
|
||||
await db.delete(toolsTable).where(eq(toolsTable.id, params.data.id));
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db, usersTable } from "@workspace/db";
|
||||
import { requireAdmin } from "../middleware/auth";
|
||||
import { writeAuditLog } from "../lib/audit";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
const UserCreateSchema = z.object({
|
||||
username: z.string().min(2),
|
||||
password: z.string().min(6),
|
||||
email: z.string().optional(),
|
||||
role: z.enum(["admin", "user"]).optional().default("user"),
|
||||
});
|
||||
|
||||
const UserRoleUpdateSchema = z.object({
|
||||
role: z.enum(["admin", "user"]),
|
||||
});
|
||||
|
||||
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
const users = await db
|
||||
.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
email: usersTable.email,
|
||||
role: usersTable.role,
|
||||
createdAt: usersTable.createdAt,
|
||||
})
|
||||
.from(usersTable)
|
||||
.orderBy(usersTable.createdAt);
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
const parsed = UserCreateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ id: usersTable.id })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.username, parsed.data.username))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
res.status(409).json({ error: "Username already exists" });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 10);
|
||||
const [user] = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
username: parsed.data.username,
|
||||
passwordHash,
|
||||
email: parsed.data.email ?? null,
|
||||
role: parsed.data.role ?? "user",
|
||||
})
|
||||
.returning({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
email: usersTable.email,
|
||||
role: usersTable.role,
|
||||
createdAt: usersTable.createdAt,
|
||||
});
|
||||
|
||||
await writeAuditLog(req, "user", user.id, "create", { username: user.username, role: user.role });
|
||||
res.status(201).json(user);
|
||||
});
|
||||
|
||||
router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ error: "Invalid user id" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = UserRoleUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.update(usersTable)
|
||||
.set({ role: parsed.data.role })
|
||||
.where(eq(usersTable.id, id))
|
||||
.returning({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
email: usersTable.email,
|
||||
role: usersTable.role,
|
||||
createdAt: usersTable.createdAt,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeAuditLog(req, "user", id, "update", { role: parsed.data.role });
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ error: "Invalid user id" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.session.user?.sub === String(id)) {
|
||||
res.status(400).json({ error: "Cannot delete your own account" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [user] = await db.delete(usersTable).where(eq(usersTable.id, id)).returning();
|
||||
if (!user) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeAuditLog(req, "user", id, "delete", { username: user.username });
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
export default router;
|
||||
+2
@@ -7,6 +7,8 @@ declare module "express-session" {
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
role?: "admin" | "user";
|
||||
isLocal?: boolean;
|
||||
};
|
||||
codeVerifier?: string;
|
||||
returnTo?: string;
|
||||
|
||||
Reference in New Issue
Block a user