feat(invitation): Improve invitation flow - Milestone 2 (#6804)

From PR: #6626 
Resolves #6763 
Resolves #6055 
Resolves #6782

## GTK
I retain the 'Invite by link' feature to prevent any breaking changes.
We could make the invitation by link optional through an admin setting,
allowing users to rely solely on personal invitations.

## Todo
- [x] Add an expiration date to an invitation
- [x] Allow to renew an invitation to postpone the expiration date
- [x] Refresh the UI
- [x] Add the new personal token in the link sent to new user
- [x] Display an error if a user tries to use an expired invitation
- [x] Display an error if a user uses another mail than the one in the
invitation

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Antoine Moreaux
2024-09-18 23:27:31 +02:00
committed by GitHub
parent ad18c44f25
commit 89c97993e3
81 changed files with 1726 additions and 363 deletions

View File

@ -12,7 +12,8 @@ import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controller
import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@ -50,6 +51,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
ConnectedAccountWorkspaceEntity,
]),
HttpModule,
TokenModule,
UserWorkspaceModule,
WorkspaceModule,
OnboardingModule,
@ -65,9 +67,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
providers: [
SignInUpService,
AuthService,
TokenService,
JwtAuthStrategy,
AuthResolver,
TokenService,
GoogleAPIsService,
AppTokenService,
],

View File

