Build exceptions and handler (#6459)

Adding exceptions and handler for auth services.

Tested with:
- Workspace creation
- Workspace signup
- Workspace invitation
- Reset password
- Adding email account
- Impersonation

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Thomas Trompette
2024-08-07 11:42:49 +02:00
committed by GitHub
parent f09e61bb9f
commit 2abb6adb61
19 changed files with 580 additions and 269 deletions

View File

@ -0,0 +1,17 @@
import { CustomException } from 'src/utils/custom-exception';
export class AuthException extends CustomException {
code: AuthExceptionCode;
constructor(message: string, code: AuthExceptionCode) {
super(message, code);
}
}
export enum AuthExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',
CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND',
INVALID_INPUT = 'INVALID_INPUT',
FORBIDDEN_EXCEPTION = 'FORBIDDEN_EXCEPTION',
INVALID_DATA = 'INVALID_DATA',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
}

View File

@ -1,14 +1,5 @@
import { import { UseFilters, UseGuards } from '@nestjs/common';
BadRequestException,
ForbiddenException,
InternalServerErrorException,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input'; import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input'; import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
@ -24,6 +15,7 @@ import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input'; import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity'; import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity';
import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input'; import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@ -31,7 +23,6 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard'; import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { CaptchaGuard } from 'src/engine/integrations/captcha/captcha.guard'; import { CaptchaGuard } from 'src/engine/integrations/captcha/captcha.guard';
import { assert } from 'src/utils/assert';
import { ChallengeInput } from './dto/challenge.input'; import { ChallengeInput } from './dto/challenge.input';
import { ImpersonateInput } from './dto/impersonate.input'; import { ImpersonateInput } from './dto/impersonate.input';
@ -48,10 +39,9 @@ import { AuthService } from './services/auth.service';
import { TokenService } from './services/token.service'; import { TokenService } from './services/token.service';
@Resolver() @Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter)
export class AuthResolver { export class AuthResolver {
constructor( constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private authService: AuthService, private authService: AuthService,
private tokenService: TokenService, private tokenService: TokenService,
private userService: UserService, private userService: UserService,
@ -81,16 +71,10 @@ export class AuthResolver {
@Query(() => Workspace) @Query(() => Workspace)
async findWorkspaceFromInviteHash( async findWorkspaceFromInviteHash(
@Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput, @Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput,
) { ): Promise<Workspace> {
const workspace = await this.workspaceRepository.findOneBy({ return await this.authService.findWorkspaceFromInviteHashOrFail(
inviteHash: workspaceInviteHashValidInput.inviteHash, workspaceInviteHashValidInput.inviteHash,
}); );
if (!workspace) {
throw new BadRequestException('Workspace does not exist');
}
return workspace;
} }
@UseGuards(CaptchaGuard) @UseGuards(CaptchaGuard)
@ -151,8 +135,6 @@ export class AuthResolver {
verifyInput.loginToken, verifyInput.loginToken,
); );
assert(email, 'Invalid token', ForbiddenException);
const result = await this.authService.verify(email); const result = await this.authService.verify(email);
return result; return result;
@ -188,10 +170,6 @@ export class AuthResolver {
@Mutation(() => AuthTokens) @Mutation(() => AuthTokens)
async renewToken(@Args() args: AppTokenInput): Promise<AuthTokens> { async renewToken(@Args() args: AppTokenInput): Promise<AuthTokens> {
if (!args.appToken) {
throw new BadRequestException('Refresh token is mendatory');
}
const tokens = await this.tokenService.generateTokensFromRefreshToken( const tokens = await this.tokenService.generateTokensFromRefreshToken(
args.appToken, args.appToken,
); );
@ -205,10 +183,7 @@ export class AuthResolver {
@Args() impersonateInput: ImpersonateInput, @Args() impersonateInput: ImpersonateInput,
@AuthUser() user: User, @AuthUser() user: User,
): Promise<Verify> { ): Promise<Verify> {
// Check if user can impersonate return await this.authService.impersonate(impersonateInput.userId, user);
assert(user.canImpersonate, 'User cannot impersonate', ForbiddenException);
return this.authService.impersonate(impersonateInput.userId);
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ -240,20 +215,13 @@ export class AuthResolver {
@Mutation(() => InvalidatePassword) @Mutation(() => InvalidatePassword)
async updatePasswordViaResetToken( async updatePasswordViaResetToken(
@Args() args: UpdatePasswordViaResetTokenInput, @Args()
{ passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput,
): Promise<InvalidatePassword> { ): Promise<InvalidatePassword> {
const { id } = await this.tokenService.validatePasswordResetToken( const { id } =
args.passwordResetToken, await this.tokenService.validatePasswordResetToken(passwordResetToken);
);
assert(id, 'User not found', NotFoundException); await this.authService.updatePassword(id, newPassword);
const { success } = await this.authService.updatePassword(
id,
args.newPassword,
);
assert(success, 'Password update failed', InternalServerErrorException);
return await this.tokenService.invalidatePasswordResetToken(id); return await this.tokenService.invalidatePasswordResetToken(id);
} }

View File

@ -3,12 +3,17 @@ import {
Get, Get,
Req, Req,
Res, Res,
UnauthorizedException, UseFilters,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard'; import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard';
import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard'; import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service'; import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
@ -19,6 +24,7 @@ import { EnvironmentService } from 'src/engine/integrations/environment/environm
import { LoadServiceWithWorkspaceContext } from 'src/engine/twenty-orm/context/load-service-with-workspace.context'; import { LoadServiceWithWorkspaceContext } from 'src/engine/twenty-orm/context/load-service-with-workspace.context';
@Controller('auth/google-apis') @Controller('auth/google-apis')
@UseFilters(AuthRestApiExceptionFilter)
export class GoogleAPIsAuthController { export class GoogleAPIsAuthController {
constructor( constructor(
private readonly googleAPIsService: GoogleAPIsService, private readonly googleAPIsService: GoogleAPIsService,
@ -59,13 +65,17 @@ export class GoogleAPIsAuthController {
const demoWorkspaceIds = this.environmentService.get('DEMO_WORKSPACE_IDS'); const demoWorkspaceIds = this.environmentService.get('DEMO_WORKSPACE_IDS');
if (demoWorkspaceIds.includes(workspaceId)) { if (demoWorkspaceIds.includes(workspaceId)) {
throw new UnauthorizedException( throw new AuthException(
'Cannot connect Google account to demo workspace', 'Cannot connect Google account to demo workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
); );
} }
if (!workspaceId) { if (!workspaceId) {
throw new Error('Workspace not found'); throw new AuthException(
'Workspace not found',
AuthExceptionCode.INVALID_INPUT,
);
} }
const handle = emails[0].value; const handle = emails[0].value;

View File

@ -1,14 +1,23 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; import {
Controller,
Get,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard';
import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oauth.guard'; import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oauth.guard';
import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
@Controller('auth/google') @Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter)
export class GoogleAuthController { export class GoogleAuthController {
constructor( constructor(
private readonly tokenService: TokenService, private readonly tokenService: TokenService,

View File

@ -1,8 +1,16 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; import {
Controller,
Get,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard'; import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard';
import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard'; import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'; import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
@ -10,6 +18,7 @@ import { TokenService } from 'src/engine/core-modules/auth/services/token.servic
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
@Controller('auth/microsoft') @Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
export class MicrosoftAuthController { export class MicrosoftAuthController {
constructor( constructor(
private readonly tokenService: TokenService, private readonly tokenService: TokenService,

View File

@ -1,11 +1,13 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Post, UseFilters } from '@nestjs/common';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input';
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
@Controller('auth/verify') @Controller('auth/verify')
@UseFilters(AuthRestApiExceptionFilter)
export class VerifyAuthController { export class VerifyAuthController {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,

View File

@ -0,0 +1,31 @@
import { Catch } from '@nestjs/common';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
ForbiddenError,
InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(AuthException)
export class AuthGraphqlApiExceptionFilter {
catch(exception: AuthException) {
switch (exception.code) {
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.CLIENT_NOT_FOUND:
throw new NotFoundError(exception.message);
case AuthExceptionCode.INVALID_INPUT:
throw new UserInputError(exception.message);
case AuthExceptionCode.FORBIDDEN_EXCEPTION:
throw new ForbiddenError(exception.message);
case AuthExceptionCode.INVALID_DATA:
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
default:
throw new InternalServerError(exception.message);
}
}
}

View File

@ -0,0 +1,33 @@
import {
ArgumentsHost,
BadRequestException,
Catch,
ExceptionFilter,
InternalServerErrorException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
@Catch(AuthException)
export class AuthRestApiExceptionFilter implements ExceptionFilter {
catch(exception: AuthException, _: ArgumentsHost) {
switch (exception.code) {
case AuthExceptionCode.USER_NOT_FOUND:
case AuthExceptionCode.CLIENT_NOT_FOUND:
throw new NotFoundException(exception.message);
case AuthExceptionCode.INVALID_INPUT:
throw new BadRequestException(exception.message);
case AuthExceptionCode.FORBIDDEN_EXCEPTION:
throw new UnauthorizedException(exception.message);
case AuthExceptionCode.INVALID_DATA:
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
default:
throw new InternalServerErrorException(exception.message);
}
}
}

View File

@ -1,13 +1,13 @@
import { import { ExecutionContext, Injectable } from '@nestjs/common';
ExecutionContext,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { import {
GoogleAPIScopeConfig, GoogleAPIScopeConfig,
@ -39,7 +39,10 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') && !this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') &&
!this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED') !this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED')
) { ) {
throw new NotFoundException('Google apis auth is not enabled'); throw new AuthException(
'Google apis auth is not enabled',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} }
const { workspaceId } = await this.tokenService.verifyTransientToken( const { workspaceId } = await this.tokenService.verifyTransientToken(

View File

@ -1,13 +1,13 @@
import { import { ExecutionContext, Injectable } from '@nestjs/common';
ExecutionContext,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service'; import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { GoogleAPIScopeConfig } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy'; import { GoogleAPIScopeConfig } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy';
import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy'; import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy';
@ -36,7 +36,10 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
!this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') && !this.environmentService.get('MESSAGING_PROVIDER_GMAIL_ENABLED') &&
!this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED') !this.environmentService.get('CALENDAR_PROVIDER_GOOGLE_ENABLED')
) { ) {
throw new NotFoundException('Google apis auth is not enabled'); throw new AuthException(
'Google apis auth is not enabled',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} }
const { workspaceId } = await this.tokenService.verifyTransientToken( const { workspaceId } = await this.tokenService.verifyTransientToken(

View File

@ -1,9 +1,13 @@
import { Injectable, CanActivate, NotFoundException } from '@nestjs/common'; import { CanActivate, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { GoogleStrategy } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; import { GoogleStrategy } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
@Injectable() @Injectable()
export class GoogleProviderEnabledGuard implements CanActivate { export class GoogleProviderEnabledGuard implements CanActivate {
@ -11,7 +15,10 @@ export class GoogleProviderEnabledGuard implements CanActivate {
canActivate(): boolean | Promise<boolean> | Observable<boolean> { canActivate(): boolean | Promise<boolean> | Observable<boolean> {
if (!this.environmentService.get('AUTH_GOOGLE_ENABLED')) { if (!this.environmentService.get('AUTH_GOOGLE_ENABLED')) {
throw new NotFoundException('Google auth is not enabled'); throw new AuthException(
'Google auth is not enabled',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} }
new GoogleStrategy(this.environmentService); new GoogleStrategy(this.environmentService);

View File

@ -1,9 +1,13 @@
import { Injectable, CanActivate, NotFoundException } from '@nestjs/common'; import { CanActivate, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { MicrosoftStrategy } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; import { MicrosoftStrategy } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
@Injectable() @Injectable()
export class MicrosoftProviderEnabledGuard implements CanActivate { export class MicrosoftProviderEnabledGuard implements CanActivate {
@ -11,7 +15,10 @@ export class MicrosoftProviderEnabledGuard implements CanActivate {
canActivate(): boolean | Promise<boolean> | Observable<boolean> { canActivate(): boolean | Promise<boolean> | Observable<boolean> {
if (!this.environmentService.get('AUTH_MICROSOFT_ENABLED')) { if (!this.environmentService.get('AUTH_MICROSOFT_ENABLED')) {
throw new NotFoundException('Microsoft auth is not enabled'); throw new AuthException(
'Microsoft auth is not enabled',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} }
new MicrosoftStrategy(this.environmentService); new MicrosoftStrategy(this.environmentService);

View File

@ -1,45 +1,43 @@
import { import { Injectable } from '@nestjs/common';
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { Repository } from 'typeorm';
import { render } from '@react-email/components'; import { render } from '@react-email/components';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { addMilliseconds } from 'date-fns'; import { addMilliseconds } from 'date-fns';
import ms from 'ms'; import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { Repository } from 'typeorm';
import { NodeEnvironment } from 'src/engine/integrations/environment/interfaces/node-environment.interface'; import { NodeEnvironment } from 'src/engine/integrations/environment/interfaces/node-environment.interface';
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input'; import {
import { assert } from 'src/utils/assert'; AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { import {
PASSWORD_REGEX, PASSWORD_REGEX,
compareHash, compareHash,
hashPassword, hashPassword,
} from 'src/engine/core-modules/auth/auth.util'; } from 'src/engine/core-modules/auth/auth.util';
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity'; import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity';
import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity'; import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity';
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity'; import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { EmailService } from 'src/engine/integrations/email/email.service'; import { EmailService } from 'src/engine/integrations/email/email.service';
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { TokenService } from './token.service'; import { TokenService } from './token.service';
@ -70,15 +68,31 @@ export class AuthService {
email: challengeInput.email, email: challengeInput.email,
}); });
assert(user, "This user doesn't exist", NotFoundException); if (!user) {
assert(user.passwordHash, 'Incorrect login method', ForbiddenException); throw new AuthException(
'User not found',
AuthExceptionCode.USER_NOT_FOUND,
);
}
if (!user.passwordHash) {
throw new AuthException(
'Incorrect login method',
AuthExceptionCode.INVALID_INPUT,
);
}
const isValid = await compareHash( const isValid = await compareHash(
challengeInput.password, challengeInput.password,
user.passwordHash, user.passwordHash,
); );
assert(isValid, 'Wrong password', ForbiddenException); if (!isValid) {
throw new AuthException(
'Wrong password',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return user; return user;
} }
@ -112,6 +126,13 @@ export class AuthService {
} }
async verify(email: string): Promise<Verify> { async verify(email: string): Promise<Verify> {
if (!email) {
throw new AuthException(
'Email is required',
AuthExceptionCode.INVALID_INPUT,
);
}
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { where: {
email, email,
@ -119,13 +140,19 @@ export class AuthService {
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
}); });
assert(user, "This user doesn't exist", NotFoundException); if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.USER_NOT_FOUND,
);
}
assert( if (!user.defaultWorkspace) {
user.defaultWorkspace, throw new AuthException(
'User has no default workspace', 'User has no default workspace',
NotFoundException, AuthExceptionCode.INVALID_DATA,
); );
}
// passwordHash is hidden for security reasons // passwordHash is hidden for security reasons
user.passwordHash = ''; user.passwordHash = '';
@ -165,18 +192,33 @@ export class AuthService {
return { isValid: !!workspace }; return { isValid: !!workspace };
} }
async impersonate(userId: string) { async impersonate(userIdToImpersonate: string, userImpersonating: User) {
if (!userImpersonating.canImpersonate) {
throw new AuthException(
'User cannot impersonate',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { where: {
id: userId, id: userIdToImpersonate,
}, },
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'], relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
}); });
assert(user, "This user doesn't exist", NotFoundException); if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.USER_NOT_FOUND,
);
}
if (!user.defaultWorkspace.allowImpersonation) { if (!user.defaultWorkspace.allowImpersonation) {
throw new ForbiddenException('Impersonation not allowed'); throw new AuthException(
'Impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} }
const accessToken = await this.tokenService.generateAccessToken(user.id); const accessToken = await this.tokenService.generateAccessToken(user.id);
@ -215,15 +257,24 @@ export class AuthService {
const client = apps.find((app) => app.id === clientId); const client = apps.find((app) => app.id === clientId);
if (!client) { if (!client) {
throw new NotFoundException(`Invalid client '${clientId}'`); throw new AuthException(
`Client not found for '${clientId}'`,
AuthExceptionCode.CLIENT_NOT_FOUND,
);
} }
if (!client.redirectUrl || !authorizeAppInput.redirectUrl) { if (!client.redirectUrl || !authorizeAppInput.redirectUrl) {
throw new NotFoundException(`redirectUrl not found for '${clientId}'`); throw new AuthException(
`redirectUrl not found for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} }
if (client.redirectUrl !== authorizeAppInput.redirectUrl) { if (client.redirectUrl !== authorizeAppInput.redirectUrl) {
throw new ForbiddenException(`redirectUrl mismatch for '${clientId}'`); throw new AuthException(
`redirectUrl mismatch for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} }
const authorizationCode = crypto.randomBytes(42).toString('hex'); const authorizationCode = crypto.randomBytes(42).toString('hex');
@ -272,13 +323,30 @@ export class AuthService {
userId: string, userId: string,
newPassword: string, newPassword: string,
): Promise<UpdatePassword> { ): Promise<UpdatePassword> {
if (!userId) {
throw new AuthException(
'User ID is required',
AuthExceptionCode.INVALID_INPUT,
);
}
const user = await this.userRepository.findOneBy({ id: userId }); const user = await this.userRepository.findOneBy({ id: userId });
assert(user, 'User not found', NotFoundException); if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.USER_NOT_FOUND,
);
}
const isPasswordValid = PASSWORD_REGEX.test(newPassword); const isPasswordValid = PASSWORD_REGEX.test(newPassword);
assert(isPasswordValid, 'Password too weak', BadRequestException); if (!isPasswordValid) {
throw new AuthException(
'Password is too weak',
AuthExceptionCode.INVALID_INPUT,
);
}
const newPasswordHash = await hashPassword(newPassword); const newPasswordHash = await hashPassword(newPassword);
@ -311,4 +379,21 @@ export class AuthService {
return { success: true }; return { success: true };
} }
async findWorkspaceFromInviteHashOrFail(
inviteHash: string,
): Promise<Workspace> {
const workspace = await this.workspaceRepository.findOneBy({
inviteHash,
});
if (!workspace) {
throw new AuthException(
'Workspace does not exist',
AuthExceptionCode.INVALID_INPUT,
);
}
return workspace;
}
} }

View File

@ -1,9 +1,5 @@
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { import { Injectable } from '@nestjs/common';
BadRequestException,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import FileType from 'file-type'; import FileType from 'file-type';
@ -12,6 +8,10 @@ import { v4 } from 'uuid';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { import {
PASSWORD_REGEX, PASSWORD_REGEX,
compareHash, compareHash,
@ -26,7 +26,6 @@ import {
WorkspaceActivationStatus, WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity'; } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { assert } from 'src/utils/assert';
import { getImageBufferFromUrl } from 'src/utils/image'; import { getImageBufferFromUrl } from 'src/utils/image';
export type SignInUpServiceInput = { export type SignInUpServiceInput = {
@ -66,12 +65,22 @@ export class SignInUpService {
if (!firstName) firstName = ''; if (!firstName) firstName = '';
if (!lastName) lastName = ''; if (!lastName) lastName = '';
assert(email, 'Email is required', BadRequestException); if (!email) {
throw new AuthException(
'Email is required',
AuthExceptionCode.INVALID_INPUT,
);
}
if (password) { if (password) {
const isPasswordValid = PASSWORD_REGEX.test(password); const isPasswordValid = PASSWORD_REGEX.test(password);
assert(isPasswordValid, 'Password too weak', BadRequestException); if (!isPasswordValid) {
throw new AuthException(
'Password too weak',
AuthExceptionCode.INVALID_INPUT,
);
}
} }
const passwordHash = password ? await hashPassword(password) : undefined; const passwordHash = password ? await hashPassword(password) : undefined;
@ -89,7 +98,12 @@ export class SignInUpService {
existingUser.passwordHash, existingUser.passwordHash,
); );
assert(isValid, 'Wrong password', ForbiddenException); if (!isValid) {
throw new AuthException(
'Wrong password',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
} }
if (workspaceInviteHash) { if (workspaceInviteHash) {
@ -137,17 +151,19 @@ export class SignInUpService {
inviteHash: workspaceInviteHash, inviteHash: workspaceInviteHash,
}); });
assert( if (!workspace) {
workspace, throw new AuthException(
'This workspace inviteHash is invalid', 'Invit hash is invalid',
ForbiddenException, AuthExceptionCode.FORBIDDEN_EXCEPTION,
); );
}
assert( if (!(workspace.activationStatus === WorkspaceActivationStatus.ACTIVE)) {
workspace.activationStatus === WorkspaceActivationStatus.ACTIVE, throw new AuthException(
'Workspace is not ready to welcome new members', 'Workspace is not ready to welcome new members',
ForbiddenException, AuthExceptionCode.FORBIDDEN_EXCEPTION,
); );
}
if (existingUser) { if (existingUser) {
const updatedUser = await this.userWorkspaceService.addUserToWorkspace( const updatedUser = await this.userWorkspaceService.addUserToWorkspace(
@ -203,11 +219,12 @@ export class SignInUpService {
lastName: string; lastName: string;
picture: SignInUpServiceInput['picture']; picture: SignInUpServiceInput['picture'];
}) { }) {
assert( if (this.environmentService.get('IS_SIGN_UP_DISABLED')) {
!this.environmentService.get('IS_SIGN_UP_DISABLED'), throw new AuthException(
'Sign up is disabled', 'Sign up is disabled',
ForbiddenException, AuthExceptionCode.FORBIDDEN_EXCEPTION,
); );
}
const workspaceToCreate = this.workspaceRepository.create({ const workspaceToCreate = this.workspaceRepository.create({
displayName: '', displayName: '',

View File

@ -1,8 +1,3 @@
import {
BadRequestException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
@ -14,6 +9,7 @@ import {
AppToken, AppToken,
AppTokenType, AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity'; } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
@ -106,7 +102,7 @@ describe('TokenService', () => {
expect(result.passwordResetTokenExpiresAt).toBeDefined(); expect(result.passwordResetTokenExpiresAt).toBeDefined();
}); });
it('should throw BadRequestException if an existing valid token is found', async () => { it('should throw AuthException if an existing valid token is found', async () => {
const mockUser = { id: '1', email: 'test@example.com' } as User; const mockUser = { id: '1', email: 'test@example.com' } as User;
const mockToken = { const mockToken = {
userId: '1', userId: '1',
@ -120,18 +116,18 @@ describe('TokenService', () => {
await expect( await expect(
service.generatePasswordResetToken(mockUser.email), service.generatePasswordResetToken(mockUser.email),
).rejects.toThrow(BadRequestException); ).rejects.toThrow(AuthException);
}); });
it('should throw NotFoundException if no user is found', async () => { it('should throw AuthException if no user is found', async () => {
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null); jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
await expect( await expect(
service.generatePasswordResetToken('nonexistent@example.com'), service.generatePasswordResetToken('nonexistent@example.com'),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(AuthException);
}); });
it('should throw InternalServerErrorException if environment variable is not found', async () => { it('should throw AuthException if environment variable is not found', async () => {
const mockUser = { id: '1', email: 'test@example.com' } as User; const mockUser = { id: '1', email: 'test@example.com' } as User;
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser); jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
@ -139,7 +135,7 @@ describe('TokenService', () => {
await expect( await expect(
service.generatePasswordResetToken(mockUser.email), service.generatePasswordResetToken(mockUser.email),
).rejects.toThrow(InternalServerErrorException); ).rejects.toThrow(AuthException);
}); });
}); });
@ -181,17 +177,17 @@ describe('TokenService', () => {
expect(result).toEqual({ id: mockUser.id, email: mockUser.email }); expect(result).toEqual({ id: mockUser.id, email: mockUser.email });
}); });
it('should throw NotFoundException if token is invalid or expired', async () => { it('should throw AuthException if token is invalid or expired', async () => {
const resetToken = 'invalid-reset-token'; const resetToken = 'invalid-reset-token';
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
await expect( await expect(
service.validatePasswordResetToken(resetToken), service.validatePasswordResetToken(resetToken),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(AuthException);
}); });
it('should throw NotFoundException if user does not exist for a valid token', async () => { it('should throw AuthException if user does not exist for a valid token', async () => {
const resetToken = 'orphan-token'; const resetToken = 'orphan-token';
const hashedToken = crypto const hashedToken = crypto
.createHash('sha256') .createHash('sha256')
@ -212,10 +208,10 @@ describe('TokenService', () => {
await expect( await expect(
service.validatePasswordResetToken(resetToken), service.validatePasswordResetToken(resetToken),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(AuthException);
}); });
it('should throw NotFoundException if token is revoked', async () => { it('should throw AuthException if token is revoked', async () => {
const resetToken = 'revoked-token'; const resetToken = 'revoked-token';
const hashedToken = crypto const hashedToken = crypto
.createHash('sha256') .createHash('sha256')
@ -234,7 +230,7 @@ describe('TokenService', () => {
.mockResolvedValue(mockToken as AppToken); .mockResolvedValue(mockToken as AppToken);
await expect( await expect(
service.validatePasswordResetToken(resetToken), service.validatePasswordResetToken(resetToken),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(AuthException);
}); });
}); });
}); });

View File

@ -1,12 +1,4 @@
import { import { Injectable } from '@nestjs/common';
BadRequestException,
ForbiddenException,
Injectable,
InternalServerErrorException,
NotFoundException,
UnauthorizedException,
UnprocessableEntityException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto'; import crypto from 'crypto';
@ -24,6 +16,10 @@ import {
AppToken, AppToken,
AppTokenType, AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity'; } from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity'; import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity';
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity'; import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input'; import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
@ -45,7 +41,6 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/integrations/email/email.service'; import { EmailService } from 'src/engine/integrations/email/email.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { assert } from 'src/utils/assert';
@Injectable() @Injectable()
export class TokenService { export class TokenService {
@ -68,7 +63,13 @@ export class TokenService {
): Promise<AuthToken> { ): Promise<AuthToken> {
const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN'); const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN');
assert(expiresIn, '', InternalServerErrorException); if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
@ -77,11 +78,17 @@ export class TokenService {
}); });
if (!user) { if (!user) {
throw new NotFoundException('User is not found'); throw new AuthException(
'User is not found',
AuthExceptionCode.INVALID_INPUT,
);
} }
if (!user.defaultWorkspace) { if (!user.defaultWorkspace) {
throw new NotFoundException('User does not have a default workspace'); throw new AuthException(
'User does not have a default workspace',
AuthExceptionCode.INVALID_DATA,
);
} }
const jwtPayload: JwtPayload = { const jwtPayload: JwtPayload = {
@ -99,7 +106,13 @@ export class TokenService {
const secret = this.environmentService.get('REFRESH_TOKEN_SECRET'); const secret = this.environmentService.get('REFRESH_TOKEN_SECRET');
const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN'); const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN');
assert(expiresIn, '', InternalServerErrorException); if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const refreshTokenPayload = { const refreshTokenPayload = {
@ -130,7 +143,13 @@ export class TokenService {
const secret = this.environmentService.get('LOGIN_TOKEN_SECRET'); const secret = this.environmentService.get('LOGIN_TOKEN_SECRET');
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN'); const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
assert(expiresIn, '', InternalServerErrorException); if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload = { const jwtPayload = {
sub: email, sub: email,
@ -155,7 +174,13 @@ export class TokenService {
'SHORT_TERM_TOKEN_EXPIRES_IN', 'SHORT_TERM_TOKEN_EXPIRES_IN',
); );
assert(expiresIn, '', InternalServerErrorException); if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload = { const jwtPayload = {
sub: workspaceMemberId, sub: workspaceMemberId,
@ -212,7 +237,10 @@ export class TokenService {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
if (!token) { if (!token) {
throw new UnauthorizedException('missing authentication token'); throw new AuthException(
'missing authentication token',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} }
const decoded = await this.verifyJwt( const decoded = await this.verifyJwt(
token, token,
@ -257,22 +285,35 @@ export class TokenService {
): Promise<AuthTokens> { ): Promise<AuthTokens> {
const userExists = await this.userRepository.findBy({ id: user.id }); const userExists = await this.userRepository.findBy({ id: user.id });
assert(userExists, 'User not found', NotFoundException); if (!userExists) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const workspace = await this.workspaceRepository.findOne({ const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId }, where: { id: workspaceId },
relations: ['workspaceUsers'], relations: ['workspaceUsers'],
}); });
assert(workspace, 'workspace doesnt exist', NotFoundException); if (!workspace) {
throw new AuthException(
'workspace doesnt exist',
AuthExceptionCode.INVALID_INPUT,
);
}
assert( if (
workspace.workspaceUsers !workspace.workspaceUsers
.map((userWorkspace) => userWorkspace.userId) .map((userWorkspace) => userWorkspace.userId)
.includes(user.id), .includes(user.id)
'user does not belong to workspace', ) {
ForbiddenException, throw new AuthException(
); 'user does not belong to workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
await this.userRepository.save({ await this.userRepository.save({
id: user.id, id: user.id,
@ -293,29 +334,17 @@ export class TokenService {
async verifyAuthorizationCode( async verifyAuthorizationCode(
exchangeAuthCodeInput: ExchangeAuthCodeInput, exchangeAuthCodeInput: ExchangeAuthCodeInput,
): Promise<ExchangeAuthCode> { ): Promise<ExchangeAuthCode> {
const { authorizationCode, codeVerifier, clientSecret } = const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
exchangeAuthCodeInput;
assert( if (!authorizationCode) {
authorizationCode, throw new AuthException(
'Authorization code not found', 'Authorization code not found',
NotFoundException, AuthExceptionCode.INVALID_INPUT,
); );
}
assert(
!codeVerifier || !clientSecret,
'client secret or code verifier not found',
NotFoundException,
);
let userId = ''; let userId = '';
if (clientSecret) {
// TODO: replace this with call to third party apps table
// assert(client.secret, 'client secret code does not exist', ForbiddenException);
throw new ForbiddenException();
}
if (codeVerifier) { if (codeVerifier) {
const authorizationCodeAppToken = await this.appTokenRepository.findOne({ const authorizationCodeAppToken = await this.appTokenRepository.findOne({
where: { where: {
@ -323,17 +352,19 @@ export class TokenService {
}, },
}); });
assert( if (!authorizationCodeAppToken) {
authorizationCodeAppToken, throw new AuthException(
'Authorization code does not exist', 'Authorization code does not exist',
NotFoundException, AuthExceptionCode.INVALID_INPUT,
); );
}
assert( if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) {
authorizationCodeAppToken.expiresAt.getTime() >= Date.now(), throw new AuthException(
'Authorization code expired.', 'Authorization code expired.',
ForbiddenException, AuthExceptionCode.FORBIDDEN_EXCEPTION,
); );
}
const codeChallenge = crypto const codeChallenge = crypto
.createHash('sha256') .createHash('sha256')
@ -350,26 +381,32 @@ export class TokenService {
}, },
}); });
assert( if (!codeChallengeAppToken) {
codeChallengeAppToken, throw new AuthException(
'code verifier doesnt match the challenge', 'code verifier doesnt match the challenge',
ForbiddenException, AuthExceptionCode.FORBIDDEN_EXCEPTION,
); );
}
assert( if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) {
codeChallengeAppToken.expiresAt.getTime() >= Date.now(), throw new AuthException(
'code challenge expired.', 'code challenge expired.',
ForbiddenException, AuthExceptionCode.FORBIDDEN_EXCEPTION,
); );
}
assert( if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) {
codeChallengeAppToken.userId === authorizationCodeAppToken.userId, throw new AuthException(
'authorization code / code verifier was not created by same client', 'authorization code / code verifier was not created by same client',
ForbiddenException, AuthExceptionCode.FORBIDDEN_EXCEPTION,
); );
}
if (codeChallengeAppToken.revokedAt) { if (codeChallengeAppToken.revokedAt) {
throw new ForbiddenException('Token has been revoked.'); throw new AuthException(
'Token has been revoked.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} }
await this.appTokenRepository.save({ await this.appTokenRepository.save({
@ -386,13 +423,17 @@ export class TokenService {
}); });
if (!user) { if (!user) {
throw new NotFoundException( throw new AuthException(
'User who generated the token does not exist', 'User who generated the token does not exist',
AuthExceptionCode.INVALID_INPUT,
); );
} }
if (!user.defaultWorkspace) { if (!user.defaultWorkspace) {
throw new NotFoundException('User does not have a default workspace'); throw new AuthException(
'User does not have a default workspace',
AuthExceptionCode.INVALID_DATA,
);
} }
const accessToken = await this.generateAccessToken( const accessToken = await this.generateAccessToken(
@ -414,24 +455,35 @@ export class TokenService {
const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN'); const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN');
const jwtPayload = await this.verifyJwt(refreshToken, secret); const jwtPayload = await this.verifyJwt(refreshToken, secret);
assert( if (!(jwtPayload.jti && jwtPayload.sub)) {
jwtPayload.jti && jwtPayload.sub, throw new AuthException(
'This refresh token is malformed', 'This refresh token is malformed',
UnprocessableEntityException, AuthExceptionCode.INVALID_INPUT,
); );
}
const token = await this.appTokenRepository.findOneBy({ const token = await this.appTokenRepository.findOneBy({
id: jwtPayload.jti, id: jwtPayload.jti,
}); });
assert(token, "This refresh token doesn't exist", NotFoundException); if (!token) {
throw new AuthException(
"This refresh token doesn't exist",
AuthExceptionCode.INVALID_INPUT,
);
}
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { id: jwtPayload.sub }, where: { id: jwtPayload.sub },
relations: ['appTokens'], relations: ['appTokens'],
}); });
assert(user, 'User not found', NotFoundException); if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
// Check if revokedAt is less than coolDown // Check if revokedAt is less than coolDown
if ( if (
@ -452,8 +504,9 @@ export class TokenService {
}), }),
); );
throw new ForbiddenException( throw new AuthException(
'Suspicious activity detected, this refresh token has been revoked. All tokens has been revoked.', 'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
); );
} }
@ -464,6 +517,13 @@ export class TokenService {
accessToken: AuthToken; accessToken: AuthToken;
refreshToken: AuthToken; refreshToken: AuthToken;
}> { }> {
if (!token) {
throw new AuthException(
'Refresh token not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const { const {
user, user,
token: { id }, token: { id },
@ -502,11 +562,20 @@ export class TokenService {
); );
} catch (error) { } catch (error) {
if (error instanceof TokenExpiredError) { if (error instanceof TokenExpiredError) {
throw new UnauthorizedException('Token has expired.'); throw new AuthException(
'Token has expired.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} else if (error instanceof JsonWebTokenError) { } else if (error instanceof JsonWebTokenError) {
throw new UnauthorizedException('Token invalid.'); throw new AuthException(
'Token invalid.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
} else { } else {
throw new UnprocessableEntityException(); throw new AuthException(
'Unknown token error.',
AuthExceptionCode.INVALID_INPUT,
);
} }
} }
} }
@ -516,17 +585,23 @@ export class TokenService {
email, email,
}); });
assert(user, 'User not found', NotFoundException); if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const expiresIn = this.environmentService.get( const expiresIn = this.environmentService.get(
'PASSWORD_RESET_TOKEN_EXPIRES_IN', 'PASSWORD_RESET_TOKEN_EXPIRES_IN',
); );
assert( if (!expiresIn) {
expiresIn, throw new AuthException(
'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found', 'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found',
InternalServerErrorException, AuthExceptionCode.INTERNAL_SERVER_ERROR,
); );
}
const existingToken = await this.appTokenRepository.findOne({ const existingToken = await this.appTokenRepository.findOne({
where: { where: {
@ -543,10 +618,9 @@ export class TokenService {
{ long: true }, { long: true },
); );
assert( throw new AuthException(
false,
`Token has already been generated. Please wait for ${timeToWait} to generate again.`, `Token has already been generated. Please wait for ${timeToWait} to generate again.`,
BadRequestException, AuthExceptionCode.INVALID_INPUT,
); );
} }
@ -579,7 +653,12 @@ export class TokenService {
email, email,
}); });
assert(user, 'User not found', NotFoundException); if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL'); const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`; const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`;
@ -636,13 +715,23 @@ export class TokenService {
}, },
}); });
assert(token, 'Token is invalid', NotFoundException); if (!token) {
throw new AuthException(
'Token is invalid',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const user = await this.userRepository.findOneBy({ const user = await this.userRepository.findOneBy({
id: token.userId, id: token.userId,
}); });
assert(user, 'Token is invalid', NotFoundException); if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
return { return {
id: user.id, id: user.id,
@ -657,7 +746,12 @@ export class TokenService {
id: userId, id: userId,
}); });
assert(user, 'User not found', NotFoundException); if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
await this.appTokenRepository.update( await this.appTokenRepository.update(
{ {

View File

@ -1,8 +1,4 @@
import { import { Injectable } from '@nestjs/common';
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@ -10,12 +6,15 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { assert } from 'src/utils/assert';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity'; import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
export type JwtPayload = { sub: string; workspaceId: string; jti?: string }; export type JwtPayload = { sub: string; workspaceId: string; jti?: string };
@ -46,8 +45,12 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
let apiKey: ApiKeyWorkspaceEntity | null = null; let apiKey: ApiKeyWorkspaceEntity | null = null;
if (!workspace) { if (!workspace) {
throw new UnauthorizedException(); throw new AuthException(
'Workspace not found',
AuthExceptionCode.INVALID_INPUT,
);
} }
if (payload.jti) { if (payload.jti) {
// TODO: Check why it's not working // TODO: Check why it's not working
// const apiKeyRepository = // const apiKeyRepository =
@ -71,11 +74,12 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
apiKey = res?.[0]; apiKey = res?.[0];
assert( if (!apiKey || apiKey.revokedAt) {
apiKey && !apiKey.revokedAt, throw new AuthException(
'This API Key is revoked', 'This API Key is revoked',
ForbiddenException, AuthExceptionCode.FORBIDDEN_EXCEPTION,
); );
}
} }
if (payload.workspaceId) { if (payload.workspaceId) {
@ -84,7 +88,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
relations: ['defaultWorkspace'], relations: ['defaultWorkspace'],
}); });
if (!user) { if (!user) {
throw new UnauthorizedException(); throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
} }
} }

View File

@ -1,10 +1,13 @@
import { BadRequestException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express'; import { Request } from 'express';
import { VerifyCallback } from 'passport-google-oauth20'; import { VerifyCallback } from 'passport-google-oauth20';
import { Strategy } from 'passport-microsoft'; import { Strategy } from 'passport-microsoft';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service'; import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
export type MicrosoftRequest = Omit< export type MicrosoftRequest = Omit<
@ -60,7 +63,10 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
const email = emails?.[0]?.value ?? null; const email = emails?.[0]?.value ?? null;
if (!email) { if (!email) {
throw new BadRequestException('No email found in your Microsoft profile'); throw new AuthException(
'Email not found',
AuthExceptionCode.INVALID_INPUT,
);
} }
const user: MicrosoftRequest['user'] = { const user: MicrosoftRequest['user'] = {

View File

@ -1,3 +1,7 @@
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type'; import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
type GoogleAPIsRequestExtraParams = { type GoogleAPIsRequestExtraParams = {
@ -19,7 +23,10 @@ export const setRequestExtraParams = (
} = params; } = params;
if (!transientToken) { if (!transientToken) {
throw new Error('transientToken is required'); throw new AuthException(
'transientToken is required',
AuthExceptionCode.INVALID_INPUT,
);
} }
request.params.transientToken = transientToken; request.params.transientToken = transientToken;