- 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]>
410 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|