@ -1,17 +1,17 @@
import { CanActivate } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CanActivate } from '@nestjs/common';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthResolver } from './auth.resolver';
import { TokenService } from './services/token.service';
import { AuthService } from './services/auth.service';
import { TokenService } from './token/services/token.service';
describe('AuthResolver', () => {
let resolver: AuthResolver;

View File

@ -37,7 +37,7 @@ import { VerifyInput } from './dto/verify.input';
import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
import { AuthService } from './services/auth.service';
import { TokenService } from './services/token.service';
import { TokenService } from './token/services/token.service';
@Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter)

View File

@ -17,10 +17,10 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
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 { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
@Controller('auth/google-apis')
@UseFilters(AuthRestApiExceptionFilter)

View File

@ -13,8 +13,8 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
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 { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
@Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter)
@ -34,8 +34,14 @@ export class GoogleAuthController {
@Get('redirect')
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard)
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
const { firstName, lastName, email, picture, workspaceInviteHash } =
req.user;
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
} = req.user;
const user = await this.authService.signInUp({
email,
@ -43,6 +49,7 @@ export class GoogleAuthController {
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
fromSSO: true,
});

View File

@ -14,8 +14,8 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
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 { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
@Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
@ -39,8 +39,14 @@ export class MicrosoftAuthController {
@Req() req: MicrosoftRequest,
@Res() res: Response,
) {
const { firstName, lastName, email, picture, workspaceInviteHash } =
req.user;
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
} = req.user;
const user = await this.authService.signInUp({
email,
@ -48,6 +54,7 @@ export class MicrosoftAuthController {
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
fromSSO: true,
});

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
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/token/services/token.service';
import { VerifyAuthController } from './verify-auth.controller';

View File

@ -4,7 +4,7 @@ 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/token/services/token.service';
@Controller('auth/verify')
@UseFilters(AuthRestApiExceptionFilter)

View File

@ -19,6 +19,11 @@ export class SignUpInput {
@IsOptional()
workspaceInviteHash?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
workspacePersonalInviteToken?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()

View File

@ -0,0 +1,12 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
@ArgsType()
export class WorkspaceInviteTokenInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
@MinLength(10)
inviteToken: string;
}

View File

@ -12,11 +12,20 @@ export class GoogleOauthGuard extends AuthGuard('google') {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const workspaceInviteHash = request.query.inviteHash;
const workspacePersonalInviteToken = request.query.inviteToken;
if (workspaceInviteHash && typeof workspaceInviteHash === 'string') {
request.params.workspaceInviteHash = workspaceInviteHash;
}
if (
workspacePersonalInviteToken &&
typeof workspacePersonalInviteToken === 'string'
) {
request.params.workspacePersonalInviteToken =
workspacePersonalInviteToken;
}
return (await super.canActivate(context)) as boolean;
}
}

View File

@ -12,11 +12,20 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const workspaceInviteHash = request.query.inviteHash;
const workspacePersonalInviteToken = request.query.inviteToken;
if (workspaceInviteHash && typeof workspaceInviteHash === 'string') {
request.params.workspaceInviteHash = workspaceInviteHash;
}
if (
workspacePersonalInviteToken &&
typeof workspacePersonalInviteToken === 'string'
) {
request.params.workspacePersonalInviteToken =
workspacePersonalInviteToken;
}
return (await super.canActivate(context)) as boolean;
}
}

View File

@ -1,17 +1,17 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { AuthService } from './auth.service';
import { TokenService } from './token.service';
describe('AuthService', () => {
let service: AuthService;

View File

@ -32,14 +32,13 @@ 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 { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TokenService } from './token.service';
@Injectable()
export class AuthService {
constructor(
@ -94,6 +93,7 @@ export class AuthService {
email,
password,
workspaceInviteHash,
workspacePersonalInviteToken,
firstName,
lastName,
picture,
@ -104,6 +104,7 @@ export class AuthService {
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string | null;
workspacePersonalInviteToken?: string | null;
picture?: string | null;
fromSSO: boolean;
}) {
@ -113,6 +114,7 @@ export class AuthService {
firstName,
lastName,
workspaceInviteHash,
workspacePersonalInviteToken,
picture,
fromSSO,
});

View File

@ -9,6 +9,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
describe('SignInUpService', () => {
let service: SignInUpService;
@ -29,6 +30,10 @@ describe('SignInUpService', () => {
provide: getRepositoryToken(User, 'core'),
useValue: {},
},
{
provide: getRepositoryToken(AppToken, 'core'),
useValue: {},
},
{
provide: UserWorkspaceService,
useValue: {},

View File

@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import FileType from 'file-type';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { isDefined } from 'class-validator';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
@ -27,6 +28,7 @@ import {
} from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { getImageBufferFromUrl } from 'src/utils/image';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
export type SignInUpServiceInput = {
email: string;
@ -34,6 +36,7 @@ export type SignInUpServiceInput = {
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string | null;
workspacePersonalInviteToken?: string | null;
picture?: string | null;
fromSSO: boolean;
};
@ -45,6 +48,8 @@ export class SignInUpService {
private readonly fileUploadService: FileUploadService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly userWorkspaceService: UserWorkspaceService,
@ -56,6 +61,7 @@ export class SignInUpService {
async signInUp({
email,
workspaceInviteHash,
workspacePersonalInviteToken,
password,
firstName,
lastName,
@ -111,6 +117,7 @@ export class SignInUpService {
email,
passwordHash,
workspaceInviteHash,
workspacePersonalInviteToken,
firstName,
lastName,
picture,
@ -134,6 +141,7 @@ export class SignInUpService {
email,
passwordHash,
workspaceInviteHash,
workspacePersonalInviteToken,
firstName,
lastName,
picture,
@ -141,19 +149,25 @@ export class SignInUpService {
}: {
email: string;
passwordHash: string | undefined;
workspaceInviteHash: string;
workspaceInviteHash: string | null;
workspacePersonalInviteToken: string | null | undefined;
firstName: string;
lastName: string;
picture: SignInUpServiceInput['picture'];
existingUser: User | null;
}) {
const workspace = await this.workspaceRepository.findOneBy({
inviteHash: workspaceInviteHash,
const isNewUser = !isDefined(existingUser);
let user = existingUser;
const workspace = await this.findWorkspaceAndValidateInvitation({
workspacePersonalInviteToken,
workspaceInviteHash,
email,
});
if (!workspace) {
throw new AuthException(
'Invit hash is invalid',
'Workspace not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
@ -165,32 +179,76 @@ export class SignInUpService {
);
}
if (existingUser) {
const updatedUser = await this.userWorkspaceService.addUserToWorkspace(
existingUser,
workspace,
);
if (isNewUser) {
const imagePath = await this.uploadPicture(picture, workspace.id);
return Object.assign(existingUser, updatedUser);
const userToCreate = this.userRepository.create({
email: email,
firstName: firstName,
lastName: lastName,
defaultAvatarUrl: imagePath,
canImpersonate: false,
passwordHash,
defaultWorkspace: workspace,
});
user = await this.userRepository.save(userToCreate);
}
const imagePath = await this.uploadPicture(picture, workspace.id);
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const userToCreate = this.userRepository.create({
email: email,
firstName: firstName,
lastName: lastName,
defaultAvatarUrl: imagePath,
canImpersonate: false,
passwordHash,
defaultWorkspace: workspace,
});
const updatedUser = workspacePersonalInviteToken
? await this.userWorkspaceService.addUserToWorkspaceByInviteToken(
workspacePersonalInviteToken,
user,
)
: await this.userWorkspaceService.addUserToWorkspace(user, workspace);
const user = await this.userRepository.save(userToCreate);
if (isNewUser) {
await this.activateOnboardingForNewUser(user, workspace, {
firstName,
lastName,
});
}
await this.userWorkspaceService.create(user.id, workspace.id);
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
return Object.assign(user, updatedUser);
}
private async findWorkspaceAndValidateInvitation({
workspacePersonalInviteToken,
workspaceInviteHash,
email,
}) {
if (!workspacePersonalInviteToken && !workspaceInviteHash) {
throw new Error('No invite token or hash provided');
}
if (!workspacePersonalInviteToken && workspaceInviteHash) {
return (
(await this.workspaceRepository.findOneBy({
inviteHash: workspaceInviteHash,
})) ?? undefined
);
}
const appToken = await this.userWorkspaceService.validateInvitation(
workspacePersonalInviteToken,
email,
);
return appToken?.workspace;
}
private async activateOnboardingForNewUser(
user: User,
workspace: Workspace,
{ firstName, lastName }: { firstName: string; lastName: string },
) {
await this.onboardingService.setOnboardingConnectAccountPending({
userId: user.id,
workspaceId: workspace.id,
@ -204,8 +262,6 @@ export class SignInUpService {
value: true,
});
}
return user;
}
private async signUpOnNewWorkspace({

View File

@ -16,6 +16,7 @@ export type GoogleRequest = Omit<
email: string;
picture: string | null;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
};
};
@ -36,6 +37,12 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
...options,
state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash,
...(req.params.workspacePersonalInviteToken
? {
workspacePersonalInviteToken:
req.params.workspacePersonalInviteToken,
}
: {}),
}),
};
@ -61,6 +68,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
lastName: name.familyName,
picture: photos?.[0]?.value,
workspaceInviteHash: state.workspaceInviteHash,
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
};
done(null, user);

View File

@ -20,6 +20,7 @@ export type MicrosoftRequest = Omit<
email: string;
picture: string | null;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
};
};
@ -40,6 +41,12 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
...options,
state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash,
...(req.params.workspacePersonalInviteToken
? {
workspacePersonalInviteToken:
req.params.workspacePersonalInviteToken,
}
: {}),
}),
};
@ -75,6 +82,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
lastName: name.familyName,
picture: photos?.[0]?.value,
workspaceInviteHash: state.workspaceInviteHash,
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
};
done(null, user);

View File

@ -175,6 +175,33 @@ export class TokenService {
};
}
async generateInvitationToken(workspaceId: string, email: string) {
const expiresIn = this.environmentService.get(
'INVITATION_TOKEN_EXPIRES_IN',
);
if (!expiresIn) {
throw new AuthException(
'Expiration time for invitation token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const invitationToken = this.appTokenRepository.create({
workspaceId,
expiresAt,
type: AppTokenType.InvitationToken,
value: crypto.randomBytes(32).toString('hex'),
context: {
email,
},
});
return this.appTokenRepository.save(invitationToken);
}
async generateLoginToken(email: string): Promise<AuthToken> {
const secret = this.environmentService.get('LOGIN_TOKEN_SECRET');
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
@ -416,7 +443,7 @@ export class TokenService {
},
});
if (!codeChallengeAppToken) {
if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
throw new AuthException(
'code verifier doesnt match the challenge',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
@ -750,7 +777,7 @@ export class TokenService {
},
});
if (!token) {
if (!token || !token.userId) {
throw new AuthException(
'Token is invalid',
AuthExceptionCode.FORBIDDEN_EXCEPTION,

View File

@ -0,0 +1,26 @@
/* eslint-disable no-restricted-imports */
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@Module({
imports: [
JwtModule,
TypeOrmModule.forFeature([User, AppToken, Workspace], 'core'),
TypeORMModule,
DataSourceModule,
EmailModule,
],
providers: [TokenService, JwtAuthStrategy],
exports: [TokenService],
})
export class TokenModule {}