1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-11-01 19:07:38 +01:00
unleash.unleash/src/lib/services/user-service.ts

473 lines
15 KiB
TypeScript
Raw Normal View History

2022-01-06 20:43:57 +01:00
import bcrypt from 'bcryptjs';
import owasp from 'owasp-password-strength-test';
import Joi from 'joi';
import { URL } from 'url';
import { Logger } from '../logger';
import User, { IUser } from '../types/user';
import isEmail from '../util/is-email';
import { AccessService } from './access-service';
import ResetTokenService from './reset-token-service';
import InvalidTokenError from '../error/invalid-token-error';
import NotFoundError from '../error/notfound-error';
import OwaspValidationError from '../error/owasp-validation-error';
import { EmailService } from './email-service';
import { IUnleashConfig } from '../types/option';
import SessionService from './session-service';
import { IUnleashStores } from '../types/stores';
import PasswordUndefinedError from '../error/password-undefined';
2021-04-29 10:21:29 +02:00
import { USER_UPDATED, USER_CREATED, USER_DELETED } from '../types/events';
import { IEventStore } from '../types/stores/event-store';
import { IUserStore } from '../types/stores/user-store';
import { RoleName } from '../types/model';
import SettingService from './setting-service';
import { SimpleAuthSettings } from '../server-impl';
import { simpleAuthSettingsKey } from '../types/settings/simple-auth-settings';
import DisabledError from '../error/disabled-error';
import PasswordMismatch from '../error/password-mismatch';
import BadDataError from '../error/bad-data-error';
import { isDefined } from '../util/isDefined';
import { TokenUserSchema } from '../openapi/spec/token-user-schema';
import { IFlagResolver } from 'lib/types/experimental';
import { minutesToMilliseconds } from 'date-fns';
const systemUser = new User({ id: -1, username: 'system' });
export interface ICreateUser {
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;
}
interface IUserWithRole extends IUser {
rootRole: number;
}
const saltRounds = 10;
class UserService {
private logger: Logger;
private store: IUserStore;
private eventStore: IEventStore;
private accessService: AccessService;
private resetTokenService: ResetTokenService;
private sessionService: SessionService;
private emailService: EmailService;
private settingService: SettingService;
private passwordResetTimeouts: { [key: string]: NodeJS.Timeout } = {};
private seenTimer: NodeJS.Timeout;
private lastSeenSecrets: Set<string> = new Set<string>();
private flagResolver: IFlagResolver;
constructor(
stores: Pick<IUnleashStores, 'userStore' | 'eventStore'>,
{
getLogger,
authentication,
flagResolver,
}: Pick<
IUnleashConfig,
'getLogger' | 'authentication' | 'flagResolver'
>,
services: {
accessService: AccessService;
resetTokenService: ResetTokenService;
emailService: EmailService;
sessionService: SessionService;
settingService: SettingService;
},
) {
this.flagResolver = flagResolver;
this.logger = getLogger('service/user-service.js');
this.store = stores.userStore;
this.eventStore = stores.eventStore;
this.accessService = services.accessService;
this.resetTokenService = services.resetTokenService;
this.emailService = services.emailService;
this.sessionService = services.sessionService;
this.settingService = services.settingService;
if (authentication && authentication.createAdminUser) {
process.nextTick(() => this.initAdminUser());
}
if (this.flagResolver.isEnabled('tokensLastSeen')) {
this.updateLastSeen();
}
}
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(): Promise<void> {
const userCount = await this.store.count();
if (userCount === 0) {
// create default admin user
try {
2021-04-26 11:28:51 +02:00
const pwd = 'unleash4all';
this.logger.info(
2021-04-26 11:28:51 +02:00
`Creating default user "admin" with password "${pwd}"`,
);
const user = await this.store.insert({
username: 'admin',
});
2021-04-26 11:28:51 +02:00
const passwordHash = await bcrypt.hash(pwd, saltRounds);
await this.store.setPasswordHash(user.id, passwordHash);
await this.accessService.setUserRootRole(
user.id,
RoleName.ADMIN,
);
} catch (e) {
this.logger.error('Unable to create default user "admin"');
}
}
}
async getAll(): Promise<IUserWithRole[]> {
const users = await this.store.getAll();
const defaultRole = await this.accessService.getRootRole(
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 };
});
return usersWithRootRole;
}
async getUser(id: number): Promise<IUserWithRole> {
const roles = await this.accessService.getUserRootRoles(id);
const defaultRole = await this.accessService.getRootRole(
RoleName.VIEWER,
);
const roleId = roles.length > 0 ? roles[0].id : defaultRole.id;
const user = await this.store.get(id);
return { ...user, rootRole: roleId };
}
async search(query: string): Promise<IUser[]> {
return this.store.search(query);
}
async getByEmail(email: string): Promise<IUser> {
return this.store.getByQuery({ email });
}
async createUser(
{ username, email, name, password, rootRole }: ICreateUser,
updatedBy?: User,
): Promise<IUser> {
if (!username && !email) {
throw new BadDataError('You must specify username or email');
}
if (email) {
Joi.assert(email, Joi.string().email(), 'Email');
}
const exists = await this.store.hasUser({ username, email });
if (exists) {
throw new Error('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);
}
await this.eventStore.store({
type: USER_CREATED,
createdBy: this.getCreatedBy(updatedBy),
data: this.mapUserToData(user),
});
return user;
}
private getCreatedBy(updatedBy: User = systemUser) {
return updatedBy.username || updatedBy.email;
}
private mapUserToData(user?: IUser): any {
if (!user) {
return undefined;
}
return {
id: user.id,
name: user.name,
username: user.username,
email: user.email,
};
}
async updateUser(
{ id, name, email, rootRole }: IUpdateUser,
updatedBy?: User,
): Promise<IUser> {
const preUser = await this.store.get(id);
if (email) {
Joi.assert(email, Joi.string().email(), '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;
await this.eventStore.store({
type: USER_UPDATED,
createdBy: this.getCreatedBy(updatedBy),
data: this.mapUserToData(user),
preData: this.mapUserToData(preUser),
});
return user;
}
async deleteUser(userId: number, updatedBy?: User): Promise<void> {
const user = await this.store.get(userId);
await this.accessService.wipeUserPermissions(userId);
await this.sessionService.deleteSessionsForUser(userId);
await this.store.delete(userId);
await this.eventStore.store({
type: USER_DELETED,
createdBy: this.getCreatedBy(updatedBy),
preData: this.mapUserToData(user),
});
}
async loginUser(usernameOrEmail: string, password: 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 };
const user = await this.store.getByQuery(idQuery);
const passwordHash = await this.store.getPasswordHash(user.id);
const match = await bcrypt.compare(password, passwordHash);
if (match) {
await this.store.successfullyLogin(user);
return user;
}
throw new PasswordMismatch();
}
/**
* 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.
2021-08-25 12:43:42 +02:00
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,
});
} else {
throw e;
}
}
await this.store.successfullyLogin(user);
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);
await this.sessionService.deleteSessionsForUser(userId);
}
async getUserForToken(token: string): Promise<TokenUserSchema> {
const { createdBy, userId } = await this.resetTokenService.isValid(
token,
);
const user = await this.getUser(userId);
feat: custom project roles (#1220) * wip: environment for permissions * fix: add migration for roles * fix: connect environment with access service * feat: add tests * chore: Implement scaffolding for new rbac * fix: add fake store * feat: Add api endpoints for roles and permissions list * feat: Add ability to provide permissions when creating a role and rename environmentName to name in the list permissions datastructure * fix: Make project roles resolve correctly against new environments permissions structure * fix: Patch migration to also populate permission names * fix: Make permissions actually work with new environments * fix: Add back to get permissions working for editor role * fix: Removed ability to set role type through api during creation - it's now always custom * feat: Return permissions on get role endpoint * feat: Add in support for updating roles * fix: Get a bunch of tests working and delete a few that make no sense anymore * chore: A few small cleanups - remove logging and restore default on dev server config * chore: Refactor role/access stores into more logical domains * feat: Add in validation for roles * feat: Patch db migration to handle old stucture * fix: migration for project roles * fix: patch a few broken tests * fix: add permissions to editor * fix: update test name * fix: update user permission mapping * fix: create new user * fix: update root role test * fix: update tests * feat: Validation now works when updating a role * fix: Add in very barebones down migration for rbac so that tests work * fix: Improve responses from role resolution - getting a non existant role will throw a NotFound error * fix: remove unused permissions * fix: add test for connecting roles and deleting project * fix: add test for adding a project member with a custom role * fix: add test for changing user role * fix: add guard for deleting role if the role is in use * fix: alter migration * chore: Minor code cleanups * chore: Small code cleanups * chore: More minor cleanups of code * chore: Trim some dead code to make the linter happy * feat: Schema validation for roles * fix: setup permission for variant * fix: remove unused import * feat: Add cascading delete for role_permissions when deleting a role * feat: add configuration option for disabling legacy api * chore: update frontend to beta version * 4.6.0-beta.0 * fix: export default project constant * fix: update snapshot * fix: module pattern ../../lib * fix: move DEFAULT_PROJECT to types * fix: remove debug logging * fix: remove debug log state * fix: Change permission descriptions * fix: roles should have unique name * fix: root roles should be connected to the default project * fix: typo in role-schema.ts * fix: Role permission empty string for non environment type * feat: new permission for moving project * fix: add event for changeProject * fix: Removing a user from a project will now check to see if that project has an owner, rather than checking if any project has an owner * fix: add tests for move project * fix: Add in missing create/delete tag permissions * fix: Removed duplicate impl caused by multiple good samaritans putting it back in! * fix: Trim out add tag permissions, for now at least * chore: Trim out new add and delete tag permissions - we're going with update feature instead * chore: update frontend * 4.6.0-beta.1 * feat: Prevent editing of built in roles * fix: Patch an issue where permissions for variants/environments didn't match the front end * fix: lint Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com> Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
2022-01-13 11:14:17 +01:00
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);
const allowed = await this.resetTokenService.useAccessToken({
userId: user.id,
token,
});
if (allowed) {
await this.changePassword(user.id, password);
await this.sessionService.deleteSessionsForUser(user.id);
} else {
throw new InvalidTokenError();
}
}
async createResetPasswordEmail(
receiverEmail: string,
user: User = systemUser,
): Promise<URL> {
const receiver = await this.getByEmail(receiverEmail);
if (!receiver) {
throw new NotFoundError(`Could not find ${receiverEmail}`);
}
if (this.passwordResetTimeouts[receiver.id]) {
return;
}
const resetLink = await this.resetTokenService.createResetPasswordUrl(
receiver.id,
user.username || user.email,
);
this.passwordResetTimeouts[receiver.id] = setTimeout(() => {
delete this.passwordResetTimeouts[receiver.id];
}, 1000 * 60); // 1 minute
await this.emailService.sendResetMail(
receiver.name,
receiver.email,
resetLink.toString(),
);
return resetLink;
}
async getUserByPersonalAccessToken(secret: string): Promise<IUser> {
return this.store.getUserByPersonalAccessToken(secret);
}
async updateLastSeen(): Promise<void> {
if (this.lastSeenSecrets.size > 0) {
const toStore = [...this.lastSeenSecrets];
this.lastSeenSecrets = new Set<string>();
await this.store.markSeenAt(toStore);
}
this.seenTimer = setTimeout(
async () => this.updateLastSeen(),
minutesToMilliseconds(3),
).unref();
}
addPATSeen(secret: string): void {
if (this.flagResolver.isEnabled('tokensLastSeen')) {
this.lastSeenSecrets.add(secret);
}
}
destroy(): void {
clearTimeout(this.seenTimer);
this.seenTimer = null;
}
}
module.exports = UserService;
export default UserService;