Files
ApiCore/ApiCore-Backend/src/users/users.service.ts
T
Nick SzaunigandClaude Opus 5.5 0ba8996e3a feat(auth): 12-character minimum, 72-byte maximum and breach check for new passwords
- New passwords (create user, admin reset, own change) need at least 12
  characters (ASVS 2.1.1) and may not exceed 72 bytes, which bcrypt would
  silently cut off.
- They are checked against Have I Been Pwned with k-anonymity (only the
  first 5 SHA-1 characters leave the server, padded responses); an
  unreachable service is logged and does not block. PASSWORD_BREACH_CHECK
  =false disables it for offline deployments.
- Login accepts existing passwords as before, capped at 256 characters to
  bound anonymous bcrypt work.
- Password fields in the UI say the rule up front.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:47:09 +02:00

410 lines
13 KiB
TypeScript

import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { assertPasswordNotBreached } from '../common/password/password-policy';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { Prisma } from '../../generated/prisma';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
const BCRYPT_SALT_ROUNDS = 12;
const USER_WITH_ROLE = {
include: { role: true },
} satisfies { include: Prisma.UserInclude };
type UserWithRole = Prisma.UserGetPayload<typeof USER_WITH_ROLE>;
export interface UserSummary {
id: string;
email: string;
name: string;
roleId: string;
roleKey: string;
roleName: string;
isActive: boolean;
createdAt: Date;
deactivatedAt: Date | null;
mfaEnabled: boolean;
}
@Injectable()
export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {}
private toSummary(user: UserWithRole): UserSummary {
return {
id: user.id,
email: user.email,
name: user.name,
roleId: user.roleId,
roleKey: user.role.key,
roleName: user.role.name,
isActive: user.isActive,
createdAt: user.createdAt,
deactivatedAt: user.deactivatedAt,
mfaEnabled: user.mfaEnabled,
};
}
/**
* Assigning a role is gated by its own permission, separate from
* users:create/users:update - otherwise any role holding those (e.g. a
* minimal "user manager" role) could hand out any role, including a
* system/admin one, to itself or a colluding account. actingUserId is
* only absent for non-HTTP callers (there are none today, and tests that
* exercise the service directly without an actor), where there is no
* caller to check permissions against.
*/
private async assertCanAssignRole(actingUserId?: string): Promise<void> {
if (!actingUserId) {
return;
}
const allowed = await this.permissionResolver.hasAny(actingUserId, [
'users:assign-role',
]);
if (!allowed) {
throw new ForbiddenException('Missing permission to assign roles');
}
}
/**
* Nobody may act on - or hand out - more than they can do themselves: a
* limited "user manager" holding users:update could otherwise reset an
* administrator's password or MFA, change their email to one they control,
* or give a colluding account the ADMIN role, and so become admin. Same
* rule as RolesService.assertCanGrantPermissions. Reads the role's grants
* directly (not the cached effective set), so a deactivated admin counts
* as an admin too.
*/
private async assertWithinCallerPermissions(
roleId: string,
actingUserId?: string,
): Promise<void> {
if (!actingUserId) {
return;
}
const grants = await this.prisma.rolePermission.findMany({
where: { roleId },
select: { permission: { select: { key: true } } },
});
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
const exceeds = grants.some(
(grant) => !callerPermissions.has(grant.permission.key),
);
if (exceeds) {
throw new ForbiddenException(
'Dieses Konto hat mehr Rechte als Sie selbst und kann von Ihnen nicht verwaltet werden',
);
}
}
private async assertActiveRole(roleId: string): Promise<void> {
const role = await this.prisma.role.findUnique({ where: { id: roleId } });
if (!role) {
throw new NotFoundException('Role not found');
}
if (role.status !== 'ACTIVE') {
throw new ConflictException('Role is not active');
}
}
/**
* Blocks removing (by deactivation or role reassignment) the last active
* user still holding a system role (ADMIN). Without this, an operator
* could lock the app out of user/role management entirely, with no
* self-service recovery: prisma/seed.ts skips seeding once a user with
* its configured email already exists, so it cannot restore access to a
* merely demoted/deactivated account.
*/
private async assertNotLastSystemRoleUser(userId: string): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { roleId: true, role: { select: { isSystem: true } } },
});
if (!user || !user.role.isSystem) {
return;
}
const activeCount = await this.prisma.user.count({
where: { roleId: user.roleId, isActive: true },
});
if (activeCount <= 1) {
throw new ConflictException(
'Cannot remove the last active user holding a system role',
);
}
}
async findAll(): Promise<UserSummary[]> {
const users = await this.prisma.user.findMany({
...USER_WITH_ROLE,
orderBy: { createdAt: 'asc' },
});
return users.map((user) => this.toSummary(user));
}
async findById(id: string): Promise<UserWithRole> {
const user = await this.prisma.user.findUnique({
where: { id },
...USER_WITH_ROLE,
});
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
async findSummaryById(id: string): Promise<UserSummary> {
return this.toSummary(await this.findById(id));
}
/** Case-insensitive: "[email protected]" and "[email protected]" are one account. */
async findByEmail(email: string): Promise<UserWithRole | null> {
return this.prisma.user.findFirst({
where: { email: { equals: email.trim(), mode: 'insensitive' } },
...USER_WITH_ROLE,
});
}
async findBySsoSubject(ssoSubject: string): Promise<UserWithRole | null> {
return this.prisma.user.findUnique({
where: { ssoSubject },
...USER_WITH_ROLE,
});
}
async linkSsoSubject(userId: string, ssoSubject: string): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: { ssoSubject },
});
await this.auditLog.record({
entityType: 'User',
entityId: userId,
action: 'UPDATE',
userId,
afterData: { ssoLinked: true },
});
}
async create(
dto: CreateUserDto,
actingUserId?: string,
): Promise<UserSummary> {
const existing = await this.findByEmail(dto.email);
if (existing) {
throw new ConflictException('Email already in use');
}
await this.assertActiveRole(dto.roleId);
await this.assertCanAssignRole(actingUserId);
await this.assertWithinCallerPermissions(dto.roleId, actingUserId);
await assertPasswordNotBreached(dto.password);
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
const user = await this.prisma.user.create({
data: {
email: dto.email,
passwordHash,
name: dto.name,
roleId: dto.roleId,
},
...USER_WITH_ROLE,
});
await this.auditLog.record({
entityType: 'User',
entityId: user.id,
action: 'CREATE',
userId: actingUserId,
afterData: this.toSummary(user),
});
return this.toSummary(user);
}
async update(
id: string,
dto: UpdateUserDto,
actingUserId?: string,
): Promise<UserSummary> {
const before = await this.findById(id);
await this.assertWithinCallerPermissions(before.roleId, actingUserId);
const roleChanged = Boolean(dto.roleId) && dto.roleId !== before.roleId;
const emailChanged = dto.email !== undefined && dto.email !== before.email;
if (dto.roleId) {
await this.assertActiveRole(dto.roleId);
}
if (roleChanged) {
// Role changes for yourself always require a second party, regardless
// of what permission the caller holds - otherwise anyone who can
// reach this endpoint at all could grant themselves admin.
if (actingUserId && id === actingUserId) {
throw new ForbiddenException('Cannot change your own role');
}
await this.assertCanAssignRole(actingUserId);
await this.assertWithinCallerPermissions(dto.roleId!, actingUserId);
await this.assertNotLastSystemRoleUser(id);
}
const user = await this.prisma.user.update({
where: { id },
data: {
name: dto.name,
email: dto.email,
roleId: dto.roleId,
// A new email is a new identity: the next Microsoft sign-in links
// afresh instead of reusing the old account's binding.
...(emailChanged ? { ssoSubject: null } : {}),
},
...USER_WITH_ROLE,
});
if (emailChanged) {
// The email is the login name and the SSO link: sessions opened under
// the old one end here.
await this.prisma.refreshToken.updateMany({
where: { userId: id, revokedAt: null },
data: { revokedAt: new Date() },
});
}
if (roleChanged || emailChanged) {
this.permissionResolver.invalidateUser(id);
}
await this.auditLog.record({
entityType: 'User',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: this.toSummary(before),
afterData: this.toSummary(user),
});
return this.toSummary(user);
}
async deactivate(id: string, actingUserId?: string): Promise<UserSummary> {
const before = await this.findById(id);
await this.assertWithinCallerPermissions(before.roleId, actingUserId);
await this.assertNotLastSystemRoleUser(id);
const user = await this.prisma.user.update({
where: { id },
data: { isActive: false, deactivatedAt: new Date() },
...USER_WITH_ROLE,
});
this.permissionResolver.invalidateUser(id);
await this.auditLog.record({
entityType: 'User',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: this.toSummary(before),
afterData: this.toSummary(user),
});
return this.toSummary(user);
}
/**
* Recovery path for a user who lost their MFA device (no email-reset flow
* exists in this project). Deliberately blocked for the caller's own
* account: unlike resetting someone else's MFA, self-service MFA removal
* belongs behind a password check (POST /profile/mfa/disable) - otherwise
* a stolen access token alone (no password) would be enough to strip the
* victim's own MFA protection via this admin route.
*/
/**
* Sets a new password for another user. Mirrors ProfileService.changePassword
* in what it invalidates: every refresh token is revoked and the permission
* cache is cleared, so sessions opened with the old password stop working at
* once rather than living on until their access token expires.
*
* Your own password goes through /profile/password, which requires the
* current one - otherwise anyone holding users:update could silently take
* over their own account's credentials without proving they own it.
*/
async resetPassword(
id: string,
dto: ResetUserPasswordDto,
actingUserId?: string,
): Promise<UserSummary> {
if (actingUserId && id === actingUserId) {
throw new ForbiddenException(
'Use /profile/password to change your own password',
);
}
const target = await this.findById(id);
await this.assertWithinCallerPermissions(target.roleId, actingUserId);
await assertPasswordNotBreached(dto.password);
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
const user = await this.prisma.user.update({
where: { id },
data: { passwordHash, passwordChangedAt: new Date() },
...USER_WITH_ROLE,
});
await this.prisma.refreshToken.updateMany({
where: { userId: id, revokedAt: null },
data: { revokedAt: new Date() },
});
this.permissionResolver.invalidateUser(id);
// The new password never reaches the audit log - only the fact that it
// was replaced, and by whom.
await this.auditLog.record({
entityType: 'User',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
});
return this.toSummary(user);
}
async resetMfa(id: string, actingUserId?: string): Promise<UserSummary> {
if (actingUserId && id === actingUserId) {
throw new ForbiddenException(
'Use /profile/mfa/disable to disable your own MFA',
);
}
const target = await this.findById(id);
await this.assertWithinCallerPermissions(target.roleId, actingUserId);
const user = await this.prisma.user.update({
where: { id },
data: { mfaEnabled: false, mfaSecret: null },
...USER_WITH_ROLE,
});
await this.auditLog.record({
entityType: 'User',
entityId: id,
action: 'MFA_DISABLED',
userId: actingUserId,
});
return this.toSummary(user);
}
async activate(id: string, actingUserId?: string): Promise<UserSummary> {
const before = await this.findById(id);
await this.assertWithinCallerPermissions(before.roleId, actingUserId);
if (before.isActive) {
throw new ConflictException('User is already active');
}
const user = await this.prisma.user.update({
where: { id },
data: { isActive: true, deactivatedAt: null },
...USER_WITH_ROLE,
});
this.permissionResolver.invalidateUser(id);
await this.auditLog.record({
entityType: 'User',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: this.toSummary(before),
afterData: this.toSummary(user),
});
return this.toSummary(user);
}
}