Feat/2fa (#9634)
# Description Closes #7003 Implements 2FA with TOTP. >[!WARNING] > This is a draft PR, with only partial changes, made as a mean of discussion about #7003 (it's easier to reason about real code) ## Behaviour - a `totpSecret` is stored for each user - use [`otplib`](https://github.com/yeojz/otplib/tree/master) to create a QR code and to validate an `otp` against an `totpSecret` (great [demo website](https://otplib.yeojz.dev/) by `otplib`) - OTP is asked upon each login attempt ## Source Inspired by: - [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) - Cal.com's implementation of 2FA, namely - [raising a 401](c21ba636d2/packages/features/auth/lib/next-auth-options.ts (L188-L190)) when missing OTP and 2FA is enabled, with a [specific error code](c21ba636d2/packages/features/auth/lib/ErrorCode.ts (L9)) - [catching the 401](c21ba636d2/apps/web/modules/auth/login-view.tsx (L160)) in the frontend and [displaying](c21ba636d2/apps/web/modules/auth/login-view.tsx (L276)) the OTP input ## Remaining - [ ] encrypt `totpSecret` at rest using a symetric algorithm --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@ -0,0 +1,49 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
|
||||
@Entity({ name: 'twoFactorMethod', schema: 'core' })
|
||||
@ObjectType('TwoFactorMethod')
|
||||
export class TwoFactorMethod {
|
||||
@Field()
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field({ nullable: false })
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
userWorkspaceId: string;
|
||||
|
||||
@Field(() => UserWorkspace)
|
||||
@ManyToOne(
|
||||
() => UserWorkspace,
|
||||
(userWorkspace) => userWorkspace.twoFactorMethods,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: 'userWorkspaceId' })
|
||||
userWorkspace: Relation<UserWorkspace>;
|
||||
|
||||
@Field()
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Field()
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@Column({ nullable: true, type: 'timestamptz' })
|
||||
deletedAt: Date;
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
|
||||
import { TwoFactorMethod } from './two-factor-method.entity';
|
||||
import { TwoFactorMethodService } from './two-factor-method.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TwoFactorMethod, UserWorkspace], 'core')],
|
||||
providers: [TwoFactorMethodService],
|
||||
exports: [TwoFactorMethodService],
|
||||
})
|
||||
export class TwoFactorMethodModule {}
|
||||
@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TwoFactorMethod } from './two-factor-method.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorMethodService {
|
||||
constructor(
|
||||
@InjectRepository(TwoFactorMethod)
|
||||
private readonly twoFactorMethodRepository: Repository<TwoFactorMethod>,
|
||||
) {}
|
||||
|
||||
async createTwoFactorMethod(
|
||||
userWorkspaceId: string,
|
||||
): Promise<TwoFactorMethod> {
|
||||
const twoFactorMethod = this.twoFactorMethodRepository.create({
|
||||
userWorkspace: { id: userWorkspaceId },
|
||||
});
|
||||
|
||||
return this.twoFactorMethodRepository.save(twoFactorMethod);
|
||||
}
|
||||
|
||||
async findAll(): Promise<TwoFactorMethod[]> {
|
||||
return this.twoFactorMethodRepository.find();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<TwoFactorMethod | null> {
|
||||
return this.twoFactorMethodRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.twoFactorMethodRepository.delete(id);
|
||||
}
|
||||
}
|
||||
@ -7,15 +7,17 @@ import {
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { TwoFactorMethod } from 'src/engine/core-modules/two-factor-method/two-factor-method.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@Entity({ name: 'userWorkspace', schema: 'core' })
|
||||
@ObjectType('UserWorkspace')
|
||||
@ -58,4 +60,10 @@ export class UserWorkspace {
|
||||
@Field({ nullable: true })
|
||||
@Column({ nullable: true, type: 'timestamptz' })
|
||||
deletedAt: Date;
|
||||
|
||||
@OneToMany(
|
||||
() => TwoFactorMethod,
|
||||
(twoFactorMethod) => twoFactorMethod.userWorkspace,
|
||||
)
|
||||
twoFactorMethods: Relation<TwoFactorMethod[]>;
|
||||
}
|
||||
|
||||
@ -4,22 +4,23 @@ import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { TwoFactorMethod } from 'src/engine/core-modules/two-factor-method/two-factor-method.entity';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature(
|
||||
[User, UserWorkspace, Workspace],
|
||||
[User, UserWorkspace, Workspace, TwoFactorMethod],
|
||||
'core',
|
||||
),
|
||||
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
|
||||
|
||||
Reference in New Issue
Block a user