feat(EE): MFA implementation (#1381)

* feat(EE): MFA implementation for enterprise edition
- Add TOTP-based two-factor authentication
- Add backup codes support
- Add MFA enforcement at workspace level
- Add MFA setup and challenge UI pages
- Support MFA for login and password reset flows
- Add MFA validation for secure pages
* fix types
* remove unused object
* sync
* remove unused type
* sync
* refactor: rename MFA enabled field to is_enabled
* sync
This commit is contained in:
Philip Okugbe
2025-07-25 00:18:53 +01:00
committed by GitHub
parent 8522844673
commit 662460252f
49 changed files with 2026 additions and 54 deletions
@@ -0,0 +1,39 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('user_mfa')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade').notNull(),
)
.addColumn('method', 'varchar', (col) => col.notNull().defaultTo('totp'))
.addColumn('secret', 'text', (col) => col)
.addColumn('is_enabled', 'boolean', (col) => col.defaultTo(false))
.addColumn('backup_codes', sql`text[]`, (col) => col)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addUniqueConstraint('user_mfa_user_id_unique', ['user_id'])
.execute();
// Add MFA policy columns to workspaces
await db.schema
.alterTable('workspaces')
.addColumn('enforce_mfa', 'boolean', (col) => col.defaultTo(false))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('workspaces').dropColumn('enforce_mfa').execute();
await db.schema.dropTable('user_mfa').execute();
}
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Users } from '@docmost/db/types/db';
import { DB, Users } from '@docmost/db/types/db';
import { hashPassword } from '../../../common/helpers';
import { dbOrTx } from '@docmost/db/utils';
import {
@@ -11,7 +11,8 @@ import {
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { sql } from 'kysely';
import { ExpressionBuilder, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
@Injectable()
export class UserRepo {
@@ -40,6 +41,7 @@ export class UserRepo {
workspaceId: string,
opts?: {
includePassword?: boolean;
includeUserMfa?: boolean;
trx?: KyselyTransaction;
},
): Promise<User> {
@@ -48,6 +50,7 @@ export class UserRepo {
.selectFrom('users')
.select(this.baseFields)
.$if(opts?.includePassword, (qb) => qb.select('password'))
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
.where('id', '=', userId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
@@ -58,6 +61,7 @@ export class UserRepo {
workspaceId: string,
opts?: {
includePassword?: boolean;
includeUserMfa?: boolean;
trx?: KyselyTransaction;
},
): Promise<User> {
@@ -66,6 +70,7 @@ export class UserRepo {
.selectFrom('users')
.select(this.baseFields)
.$if(opts?.includePassword, (qb) => qb.select('password'))
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
@@ -177,4 +182,18 @@ export class UserRepo {
.returning(this.baseFields)
.executeTakeFirst();
}
withUserMfa(eb: ExpressionBuilder<DB, 'users'>) {
return jsonObjectFrom(
eb
.selectFrom('userMfa')
.select([
'userMfa.id',
'userMfa.method',
'userMfa.isEnabled',
'userMfa.createdAt',
])
.whereRef('userMfa.userId', '=', 'users.id'),
).as('mfa');
}
}
@@ -32,6 +32,7 @@ export class WorkspaceRepo {
'trialEndAt',
'enforceSso',
'plan',
'enforceMfa',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}
+14
View File
@@ -247,6 +247,18 @@ export interface Spaces {
workspaceId: string;
}
export interface UserMfa {
backupCodes: string[] | null;
createdAt: Generated<Timestamp>;
id: Generated<string>;
isEnabled: Generated<boolean | null>;
method: Generated<string>;
secret: string | null;
updatedAt: Generated<Timestamp>;
userId: string;
workspaceId: string;
}
export interface Users {
avatarUrl: string | null;
createdAt: Generated<Timestamp>;
@@ -300,6 +312,7 @@ export interface Workspaces {
deletedAt: Timestamp | null;
description: string | null;
emailDomains: Generated<string[] | null>;
enforceMfa: Generated<boolean | null>;
enforceSso: Generated<boolean>;
hostname: string | null;
id: Generated<string>;
@@ -329,6 +342,7 @@ export interface DB {
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
userMfa: UserMfa;
users: Users;
userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations;
@@ -18,6 +18,7 @@ import {
AuthAccounts,
Shares,
FileTasks,
UserMfa as _UserMFA,
} from './db';
// Workspace
@@ -113,3 +114,8 @@ export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;
export type FileTask = Selectable<FileTasks>;
export type InsertableFileTask = Insertable<FileTasks>;
export type UpdatableFileTask = Updateable<Omit<FileTasks, 'id'>>;
// UserMFA
export type UserMFA = Selectable<_UserMFA>;
export type InsertableUserMFA = Insertable<_UserMFA>;
export type UpdatableUserMFA = Updateable<Omit<_UserMFA, 'id'>>;