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
This commit is contained in:
Philipinho
2025-07-17 20:52:07 -07:00
parent 44e592763d
commit 8cd4b25dbc
49 changed files with 2051 additions and 54 deletions
@@ -0,0 +1,41 @@
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(50)', (col) =>
col.notNull().defaultTo('totp'),
)
.addColumn('secret', 'varchar(255)', (col) => col)
.addColumn('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.enabled',
'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) {}
+34
View File
@@ -62,6 +62,7 @@ export interface AuthProviders {
deletedAt: Timestamp | null;
id: Generated<string>;
isEnabled: Generated<boolean>;
isGroupSyncEnabled: Generated<boolean>;
name: string;
oidcClientId: string | null;
oidcClientSecret: string | null;
@@ -122,6 +123,7 @@ export interface Comments {
pageId: string;
parentCommentId: string | null;
resolvedAt: Timestamp | null;
resolvedById: string | null;
selection: string | null;
type: string | null;
workspaceId: string;
@@ -165,6 +167,23 @@ export interface GroupUsers {
userId: string;
}
export interface Notifications {
actorId: string | null;
context: Generated<Json | null>;
createdAt: Generated<Timestamp>;
deduplicationKey: string | null;
emailSentAt: Timestamp | null;
entityId: string;
entityType: string;
id: Generated<string>;
readAt: Timestamp | null;
recipientId: string;
spaceId: string | null;
type: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface PageHistory {
content: Json | null;
coverPhoto: string | null;
@@ -247,6 +266,18 @@ export interface Spaces {
workspaceId: string;
}
export interface UserMfa {
backupCodes: string[] | null;
createdAt: Generated<Timestamp>;
enabled: Generated<boolean | null>;
id: Generated<string>;
method: Generated<string>;
secret: string | null;
updatedAt: Generated<Timestamp>;
userId: string;
workspaceId: string;
}
export interface Users {
avatarUrl: string | null;
createdAt: Generated<Timestamp>;
@@ -305,6 +336,7 @@ export interface Workspaces {
id: Generated<string>;
licenseKey: string | null;
logo: string | null;
enforceMfa: Generated<boolean | null>;
name: string | null;
plan: string | null;
settings: Json | null;
@@ -324,11 +356,13 @@ export interface DB {
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
notifications: Notifications;
pageHistory: PageHistory;
pages: Pages;
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'>>;