Harden authentication and put filter toggle and fields in one row
Security: per-username login throttling, constant-time unknown-user check, Sec-Fetch-Site CSRF guard for writes, session rotation on login and password change, absolute session lifetime, sessions ended on reset and deactivation, last-admin protection, input length caps, ASCII usernames, clickjacking headers. Covered by tests/authentication.test.ts. Filter panel: toggle, fields and count share one row to save space. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8d2948cdb1
commit
5073d9342a
18 changed files with 683 additions and 81 deletions
|
|
@ -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 {
|
||||
private dummyHash: Promise<string> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly repository: UserRepository,
|
||||
private readonly authConfig: AuthConfig
|
||||
|
|
@ -38,6 +60,9 @@ export class UserService {
|
|||
* escape hatch instead of a standing backdoor.
|
||||
*/
|
||||
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;
|
||||
const existingCount = await this.repository.count();
|
||||
if (existingCount > 0) return;
|
||||
|
|
@ -55,9 +80,10 @@ export class UserService {
|
|||
|
||||
async authenticate(username: string, password: string) {
|
||||
const user = await this.repository.findByUsername(username);
|
||||
// Always hash something, even for an unknown user: otherwise the
|
||||
// response time would leak which usernames exist.
|
||||
const passwordHash = user?.passwordHash ?? (await hashPassword("placeholder"));
|
||||
// Unknown users are verified against a fixed dummy hash: exactly one
|
||||
// scrypt run either way, so response time doesn't reveal which usernames
|
||||
// exist.
|
||||
const passwordHash = user?.passwordHash ?? (await this.getDummyHash());
|
||||
const passwordMatches = await verifyPassword(password, passwordHash);
|
||||
if (!user || !user.active || !passwordMatches) {
|
||||
throw new InvalidCredentialsError();
|
||||
|
|
@ -74,15 +100,32 @@ export class UserService {
|
|||
const existing = await this.repository.findByUsername(input.username);
|
||||
if (existing) throw new UsernameTakenError();
|
||||
const passwordHash = await hashPassword(input.initialPassword);
|
||||
return this.repository.create({
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
passwordHash,
|
||||
});
|
||||
try {
|
||||
return await this.repository.create({
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
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 }) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +141,13 @@ export class UserService {
|
|||
if (!user) throw new WrongPasswordError();
|
||||
const currentMatches = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!currentMatches) throw new WrongPasswordError();
|
||||
if (newPassword === currentPassword) throw new PasswordUnchangedError();
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
return this.repository.setPassword(userId, passwordHash, false);
|
||||
}
|
||||
|
||||
private getDummyHash(): Promise<string> {
|
||||
this.dummyHash ??= hashPassword("dummy-password-for-constant-time-login");
|
||||
return this.dummyHash;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue