diff --git a/api/src/controllers/users.ts b/api/src/controllers/users.ts index b9c91d2673..2f6f7436cf 100644 --- a/api/src/controllers/users.ts +++ b/api/src/controllers/users.ts @@ -4,9 +4,10 @@ import { InvalidCredentialsException, ForbiddenException, InvalidPayloadExceptio import { respond } from '../middleware/respond'; import useCollection from '../middleware/use-collection'; import { validateBatch } from '../middleware/validate-batch'; -import { AuthenticationService, MetaService, UsersService, TFAService } from '../services'; +import { AuthenticationService, MetaService, UsersService, RolesService, TFAService } from '../services'; import { PrimaryKey } from '../types'; import asyncHandler from '../utils/async-handler'; +import { Role } from '@directus/shared/types'; const router = express.Router(); @@ -354,6 +355,37 @@ router.post( throw new InvalidPayloadException(`"otp" is required`); } + // Override permissions only when enforce TFA is enabled in role + if (req.accountability.role) { + const rolesService = new RolesService({ + schema: req.schema, + }); + const role = (await rolesService.readOne(req.accountability.role)) as Role; + + if (role && role.enforce_tfa) { + const existingPermission = await req.accountability.permissions?.find( + (p) => p.collection === 'directus_users' && p.action === 'update' + ); + + if (existingPermission) { + existingPermission.fields = ['tfa_secret']; + existingPermission.permissions = { id: { _eq: req.accountability.user } }; + existingPermission.presets = null; + existingPermission.validation = null; + } else { + (req.accountability.permissions || (req.accountability.permissions = [])).push({ + action: 'update', + collection: 'directus_users', + fields: ['tfa_secret'], + permissions: { id: { _eq: req.accountability.user } }, + presets: null, + role: req.accountability.role, + validation: null, + }); + } + } + } + const service = new TFAService({ accountability: req.accountability, schema: req.schema, @@ -377,6 +409,37 @@ router.post( throw new InvalidPayloadException(`"otp" is required`); } + // Override permissions only when enforce TFA is enabled in role + if (req.accountability.role) { + const rolesService = new RolesService({ + schema: req.schema, + }); + const role = (await rolesService.readOne(req.accountability.role)) as Role; + + if (role && role.enforce_tfa) { + const existingPermission = await req.accountability.permissions?.find( + (p) => p.collection === 'directus_users' && p.action === 'update' + ); + + if (existingPermission) { + existingPermission.fields = ['tfa_secret']; + existingPermission.permissions = { id: { _eq: req.accountability.user } }; + existingPermission.presets = null; + existingPermission.validation = null; + } else { + (req.accountability.permissions || (req.accountability.permissions = [])).push({ + action: 'update', + collection: 'directus_users', + fields: ['tfa_secret'], + permissions: { id: { _eq: req.accountability.user } }, + presets: null, + role: req.accountability.role, + validation: null, + }); + } + } + } + const service = new TFAService({ accountability: req.accountability, schema: req.schema, @@ -394,4 +457,26 @@ router.post( respond ); +router.post( + '/:pk/tfa/disable', + asyncHandler(async (req, _res, next) => { + if (!req.accountability?.user) { + throw new InvalidCredentialsException(); + } + + if (!req.accountability.admin || !req.params.pk) { + throw new ForbiddenException(); + } + + const service = new TFAService({ + accountability: req.accountability, + schema: req.schema, + }); + + await service.disableTFA(req.params.pk); + return next(); + }), + respond +); + export default router; diff --git a/app/src/app.vue b/app/src/app.vue index 07d67e4c86..b20d92cad5 100644 --- a/app/src/app.vue +++ b/app/src/app.vue @@ -22,12 +22,13 @@ + + diff --git a/app/src/stores/user.ts b/app/src/stores/user.ts index 2c3c11cbe1..55beec8535 100644 --- a/app/src/stores/user.ts +++ b/app/src/stores/user.ts @@ -42,10 +42,12 @@ export const useUserStore = defineStore({ 'email', 'last_page', 'theme', + 'tfa_secret', 'avatar.id', 'role.admin_access', 'role.app_access', 'role.id', + 'role.enforce_tfa', ]; const { data } = await api.get(`/users/me`, { params: { fields } }); diff --git a/packages/shared/src/types/users.ts b/packages/shared/src/types/users.ts index be9711413d..ba14b8355d 100644 --- a/packages/shared/src/types/users.ts +++ b/packages/shared/src/types/users.ts @@ -3,7 +3,7 @@ export type Role = { name: string; description: string; icon: string; - enforce_2fa: null | boolean; + enforce_tfa: null | boolean; external_id: null | string; ip_whitelist: string[]; app_access: boolean; @@ -27,7 +27,7 @@ export type User = { last_login: string; last_page: string; external_id: string; - '2fa_secret': string; + tfa_secret: string; theme: 'auto' | 'dark' | 'light'; role: Role; password_reset_token: string | null; diff --git a/packages/specs/src/paths/roles/role.yaml b/packages/specs/src/paths/roles/role.yaml index ae4a3377db..a43b87e06b 100644 --- a/packages/specs/src/paths/roles/role.yaml +++ b/packages/specs/src/paths/roles/role.yaml @@ -37,7 +37,7 @@ patch: description: description: Description of the role. type: string - enforce_2fa: + enforce_tfa: description: Whether or not this role enforces the use of 2FA. type: boolean external_id: diff --git a/packages/specs/src/paths/roles/roles.yaml b/packages/specs/src/paths/roles/roles.yaml index bd69e4e6c2..4288bafb05 100644 --- a/packages/specs/src/paths/roles/roles.yaml +++ b/packages/specs/src/paths/roles/roles.yaml @@ -48,7 +48,7 @@ post: description: description: Description of the role. type: string - enforce_2fa: + enforce_tfa: description: Whether or not this role enforces the use of 2FA. type: boolean external_id: