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:
@ -46,6 +46,7 @@
|
|||||||
"monaco-editor": "^0.51.0",
|
"monaco-editor": "^0.51.0",
|
||||||
"monaco-editor-auto-typings": "^0.4.5",
|
"monaco-editor-auto-typings": "^0.4.5",
|
||||||
"openid-client": "^5.7.0",
|
"openid-client": "^5.7.0",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"psl": "^1.9.0",
|
"psl": "^1.9.0",
|
||||||
"redis": "^4.7.0",
|
"redis": "^4.7.0",
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class TwoFactorMethod1737033794408 implements MigrationInterface {
|
||||||
|
name = 'TwoFactorMethod1737033794408';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "core"."twoFactorMethod" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userWorkspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_752f0250dd6824289ceddd8b054" PRIMARY KEY ("id"))`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "core"."twoFactorMethod" ADD CONSTRAINT "FK_c1044145be65a4ee65c07e0a658" FOREIGN KEY ("userWorkspaceId") REFERENCES "core"."userWorkspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "core"."twoFactorMethod" DROP CONSTRAINT "FK_c1044145be65a4ee65c07e0a658"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "core"."twoFactorMethod"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
|
|||||||
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||||
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
|
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
|
||||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||||
|
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 { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||||
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';
|
||||||
@ -47,6 +48,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
|
|||||||
BillingEntitlement,
|
BillingEntitlement,
|
||||||
PostgresCredentials,
|
PostgresCredentials,
|
||||||
WorkspaceSSOIdentityProvider,
|
WorkspaceSSOIdentityProvider,
|
||||||
|
TwoFactorMethod,
|
||||||
],
|
],
|
||||||
metadataTableName: '_typeorm_generated_columns_and_materialized_views',
|
metadataTableName: '_typeorm_generated_columns_and_materialized_views',
|
||||||
ssl: environmentService.get('PG_SSL_ALLOW_SELF_SIGNED')
|
ssl: environmentService.get('PG_SSL_ALLOW_SELF_SIGNED')
|
||||||
|
|||||||
@ -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,
|
Entity,
|
||||||
JoinColumn,
|
JoinColumn,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
Relation,
|
Relation,
|
||||||
Unique,
|
Unique,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} 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 { 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 { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
|
||||||
|
|
||||||
@Entity({ name: 'userWorkspace', schema: 'core' })
|
@Entity({ name: 'userWorkspace', schema: 'core' })
|
||||||
@ObjectType('UserWorkspace')
|
@ObjectType('UserWorkspace')
|
||||||
@ -58,4 +60,10 @@ export class UserWorkspace {
|
|||||||
@Field({ nullable: true })
|
@Field({ nullable: true })
|
||||||
@Column({ nullable: true, type: 'timestamptz' })
|
@Column({ nullable: true, type: 'timestamptz' })
|
||||||
deletedAt: Date;
|
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 { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||||
|
|
||||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
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 { 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 { 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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
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 { 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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
NestjsQueryGraphQLModule.forFeature({
|
NestjsQueryGraphQLModule.forFeature({
|
||||||
imports: [
|
imports: [
|
||||||
NestjsQueryTypeOrmModule.forFeature(
|
NestjsQueryTypeOrmModule.forFeature(
|
||||||
[User, UserWorkspace, Workspace],
|
[User, UserWorkspace, Workspace, TwoFactorMethod],
|
||||||
'core',
|
'core',
|
||||||
),
|
),
|
||||||
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
|
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
|
||||||
|
|||||||
67
yarn.lock
67
yarn.lock
@ -9847,6 +9847,54 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@otplib/core@npm:^12.0.1":
|
||||||
|
version: 12.0.1
|
||||||
|
resolution: "@otplib/core@npm:12.0.1"
|
||||||
|
checksum: 10c0/efe703d92d50cf11b39e47fc6ccd10c01d8bfbd9423724e88aecf2470f740562b2422c10b779e0b7d1d29c09f5d3d00de69200f04c8250e2adeb13a15e5dee7f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@otplib/plugin-crypto@npm:^12.0.1":
|
||||||
|
version: 12.0.1
|
||||||
|
resolution: "@otplib/plugin-crypto@npm:12.0.1"
|
||||||
|
dependencies:
|
||||||
|
"@otplib/core": "npm:^12.0.1"
|
||||||
|
checksum: 10c0/7ad0cda9c643411a13a118bcf0d031ef61a9031f794a21b549fb3f1e38334f83c5481a9f706eb5b25066325759c1a598fbc1f9c05c4620dff5addccbe2ad23ad
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@otplib/plugin-thirty-two@npm:^12.0.1":
|
||||||
|
version: 12.0.1
|
||||||
|
resolution: "@otplib/plugin-thirty-two@npm:12.0.1"
|
||||||
|
dependencies:
|
||||||
|
"@otplib/core": "npm:^12.0.1"
|
||||||
|
thirty-two: "npm:^1.0.2"
|
||||||
|
checksum: 10c0/971a6f592c0f33cb258dcb750f6f0c058cbfd45e0212ea6f29d0c75dc6c5fcb67dbc8f19c3e8ae710e93292319b8d3d15fd7281714deae9fadaab794f9d44544
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@otplib/preset-default@npm:^12.0.1":
|
||||||
|
version: 12.0.1
|
||||||
|
resolution: "@otplib/preset-default@npm:12.0.1"
|
||||||
|
dependencies:
|
||||||
|
"@otplib/core": "npm:^12.0.1"
|
||||||
|
"@otplib/plugin-crypto": "npm:^12.0.1"
|
||||||
|
"@otplib/plugin-thirty-two": "npm:^12.0.1"
|
||||||
|
checksum: 10c0/cd683861b96d04b7581534169a5a130fc049bd3188a850a1642ba4cf2a53866dedafe5201763697e38a3f0726103783baf338448153752c464fff797948dc01c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@otplib/preset-v11@npm:^12.0.1":
|
||||||
|
version: 12.0.1
|
||||||
|
resolution: "@otplib/preset-v11@npm:12.0.1"
|
||||||
|
dependencies:
|
||||||
|
"@otplib/core": "npm:^12.0.1"
|
||||||
|
"@otplib/plugin-crypto": "npm:^12.0.1"
|
||||||
|
"@otplib/plugin-thirty-two": "npm:^12.0.1"
|
||||||
|
checksum: 10c0/5a1bf8198f169182e267ab6ae3115f2441190f34a212e8232ff4b9c2c8a7253fddf3aa185ab9a6246487e9c1052b676395e80b6e0b2825fa218eb8e29bd7b14b
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@parcel/watcher-android-arm64@npm:2.4.1":
|
"@parcel/watcher-android-arm64@npm:2.4.1":
|
||||||
version: 2.4.1
|
version: 2.4.1
|
||||||
resolution: "@parcel/watcher-android-arm64@npm:2.4.1"
|
resolution: "@parcel/watcher-android-arm64@npm:2.4.1"
|
||||||
@ -38084,6 +38132,17 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"otplib@npm:^12.0.1":
|
||||||
|
version: 12.0.1
|
||||||
|
resolution: "otplib@npm:12.0.1"
|
||||||
|
dependencies:
|
||||||
|
"@otplib/core": "npm:^12.0.1"
|
||||||
|
"@otplib/preset-default": "npm:^12.0.1"
|
||||||
|
"@otplib/preset-v11": "npm:^12.0.1"
|
||||||
|
checksum: 10c0/5a6cd332ed38f9fb6915407775bb9b4bf298d4d32c7c5691291add913dc1788064ab4ca353315f8add4ea704af2cb2c970d2f2d354725c6daa0c945cd5d09811
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"outvariant@npm:1.4.0":
|
"outvariant@npm:1.4.0":
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
resolution: "outvariant@npm:1.4.0"
|
resolution: "outvariant@npm:1.4.0"
|
||||||
@ -44756,6 +44815,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"thirty-two@npm:^1.0.2":
|
||||||
|
version: 1.0.2
|
||||||
|
resolution: "thirty-two@npm:1.0.2"
|
||||||
|
checksum: 10c0/a6f8c69a153a1aa4d6948eabe004f5a38e45cb869d7d6c535df7460fc44c95c596a60efc56cce56b5e4f0988601db1298be5d1b6ae3fe12dcdeb461c9aeadd17
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"throttle-debounce@npm:^3.0.1":
|
"throttle-debounce@npm:^3.0.1":
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
resolution: "throttle-debounce@npm:3.0.1"
|
resolution: "throttle-debounce@npm:3.0.1"
|
||||||
@ -45668,6 +45734,7 @@ __metadata:
|
|||||||
monaco-editor: "npm:^0.51.0"
|
monaco-editor: "npm:^0.51.0"
|
||||||
monaco-editor-auto-typings: "npm:^0.4.5"
|
monaco-editor-auto-typings: "npm:^0.4.5"
|
||||||
openid-client: "npm:^5.7.0"
|
openid-client: "npm:^5.7.0"
|
||||||
|
otplib: "npm:^12.0.1"
|
||||||
passport: "npm:^0.7.0"
|
passport: "npm:^0.7.0"
|
||||||
psl: "npm:^1.9.0"
|
psl: "npm:^1.9.0"
|
||||||
redis: "npm:^4.7.0"
|
redis: "npm:^4.7.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user