168 lines
5.2 KiB
TypeScript
168 lines
5.2 KiB
TypeScript
import { EmailAddress, UniqueID } from "@repo/rdx-ddd";
|
|
import { Result } from "@repo/rdx-utils";
|
|
|
|
import type {
|
|
BootstrapAdminRequestDTO,
|
|
ChangePasswordRequestDTO,
|
|
CreateUserRequestDTO,
|
|
LoginRequestDTO,
|
|
ResetPasswordRequestDTO,
|
|
UpdateUserRequestDTO,
|
|
} from "../../common";
|
|
import { IdentityError } from "../domain/identity-error";
|
|
import type { User } from "../domain/user.aggregate";
|
|
|
|
import type { IPasswordHasher, IUserRepository } from "./identity-ports";
|
|
import type { UserInputMapper } from "./user-input.mapper";
|
|
|
|
/** Contexto interno obtenido de la sesión del servidor, nunca del body. */
|
|
export type IdentityContext = Readonly<{ userId: string; authVersion: number }>;
|
|
|
|
export class IdentityUseCases {
|
|
constructor(
|
|
private readonly users: IUserRepository,
|
|
private readonly passwords: IPasswordHasher,
|
|
private readonly inputs: UserInputMapper,
|
|
private readonly dummyPasswordHash: string
|
|
) {}
|
|
|
|
async session(context: IdentityContext | undefined): Promise<User | undefined> {
|
|
if (!context) {
|
|
return undefined;
|
|
}
|
|
const id = UniqueID.create(context.userId);
|
|
|
|
if (!id.isSuccess) {
|
|
return undefined;
|
|
}
|
|
const user = (await this.users.findById(id.data)).getOrUndefined();
|
|
|
|
return user?.active && user.authVersion === context.authVersion ? user : undefined;
|
|
}
|
|
|
|
async setupRequired() {
|
|
return this.users.setupRequired();
|
|
}
|
|
|
|
/** Capacidad no delegable: se consulta el usuario vigente, nunca un privilegio del cliente. */
|
|
async canManageGlobalResources(context: IdentityContext | undefined) {
|
|
const actor = await this.session(context);
|
|
|
|
if (!actor) {
|
|
return Result.fail(new IdentityError("UNAUTHENTICATED"));
|
|
}
|
|
|
|
return actor.isInitialAdmin && (await this.users.isInitialAdministrator(actor.id))
|
|
? Result.ok(undefined)
|
|
: Result.fail(new IdentityError("FORBIDDEN"));
|
|
}
|
|
|
|
async bootstrap(dto: BootstrapAdminRequestDTO) {
|
|
if (!(await this.users.setupRequired())) {
|
|
return Result.fail(new IdentityError("SETUP_CLOSED"));
|
|
}
|
|
const user = await this.inputs.map(dto, true);
|
|
|
|
return user.isSuccess ? this.users.bootstrap(user.data) : user;
|
|
}
|
|
|
|
async login(dto: LoginRequestDTO): Promise<Result<User, IdentityError>> {
|
|
const email = EmailAddress.create(dto.email);
|
|
|
|
if (!email.isSuccess) {
|
|
return Result.fail(new IdentityError("INVALID_CREDENTIALS"));
|
|
}
|
|
const user = (await this.users.findByEmail(email.data)).getOrUndefined();
|
|
const valid = await this.passwords.verify(
|
|
dto.password,
|
|
user?.passwordHash.toPrimitive() ?? this.dummyPasswordHash
|
|
);
|
|
|
|
if (!(valid && user?.active)) {
|
|
return Result.fail(new IdentityError("INVALID_CREDENTIALS"));
|
|
}
|
|
|
|
return Result.ok(user);
|
|
}
|
|
|
|
private async administrator(context: IdentityContext) {
|
|
const actor = await this.session(context);
|
|
|
|
return actor?.isInitialAdmin ? Result.ok(actor) : Result.fail(new IdentityError("FORBIDDEN"));
|
|
}
|
|
|
|
async list(context: IdentityContext) {
|
|
const access = await this.administrator(context);
|
|
|
|
return access.isSuccess ? Result.ok(await this.users.list()) : access;
|
|
}
|
|
|
|
async create(dto: CreateUserRequestDTO, context: IdentityContext) {
|
|
const access = await this.administrator(context);
|
|
|
|
if (!access.isSuccess) {
|
|
return access;
|
|
}
|
|
const user = await this.inputs.map(dto, false);
|
|
|
|
return user.isSuccess ? this.users.create(user.data) : user;
|
|
}
|
|
|
|
async update(id: string, dto: UpdateUserRequestDTO, context: IdentityContext) {
|
|
const access = await this.administrator(context);
|
|
|
|
if (!access.isSuccess) {
|
|
return access;
|
|
}
|
|
const userId = UniqueID.create(id);
|
|
const profile = this.inputs.mapProfile(dto);
|
|
|
|
if (!(userId.isSuccess && profile.isSuccess)) {
|
|
return Result.fail(new IdentityError("INVALID_REQUEST"));
|
|
}
|
|
|
|
return this.users.update(userId.data, (user) =>
|
|
user.update({ ...profile.data, active: dto.active })
|
|
);
|
|
}
|
|
|
|
async resetPassword(id: string, dto: ResetPasswordRequestDTO, context: IdentityContext) {
|
|
const access = await this.administrator(context);
|
|
|
|
if (!access.isSuccess) {
|
|
return access;
|
|
}
|
|
const userId = UniqueID.create(id);
|
|
|
|
if (!userId.isSuccess) {
|
|
return Result.fail(new IdentityError("INVALID_REQUEST"));
|
|
}
|
|
if (userId.data.equals(access.data.id)) {
|
|
return Result.fail(new IdentityError("ADMIN_PROTECTED"));
|
|
}
|
|
const hash = await this.inputs.hashPassword(dto.password);
|
|
|
|
return this.users.update(userId.data, (user) => Result.ok(user.changePassword(hash)));
|
|
}
|
|
|
|
async changePassword(dto: ChangePasswordRequestDTO, context: IdentityContext) {
|
|
const actor = await this.session(context);
|
|
|
|
if (!actor) {
|
|
return Result.fail(new IdentityError("UNAUTHENTICATED"));
|
|
}
|
|
if (!(await this.passwords.verify(dto.currentPassword, actor.passwordHash.toPrimitive()))) {
|
|
return Result.fail(new IdentityError("INVALID_CREDENTIALS"));
|
|
}
|
|
const hash = await this.inputs.hashPassword(dto.password);
|
|
|
|
return this.users.update(actor.id, (user) => {
|
|
if (user.authVersion !== actor.authVersion) {
|
|
return Result.fail(new IdentityError("UNAUTHENTICATED"));
|
|
}
|
|
|
|
return Result.ok(user.changePassword(hash));
|
|
});
|
|
}
|
|
}
|