forked from jappel/leistungsbilanz-ts
Add login-required user management (admin/user roles, Config->Benutzer)
No self-signup: admins create every account in Config -> Benutzer with a typed start password; a forced password change gates first use. Session- cookie auth protects all /api/* routes; /api/users additionally requires the admin role. See docs/authentication.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b50c00c0ac
commit
c56cfb30ad
36 changed files with 4928 additions and 38 deletions
111
src/server/auth/auth.middleware.ts
Normal file
111
src/server/auth/auth.middleware.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||
import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js";
|
||||
import { readCookie } from "./cookie.js";
|
||||
import type { SessionStore } from "./sessions.js";
|
||||
import type { UserRepository } from "../../db/repositories/user.repository.js";
|
||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
authUser?: AuthUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionIdFromRequest(req: {
|
||||
headers: { cookie?: string | undefined };
|
||||
}): string | undefined {
|
||||
return readCookie(req.headers.cookie, SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function identityFromRequest(
|
||||
req: { headers: { cookie?: string | undefined } },
|
||||
sessions: SessionStore,
|
||||
users: UserRepository,
|
||||
slideExpiry = true
|
||||
): Promise<AuthUser | undefined> {
|
||||
const session = sessions.get(sessionIdFromRequest(req), slideExpiry);
|
||||
if (!session) return undefined;
|
||||
const user = await users.findById(session.userId);
|
||||
if (!user || !user.active) return undefined;
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
export function cookieOptions(authConfig: AuthConfig) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: authConfig.cookieSecure,
|
||||
path: "/",
|
||||
maxAge: authConfig.sessionTtlMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Protects /api. Only the login route and the status check stay reachable without a session. */
|
||||
export function createAuthGuard(
|
||||
authConfig: AuthConfig,
|
||||
sessions: SessionStore,
|
||||
users: UserRepository,
|
||||
openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"]
|
||||
): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (openPaths.includes(req.path)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
identityFromRequest(req, sessions, users)
|
||||
.then((identity) => {
|
||||
if (!identity) {
|
||||
res.status(401).json({ error: "Nicht angemeldet." });
|
||||
return;
|
||||
}
|
||||
req.authUser = identity;
|
||||
const sessionId = sessionIdFromRequest(req);
|
||||
if (sessionId) {
|
||||
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig));
|
||||
}
|
||||
next();
|
||||
})
|
||||
.catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
/** Blocks everything except logout and the password change while a start password is still in use. */
|
||||
export function requirePasswordChanged(): RequestHandler {
|
||||
const allowed = new Set(["/auth/me", "/auth/change-password", "/auth/logout"]);
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.authUser?.mustChangePassword || allowed.has(req.path)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({
|
||||
error: "Bitte zuerst ein eigenes Passwort setzen.",
|
||||
mustChangePassword: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function requireRole(role: UserRole): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.authUser?.role !== role) {
|
||||
res.status(403).json({ error: "Keine Berechtigung für diese Aktion." });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue