Harden user management integration
This commit is contained in:
parent
73579d1e79
commit
847f4e9493
18 changed files with 394 additions and 111 deletions
|
|
@ -1,8 +1,10 @@
|
|||
import type { UserRepository } from "../../db/repositories/user.repository.js";
|
||||
import { hashPassword, verifyPassword } from "../../server/auth/password.js";
|
||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||
import type { AuthConfig } from "../../server/auth/auth-config.js";
|
||||
import { createLogger } from "../../shared/logging/logger.js";
|
||||
import {
|
||||
LastActiveAdminStoreError,
|
||||
UsernameAlreadyExistsStoreError,
|
||||
type UserStore,
|
||||
} from "../ports/user.store.js";
|
||||
|
||||
const logger = createLogger("user-service");
|
||||
|
||||
|
|
@ -36,20 +38,22 @@ export class LastAdminError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
|
||||
);
|
||||
export interface PasswordHasher {
|
||||
hash(password: string): Promise<string>;
|
||||
verify(password: string, storedHash: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface UserServiceOptions {
|
||||
bootstrap: { username: string; password: string } | null;
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
private dummyHash: Promise<string> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly repository: UserRepository,
|
||||
private readonly authConfig: AuthConfig
|
||||
private readonly repository: UserStore,
|
||||
private readonly options: UserServiceOptions,
|
||||
private readonly passwords: PasswordHasher
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -65,21 +69,21 @@ export class UserService {
|
|||
await this.getDummyHash();
|
||||
const existingCount = await this.repository.count();
|
||||
if (existingCount > 0) return;
|
||||
if (!this.authConfig.bootstrap) {
|
||||
if (!this.options.bootstrap) {
|
||||
logger.warn(
|
||||
"no users exist and AUTH_BOOTSTRAP_USERNAME/AUTH_BOOTSTRAP_PASSWORT are not set - nobody can sign in"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const passwordHash = await hashPassword(this.authConfig.bootstrap.password);
|
||||
const passwordHash = await this.passwords.hash(this.options.bootstrap.password);
|
||||
await this.repository.create({
|
||||
username: this.authConfig.bootstrap.username,
|
||||
username: this.options.bootstrap.username,
|
||||
name: "Administrator",
|
||||
role: "admin",
|
||||
passwordHash,
|
||||
});
|
||||
logger.info("bootstrap admin created", {
|
||||
username: this.authConfig.bootstrap.username,
|
||||
username: this.options.bootstrap.username,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +93,7 @@ export class UserService {
|
|||
// 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);
|
||||
const passwordMatches = await this.passwords.verify(password, passwordHash);
|
||||
if (!user || !user.active || !passwordMatches) {
|
||||
throw new InvalidCredentialsError();
|
||||
}
|
||||
|
|
@ -104,7 +108,7 @@ export class UserService {
|
|||
}) {
|
||||
const existing = await this.repository.findByUsername(input.username);
|
||||
if (existing) throw new UsernameTakenError();
|
||||
const passwordHash = await hashPassword(input.initialPassword);
|
||||
const passwordHash = await this.passwords.hash(input.initialPassword);
|
||||
try {
|
||||
return await this.repository.create({
|
||||
username: input.username,
|
||||
|
|
@ -114,29 +118,23 @@ export class UserService {
|
|||
});
|
||||
} catch (error) {
|
||||
// Two admins creating the same name at once: the unique index decides.
|
||||
if (isUniqueConstraintError(error)) throw new UsernameTakenError();
|
||||
if (error instanceof UsernameAlreadyExistsStoreError) 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();
|
||||
}
|
||||
try {
|
||||
return await this.repository.updateRoleAndActive(userId, patch);
|
||||
} catch (error) {
|
||||
if (error instanceof LastActiveAdminStoreError) throw new LastAdminError();
|
||||
throw error;
|
||||
}
|
||||
return this.repository.updateRoleAndActive(userId, patch);
|
||||
}
|
||||
|
||||
/** Admin-triggered reset with a new start password; forces a change on next login. */
|
||||
async resetPassword(userId: string, newPassword: string) {
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
const passwordHash = await this.passwords.hash(newPassword);
|
||||
return this.repository.setPassword(userId, passwordHash, true);
|
||||
}
|
||||
|
||||
|
|
@ -144,15 +142,15 @@ export class UserService {
|
|||
async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const user = await this.repository.findById(userId);
|
||||
if (!user) throw new WrongPasswordError();
|
||||
const currentMatches = await verifyPassword(currentPassword, user.passwordHash);
|
||||
const currentMatches = await this.passwords.verify(currentPassword, user.passwordHash);
|
||||
if (!currentMatches) throw new WrongPasswordError();
|
||||
if (newPassword === currentPassword) throw new PasswordUnchangedError();
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
const passwordHash = await this.passwords.hash(newPassword);
|
||||
return this.repository.setPassword(userId, passwordHash, false);
|
||||
}
|
||||
|
||||
private getDummyHash(): Promise<string> {
|
||||
this.dummyHash ??= hashPassword("dummy-password-for-constant-time-login");
|
||||
this.dummyHash ??= this.passwords.hash("dummy-password-for-constant-time-login");
|
||||
return this.dummyHash;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue