mirror of
https://github.com/docmost/docmost.git
synced 2026-05-15 05:04:06 +08:00
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:
@@ -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
@@ -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'>>;
|
||||
|
||||
Reference in New Issue
Block a user