1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-04 11:17:02 +02:00
unleash.unleash/src/lib/services/user-service.ts
Gastón Fournier 5901475c9e
fix: audit scim user deleted events (#10322)
SCIM users deleted in bulk are not captured in the event log. We just
add an event like this:

![image](https://github.com/user-attachments/assets/2d7078b2-61d6-475e-8151-63b5b5ed7449)

This prevents partial user sync because we don't get an event when the
user was deleted.
2025-07-07 12:17:37 +02:00

664 lines
21 KiB
TypeScript

import bcrypt from 'bcryptjs';
import owasp from 'owasp-password-strength-test';
import Joi from 'joi';
import type { URL } from 'url';
import type { Logger } from '../logger.js';
import User, {
type IAuditUser,
type IUser,
type IUserWithRootRole,
} from '../types/user.js';
import isEmail from '../util/is-email.js';
import type { AccessService } from './access-service.js';
import type ResetTokenService from './reset-token-service.js';
import NotFoundError from '../error/notfound-error.js';
import OwaspValidationError from '../error/owasp-validation-error.js';
import type { EmailService } from './email-service.js';
import type {
IAuthOption,
IUnleashConfig,
UsernameAdminUser,
} from '../types/option.js';
import type SessionService from './session-service.js';
import type { IUnleashStores } from '../types/stores.js';
import PasswordUndefinedError from '../error/password-undefined.js';
import {
ScimUsersDeleted,
UserCreatedEvent,
UserDeletedEvent,
UserUpdatedEvent,
} from '../types/index.js';
import type { IUserStore } from '../types/index.js';
import { RoleName } from '../types/model.js';
import type SettingService from './setting-service.js';
import {
type SimpleAuthSettings,
simpleAuthSettingsKey,
} from '../types/settings/simple-auth-settings.js';
import DisabledError from '../error/disabled-error.js';
import BadDataError from '../error/bad-data-error.js';
import { isDefined } from '../util/index.js';
import type { TokenUserSchema } from '../openapi/index.js';
import PasswordMismatch from '../error/password-mismatch.js';
import type EventService from '../features/events/event-service.js';
import {
type IFlagResolver,
SYSTEM_USER,
SYSTEM_USER_AUDIT,
} from '../types/index.js';
import { PasswordPreviouslyUsedError } from '../error/password-previously-used.js';
import { RateLimitError } from '../error/rate-limit-error.js';
import type EventEmitter from 'events';
import { USER_LOGIN } from '../metric-events.js';
export interface ICreateUserWithRole {
name?: string;
email?: string;
username?: string;
password?: string;
rootRole: number | RoleName;
}
export interface IUpdateUser {
id: number;
name?: string;
email?: string;
rootRole?: number | RoleName;
}
export interface ILoginUserRequest {
email: string;
name?: string;
rootRole?: number | RoleName;
autoCreate?: boolean;
}
const saltRounds = 10;
const disallowNPreviousPasswords = 5;
class UserService {
private logger: Logger;
private store: IUserStore;
private eventService: EventService;
private eventBus: EventEmitter;
private accessService: AccessService;
private resetTokenService: ResetTokenService;
private sessionService: SessionService;
private emailService: EmailService;
private settingService: SettingService;
private flagResolver: IFlagResolver;
private passwordResetTimeouts: { [key: string]: NodeJS.Timeout } = {};
private baseUriPath: string;
readonly unleashUrl: string;
readonly maxParallelSessions: number;
constructor(
stores: Pick<IUnleashStores, 'userStore'>,
{
server,
getLogger,
eventBus,
flagResolver,
session,
}: Pick<
IUnleashConfig,
'getLogger' | 'server' | 'eventBus' | 'flagResolver' | 'session'
>,
services: {
accessService: AccessService;
resetTokenService: ResetTokenService;
emailService: EmailService;
eventService: EventService;
sessionService: SessionService;
settingService: SettingService;
},
) {
this.logger = getLogger('service/user-service.js');
this.store = stores.userStore;
this.eventBus = eventBus;
this.eventService = services.eventService;
this.accessService = services.accessService;
this.resetTokenService = services.resetTokenService;
this.emailService = services.emailService;
this.sessionService = services.sessionService;
this.settingService = services.settingService;
this.flagResolver = flagResolver;
this.maxParallelSessions = session.maxParallelSessions;
this.baseUriPath = server.baseUriPath || '';
this.unleashUrl = server.unleashUrl;
}
validatePassword(password: string): boolean {
if (password) {
const result = owasp.test(password);
if (!result.strong) {
throw new OwaspValidationError(result);
} else return true;
} else {
throw new PasswordUndefinedError();
}
}
async initAdminUser({
createAdminUser,
initialAdminUser,
}: Pick<
IAuthOption,
'createAdminUser' | 'initialAdminUser'
>): Promise<void> {
if (!createAdminUser) return Promise.resolve();
return this.initAdminUsernameUser(initialAdminUser);
}
async initAdminUsernameUser(
usernameAdminUser?: UsernameAdminUser,
): Promise<void> {
const username = usernameAdminUser?.username || 'admin';
const password = usernameAdminUser?.password || 'unleash4all';
const userCount = await this.store.count();
if (userCount === 0) {
// create default admin user
try {
this.logger.info(
`Creating default admin user, with username '${username}' and password '${password}'`,
);
const user = await this.store.insert({
username,
});
const passwordHash = await bcrypt.hash(password, saltRounds);
await this.store.setPasswordHash(
user.id,
passwordHash,
disallowNPreviousPasswords,
);
await this.accessService.setUserRootRole(
user.id,
RoleName.ADMIN,
);
} catch (e) {
this.logger.error(
`Unable to create default user '${username}'`,
);
}
}
}
async getAll(): Promise<IUserWithRootRole[]> {
const users = await this.store.getAll();
const defaultRole = await this.accessService.getPredefinedRole(
RoleName.VIEWER,
);
const userRoles = await this.accessService.getRootRoleForAllUsers();
const usersWithRootRole = users.map((u) => {
const rootRole = userRoles.find((r) => r.userId === u.id);
const roleId = rootRole ? rootRole.roleId : defaultRole.id;
return { ...u, rootRole: roleId };
});
if (this.flagResolver.isEnabled('showUserDeviceCount')) {
const sessionCounts = await this.sessionService.getSessionsCount();
const usersWithSessionCounts = usersWithRootRole.map((u) => ({
...u,
activeSessions: sessionCounts[u.id] || 0,
}));
return usersWithSessionCounts;
}
return usersWithRootRole;
}
async getUser(id: number): Promise<IUserWithRootRole> {
const user = await this.store.get(id);
if (user === undefined) {
throw new NotFoundError(`Could not find user with id ${id}`);
}
const rootRole = await this.accessService.getRootRoleForUser(id);
return { ...user, id, rootRole: rootRole.id };
}
async search(query: string): Promise<IUser[]> {
return this.store.search(query);
}
async getByEmail(email: string): Promise<IUser> {
return this.store.getByQuery({ email });
}
private validateEmail(email?: string): void {
if (email) {
Joi.assert(
email,
Joi.string().email({
ignoreLength: true,
minDomainSegments: 1,
}),
'Email',
);
}
}
async createUser(
{ username, email, name, password, rootRole }: ICreateUserWithRole,
auditUser: IAuditUser = SYSTEM_USER_AUDIT,
): Promise<IUserWithRootRole> {
if (!username && !email) {
throw new BadDataError('You must specify username or email');
}
Joi.assert(name, Joi.string(), 'Name');
this.validateEmail(email);
const exists = await this.store.hasUser({ username, email });
if (exists) {
throw new BadDataError('User already exists');
}
const user = await this.store.insert({
username,
email,
name,
});
await this.accessService.setUserRootRole(user.id, rootRole);
if (password) {
const passwordHash = await bcrypt.hash(password, saltRounds);
await this.store.setPasswordHash(
user.id,
passwordHash,
disallowNPreviousPasswords,
);
}
const userCreated = await this.getUser(user.id);
await this.eventService.storeEvent(
new UserCreatedEvent({
auditUser,
userCreated,
}),
);
return userCreated;
}
async newUserInviteLink(
{ id: userId }: Pick<IUserWithRootRole, 'id'>,
auditUser: IAuditUser = SYSTEM_USER_AUDIT,
): Promise<string> {
const passwordAuthSettings =
await this.settingService.getWithDefault<SimpleAuthSettings>(
simpleAuthSettingsKey,
{ disabled: false },
);
let inviteLink = this.unleashUrl;
if (!passwordAuthSettings.disabled) {
const inviteUrl = await this.resetTokenService.createNewUserUrl(
userId,
auditUser.username,
);
inviteLink = inviteUrl.toString();
}
return inviteLink;
}
async sendWelcomeEmail(
user: IUserWithRootRole,
inviteLink: string,
): Promise<boolean> {
let emailSent = false;
const emailConfigured = this.emailService.configured();
if (emailConfigured && user.email) {
try {
await this.emailService.sendGettingStartedMail(
user.name || '',
user.email,
this.unleashUrl,
inviteLink,
);
emailSent = true;
} catch (e) {
this.logger.warn(
'email was configured, but sending failed due to: ',
e,
);
}
} else {
this.logger.warn(
'email was not sent to the user because email configuration is lacking',
);
}
return emailSent;
}
async updateUser(
{ id, name, email, rootRole }: IUpdateUser,
auditUser: IAuditUser,
): Promise<IUserWithRootRole> {
const preUser = await this.getUser(id);
this.validateEmail(email);
if (rootRole) {
await this.accessService.setUserRootRole(id, rootRole);
}
const payload: Partial<IUser> = {
name: name || preUser.name,
email: email || preUser.email,
};
// Empty updates will throw, so make sure we have something to update.
const user = Object.values(payload).some(isDefined)
? await this.store.update(id, payload)
: preUser;
const storedUser = await this.getUser(user.id);
await this.eventService.storeEvent(
new UserUpdatedEvent({
auditUser,
preUser: preUser,
postUser: storedUser,
}),
);
return storedUser;
}
async deleteUser(userId: number, auditUser: IAuditUser): Promise<void> {
const user = await this.getUser(userId);
await this.accessService.wipeUserPermissions(userId);
await this.sessionService.deleteSessionsForUser(userId);
await this.store.delete(userId);
await this.eventService.storeEvent(
new UserDeletedEvent({
deletedUser: user,
auditUser,
}),
);
}
async deleteScimUsers(auditUser: IAuditUser): Promise<void> {
const users = await this.store.deleteScimUsers();
// Note: after deletion we can't get the role for the user
const viewerRole = await this.accessService.getPredefinedRole(
RoleName.VIEWER,
);
if (users.length > 0) {
const deletions = users.map((user) => {
return new UserDeletedEvent({
deletedUser: { ...user, rootRole: viewerRole.id },
auditUser,
});
});
await this.eventService.storeEvents([
...deletions,
new ScimUsersDeleted({
data: null,
auditUser,
}),
]);
}
}
async loginUser(
usernameOrEmail: string,
password: string,
device?: { userAgent?: string; ip: string },
): Promise<IUser> {
const settings = await this.settingService.get<SimpleAuthSettings>(
simpleAuthSettingsKey,
);
if (settings?.disabled) {
throw new DisabledError(
'Logging in with username/password has been disabled.',
);
}
const idQuery = isEmail(usernameOrEmail)
? { email: usernameOrEmail }
: { username: usernameOrEmail };
let user: IUser | undefined, passwordHash: string | undefined;
try {
user = await this.store.getByQuery(idQuery);
passwordHash = await this.store.getPasswordHash(user.id);
} catch (error) {}
if (user && passwordHash) {
const match = await bcrypt.compare(password, passwordHash);
if (match) {
const loginOrder = await this.store.successfullyLogin(user);
const sessions = await this.sessionService.getSessionsForUser(
user.id,
);
if (sessions.length >= 5 && device) {
this.logger.info(
`Excessive login (user id: ${user.id}, user agent: ${device.userAgent}, IP: ${device.ip})`,
);
}
// subtract current user session that will be created
const deletedSessionsCount =
await this.sessionService.deleteStaleSessionsForUser(
user.id,
Math.max(this.maxParallelSessions - 1, 0),
);
user.deletedSessions = deletedSessionsCount;
user.activeSessions = this.maxParallelSessions;
this.eventBus.emit(USER_LOGIN, { loginOrder });
return user;
}
}
throw new PasswordMismatch(
`The combination of password and username you provided is invalid. If you have forgotten your password, visit ${this.baseUriPath}/forgotten-password or get in touch with your instance administrator.`,
);
}
/**
* Used to login users without specifying password. Used when integrating
* with external identity providers.
*
* @param usernameOrEmail
* @param autoCreateUser
* @returns
*/
async loginUserWithoutPassword(
email: string,
autoCreateUser: boolean = false,
): Promise<IUser> {
return this.loginUserSSO({ email, autoCreate: autoCreateUser });
}
async loginUserSSO({
email,
name,
rootRole,
autoCreate = false,
}: ILoginUserRequest): Promise<IUser> {
let user: IUser;
try {
user = await this.store.getByQuery({ email });
// Update user if autCreate is enabled.
if (name && user.name !== name) {
user = await this.store.update(user.id, { name, email });
}
} catch (e) {
// User does not exists. Create if 'autoCreate' is enabled
if (autoCreate) {
user = await this.createUser(
{
email,
name,
rootRole: rootRole || RoleName.EDITOR,
},
SYSTEM_USER_AUDIT,
);
} else {
throw e;
}
}
const loginOrder = await this.store.successfullyLogin(user);
this.eventBus.emit(USER_LOGIN, { loginOrder });
return user;
}
async loginDemoAuthDefaultAdmin(): Promise<IUser> {
const user = await this.store.getByQuery({ id: 1 });
const loginOrder = await this.store.successfullyLogin(user);
this.eventBus.emit(USER_LOGIN, { loginOrder });
return user;
}
async changePassword(userId: number, password: string): Promise<void> {
this.validatePassword(password);
const passwordHash = await bcrypt.hash(password, saltRounds);
await this.store.setPasswordHash(
userId,
passwordHash,
disallowNPreviousPasswords,
);
await this.sessionService.deleteSessionsForUser(userId);
await this.resetTokenService.expireExistingTokensForUser(userId);
}
async changePasswordWithPreviouslyUsedPasswordCheck(
userId: number,
password: string,
): Promise<void> {
const previouslyUsed =
await this.store.getPasswordsPreviouslyUsed(userId);
const usedBefore = previouslyUsed.some((previouslyUsed) =>
bcrypt.compareSync(password, previouslyUsed),
);
if (usedBefore) {
throw new PasswordPreviouslyUsedError();
}
await this.changePassword(userId, password);
}
async changePasswordWithVerification(
userId: number,
newPassword: string,
oldPassword: string,
): Promise<void> {
const currentPasswordHash = await this.store.getPasswordHash(userId);
const match = await bcrypt.compare(oldPassword, currentPasswordHash);
if (!match) {
throw new PasswordMismatch(
`The old password you provided is invalid. If you have forgotten your password, visit ${this.baseUriPath}/forgotten-password or get in touch with your instance administrator.`,
);
}
await this.changePasswordWithPreviouslyUsedPasswordCheck(
userId,
newPassword,
);
}
async getUserForToken(token: string): Promise<TokenUserSchema> {
const { createdBy, userId } =
await this.resetTokenService.isValid(token);
const user = await this.getUser(userId);
const role = await this.accessService.getRoleData(user.rootRole);
return {
token,
createdBy,
email: user.email!,
name: user.name,
id: user.id,
role: {
id: user.rootRole,
description: role.role.description,
type: role.role.type,
name: role.role.name,
},
};
}
/**
* If the password is a strong password will update password and delete all sessions for the user we're changing the password for
* @param token - the token authenticating this request
* @param password - new password
*/
async resetPassword(token: string, password: string): Promise<void> {
this.validatePassword(password);
const user = await this.getUserForToken(token);
await this.changePasswordWithPreviouslyUsedPasswordCheck(
user.id,
password,
);
await this.resetTokenService.useAccessToken({
userId: user.id,
token,
});
}
async createResetPasswordEmail(
receiverEmail: string,
user: IUser = new User({
id: SYSTEM_USER.id,
username: SYSTEM_USER.username,
}),
): Promise<URL> {
const receiver = await this.getByEmail(receiverEmail);
if (!receiver) {
throw new NotFoundError(`Could not find ${receiverEmail}`);
}
if (this.passwordResetTimeouts[receiver.id]) {
throw new RateLimitError(
'You can only send one new reset password email per minute, per user. Please try again later.',
);
}
const resetLink = await this.resetTokenService.createResetPasswordUrl(
receiver.id,
user.username || user.email || SYSTEM_USER_AUDIT.username,
);
this.passwordResetTimeouts[receiver.id] = setTimeout(() => {
delete this.passwordResetTimeouts[receiver.id];
}, 1000 * 60); // 1 minute
await this.emailService.sendResetMail(
receiver.name!,
receiverEmail,
resetLink.toString(),
);
return resetLink;
}
}
export default UserService;