n8n/packages/cli/src/controllers/auth.controller.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

220 lines
7.2 KiB
TypeScript
Raw Normal View History

import validator from 'validator';
import { AuthService } from '@/auth/auth.service';
import { Get, Post, RestController } from '@/decorators';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { Request, Response } from 'express';
import type { User } from '@db/entities/User';
import { AuthenticatedRequest, LoginRequest, UserRequest } from '@/requests';
import type { PublicUser } from '@/Interfaces';
import { handleEmailLogin, handleLdapLogin } from '@/auth';
import { PostHogClient } from '@/posthog';
import {
getCurrentAuthenticationMethod,
isLdapCurrentAuthenticationMethod,
isSamlCurrentAuthenticationMethod,
} from '@/sso/ssoHelpers';
import { InternalHooks } from '../InternalHooks';
feat(API): Implement users account quota guards (#6434) * feat(cli): Implement users account quota guards Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Remove comment Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Address PR comments - Getting `usersQuota` from `Settings` repo - Revert `isUserManagementEnabled` helper - Fix FE listing of users Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Refactor isWithinUserQuota getter and fix tests Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Revert testDb.ts changes Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Cleanup & improve types Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Fix duplicated method * Fix failing test * Remove `isUserManagementEnabled` completely Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Check for globalRole.name to determine if user is owner Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Fix unit tests Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Set isInstanceOwnerSetUp in specs * Fix SettingsUserView UM Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * refactor: License typings suggestions for users quota guards (#6636) refactor: License typings suggestions * Update packages/cli/src/Ldap/helpers.ts Co-authored-by: Iván Ovejero <ivov.src@gmail.com> * Update packages/cli/test/integration/shared/utils.ts Co-authored-by: Iván Ovejero <ivov.src@gmail.com> * Address PR comments Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Use 403 for all user quota related errors Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> --------- Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
2023-07-12 05:11:46 -07:00
import { License } from '@/License';
import { UserService } from '@/services/user.service';
import { MfaService } from '@/Mfa/mfa.service';
import { Logger } from '@/Logger';
import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { ApplicationError } from 'n8n-workflow';
import { UserRepository } from '@/databases/repositories/user.repository';
@RestController()
export class AuthController {
constructor(
private readonly logger: Logger,
private readonly internalHooks: InternalHooks,
private readonly authService: AuthService,
private readonly mfaService: MfaService,
private readonly userService: UserService,
private readonly license: License,
private readonly userRepository: UserRepository,
private readonly postHog?: PostHogClient,
) {}
/** Log in a user */
@Post('/login', { skipAuth: true, rateLimit: true })
async login(req: LoginRequest, res: Response): Promise<PublicUser | undefined> {
const { email, password, mfaToken, mfaRecoveryCode } = req.body;
if (!email) throw new ApplicationError('Email is required to log in');
if (!password) throw new ApplicationError('Password is required to log in');
let user: User | undefined;
let usedAuthenticationMethod = getCurrentAuthenticationMethod();
if (isSamlCurrentAuthenticationMethod()) {
// attempt to fetch user data with the credentials, but don't log in yet
const preliminaryUser = await handleEmailLogin(email, password);
// if the user is an owner, continue with the login
if (
preliminaryUser?.role === 'global:owner' ||
preliminaryUser?.settings?.allowSSOManualLogin
) {
user = preliminaryUser;
usedAuthenticationMethod = 'email';
} else {
throw new AuthError('SSO is enabled, please log in with SSO');
}
} else if (isLdapCurrentAuthenticationMethod()) {
const preliminaryUser = await handleEmailLogin(email, password);
if (preliminaryUser?.role === 'global:owner') {
user = preliminaryUser;
usedAuthenticationMethod = 'email';
} else {
user = await handleLdapLogin(email, password);
}
} else {
user = await handleEmailLogin(email, password);
}
if (user) {
if (user.mfaEnabled) {
if (!mfaToken && !mfaRecoveryCode) {
throw new AuthError('MFA Error', 998);
}
const { decryptedRecoveryCodes, decryptedSecret } =
await this.mfaService.getSecretAndRecoveryCodes(user.id);
user.mfaSecret = decryptedSecret;
user.mfaRecoveryCodes = decryptedRecoveryCodes;
const isMFATokenValid =
(await this.validateMfaToken(user, mfaToken)) ||
(await this.validateMfaRecoveryCode(user, mfaRecoveryCode));
if (!isMFATokenValid) {
throw new AuthError('Invalid mfa token or recovery code');
}
}
this.authService.issueCookie(res, user);
void this.internalHooks.onUserLoginSuccess({
user,
authenticationMethod: usedAuthenticationMethod,
});
return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true });
}
void this.internalHooks.onUserLoginFailed({
user: email,
authenticationMethod: usedAuthenticationMethod,
reason: 'wrong credentials',
});
throw new AuthError('Wrong username or password. Do you have caps lock on?');
}
/** Check if the user is already logged in */
@Get('/login')
async currentUser(req: AuthenticatedRequest): Promise<PublicUser> {
return await this.userService.toPublic(req.user, {
posthog: this.postHog,
withScopes: true,
});
}
/** Validate invite token to enable invitee to set up their account */
@Get('/resolve-signup-token', { skipAuth: true })
async resolveSignupToken(req: UserRequest.ResolveSignUp) {
const { inviterId, inviteeId } = req.query;
const isWithinUsersLimit = this.license.isWithinUsersLimit();
feat(API): Implement users account quota guards (#6434) * feat(cli): Implement users account quota guards Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Remove comment Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Address PR comments - Getting `usersQuota` from `Settings` repo - Revert `isUserManagementEnabled` helper - Fix FE listing of users Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Refactor isWithinUserQuota getter and fix tests Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Revert testDb.ts changes Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Cleanup & improve types Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Fix duplicated method * Fix failing test * Remove `isUserManagementEnabled` completely Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Check for globalRole.name to determine if user is owner Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Fix unit tests Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Set isInstanceOwnerSetUp in specs * Fix SettingsUserView UM Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * refactor: License typings suggestions for users quota guards (#6636) refactor: License typings suggestions * Update packages/cli/src/Ldap/helpers.ts Co-authored-by: Iván Ovejero <ivov.src@gmail.com> * Update packages/cli/test/integration/shared/utils.ts Co-authored-by: Iván Ovejero <ivov.src@gmail.com> * Address PR comments Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> * Use 403 for all user quota related errors Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> --------- Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
2023-07-12 05:11:46 -07:00
if (!isWithinUsersLimit) {
this.logger.debug('Request to resolve signup token failed because of users quota reached', {
inviterId,
inviteeId,
});
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!inviterId || !inviteeId) {
this.logger.debug(
'Request to resolve signup token failed because of missing user IDs in query string',
{ inviterId, inviteeId },
);
throw new BadRequestError('Invalid payload');
}
// Postgres validates UUID format
for (const userId of [inviterId, inviteeId]) {
if (!validator.isUUID(userId)) {
this.logger.debug('Request to resolve signup token failed because of invalid user ID', {
userId,
});
throw new BadRequestError('Invalid userId');
}
}
const users = await this.userRepository.findManyByIds([inviterId, inviteeId]);
if (users.length !== 2) {
this.logger.debug(
'Request to resolve signup token failed because the ID of the inviter and/or the ID of the invitee were not found in database',
{ inviterId, inviteeId },
);
throw new BadRequestError('Invalid invite URL');
}
const invitee = users.find((user) => user.id === inviteeId);
if (!invitee || invitee.password) {
this.logger.error('Invalid invite URL - invitee already setup', {
inviterId,
inviteeId,
});
throw new BadRequestError('The invitation was likely either deleted or already claimed');
}
const inviter = users.find((user) => user.id === inviterId);
if (!inviter?.email || !inviter?.firstName) {
this.logger.error(
'Request to resolve signup token failed because inviter does not exist or is not set up',
{
inviterId: inviter?.id,
},
);
throw new BadRequestError('Invalid request');
}
void this.internalHooks.onUserInviteEmailClick({ inviter, invitee });
const { firstName, lastName } = inviter;
return { inviter: { firstName, lastName } };
}
/** Log out a user */
@Post('/logout')
logout(_: Request, res: Response) {
this.authService.clearCookie(res);
return { loggedOut: true };
}
private async validateMfaToken(user: User, token?: string) {
if (!!!token) return false;
return this.mfaService.totp.verifySecret({
secret: user.mfaSecret ?? '',
token,
});
}
private async validateMfaRecoveryCode(user: User, mfaRecoveryCode?: string) {
if (!!!mfaRecoveryCode) return false;
const index = user.mfaRecoveryCodes.indexOf(mfaRecoveryCode);
if (index === -1) return false;
// remove used recovery code
user.mfaRecoveryCodes.splice(index, 1);
await this.userService.update(user.id, {
mfaRecoveryCodes: this.mfaService.encryptRecoveryCodes(user.mfaRecoveryCodes),
});
return true;
}
}