User management with login, roles and a Config area #25
18 changed files with 683 additions and 81 deletions
|
|
@ -51,6 +51,7 @@ services:
|
||||||
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
|
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
|
||||||
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
|
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
|
||||||
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"
|
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"
|
||||||
|
AUTH_SESSION_MAX_DAYS: "${AUTH_SESSION_MAX_DAYS:-7}"
|
||||||
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
|
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
|
||||||
# Nicht auf dem Host veroeffentlicht: die API wird ueber den
|
# Nicht auf dem Host veroeffentlicht: die API wird ueber den
|
||||||
# Next.js-Rewrite im Web-Dienst erreicht.
|
# Next.js-Rewrite im Web-Dienst erreicht.
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
|
||||||
`username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`,
|
`username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`,
|
||||||
`role`, `active`, `mustChangePassword`.
|
`role`, `active`, `mustChangePassword`.
|
||||||
- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie
|
- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie
|
||||||
`lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h). Ein
|
`lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h) mit
|
||||||
|
absoluter Obergrenze (`AUTH_SESSION_MAX_DAYS`, Standard 7). Ein
|
||||||
Neustart der API beendet alle Sessions – für einen Single-Container-Dienst
|
Neustart der API beendet alle Sessions – für einen Single-Container-Dienst
|
||||||
ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle.
|
ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle.
|
||||||
- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*`
|
- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*`
|
||||||
|
|
@ -44,6 +45,28 @@ Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
|
||||||
(`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht
|
(`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht
|
||||||
angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App.
|
angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App.
|
||||||
|
|
||||||
|
## Schutzmechanismen
|
||||||
|
|
||||||
|
| Angriff | Schutz |
|
||||||
|
|---|---|
|
||||||
|
| Passwort-Raten (online) | `LoginThrottle`: 5 Fehlversuche je Anmeldename in 15 min → HTTP 429 bis das Fenster abläuft. Schlüssel ist der Name, nicht die IP (alle Anfragen kommen über den Next-Rewrite mit derselben Quell-IP). |
|
||||||
|
| Passwort-Raten (offline, DB-Diebstahl) | scrypt (N=16384, r=8, p=1), eigenes Salt je Hash |
|
||||||
|
| Nutzernamen ausspähen (Timing) | Unbekannte Namen werden gegen einen festen Dummy-Hash geprüft – immer genau ein scrypt-Lauf; einheitliche Fehlermeldung |
|
||||||
|
| CSRF | SameSite=Lax **plus** `requireSameOriginForWrites`: schreibende Anfragen nur mit `Sec-Fetch-Site: same-origin`. Nötig, weil andere Dienste auf demselben Host (andere Ports) als „same-site“ gelten |
|
||||||
|
| Session-Fixation | Neue Session-ID bei jedem Login und nach jedem Passwortwechsel |
|
||||||
|
| Gestohlenes Cookie | httpOnly; gleitende Laufzeit (`AUTH_SESSION_HOURS`, 12 h) **und** harte Obergrenze (`AUTH_SESSION_MAX_DAYS`, 7 Tage) |
|
||||||
|
| Kompromittiertes Konto | Passwort-Reset und Deaktivieren beenden sofort alle Sessions des Kontos; Passwortwechsel beendet alle anderen |
|
||||||
|
| Aussperren | Der letzte aktive Admin kann weder herabgestuft noch deaktiviert werden; eigener Zugang ist in der Liste gesperrt |
|
||||||
|
| Rechteausweitung / Mass Assignment | Rolle kommt aus der DB, nie aus der Anfrage; zod-Schemas mit `.strict()` |
|
||||||
|
| DoS über riesige Passwörter | Längengrenzen (Name 100, Passwort 200 Zeichen) vor scrypt |
|
||||||
|
| Look-alike-Namen | Anmeldename nur `a-z 0-9 . _ @ -`, klein geschrieben |
|
||||||
|
| Clickjacking | `X-Frame-Options: SAMEORIGIN`, `frame-ancestors 'self'` (next.config.mjs) |
|
||||||
|
| Speicherwachstum | Max. 20 Sessions je Nutzer, periodisches Aufräumen abgelaufener Sessions, Throttle-Tabelle begrenzt |
|
||||||
|
|
||||||
|
**Nicht durch Code lösbar:** Ohne TLS gehen Passwort und Session-Cookie im
|
||||||
|
Klartext durchs Netz. Für Zugriff außerhalb des LANs HTTPS über einen
|
||||||
|
Reverse-Proxy vorschalten und `COOKIE_SECURE=true` setzen.
|
||||||
|
|
||||||
## Bootstrap-Admin
|
## Bootstrap-Admin
|
||||||
|
|
||||||
`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe
|
`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe
|
||||||
|
|
@ -56,6 +79,7 @@ in `compose.yaml` selbst.
|
||||||
## Bewusst nicht umgesetzt (Stand jetzt)
|
## Bewusst nicht umgesetzt (Stand jetzt)
|
||||||
|
|
||||||
- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen
|
- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen
|
||||||
- Konten-Sperre nach Fehlversuchen
|
- Dauerhafte Konten-Sperre nach Fehlversuchen (es gibt nur die zeitlich
|
||||||
|
begrenzte Drosselung, siehe oben)
|
||||||
- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung
|
- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung
|
||||||
- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle
|
- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,22 @@ const nextConfig = {
|
||||||
typescript: {
|
typescript: {
|
||||||
tsconfigPath: "./tsconfig.next.json",
|
tsconfigPath: "./tsconfig.next.json",
|
||||||
},
|
},
|
||||||
|
poweredByHeader: false,
|
||||||
|
// Clickjacking: the Config pages hold admin actions, so no foreign page
|
||||||
|
// may frame the app. Same-origin framing stays allowed.
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/:path*",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||||
|
{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" },
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "Referrer-Policy", value: "same-origin" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -452,13 +452,25 @@ a {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel-header {
|
.filter-panel-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel-header .filter-panel-toggle {
|
/* Expanded: the fields carry a label above their input, so toggle and
|
||||||
|
count sit on the input line (bottom) instead of the label line. */
|
||||||
|
.filter-panel-row-expanded {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-row-expanded .filter-panel-toggle,
|
||||||
|
.filter-panel-row-expanded .filter-panel-count {
|
||||||
|
margin-bottom: calc((2.1rem - 24px) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel-row .filter-panel-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -468,7 +480,7 @@ a {
|
||||||
color: var(--color-ink-soft);
|
color: var(--color-ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel-header .filter-panel-toggle:hover {
|
.filter-panel-row .filter-panel-toggle:hover {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -503,10 +515,11 @@ a {
|
||||||
|
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: var(--space-sm) var(--space-md);
|
gap: var(--space-sm) var(--space-md);
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar-field,
|
.filter-bar-field,
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,29 @@ export class WrongPasswordError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PasswordUnchangedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Das neue Passwort muss sich vom bisherigen unterscheiden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LastAdminError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Es muss mindestens ein aktiver Administrator bleiben.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueConstraintError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
"code" in error &&
|
||||||
|
(error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export class UserService {
|
export class UserService {
|
||||||
|
private dummyHash: Promise<string> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly repository: UserRepository,
|
private readonly repository: UserRepository,
|
||||||
private readonly authConfig: AuthConfig
|
private readonly authConfig: AuthConfig
|
||||||
|
|
@ -38,6 +60,9 @@ export class UserService {
|
||||||
* escape hatch instead of a standing backdoor.
|
* escape hatch instead of a standing backdoor.
|
||||||
*/
|
*/
|
||||||
async ensureBootstrapAdmin(): Promise<void> {
|
async ensureBootstrapAdmin(): Promise<void> {
|
||||||
|
// Warm the dummy hash at startup so even the first unknown-user login
|
||||||
|
// costs one scrypt run, not two.
|
||||||
|
await this.getDummyHash();
|
||||||
if (!this.authConfig.bootstrap) return;
|
if (!this.authConfig.bootstrap) return;
|
||||||
const existingCount = await this.repository.count();
|
const existingCount = await this.repository.count();
|
||||||
if (existingCount > 0) return;
|
if (existingCount > 0) return;
|
||||||
|
|
@ -55,9 +80,10 @@ export class UserService {
|
||||||
|
|
||||||
async authenticate(username: string, password: string) {
|
async authenticate(username: string, password: string) {
|
||||||
const user = await this.repository.findByUsername(username);
|
const user = await this.repository.findByUsername(username);
|
||||||
// Always hash something, even for an unknown user: otherwise the
|
// Unknown users are verified against a fixed dummy hash: exactly one
|
||||||
// response time would leak which usernames exist.
|
// scrypt run either way, so response time doesn't reveal which usernames
|
||||||
const passwordHash = user?.passwordHash ?? (await hashPassword("placeholder"));
|
// exist.
|
||||||
|
const passwordHash = user?.passwordHash ?? (await this.getDummyHash());
|
||||||
const passwordMatches = await verifyPassword(password, passwordHash);
|
const passwordMatches = await verifyPassword(password, passwordHash);
|
||||||
if (!user || !user.active || !passwordMatches) {
|
if (!user || !user.active || !passwordMatches) {
|
||||||
throw new InvalidCredentialsError();
|
throw new InvalidCredentialsError();
|
||||||
|
|
@ -74,15 +100,32 @@ export class UserService {
|
||||||
const existing = await this.repository.findByUsername(input.username);
|
const existing = await this.repository.findByUsername(input.username);
|
||||||
if (existing) throw new UsernameTakenError();
|
if (existing) throw new UsernameTakenError();
|
||||||
const passwordHash = await hashPassword(input.initialPassword);
|
const passwordHash = await hashPassword(input.initialPassword);
|
||||||
return this.repository.create({
|
try {
|
||||||
username: input.username,
|
return await this.repository.create({
|
||||||
name: input.name,
|
username: input.username,
|
||||||
role: input.role,
|
name: input.name,
|
||||||
passwordHash,
|
role: input.role,
|
||||||
});
|
passwordHash,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Two admins creating the same name at once: the unique index decides.
|
||||||
|
if (isUniqueConstraintError(error)) throw new UsernameTakenError();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) {
|
async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) {
|
||||||
|
const removesAdmin = patch.role === "user" || patch.active === false;
|
||||||
|
if (removesAdmin) {
|
||||||
|
const target = await this.repository.findById(userId);
|
||||||
|
const targetIsActiveAdmin = target?.role === "admin" && target.active;
|
||||||
|
if (targetIsActiveAdmin) {
|
||||||
|
const activeAdmins = (await this.repository.list()).filter(
|
||||||
|
(user) => user.role === "admin" && user.active
|
||||||
|
);
|
||||||
|
if (activeAdmins.length <= 1) throw new LastAdminError();
|
||||||
|
}
|
||||||
|
}
|
||||||
return this.repository.updateRoleAndActive(userId, patch);
|
return this.repository.updateRoleAndActive(userId, patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,7 +141,13 @@ export class UserService {
|
||||||
if (!user) throw new WrongPasswordError();
|
if (!user) throw new WrongPasswordError();
|
||||||
const currentMatches = await verifyPassword(currentPassword, user.passwordHash);
|
const currentMatches = await verifyPassword(currentPassword, user.passwordHash);
|
||||||
if (!currentMatches) throw new WrongPasswordError();
|
if (!currentMatches) throw new WrongPasswordError();
|
||||||
|
if (newPassword === currentPassword) throw new PasswordUnchangedError();
|
||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await hashPassword(newPassword);
|
||||||
return this.repository.setPassword(userId, passwordHash, false);
|
return this.repository.setPassword(userId, passwordHash, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDummyHash(): Promise<string> {
|
||||||
|
this.dummyHash ??= hashPassword("dummy-password-for-constant-time-login");
|
||||||
|
return this.dummyHash;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,15 @@ export function CollapsibleFilterPanel({
|
||||||
writeCollapsed(storageKey, next);
|
writeCollapsed(storageKey, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One row: toggle, then either the "Filter" label (collapsed) or the filter
|
||||||
|
// fields themselves (expanded), and the count at the right edge.
|
||||||
return (
|
return (
|
||||||
<div className="filter-panel">
|
<div className="filter-panel">
|
||||||
<div className="filter-panel-header">
|
<div
|
||||||
|
className={
|
||||||
|
isCollapsed ? "filter-panel-row" : "filter-panel-row filter-panel-row-expanded"
|
||||||
|
}
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
ariaControls={barId}
|
ariaControls={barId}
|
||||||
ariaExpanded={!isCollapsed}
|
ariaExpanded={!isCollapsed}
|
||||||
|
|
@ -66,29 +72,30 @@ export function CollapsibleFilterPanel({
|
||||||
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
|
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
|
||||||
onClick={toggleCollapsed}
|
onClick={toggleCollapsed}
|
||||||
/>
|
/>
|
||||||
<span className="filter-panel-label">Filter</span>
|
{isCollapsed ? (
|
||||||
{isCollapsed && isFilterActive ? (
|
<>
|
||||||
<span className="filter-panel-badge">Filter aktiv</span>
|
<span className="filter-panel-label">Filter</span>
|
||||||
) : null}
|
{isFilterActive ? <span className="filter-panel-badge">Filter aktiv</span> : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="filter-bar" id={barId}>
|
||||||
|
{children}
|
||||||
|
{isFilterActive ? (
|
||||||
|
<IconButton
|
||||||
|
className="filter-bar-reset"
|
||||||
|
icon={FilterX}
|
||||||
|
label="Zurücksetzen"
|
||||||
|
onClick={onReset}
|
||||||
|
showLabel
|
||||||
|
title="Alle Filter zurücksetzen"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<span aria-live="polite" className="filter-panel-count">
|
<span aria-live="polite" className="filter-panel-count">
|
||||||
{visibleCount} von {totalCount}
|
{visibleCount} von {totalCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isCollapsed ? null : (
|
|
||||||
<div className="filter-bar" id={barId}>
|
|
||||||
{children}
|
|
||||||
{isFilterActive ? (
|
|
||||||
<IconButton
|
|
||||||
className="filter-bar-reset"
|
|
||||||
icon={FilterX}
|
|
||||||
label="Zurücksetzen"
|
|
||||||
onClick={onReset}
|
|
||||||
showLabel
|
|
||||||
title="Alle Filter zurücksetzen"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
export const SESSION_COOKIE = "lb_session";
|
export const SESSION_COOKIE = "lb_session";
|
||||||
|
|
||||||
const DEFAULT_SESSION_HOURS = 12;
|
const DEFAULT_SESSION_HOURS = 12;
|
||||||
|
const DEFAULT_SESSION_MAX_DAYS = 7;
|
||||||
|
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
|
/** Idle timeout; slides forward on activity. */
|
||||||
sessionTtlMs: number;
|
sessionTtlMs: number;
|
||||||
|
/** Hard upper bound for a session, regardless of activity. */
|
||||||
|
sessionMaxAgeMs: number;
|
||||||
cookieSecure: boolean;
|
cookieSecure: boolean;
|
||||||
/**
|
/**
|
||||||
* Only used to create the very first admin when the users table is still
|
* Only used to create the very first admin when the users table is still
|
||||||
|
|
@ -15,11 +19,21 @@ export interface AuthConfig {
|
||||||
|
|
||||||
export class AuthConfigError extends Error {}
|
export class AuthConfigError extends Error {}
|
||||||
|
|
||||||
export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig {
|
function positiveNumber(raw: string | undefined, fallback: number, name: string): number {
|
||||||
const hours = Number(env.AUTH_SESSION_HOURS ?? DEFAULT_SESSION_HOURS);
|
const value = Number(raw ?? fallback);
|
||||||
if (!Number.isFinite(hours) || hours <= 0) {
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
throw new AuthConfigError("AUTH_SESSION_HOURS must be a positive number.");
|
throw new AuthConfigError(`${name} must be a positive number.`);
|
||||||
}
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig {
|
||||||
|
const hours = positiveNumber(env.AUTH_SESSION_HOURS, DEFAULT_SESSION_HOURS, "AUTH_SESSION_HOURS");
|
||||||
|
const maxDays = positiveNumber(
|
||||||
|
env.AUTH_SESSION_MAX_DAYS,
|
||||||
|
DEFAULT_SESSION_MAX_DAYS,
|
||||||
|
"AUTH_SESSION_MAX_DAYS"
|
||||||
|
);
|
||||||
|
|
||||||
const bootstrapUsername = (env.AUTH_BOOTSTRAP_USERNAME ?? "").trim().toLowerCase();
|
const bootstrapUsername = (env.AUTH_BOOTSTRAP_USERNAME ?? "").trim().toLowerCase();
|
||||||
const bootstrapPassword = env.AUTH_BOOTSTRAP_PASSWORT ?? "";
|
const bootstrapPassword = env.AUTH_BOOTSTRAP_PASSWORT ?? "";
|
||||||
|
|
@ -36,6 +50,7 @@ export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionTtlMs: hours * 60 * 60 * 1000,
|
sessionTtlMs: hours * 60 * 60 * 1000,
|
||||||
|
sessionMaxAgeMs: maxDays * 24 * 60 * 60 * 1000,
|
||||||
cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true",
|
cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true",
|
||||||
bootstrap: bootstrapUsername
|
bootstrap: bootstrapUsername
|
||||||
? { username: bootstrapUsername, password: bootstrapPassword }
|
? { username: bootstrapUsername, password: bootstrapPassword }
|
||||||
|
|
|
||||||
|
|
@ -46,17 +46,55 @@ export async function identityFromRequest(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cookieOptions(authConfig: AuthConfig) {
|
export function cookieOptions(authConfig: AuthConfig, maxAgeMs: number) {
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax" as const,
|
sameSite: "lax" as const,
|
||||||
secure: authConfig.cookieSecure,
|
secure: authConfig.cookieSecure,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: authConfig.sessionTtlMs,
|
maxAge: maxAgeMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Protects /api. Only the login route and the status check stay reachable without a session. */
|
/** Sets the session cookie with the same remaining lifetime the server uses. */
|
||||||
|
export function setSessionCookie(
|
||||||
|
res: Response,
|
||||||
|
authConfig: AuthConfig,
|
||||||
|
sessions: SessionStore,
|
||||||
|
sessionId: string
|
||||||
|
): void {
|
||||||
|
const session = sessions.get(sessionId, false);
|
||||||
|
if (!session) return;
|
||||||
|
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig, sessions.remainingMs(session)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function requireSameOriginForWrites(): RequestHandler {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (SAFE_METHODS.has(req.method)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fetchSite = req.headers["sec-fetch-site"];
|
||||||
|
if (fetchSite === undefined || fetchSite === "same-origin" || fetchSite === "none") {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(403).json({ error: "Anfrage von fremder Herkunft abgelehnt." });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Protects /api. Only login, status and logout stay reachable without a session. */
|
||||||
export function createAuthGuard(
|
export function createAuthGuard(
|
||||||
authConfig: AuthConfig,
|
authConfig: AuthConfig,
|
||||||
sessions: SessionStore,
|
sessions: SessionStore,
|
||||||
|
|
@ -76,9 +114,7 @@ export function createAuthGuard(
|
||||||
}
|
}
|
||||||
req.authUser = identity;
|
req.authUser = identity;
|
||||||
const sessionId = sessionIdFromRequest(req);
|
const sessionId = sessionIdFromRequest(req);
|
||||||
if (sessionId) {
|
if (sessionId) setSessionCookie(res, authConfig, sessions, sessionId);
|
||||||
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig));
|
|
||||||
}
|
|
||||||
next();
|
next();
|
||||||
})
|
})
|
||||||
.catch(next);
|
.catch(next);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ export function readCookie(
|
||||||
if (separatorIndex === -1) continue;
|
if (separatorIndex === -1) continue;
|
||||||
const key = part.slice(0, separatorIndex).trim();
|
const key = part.slice(0, separatorIndex).trim();
|
||||||
if (key !== name) continue;
|
if (key !== name) continue;
|
||||||
return decodeURIComponent(part.slice(separatorIndex + 1).trim());
|
// A malformed value must read as "no session", not throw into a 500.
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(part.slice(separatorIndex + 1).trim());
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
src/server/auth/login-throttle.ts
Normal file
53
src/server/auth/login-throttle.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* Slows down online password guessing per username.
|
||||||
|
*
|
||||||
|
* Keyed by username, not by client IP: every request reaches the API through
|
||||||
|
* the Next.js rewrite, so the socket address is always the web container.
|
||||||
|
* The trade-off is that someone who knows a username can keep that account
|
||||||
|
* temporarily throttled; the block lifts by itself after the window, so it is
|
||||||
|
* a nuisance, not a lockout.
|
||||||
|
*/
|
||||||
|
export class LoginThrottle {
|
||||||
|
private readonly failures = new Map<string, number[]>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly maxFailures = 5,
|
||||||
|
private readonly windowMs = 15 * 60 * 1000,
|
||||||
|
private readonly maxTrackedKeys = 10_000,
|
||||||
|
private readonly now: () => number = Date.now
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Milliseconds until another attempt is allowed; 0 = allowed now. */
|
||||||
|
retryAfterMs(username: string): number {
|
||||||
|
const recent = this.recentFailures(this.key(username));
|
||||||
|
if (recent.length < this.maxFailures) return 0;
|
||||||
|
return Math.max(0, recent[0] + this.windowMs - this.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
recordFailure(username: string): void {
|
||||||
|
const key = this.key(username);
|
||||||
|
const recent = this.recentFailures(key);
|
||||||
|
recent.push(this.now());
|
||||||
|
if (!this.failures.has(key) && this.failures.size >= this.maxTrackedKeys) {
|
||||||
|
// Bound memory under a flood of random usernames: drop the oldest key.
|
||||||
|
const oldestKey = this.failures.keys().next().value;
|
||||||
|
if (oldestKey !== undefined) this.failures.delete(oldestKey);
|
||||||
|
}
|
||||||
|
this.failures.set(key, recent);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSuccess(username: string): void {
|
||||||
|
this.failures.delete(this.key(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
private recentFailures(key: string): number[] {
|
||||||
|
const cutoff = this.now() - this.windowMs;
|
||||||
|
const recent = (this.failures.get(key) ?? []).filter((time) => time > cutoff);
|
||||||
|
if (recent.length === 0) this.failures.delete(key);
|
||||||
|
return recent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(username: string): string {
|
||||||
|
return username.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,24 +3,50 @@ import { randomUUID } from "node:crypto";
|
||||||
interface Session {
|
interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
createdAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_SESSIONS_PER_USER = 20;
|
||||||
|
const SWEEP_INTERVAL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory session store. The API runs as a single container/process, so a
|
* In-memory session store. The API runs as a single container/process, so a
|
||||||
* restart clearing every session is an acceptable trade-off for the
|
* restart clearing every session is an acceptable trade-off for the
|
||||||
* simplicity of not needing a persisted session table.
|
* simplicity of not needing a persisted session table.
|
||||||
|
*
|
||||||
|
* Sessions slide forward on activity (`idleTtlMs`) but never outlive
|
||||||
|
* `absoluteTtlMs` - otherwise a stolen cookie could be kept alive forever.
|
||||||
*/
|
*/
|
||||||
export class SessionStore {
|
export class SessionStore {
|
||||||
private readonly sessions = new Map<string, Session>();
|
private readonly sessions = new Map<string, Session>();
|
||||||
|
|
||||||
constructor(private readonly ttlMs: number) {}
|
constructor(
|
||||||
|
private readonly idleTtlMs: number,
|
||||||
|
private readonly absoluteTtlMs: number,
|
||||||
|
private readonly now: () => number = Date.now
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Removes expired sessions; unreferenced ones would otherwise pile up. */
|
||||||
|
startSweeping(): void {
|
||||||
|
setInterval(() => this.sweep(), SWEEP_INTERVAL_MS).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
sweep(): void {
|
||||||
|
const now = this.now();
|
||||||
|
for (const [id, session] of this.sessions) {
|
||||||
|
if (this.isExpired(session, now)) this.sessions.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
create(userId: string): Session {
|
create(userId: string): Session {
|
||||||
|
this.enforcePerUserLimit(userId);
|
||||||
|
const now = this.now();
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
expiresAt: Date.now() + this.ttlMs,
|
createdAt: now,
|
||||||
|
expiresAt: Math.min(now + this.idleTtlMs, now + this.absoluteTtlMs),
|
||||||
};
|
};
|
||||||
this.sessions.set(session.id, session);
|
this.sessions.set(session.id, session);
|
||||||
return session;
|
return session;
|
||||||
|
|
@ -31,20 +57,28 @@ export class SessionStore {
|
||||||
if (!sessionId) return undefined;
|
if (!sessionId) return undefined;
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return undefined;
|
if (!session) return undefined;
|
||||||
if (session.expiresAt < Date.now()) {
|
const now = this.now();
|
||||||
|
if (this.isExpired(session, now)) {
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (slideExpiry) session.expiresAt = Date.now() + this.ttlMs;
|
if (slideExpiry) {
|
||||||
|
session.expiresAt = Math.min(now + this.idleTtlMs, session.createdAt + this.absoluteTtlMs);
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remaining lifetime, used as cookie max-age so browser and server agree. */
|
||||||
|
remainingMs(session: Session): number {
|
||||||
|
return Math.max(0, session.expiresAt - this.now());
|
||||||
|
}
|
||||||
|
|
||||||
destroy(sessionId: string | undefined): void {
|
destroy(sessionId: string | undefined): void {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
this.sessions.delete(sessionId);
|
this.sessions.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ends every other session for a user, e.g. after a password change. */
|
/** Ends every session of a user, optionally except one (e.g. the caller's own). */
|
||||||
destroyAllForUser(userId: string, exceptSessionId?: string): void {
|
destroyAllForUser(userId: string, exceptSessionId?: string): void {
|
||||||
for (const [id, session] of this.sessions) {
|
for (const [id, session] of this.sessions) {
|
||||||
if (session.userId === userId && id !== exceptSessionId) {
|
if (session.userId === userId && id !== exceptSessionId) {
|
||||||
|
|
@ -52,4 +86,20 @@ export class SessionStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isExpired(session: Session, now: number): boolean {
|
||||||
|
return session.expiresAt <= now || session.createdAt + this.absoluteTtlMs <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest sessions go first, so a legitimate user logging in on a new
|
||||||
|
// device is never refused - only memory growth is bounded.
|
||||||
|
private enforcePerUserLimit(userId: string): void {
|
||||||
|
const own = [...this.sessions.values()]
|
||||||
|
.filter((session) => session.userId === userId)
|
||||||
|
.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
while (own.length >= MAX_SESSIONS_PER_USER) {
|
||||||
|
const oldest = own.shift();
|
||||||
|
if (oldest) this.sessions.delete(oldest.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { readAuthConfig } from "../auth/auth-config.js";
|
import { readAuthConfig } from "../auth/auth-config.js";
|
||||||
|
import { LoginThrottle } from "../auth/login-throttle.js";
|
||||||
import { SessionStore } from "../auth/sessions.js";
|
import { SessionStore } from "../auth/sessions.js";
|
||||||
import { UserService } from "../../domain/services/user.service.js";
|
import { UserService } from "../../domain/services/user.service.js";
|
||||||
import { userRepository } from "./application-repositories.js";
|
import { userRepository } from "./application-repositories.js";
|
||||||
|
|
||||||
export const authConfig = readAuthConfig();
|
export const authConfig = readAuthConfig();
|
||||||
export const sessionStore = new SessionStore(authConfig.sessionTtlMs);
|
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, authConfig);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.js";
|
import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.js";
|
||||||
import { InvalidCredentialsError, WrongPasswordError } from "../../domain/services/user.service.js";
|
import {
|
||||||
import { authConfig, sessionStore, userService } from "../composition/auth.js";
|
InvalidCredentialsError,
|
||||||
|
PasswordUnchangedError,
|
||||||
|
WrongPasswordError,
|
||||||
|
} from "../../domain/services/user.service.js";
|
||||||
|
import { authConfig, loginThrottle, sessionStore, userService } from "../composition/auth.js";
|
||||||
import {
|
import {
|
||||||
cookieOptions,
|
cookieOptions,
|
||||||
identityFromRequest,
|
identityFromRequest,
|
||||||
sessionIdFromRequest,
|
sessionIdFromRequest,
|
||||||
|
setSessionCookie,
|
||||||
} from "../auth/auth.middleware.js";
|
} from "../auth/auth.middleware.js";
|
||||||
import { SESSION_COOKIE } from "../auth/auth-config.js";
|
import { SESSION_COOKIE } from "../auth/auth-config.js";
|
||||||
import { userRepository } from "../composition/application-repositories.js";
|
import { userRepository } from "../composition/application-repositories.js";
|
||||||
|
|
@ -13,9 +18,7 @@ import { userRepository } from "../composition/application-repositories.js";
|
||||||
export async function getAuthStatus(req: Request, res: Response) {
|
export async function getAuthStatus(req: Request, res: Response) {
|
||||||
const identity = await identityFromRequest(req, sessionStore, userRepository);
|
const identity = await identityFromRequest(req, sessionStore, userRepository);
|
||||||
const sessionId = sessionIdFromRequest(req);
|
const sessionId = sessionIdFromRequest(req);
|
||||||
if (identity && sessionId) {
|
if (identity && sessionId) setSessionCookie(res, authConfig, sessionStore, sessionId);
|
||||||
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig));
|
|
||||||
}
|
|
||||||
res.json({
|
res.json({
|
||||||
signedIn: Boolean(identity),
|
signedIn: Boolean(identity),
|
||||||
mustChangePassword: identity?.mustChangePassword ?? false,
|
mustChangePassword: identity?.mustChangePassword ?? false,
|
||||||
|
|
@ -28,13 +31,29 @@ export async function login(req: Request, res: Response) {
|
||||||
res.status(401).json({ error: "Anmeldung fehlgeschlagen." });
|
res.status(401).json({ error: "Anmeldung fehlgeschlagen." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { username, password } = parsed.data;
|
||||||
|
|
||||||
|
const retryAfterMs = loginThrottle.retryAfterMs(username);
|
||||||
|
if (retryAfterMs > 0) {
|
||||||
|
res.setHeader("Retry-After", Math.ceil(retryAfterMs / 1000).toString());
|
||||||
|
res.status(429).json({
|
||||||
|
error: "Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await userService.authenticate(parsed.data.username, parsed.data.password);
|
const user = await userService.authenticate(username, password);
|
||||||
|
loginThrottle.recordSuccess(username);
|
||||||
|
// A fresh session id on every login: an id planted before login
|
||||||
|
// (session fixation) never becomes authenticated.
|
||||||
|
sessionStore.destroy(sessionIdFromRequest(req));
|
||||||
const session = sessionStore.create(user.id);
|
const session = sessionStore.create(user.id);
|
||||||
res.cookie(SESSION_COOKIE, session.id, cookieOptions(authConfig));
|
setSessionCookie(res, authConfig, sessionStore, session.id);
|
||||||
res.json({ signedIn: true, mustChangePassword: user.mustChangePassword });
|
res.json({ signedIn: true, mustChangePassword: user.mustChangePassword });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof InvalidCredentialsError) {
|
if (error instanceof InvalidCredentialsError) {
|
||||||
|
loginThrottle.recordFailure(username);
|
||||||
res.status(401).json({ error: error.message });
|
res.status(401).json({ error: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +78,7 @@ export async function changePassword(req: Request, res: Response) {
|
||||||
}
|
}
|
||||||
const parsed = changePasswordSchema.safeParse(req.body);
|
const parsed = changePasswordSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: parsed.error.flatten() });
|
res.status(400).json({ error: "Das neue Passwort muss 8 bis 200 Zeichen lang sein." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -69,20 +88,22 @@ export async function changePassword(req: Request, res: Response) {
|
||||||
parsed.data.newPassword
|
parsed.data.newPassword
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof WrongPasswordError) {
|
if (error instanceof WrongPasswordError || error instanceof PasswordUnchangedError) {
|
||||||
res.status(400).json({ error: error.message });
|
res.status(400).json({ error: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
// A password change invalidates every other session of this account; the
|
// Every session of this account ends - including the current one, which is
|
||||||
// current one stays so the change doesn't log the user out themselves.
|
// replaced by a fresh id so the user stays signed in on this device only.
|
||||||
sessionStore.destroyAllForUser(identity.id, sessionIdFromRequest(req));
|
sessionStore.destroyAllForUser(identity.id);
|
||||||
|
const session = sessionStore.create(identity.id);
|
||||||
|
setSessionCookie(res, authConfig, sessionStore, session.id);
|
||||||
res.json({ changed: true });
|
res.json({ changed: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout(req: Request, res: Response) {
|
export function logout(req: Request, res: Response) {
|
||||||
sessionStore.destroy(sessionIdFromRequest(req));
|
sessionStore.destroy(sessionIdFromRequest(req));
|
||||||
res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig), maxAge: undefined });
|
res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig, 0), maxAge: undefined });
|
||||||
res.json({ signedOut: true });
|
res.json({ signedOut: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
import type { ZodError } from "zod";
|
||||||
import {
|
import {
|
||||||
createUserSchema,
|
createUserSchema,
|
||||||
resetPasswordSchema,
|
resetPasswordSchema,
|
||||||
updateUserSchema,
|
updateUserSchema,
|
||||||
} from "../../shared/validation/user.schemas.js";
|
} from "../../shared/validation/user.schemas.js";
|
||||||
import { UsernameTakenError } from "../../domain/services/user.service.js";
|
import { LastAdminError, UsernameTakenError } from "../../domain/services/user.service.js";
|
||||||
import { userService } from "../composition/auth.js";
|
import { sessionStore, userService } from "../composition/auth.js";
|
||||||
import { userRepository } from "../composition/application-repositories.js";
|
import { userRepository } from "../composition/application-repositories.js";
|
||||||
|
|
||||||
// The password hash never leaves this layer.
|
// The password hash never leaves this layer.
|
||||||
|
|
@ -29,6 +30,10 @@ function toDto(user: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstIssue(error: ZodError): string {
|
||||||
|
return error.issues[0]?.message ?? "Ungültige Eingabe.";
|
||||||
|
}
|
||||||
|
|
||||||
export async function listUsers(_req: Request, res: Response) {
|
export async function listUsers(_req: Request, res: Response) {
|
||||||
const rows = await userRepository.list();
|
const rows = await userRepository.list();
|
||||||
res.json(rows.map(toDto));
|
res.json(rows.map(toDto));
|
||||||
|
|
@ -37,7 +42,7 @@ export async function listUsers(_req: Request, res: Response) {
|
||||||
export async function createUser(req: Request, res: Response) {
|
export async function createUser(req: Request, res: Response) {
|
||||||
const parsed = createUserSchema.safeParse(req.body);
|
const parsed = createUserSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: parsed.error.flatten() });
|
res.status(400).json({ error: firstIssue(parsed.error) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -64,7 +69,7 @@ export async function updateUser(req: Request, res: Response) {
|
||||||
}
|
}
|
||||||
const parsed = updateUserSchema.safeParse(req.body);
|
const parsed = updateUserSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: parsed.error.flatten() });
|
res.status(400).json({ error: firstIssue(parsed.error) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The signed-in admin can't lock themselves out by deactivating or
|
// The signed-in admin can't lock themselves out by deactivating or
|
||||||
|
|
@ -78,8 +83,18 @@ export async function updateUser(req: Request, res: Response) {
|
||||||
res.status(404).json({ error: "Benutzer nicht gefunden." });
|
res.status(404).json({ error: "Benutzer nicht gefunden." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updated = await userService.updateRoleAndActive(userId, parsed.data);
|
try {
|
||||||
res.json(updated ? toDto(updated) : null);
|
const updated = await userService.updateRoleAndActive(userId, parsed.data);
|
||||||
|
// Deactivation takes effect immediately, not just at the next login.
|
||||||
|
if (parsed.data.active === false) sessionStore.destroyAllForUser(userId);
|
||||||
|
res.json(updated ? toDto(updated) : null);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof LastAdminError) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetUserPassword(req: Request, res: Response) {
|
export async function resetUserPassword(req: Request, res: Response) {
|
||||||
|
|
@ -90,7 +105,7 @@ export async function resetUserPassword(req: Request, res: Response) {
|
||||||
}
|
}
|
||||||
const parsed = resetPasswordSchema.safeParse(req.body);
|
const parsed = resetPasswordSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ error: parsed.error.flatten() });
|
res.status(400).json({ error: firstIssue(parsed.error) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existing = await userRepository.findById(userId);
|
const existing = await userRepository.findById(userId);
|
||||||
|
|
@ -99,5 +114,8 @@ export async function resetUserPassword(req: Request, res: Response) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updated = await userService.resetPassword(userId, parsed.data.newPassword);
|
const updated = await userService.resetPassword(userId, parsed.data.newPassword);
|
||||||
|
// A reset is usually a response to a suspected compromise: every open
|
||||||
|
// session of that account ends now.
|
||||||
|
sessionStore.destroyAllForUser(userId);
|
||||||
res.json(updated ? toDto(updated) : null);
|
res.json(updated ? toDto(updated) : null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
|
||||||
import { authRouter } from "./routes/auth.routes.js";
|
import { authRouter } from "./routes/auth.routes.js";
|
||||||
import { userRouter } from "./routes/user.routes.js";
|
import { userRouter } from "./routes/user.routes.js";
|
||||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||||
import { createAuthGuard, requirePasswordChanged } from "./auth/auth.middleware.js";
|
import {
|
||||||
|
createAuthGuard,
|
||||||
|
requirePasswordChanged,
|
||||||
|
requireSameOriginForWrites,
|
||||||
|
} from "./auth/auth.middleware.js";
|
||||||
import { authConfig, sessionStore, userService } from "./composition/auth.js";
|
import { authConfig, sessionStore, userService } from "./composition/auth.js";
|
||||||
import { userRepository } from "./composition/application-repositories.js";
|
import { userRepository } from "./composition/application-repositories.js";
|
||||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
||||||
|
|
@ -19,6 +23,7 @@ const port = Number(process.env.PORT || 3000);
|
||||||
const heartbeatIntervalMs = 5 * 60 * 1000;
|
const heartbeatIntervalMs = 5 * 60 * 1000;
|
||||||
const shutdownTimeoutMs = 15 * 1000;
|
const shutdownTimeoutMs = 15 * 1000;
|
||||||
|
|
||||||
|
app.disable("x-powered-by");
|
||||||
app.use(express.json({ limit: "25mb" }));
|
app.use(express.json({ limit: "25mb" }));
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|
@ -59,6 +64,7 @@ app.get("/health", (_req, res) => {
|
||||||
// /auth/login, /auth/status and /auth/logout (see createAuthGuard's
|
// /auth/login, /auth/status and /auth/logout (see createAuthGuard's
|
||||||
// openPaths). authRouter is mounted AFTER the guard (not before) so that
|
// openPaths). authRouter is mounted AFTER the guard (not before) so that
|
||||||
// /auth/me and /auth/change-password still get req.authUser populated by it.
|
// /auth/me and /auth/change-password still get req.authUser populated by it.
|
||||||
|
app.use("/api", requireSameOriginForWrites());
|
||||||
app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository));
|
app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository));
|
||||||
app.use("/api", requirePasswordChanged());
|
app.use("/api", requirePasswordChanged());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Upper bounds keep a huge request body from being fed into scrypt.
|
||||||
|
export const MAX_USERNAME_LENGTH = 100;
|
||||||
|
export const MAX_PASSWORD_LENGTH = 200;
|
||||||
|
export const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
export const loginSchema = z
|
export const loginSchema = z
|
||||||
.object({
|
.object({
|
||||||
username: z.string().trim().min(1),
|
username: z.string().trim().min(1).max(MAX_USERNAME_LENGTH),
|
||||||
password: z.string().min(1),
|
password: z.string().min(1).max(MAX_PASSWORD_LENGTH),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export const changePasswordSchema = z
|
export const changePasswordSchema = z
|
||||||
.object({
|
.object({
|
||||||
currentPassword: z.string().min(1),
|
currentPassword: z.string().min(1).max(MAX_PASSWORD_LENGTH),
|
||||||
newPassword: z.string().min(8).max(200),
|
newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,29 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { USER_ROLES } from "../constants/user-roles.js";
|
import { USER_ROLES } from "../constants/user-roles.js";
|
||||||
|
import {
|
||||||
|
MAX_PASSWORD_LENGTH,
|
||||||
|
MAX_USERNAME_LENGTH,
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
} from "./auth.schemas.js";
|
||||||
|
|
||||||
const MIN_PASSWORD_LENGTH = 8;
|
// ASCII only: rules out look-alike Unicode names ("admіn" with a Cyrillic i)
|
||||||
|
// and invisible whitespace. E-mail addresses fit this pattern.
|
||||||
|
const USERNAME_PATTERN = /^[a-z0-9._@-]+$/;
|
||||||
|
|
||||||
export const createUserSchema = z
|
export const createUserSchema = z
|
||||||
.object({
|
.object({
|
||||||
username: z.string().trim().min(1).max(200),
|
username: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.min(1)
|
||||||
|
.max(MAX_USERNAME_LENGTH)
|
||||||
|
.regex(USERNAME_PATTERN, {
|
||||||
|
message: "Anmeldename: nur a-z, 0-9 sowie . _ @ - erlaubt.",
|
||||||
|
}),
|
||||||
name: z.string().trim().min(1).max(200),
|
name: z.string().trim().min(1).max(200),
|
||||||
role: z.enum(USER_ROLES),
|
role: z.enum(USER_ROLES),
|
||||||
initialPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200),
|
initialPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
@ -24,7 +39,7 @@ export const updateUserSchema = z
|
||||||
|
|
||||||
export const resetPasswordSchema = z
|
export const resetPasswordSchema = z
|
||||||
.object({
|
.object({
|
||||||
newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200),
|
newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(MAX_PASSWORD_LENGTH),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
|
||||||
265
tests/authentication.test.ts
Normal file
265
tests/authentication.test.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||||
|
import { UserRepository } from "../src/db/repositories/user.repository.js";
|
||||||
|
import {
|
||||||
|
InvalidCredentialsError,
|
||||||
|
LastAdminError,
|
||||||
|
PasswordUnchangedError,
|
||||||
|
UserService,
|
||||||
|
UsernameTakenError,
|
||||||
|
WrongPasswordError,
|
||||||
|
} from "../src/domain/services/user.service.js";
|
||||||
|
import { hashPassword, verifyPassword } from "../src/server/auth/password.js";
|
||||||
|
import { SessionStore } from "../src/server/auth/sessions.js";
|
||||||
|
import { LoginThrottle } from "../src/server/auth/login-throttle.js";
|
||||||
|
import { readCookie } from "../src/server/auth/cookie.js";
|
||||||
|
import { requireSameOriginForWrites } from "../src/server/auth/auth.middleware.js";
|
||||||
|
import type { AuthConfig } from "../src/server/auth/auth-config.js";
|
||||||
|
import { createUserSchema } from "../src/shared/validation/user.schemas.js";
|
||||||
|
import { loginSchema } from "../src/shared/validation/auth.schemas.js";
|
||||||
|
|
||||||
|
const HOUR = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function createUserService() {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||||
|
const repository = new UserRepository(context.db);
|
||||||
|
const config: AuthConfig = {
|
||||||
|
sessionTtlMs: 12 * HOUR,
|
||||||
|
sessionMaxAgeMs: 7 * 24 * HOUR,
|
||||||
|
cookieSecure: false,
|
||||||
|
bootstrap: { username: "admin", password: "bootstrap-password-123" },
|
||||||
|
};
|
||||||
|
return { repository, service: new UserService(repository, config) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("password hashing", () => {
|
||||||
|
it("verifies the right password and rejects a wrong one", async () => {
|
||||||
|
const hash = await hashPassword("richtiges-passwort");
|
||||||
|
assert.match(hash, /^scrypt\$16384\$8\$1\$[0-9a-f]{32}\$[0-9a-f]{128}$/);
|
||||||
|
assert.equal(await verifyPassword("richtiges-passwort", hash), true);
|
||||||
|
assert.equal(await verifyPassword("falsches-passwort", hash), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a fresh salt per hash", async () => {
|
||||||
|
assert.notEqual(await hashPassword("gleich"), await hashPassword("gleich"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed stored hashes instead of throwing", async () => {
|
||||||
|
assert.equal(await verifyPassword("x", "not-a-hash"), false);
|
||||||
|
assert.equal(await verifyPassword("x", "scrypt$a$b$c$00$00"), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session store", () => {
|
||||||
|
it("expires idle sessions and slides on activity", () => {
|
||||||
|
let now = 0;
|
||||||
|
const store = new SessionStore(1 * HOUR, 24 * HOUR, () => now);
|
||||||
|
const session = store.create("user-1");
|
||||||
|
now = 50 * 60 * 1000;
|
||||||
|
assert.ok(store.get(session.id));
|
||||||
|
now += 50 * 60 * 1000;
|
||||||
|
assert.ok(store.get(session.id), "activity must have extended the idle window");
|
||||||
|
now += 2 * HOUR;
|
||||||
|
assert.equal(store.get(session.id), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never outlives the absolute lifetime, even when constantly used", () => {
|
||||||
|
let now = 0;
|
||||||
|
const store = new SessionStore(1 * HOUR, 3 * HOUR, () => now);
|
||||||
|
const session = store.create("user-1");
|
||||||
|
for (let step = 0; step < 5; step += 1) {
|
||||||
|
now += 30 * 60 * 1000;
|
||||||
|
store.get(session.id);
|
||||||
|
}
|
||||||
|
now = 3 * HOUR;
|
||||||
|
assert.equal(store.get(session.id), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends all sessions of a user except an optional one", () => {
|
||||||
|
const store = new SessionStore(HOUR, 24 * HOUR);
|
||||||
|
const first = store.create("user-1");
|
||||||
|
const second = store.create("user-1");
|
||||||
|
const other = store.create("user-2");
|
||||||
|
store.destroyAllForUser("user-1", second.id);
|
||||||
|
assert.equal(store.get(first.id), undefined);
|
||||||
|
assert.ok(store.get(second.id));
|
||||||
|
assert.ok(store.get(other.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds the number of sessions per user by dropping the oldest", () => {
|
||||||
|
let now = 0;
|
||||||
|
const store = new SessionStore(HOUR, 24 * HOUR, () => now);
|
||||||
|
const oldest = store.create("user-1");
|
||||||
|
for (let index = 0; index < 20; index += 1) {
|
||||||
|
now += 1;
|
||||||
|
store.create("user-1");
|
||||||
|
}
|
||||||
|
assert.equal(store.get(oldest.id), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("login throttle", () => {
|
||||||
|
it("blocks a username after repeated failures and releases it after the window", () => {
|
||||||
|
let now = 0;
|
||||||
|
const throttle = new LoginThrottle(3, 15 * 60 * 1000, 100, () => now);
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
assert.equal(throttle.retryAfterMs("Admin"), 0);
|
||||||
|
throttle.recordFailure("admin ");
|
||||||
|
}
|
||||||
|
assert.ok(throttle.retryAfterMs("ADMIN") > 0, "normalized usernames share a counter");
|
||||||
|
assert.equal(throttle.retryAfterMs("someone-else"), 0);
|
||||||
|
now = 15 * 60 * 1000 + 1;
|
||||||
|
assert.equal(throttle.retryAfterMs("admin"), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the counter after a successful login", () => {
|
||||||
|
const throttle = new LoginThrottle(2);
|
||||||
|
throttle.recordFailure("admin");
|
||||||
|
throttle.recordSuccess("admin");
|
||||||
|
throttle.recordFailure("admin");
|
||||||
|
assert.equal(throttle.retryAfterMs("admin"), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cookie parsing", () => {
|
||||||
|
it("reads the session cookie and ignores malformed encodings", () => {
|
||||||
|
assert.equal(readCookie("a=1; lb_session=abc; b=2", "lb_session"), "abc");
|
||||||
|
assert.equal(readCookie("lb_session=%E0%A4%A", "lb_session"), undefined);
|
||||||
|
assert.equal(readCookie(undefined, "lb_session"), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("same-origin guard for writes", () => {
|
||||||
|
function run(method: string, fetchSite?: string) {
|
||||||
|
let status: number | undefined;
|
||||||
|
let passed = false;
|
||||||
|
const req = {
|
||||||
|
method,
|
||||||
|
headers: fetchSite ? { "sec-fetch-site": fetchSite } : {},
|
||||||
|
} as unknown as Request;
|
||||||
|
const res = {
|
||||||
|
status(code: number) {
|
||||||
|
status = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
} as unknown as Response;
|
||||||
|
requireSameOriginForWrites()(req, res, () => {
|
||||||
|
passed = true;
|
||||||
|
});
|
||||||
|
return { passed, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lets reads and same-origin writes through", () => {
|
||||||
|
assert.equal(run("GET", "cross-site").passed, true);
|
||||||
|
assert.equal(run("POST", "same-origin").passed, true);
|
||||||
|
assert.equal(run("POST").passed, true, "non-browser clients carry no ambient cookie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects writes from other sites and other ports of the same host", () => {
|
||||||
|
assert.deepEqual(run("POST", "cross-site"), { passed: false, status: 403 });
|
||||||
|
assert.deepEqual(run("PATCH", "same-site"), { passed: false, status: 403 });
|
||||||
|
assert.deepEqual(run("DELETE", "same-site"), { passed: false, status: 403 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("input validation", () => {
|
||||||
|
it("rejects look-alike and whitespace usernames and normalizes case", () => {
|
||||||
|
const base = { name: "Test", role: "user", initialPassword: "start-passwort" };
|
||||||
|
assert.equal(createUserSchema.safeParse({ ...base, username: "admіn" }).success, false);
|
||||||
|
assert.equal(createUserSchema.safeParse({ ...base, username: "ad min" }).success, false);
|
||||||
|
const parsed = createUserSchema.parse({ ...base, username: " Max.Muster@Firma.de " });
|
||||||
|
assert.equal(parsed.username, "max.muster@firma.de");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps login input length so huge bodies never reach scrypt", () => {
|
||||||
|
assert.equal(
|
||||||
|
loginSchema.safeParse({ username: "admin", password: "x".repeat(201) }).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("user service", () => {
|
||||||
|
it("creates the bootstrap admin only while the table is empty", async () => {
|
||||||
|
const { repository, service } = createUserService();
|
||||||
|
await service.ensureBootstrapAdmin();
|
||||||
|
await service.ensureBootstrapAdmin();
|
||||||
|
const users = await repository.list();
|
||||||
|
assert.equal(users.length, 1);
|
||||||
|
assert.equal(users[0].role, "admin");
|
||||||
|
assert.equal(users[0].mustChangePassword, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown users, wrong passwords and deactivated accounts alike", async () => {
|
||||||
|
const { service } = createUserService();
|
||||||
|
const user = await service.createUser({
|
||||||
|
username: "max",
|
||||||
|
name: "Max",
|
||||||
|
role: "user",
|
||||||
|
initialPassword: "start-passwort",
|
||||||
|
});
|
||||||
|
assert.ok(user);
|
||||||
|
await assert.rejects(service.authenticate("niemand", "egal"), InvalidCredentialsError);
|
||||||
|
await assert.rejects(service.authenticate("max", "falsch"), InvalidCredentialsError);
|
||||||
|
assert.equal((await service.authenticate("MAX", "start-passwort")).id, user.id);
|
||||||
|
await service.updateRoleAndActive(user.id, { active: false });
|
||||||
|
await assert.rejects(service.authenticate("max", "start-passwort"), InvalidCredentialsError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses duplicate usernames", async () => {
|
||||||
|
const { service } = createUserService();
|
||||||
|
const input = { username: "max", name: "Max", role: "user" as const, initialPassword: "start-passwort" };
|
||||||
|
await service.createUser(input);
|
||||||
|
await assert.rejects(service.createUser(input), UsernameTakenError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never removes the last active admin", async () => {
|
||||||
|
const { repository, service } = createUserService();
|
||||||
|
await service.ensureBootstrapAdmin();
|
||||||
|
const [admin] = await repository.list();
|
||||||
|
await assert.rejects(service.updateRoleAndActive(admin.id, { role: "user" }), LastAdminError);
|
||||||
|
await assert.rejects(service.updateRoleAndActive(admin.id, { active: false }), LastAdminError);
|
||||||
|
|
||||||
|
const second = await service.createUser({
|
||||||
|
username: "zweiter",
|
||||||
|
name: "Zweiter",
|
||||||
|
role: "admin",
|
||||||
|
initialPassword: "start-passwort",
|
||||||
|
});
|
||||||
|
assert.ok(second);
|
||||||
|
await service.updateRoleAndActive(admin.id, { role: "user" });
|
||||||
|
await assert.rejects(service.updateRoleAndActive(second.id, { active: false }), LastAdminError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the current password and a different new one", async () => {
|
||||||
|
const { service } = createUserService();
|
||||||
|
const user = await service.createUser({
|
||||||
|
username: "max",
|
||||||
|
name: "Max",
|
||||||
|
role: "user",
|
||||||
|
initialPassword: "start-passwort",
|
||||||
|
});
|
||||||
|
assert.ok(user);
|
||||||
|
await assert.rejects(
|
||||||
|
service.changeOwnPassword(user.id, "falsch", "neues-passwort"),
|
||||||
|
WrongPasswordError
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
service.changeOwnPassword(user.id, "start-passwort", "start-passwort"),
|
||||||
|
PasswordUnchangedError
|
||||||
|
);
|
||||||
|
const changed = await service.changeOwnPassword(user.id, "start-passwort", "neues-passwort");
|
||||||
|
assert.equal(changed?.mustChangePassword, false);
|
||||||
|
|
||||||
|
const reset = await service.resetPassword(user.id, "admin-vergeben");
|
||||||
|
assert.equal(reset?.mustChangePassword, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue