Files
tickettracker/backend/src/auth.ts
T

174 lines
4.4 KiB
TypeScript

import crypto from "node:crypto";
import { promisify } from "node:util";
import type { NextFunction, Request, Response } from "express";
import { query } from "./db.js";
const scrypt = promisify(crypto.scrypt);
const sessionCookieName = "tickettracker_session";
const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS ?? 1000 * 60 * 60 * 24 * 14);
export type UserRole = "admin" | "user";
export type AuthUser = {
id: string;
username: string;
display_name: string;
role: UserRole;
active: boolean;
};
declare global {
namespace Express {
interface Request {
user?: AuthUser;
authSessionHash?: string;
}
}
}
export async function hashPassword(password: string) {
const salt = crypto.randomBytes(16).toString("base64url");
const key = (await scrypt(password, salt, 64)) as Buffer;
return `scrypt$${salt}$${key.toString("base64url")}`;
}
export async function verifyPassword(password: string, storedHash: string) {
const [algorithm, salt, expected] = storedHash.split("$");
if (algorithm !== "scrypt" || !salt || !expected) {
return false;
}
const expectedBuffer = Buffer.from(expected, "base64url");
const actualBuffer = (await scrypt(password, salt, expectedBuffer.length)) as Buffer;
return expectedBuffer.length === actualBuffer.length && crypto.timingSafeEqual(expectedBuffer, actualBuffer);
}
export function hashToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}
function readCookie(req: Request, name: string) {
const rawCookie = req.headers.cookie;
if (!rawCookie) {
return null;
}
const cookies = rawCookie.split(";").map((part) => part.trim());
const prefix = `${name}=`;
const match = cookies.find((cookie) => cookie.startsWith(prefix));
if (!match) {
return null;
}
return decodeURIComponent(match.slice(prefix.length));
}
function publicUser(user: AuthUser) {
return {
id: user.id,
username: user.username,
display_name: user.display_name,
role: user.role,
active: user.active
};
}
export async function createAuthSession(res: Response, userId: string) {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
await query(
`
INSERT INTO auth_sessions (token_hash, user_id, expires_at)
VALUES ($1, $2, $3);
`,
[tokenHash, userId, expiresAt.toISOString()]
);
res.cookie(sessionCookieName, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.COOKIE_SECURE === "true",
maxAge: sessionMaxAgeMs,
path: "/"
});
}
export async function clearAuthSession(req: Request, res: Response) {
if (req.authSessionHash) {
await query("DELETE FROM auth_sessions WHERE token_hash = $1;", [req.authSessionHash]);
}
res.clearCookie(sessionCookieName, {
sameSite: "lax",
secure: process.env.COOKIE_SECURE === "true",
path: "/"
});
}
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
try {
const token = readCookie(req, sessionCookieName);
if (!token) {
res.status(401).json({ error: "Nicht angemeldet" });
return;
}
const tokenHash = hashToken(token);
const result = await query<AuthUser>(
`
SELECT u.id, u.username, u.display_name, u.role, u.active
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
AND s.expires_at > now()
AND u.active = true;
`,
[tokenHash]
);
if (result.rows.length === 0) {
res.status(401).json({ error: "Sitzung ist abgelaufen" });
return;
}
req.user = publicUser(result.rows[0]);
req.authSessionHash = tokenHash;
next();
} catch (error) {
next(error);
}
}
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
if (req.user?.role !== "admin") {
res.status(403).json({ error: "Adminrechte erforderlich" });
return;
}
next();
}
export function requireUser(req: Request, res: Response, next: NextFunction) {
if (req.user?.role !== "user") {
res.status(403).json({ error: "Dieser Bereich ist nur für Benutzerkonten vorgesehen" });
return;
}
next();
}
export function currentUser(req: Request) {
if (!req.user) {
throw Object.assign(new Error("Nicht angemeldet"), { status: 401 });
}
return req.user;
}