forked from jappel/leistungsbilanz-ts
Harden user management integration
This commit is contained in:
parent
73579d1e79
commit
847f4e9493
18 changed files with 394 additions and 111 deletions
|
|
@ -2,8 +2,12 @@ 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 { UserStore } from "../../domain/ports/user.store.js";
|
||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||
import {
|
||||
WRITE_REQUEST_HEADER_NAME,
|
||||
WRITE_REQUEST_HEADER_VALUE,
|
||||
} from "../../shared/constants/request-security.js";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
|
|
@ -30,7 +34,7 @@ export function sessionIdFromRequest(req: {
|
|||
export async function identityFromRequest(
|
||||
req: { headers: { cookie?: string | undefined } },
|
||||
sessions: SessionStore,
|
||||
users: UserRepository,
|
||||
users: Pick<UserStore, "findById">,
|
||||
slideExpiry = true
|
||||
): Promise<AuthUser | undefined> {
|
||||
const session = sessions.get(sessionIdFromRequest(req), slideExpiry);
|
||||
|
|
@ -74,10 +78,11 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|||
* CSRF defense for cookie-authenticated writes.
|
||||
*
|
||||
* SameSite=Lax alone is not enough here: every other service on the same
|
||||
* host (other ports of the same IP) counts as "same-site" and would get the
|
||||
* cookie attached. Browsers send Sec-Fetch-Site on every request, so a write
|
||||
* is only accepted from the app's own origin. Clients without the header
|
||||
* (curl, scripts) carry no ambient browser cookie and are not a CSRF vector.
|
||||
* host (other ports of the same IP) counts as "same-site" and may receive the
|
||||
* cookie. Fetch Metadata is also not guaranteed on plain HTTP in the LAN.
|
||||
* Every write therefore needs an application header that HTML forms cannot
|
||||
* set; a cross-origin fetch would require a CORS preflight, which the API does
|
||||
* not permit. Sec-Fetch-Site remains an additional browser-side check.
|
||||
*/
|
||||
export function requireSameOriginForWrites(): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
|
|
@ -85,6 +90,10 @@ export function requireSameOriginForWrites(): RequestHandler {
|
|||
next();
|
||||
return;
|
||||
}
|
||||
if (req.get(WRITE_REQUEST_HEADER_NAME) !== WRITE_REQUEST_HEADER_VALUE) {
|
||||
res.status(403).json({ error: "Schreibzugriff ohne gültigen Anwendungsnachweis abgelehnt." });
|
||||
return;
|
||||
}
|
||||
const fetchSite = req.headers["sec-fetch-site"];
|
||||
if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") {
|
||||
next();
|
||||
|
|
@ -98,7 +107,7 @@ export function requireSameOriginForWrites(): RequestHandler {
|
|||
export function createAuthGuard(
|
||||
authConfig: AuthConfig,
|
||||
sessions: SessionStore,
|
||||
users: UserRepository,
|
||||
users: Pick<UserStore, "findById">,
|
||||
openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"]
|
||||
): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ const SCRYPT_N = 16384;
|
|||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
const KEY_LENGTH = 64;
|
||||
const SALT_LENGTH = 16;
|
||||
const MAX_SCRYPT_MEMORY = 64 * 1024 * 1024;
|
||||
const MIN_SCRYPT_N = 1 << 14;
|
||||
const MAX_SCRYPT_N = 1 << 16;
|
||||
const MAX_SCRYPT_R = 16;
|
||||
const MAX_SCRYPT_P = 8;
|
||||
|
||||
// A hand-rolled wrapper instead of util.promisify(scrypt): promisify only
|
||||
// resolves to scrypt's 3-argument overload, so the 4-argument form used for
|
||||
|
|
@ -30,8 +36,13 @@ function scryptAsync(
|
|||
// Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the
|
||||
// hash so they can be tuned later without invalidating existing rows.
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
|
||||
const salt = randomBytes(SALT_LENGTH);
|
||||
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH, {
|
||||
N: SCRYPT_N,
|
||||
r: SCRYPT_R,
|
||||
p: SCRYPT_P,
|
||||
maxmem: MAX_SCRYPT_MEMORY,
|
||||
});
|
||||
return [
|
||||
"scrypt",
|
||||
SCRYPT_N,
|
||||
|
|
@ -52,12 +63,33 @@ export async function verifyPassword(
|
|||
const n = Number(nRaw);
|
||||
const r = Number(rRaw);
|
||||
const p = Number(pRaw);
|
||||
if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) {
|
||||
if (
|
||||
!Number.isSafeInteger(n) ||
|
||||
n < MIN_SCRYPT_N ||
|
||||
n > MAX_SCRYPT_N ||
|
||||
(n & (n - 1)) !== 0 ||
|
||||
!Number.isSafeInteger(r) ||
|
||||
r < 1 ||
|
||||
r > MAX_SCRYPT_R ||
|
||||
!Number.isSafeInteger(p) ||
|
||||
p < 1 ||
|
||||
p > MAX_SCRYPT_P ||
|
||||
!/^[0-9a-f]{32}$/i.test(saltHex) ||
|
||||
!/^[0-9a-f]{128}$/i.test(hashHex)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const salt = Buffer.from(saltHex, "hex");
|
||||
const expected = Buffer.from(hashHex, "hex");
|
||||
const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p });
|
||||
if (derivedKey.length !== expected.length) return false;
|
||||
return timingSafeEqual(derivedKey, expected);
|
||||
try {
|
||||
const derivedKey = await scryptAsync(password, salt, expected.length, {
|
||||
N: n,
|
||||
r,
|
||||
p,
|
||||
maxmem: MAX_SCRYPT_MEMORY,
|
||||
});
|
||||
return timingSafeEqual(derivedKey, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ import { LoginThrottle } from "../auth/login-throttle.js";
|
|||
import { SessionStore } from "../auth/sessions.js";
|
||||
import { UserService } from "../../domain/services/user.service.js";
|
||||
import { userRepository } from "./application-repositories.js";
|
||||
import { hashPassword, verifyPassword } from "../auth/password.js";
|
||||
|
||||
export const authConfig = readAuthConfig();
|
||||
export const sessionStore = new SessionStore(authConfig.sessionTtlMs, authConfig.sessionMaxAgeMs);
|
||||
sessionStore.startSweeping();
|
||||
export const loginThrottle = new LoginThrottle();
|
||||
export const userService = new UserService(userRepository, authConfig);
|
||||
export const userService = new UserService(
|
||||
userRepository,
|
||||
{ bootstrap: authConfig.bootstrap },
|
||||
{ hash: hashPassword, verify: verifyPassword }
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue