diff --git a/api/app/Http/Resources/Admin/AdminUserResource.php b/api/app/Http/Resources/Admin/AdminUserResource.php index 8010c970..a2e258c1 100644 --- a/api/app/Http/Resources/Admin/AdminUserResource.php +++ b/api/app/Http/Resources/Admin/AdminUserResource.php @@ -24,6 +24,7 @@ final class AdminUserResource extends JsonResource 'created_at' => $this->created_at->toIso8601String(), 'roles' => $this->getRoleNames()->values()->all(), 'is_super_admin' => $this->hasRole('super_admin'), + 'mfa_enabled' => (bool) $this->mfa_enabled, 'organisations' => $this->whenLoaded('organisations', fn () => $this->organisations->map(fn ($org) => [ 'id' => $org->id, diff --git a/apps/app/auto-imports.d.ts b/apps/app/auto-imports.d.ts index 8adf5b53..d1511d10 100644 --- a/apps/app/auto-imports.d.ts +++ b/apps/app/auto-imports.d.ts @@ -52,9 +52,11 @@ declare global { const extendRef: typeof import('@vueuse/core')['extendRef'] const formatDate: typeof import('./src/@core/utils/formatters')['formatDate'] const formatDateToMonthShort: typeof import('./src/@core/utils/formatters')['formatDateToMonthShort'] + const generateDeviceFingerprint: typeof import('./src/utils/deviceFingerprint')['generateDeviceFingerprint'] const getActivePinia: typeof import('pinia')['getActivePinia'] const getCurrentInstance: typeof import('vue')['getCurrentInstance'] const getCurrentScope: typeof import('vue')['getCurrentScope'] + const getDeviceName: typeof import('./src/utils/deviceFingerprint')['getDeviceName'] const h: typeof import('vue')['h'] const hexToRgb: typeof import('./src/@core/utils/colorConverter')['hexToRgb'] const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch'] @@ -421,9 +423,11 @@ declare module 'vue' { readonly extendRef: UnwrapRef readonly formatDate: UnwrapRef readonly formatDateToMonthShort: UnwrapRef + readonly generateDeviceFingerprint: UnwrapRef readonly getActivePinia: UnwrapRef readonly getCurrentInstance: UnwrapRef readonly getCurrentScope: UnwrapRef + readonly getDeviceName: UnwrapRef readonly h: UnwrapRef readonly hexToRgb: UnwrapRef readonly ignorableWatch: UnwrapRef diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 02a2e69c..a9583049 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -70,6 +70,10 @@ declare module 'vue' { ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default'] InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default'] InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default'] + MfaChallengeCard: typeof import('./src/components/auth/MfaChallengeCard.vue')['default'] + MfaDisableDialog: typeof import('./src/components/settings/MfaDisableDialog.vue')['default'] + MfaEmailSetupDialog: typeof import('./src/components/settings/MfaEmailSetupDialog.vue')['default'] + MfaTotpSetupDialog: typeof import('./src/components/settings/MfaTotpSetupDialog.vue')['default'] MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default'] Notifications: typeof import('./src/@core/components/Notifications.vue')['default'] OrganisationSwitcher: typeof import('./src/components/layout/OrganisationSwitcher.vue')['default'] diff --git a/apps/app/package.json b/apps/app/package.json index f0b695b9..9fcae881 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -40,6 +40,7 @@ "ofetch": "1.5.0", "pinia": "3.0.3", "prismjs": "1.30.0", + "qrcode": "^1.5.4", "roboto-fontface": "0.10.0", "shepherd.js": "13.0.3", "ufo": "1.6.1", @@ -84,6 +85,7 @@ "@tiptap/extension-underline": "^2.27.1", "@types/mapbox-gl": "3.4.1", "@types/node": "24.9.2", + "@types/qrcode": "^1.5.6", "@types/webfontloader": "1.6.38", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml index 68cc6394..8eae7e09 100644 --- a/apps/app/pnpm-lock.yaml +++ b/apps/app/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: prismjs: specifier: 1.30.0 version: 1.30.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 roboto-fontface: specifier: 0.10.0 version: 0.10.0 @@ -217,6 +220,9 @@ importers: '@types/node': specifier: 24.9.2 version: 24.9.2 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/webfontloader': specifier: 1.6.38 version: 1.6.38 @@ -1435,6 +1441,9 @@ packages: '@types/pbf@3.0.5': resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -2059,6 +2068,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001752: resolution: {integrity: sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g==} @@ -2126,6 +2139,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2267,6 +2283,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2316,6 +2336,9 @@ packages: resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3720,6 +3743,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3872,6 +3899,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3940,6 +3972,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -4054,6 +4089,9 @@ packages: resolution: {integrity: sha512-owllqNuDDEimQat7EPG0tH7JjO090xKNzUtYz6X+Sk2BXDnOCilDdNLwjWeFywG9xkJul1ULvtUQa9O4pUaY0w==} engines: {node: '>=4.0.0'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -4816,6 +4854,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -4856,6 +4897,9 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4875,10 +4919,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -6050,6 +6102,10 @@ snapshots: '@types/pbf@3.0.5': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.9.2 + '@types/semver@7.7.1': {} '@types/statuses@2.0.6': {} @@ -6825,6 +6881,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001752: {} case-police@0.6.1: {} @@ -6899,6 +6957,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -7025,6 +7089,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-is@0.1.4: {} deepmerge-ts@5.1.0: {} @@ -7064,6 +7130,8 @@ snapshots: diff-sequences@27.5.1: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -8755,6 +8823,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-html@1.8.0: @@ -8936,6 +9006,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + quansync@0.2.11: {} querystringify@2.2.0: {} @@ -9013,6 +9089,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requires-port@1.0.0: {} resolve-from@4.0.0: {} @@ -9140,6 +9218,8 @@ snapshots: serialize-to-js@3.1.2: {} + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -10073,6 +10153,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -10118,6 +10200,8 @@ snapshots: xml-name-validator@4.0.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -10131,8 +10215,27 @@ snapshots: yaml@2.8.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/apps/app/src/components/auth/MfaChallengeCard.vue b/apps/app/src/components/auth/MfaChallengeCard.vue new file mode 100644 index 00000000..7345e59f --- /dev/null +++ b/apps/app/src/components/auth/MfaChallengeCard.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/apps/app/src/components/settings/MfaDisableDialog.vue b/apps/app/src/components/settings/MfaDisableDialog.vue new file mode 100644 index 00000000..4b3ab61b --- /dev/null +++ b/apps/app/src/components/settings/MfaDisableDialog.vue @@ -0,0 +1,115 @@ + + + diff --git a/apps/app/src/components/settings/MfaEmailSetupDialog.vue b/apps/app/src/components/settings/MfaEmailSetupDialog.vue new file mode 100644 index 00000000..c76c481a --- /dev/null +++ b/apps/app/src/components/settings/MfaEmailSetupDialog.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/apps/app/src/components/settings/MfaTotpSetupDialog.vue b/apps/app/src/components/settings/MfaTotpSetupDialog.vue new file mode 100644 index 00000000..26462c00 --- /dev/null +++ b/apps/app/src/components/settings/MfaTotpSetupDialog.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/apps/app/src/composables/api/useMfa.ts b/apps/app/src/composables/api/useMfa.ts new file mode 100644 index 00000000..abb52781 --- /dev/null +++ b/apps/app/src/composables/api/useMfa.ts @@ -0,0 +1,188 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { apiClient } from '@/lib/axios' +import type { + MfaConfirmResponse, + MfaStatus, + MfaTotpSetup, + MfaVerifyPayload, + TrustedDevice, +} from '@/types/mfa' + +interface ApiResponse { + success: boolean + data: T + message: string +} + +// ─── Setup ─── + +export function useSetupTotp() { + return useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post>('/auth/mfa/setup/totp') + + return data.data + }, + }) +} + +export function useConfirmTotp() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (code: string) => { + const { data } = await apiClient.post>('/auth/mfa/setup/totp/confirm', { code }) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + }, + }) +} + +export function useSetupEmail() { + return useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post>('/auth/mfa/setup/email') + + return data + }, + }) +} + +export function useConfirmEmail() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (code: string) => { + const { data } = await apiClient.post>('/auth/mfa/setup/email/confirm', { code }) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + }, + }) +} + +// ─── Management ─── + +export function useDisableMfa() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: { code: string; method: string }) => { + const { data } = await apiClient.post>('/auth/mfa/disable', payload) + + return data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) + queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) + }, + }) +} + +export function useRegenerateBackupCodes() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: { code: string }) => { + const { data } = await apiClient.post>('/auth/mfa/backup-codes', payload) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) + }, + }) +} + +export function useMfaStatus() { + return useQuery({ + queryKey: ['mfa-status'], + queryFn: async () => { + const { data } = await apiClient.get>('/auth/mfa/status') + + return data.data + }, + }) +} + +// ─── Trusted devices ─── + +export function useTrustedDevices() { + return useQuery({ + queryKey: ['trusted-devices'], + queryFn: async () => { + const { data } = await apiClient.get>('/auth/trusted-devices') + + return data.data + }, + }) +} + +export function useRevokeDevice() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/auth/trusted-devices/${id}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trusted-devices'] }) + }, + }) +} + +export function useRevokeAllDevices() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + await apiClient.delete('/auth/trusted-devices') + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trusted-devices'] }) + }, + }) +} + +// ─── Login flow (no auth needed — uses session token) ─── + +export function useVerifyMfa() { + return useMutation({ + mutationFn: async (payload: MfaVerifyPayload) => { + const { data } = await apiClient.post('/auth/mfa/verify', payload) + + return data + }, + }) +} + +export function useSendMfaEmailCode() { + return useMutation({ + mutationFn: async (mfaSessionToken: string) => { + const { data } = await apiClient.post('/auth/mfa/email/send', { + mfa_session_token: mfaSessionToken, + }) + + return data + }, + }) +} + +// ─── Admin ─── + +export function useAdminResetMfa() { + return useMutation({ + mutationFn: async (userId: string) => { + const { data } = await apiClient.post(`/admin/users/${userId}/reset-mfa`) + + return data + }, + }) +} diff --git a/apps/app/src/layouts/components/UserProfile.vue b/apps/app/src/layouts/components/UserProfile.vue index 148bebfa..2d53703e 100644 --- a/apps/app/src/layouts/components/UserProfile.vue +++ b/apps/app/src/layouts/components/UserProfile.vue @@ -87,6 +87,17 @@ function handleLogout() { Accountinstellingen + + + Beveiliging + + +import { useAuthStore } from '@/stores/useAuthStore' +import { + useMfaStatus, + useTrustedDevices, + useRevokeDevice, + useRevokeAllDevices, + useRegenerateBackupCodes, +} from '@/composables/api/useMfa' +import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue' +import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue' +import MfaDisableDialog from '@/components/settings/MfaDisableDialog.vue' + +definePage({ + meta: { + navActiveLink: 'account-settings', + }, +}) + +const authStore = useAuthStore() + +const { data: mfaStatus, refetch: refetchMfaStatus } = useMfaStatus() +const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices() +const revokeDeviceMutation = useRevokeDevice() +const revokeAllMutation = useRevokeAllDevices() +const regenerateCodesMutation = useRegenerateBackupCodes() + +const showTotpSetup = ref(false) +const showEmailSetup = ref(false) +const showDisableDialog = ref(false) +const showRegenerateDialog = ref(false) +const regenerateCode = ref('') +const regeneratedCodes = ref([]) +const regenerateError = ref('') + +const isEnabled = computed(() => mfaStatus.value?.enabled ?? false) +const methodLabel = computed(() => { + if (mfaStatus.value?.method === 'totp') return 'Authenticator app' + if (mfaStatus.value?.method === 'email') return 'E-mailcode' + + return null +}) + +function onSetupCompleted() { + refetchMfaStatus() +} + +function onDisabled() { + refetchMfaStatus() + refetchDevices() +} + +async function handleRevokeDevice(id: string) { + await revokeDeviceMutation.mutateAsync(id) + refetchDevices() +} + +async function handleRevokeAllDevices() { + await revokeAllMutation.mutateAsync() + refetchDevices() +} + +async function handleRegenerateBackupCodes() { + regenerateError.value = '' + try { + const data = await regenerateCodesMutation.mutateAsync({ code: regenerateCode.value }) + + regeneratedCodes.value = data.backup_codes + refetchMfaStatus() + } + catch (err: unknown) { + const ax = err as { response?: { data?: { message?: string } } } + + regenerateError.value = ax.response?.data?.message ?? 'Kon codes niet genereren.' + regenerateCode.value = '' + } +} + +function copyRegeneratedCodes() { + navigator.clipboard.writeText(regeneratedCodes.value.join('\n')) +} + + + diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue index 8d7e81fd..1bf8f410 100644 --- a/apps/app/src/pages/login.vue +++ b/apps/app/src/pages/login.vue @@ -1,6 +1,5 @@ diff --git a/apps/app/src/plugins/1.router/guards.ts b/apps/app/src/plugins/1.router/guards.ts index f00e7ea5..c61f62c4 100644 --- a/apps/app/src/plugins/1.router/guards.ts +++ b/apps/app/src/plugins/1.router/guards.ts @@ -44,6 +44,12 @@ export function setupGuards(router: Router) { return { path: '/login', query: { to: to.fullPath } } } + // MFA enforcement — redirect to security settings if MFA setup is required + if (authStore.mfaSetupRequired && to.path !== '/account-settings/security') { + if (import.meta.env.DEV) console.log('🔒 MFA setup required, redirecting to security settings') + return { path: '/account-settings/security' } + } + // Platform admin routes — require super_admin role if (to.path.startsWith('/platform')) { if (!authStore.isSuperAdmin) { diff --git a/apps/app/src/stores/useAuthStore.ts b/apps/app/src/stores/useAuthStore.ts index 151f8869..f7ef2c14 100644 --- a/apps/app/src/stores/useAuthStore.ts +++ b/apps/app/src/stores/useAuthStore.ts @@ -11,6 +11,8 @@ export const useAuthStore = defineStore('auth', () => { const permissions = ref([]) const isInitialized = ref(false) + const mfaSetupRequired = ref(false) + const isAuthenticated = computed(() => !!user.value) const isSuperAdmin = computed(() => appRoles.value?.includes('super_admin') ?? false) @@ -35,6 +37,7 @@ export const useAuthStore = defineStore('auth', () => { organisations.value = me.organisations appRoles.value = me.app_roles permissions.value = me.permissions + mfaSetupRequired.value = me.mfa?.setup_required ?? false // Auto-select first organisation if none is active const orgStore = useOrganisationStore() @@ -53,6 +56,7 @@ export const useAuthStore = defineStore('auth', () => { organisations.value = [] appRoles.value = [] permissions.value = [] + mfaSetupRequired.value = false const orgStore = useOrganisationStore() orgStore.clear() @@ -120,6 +124,7 @@ export const useAuthStore = defineStore('auth', () => { isInitialized, isSuperAdmin, currentOrganisation, + mfaSetupRequired, setUser, setActiveOrganisation, logout, diff --git a/apps/app/src/types/admin.ts b/apps/app/src/types/admin.ts index 67a2136f..8b3d2d19 100644 --- a/apps/app/src/types/admin.ts +++ b/apps/app/src/types/admin.ts @@ -27,6 +27,7 @@ export interface AdminUser { email_verified_at: string | null created_at: string is_super_admin: boolean + mfa_enabled: boolean roles: string[] organisations: Array<{ id: string diff --git a/apps/app/src/types/auth.ts b/apps/app/src/types/auth.ts index add34524..a7a8305f 100644 --- a/apps/app/src/types/auth.ts +++ b/apps/app/src/types/auth.ts @@ -16,6 +16,13 @@ export interface Organisation { role: string } +export interface MfaUserInfo { + enabled: boolean + method: 'totp' | 'email' | null + confirmed_at: string | null + setup_required: boolean +} + export interface MeResponse { id: string first_name: string @@ -28,6 +35,7 @@ export interface MeResponse { organisations: Organisation[] app_roles: string[] permissions: string[] + mfa?: MfaUserInfo } export interface LoginCredentials { @@ -41,6 +49,12 @@ export interface LoginResponse { user: MeResponse } message: string + mfa_required?: boolean + mfa_session_token?: string + methods?: string[] + preferred_method?: string + expires_in?: number + mfa_setup_required?: boolean } export interface ApiErrorResponse { diff --git a/apps/app/src/types/mfa.ts b/apps/app/src/types/mfa.ts new file mode 100644 index 00000000..905cdd8f --- /dev/null +++ b/apps/app/src/types/mfa.ts @@ -0,0 +1,61 @@ +export const MfaMethod = { + TOTP: 'totp', + EMAIL: 'email', + BACKUP_CODE: 'backup_code', +} as const + +export type MfaMethod = typeof MfaMethod[keyof typeof MfaMethod] + +export interface MfaStatus { + enabled: boolean + method: 'totp' | 'email' | null + confirmed_at: string | null + backup_codes_remaining: number + is_required: boolean +} + +export interface MfaUserStatus { + enabled: boolean + method: 'totp' | 'email' | null + confirmed_at: string | null + setup_required: boolean +} + +export interface MfaTotpSetup { + secret: string + qr_code_url: string + provisioning_uri: string +} + +export interface MfaSessionResponse { + success: true + mfa_required: true + mfa_session_token: string + methods: MfaMethod[] + preferred_method: 'totp' | 'email' + expires_in: number +} + +export interface MfaConfirmResponse { + mfa_enabled: boolean + method: 'totp' | 'email' + backup_codes: string[] +} + +export interface TrustedDevice { + id: string + device_name: string | null + ip_address: string + trusted_until: string + last_used_at: string | null + created_at: string +} + +export interface MfaVerifyPayload { + mfa_session_token: string + code: string + method: MfaMethod + trust_device?: boolean + device_fingerprint?: string + device_name?: string +} diff --git a/apps/app/src/utils/deviceFingerprint.ts b/apps/app/src/utils/deviceFingerprint.ts new file mode 100644 index 00000000..89f86154 --- /dev/null +++ b/apps/app/src/utils/deviceFingerprint.ts @@ -0,0 +1,49 @@ +export function generateDeviceFingerprint(): string { + const components = [ + navigator.userAgent, + `${screen.width}x${screen.height}`, + Intl.DateTimeFormat().resolvedOptions().timeZone, + navigator.language, + navigator.hardwareConcurrency?.toString() ?? '', + ] + + const str = components.join('|') + let hash = 0 + + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + + hash = ((hash << 5) - hash) + char + hash = hash & hash + } + + return Math.abs(hash).toString(36) +} + +export function getDeviceName(): string { + const ua = navigator.userAgent + let browser = 'Onbekend' + let os = 'Onbekend' + + if (ua.includes('Chrome') && !ua.includes('Edg')) + browser = 'Chrome' + else if (ua.includes('Firefox')) + browser = 'Firefox' + else if (ua.includes('Safari') && !ua.includes('Chrome')) + browser = 'Safari' + else if (ua.includes('Edg')) + browser = 'Edge' + + if (ua.includes('Mac OS')) + os = 'macOS' + else if (ua.includes('Windows')) + os = 'Windows' + else if (ua.includes('Linux')) + os = 'Linux' + else if (ua.includes('Android')) + os = 'Android' + else if (ua.includes('iPhone') || ua.includes('iPad')) + os = 'iOS' + + return `${browser} op ${os}` +} diff --git a/apps/app/typed-router.d.ts b/apps/app/typed-router.d.ts index 166ffce4..fc772e45 100644 --- a/apps/app/typed-router.d.ts +++ b/apps/app/typed-router.d.ts @@ -21,6 +21,7 @@ declare module 'vue-router/auto-routes' { 'root': RouteRecordInfo<'root', '/', Record, Record>, '$error': RouteRecordInfo<'$error', '/:error(.*)', { error: ParamValue }, { error: ParamValue }>, 'account-settings': RouteRecordInfo<'account-settings', '/account-settings', Record, Record>, + 'account-settings-security': RouteRecordInfo<'account-settings-security', '/account-settings/security', Record, Record>, 'dashboard': RouteRecordInfo<'dashboard', '/dashboard', Record, Record>, 'events': RouteRecordInfo<'events', '/events', Record, Record>, 'events-id': RouteRecordInfo<'events-id', '/events/:id', { id: ParamValue }, { id: ParamValue }>, diff --git a/apps/portal/components.d.ts b/apps/portal/components.d.ts index 8f719db0..3533a4a5 100644 --- a/apps/portal/components.d.ts +++ b/apps/portal/components.d.ts @@ -36,6 +36,10 @@ declare module 'vue' { EventCard: typeof import('./src/components/portal/EventCard.vue')['default'] I18n: typeof import('./src/@core/components/I18n.vue')['default'] InformatieTab: typeof import('./src/components/event/InformatieTab.vue')['default'] + MfaChallengeCard: typeof import('./src/components/auth/MfaChallengeCard.vue')['default'] + MfaDisableDialog: typeof import('./src/components/settings/MfaDisableDialog.vue')['default'] + MfaEmailSetupDialog: typeof import('./src/components/settings/MfaEmailSetupDialog.vue')['default'] + MfaTotpSetupDialog: typeof import('./src/components/settings/MfaTotpSetupDialog.vue')['default'] MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default'] Notifications: typeof import('./src/@core/components/Notifications.vue')['default'] OverzichtTab: typeof import('./src/components/event/OverzichtTab.vue')['default'] diff --git a/apps/portal/package.json b/apps/portal/package.json index 3bdbc79c..d44bb7e1 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -41,6 +41,7 @@ "ofetch": "1.5.0", "pinia": "3.0.3", "prismjs": "1.30.0", + "qrcode": "^1.5.4", "roboto-fontface": "0.10.0", "shepherd.js": "13.0.3", "swiper": "11.2.10", @@ -84,6 +85,7 @@ "@tiptap/extension-underline": "^2.27.1", "@types/mapbox-gl": "3.4.1", "@types/node": "24.9.2", + "@types/qrcode": "^1.5.6", "@types/webfontloader": "1.6.38", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", diff --git a/apps/portal/pnpm-lock.yaml b/apps/portal/pnpm-lock.yaml index 22852b6d..d250b930 100644 --- a/apps/portal/pnpm-lock.yaml +++ b/apps/portal/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: prismjs: specifier: 1.30.0 version: 1.30.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 roboto-fontface: specifier: 0.10.0 version: 0.10.0 @@ -217,6 +220,9 @@ importers: '@types/node': specifier: 24.9.2 version: 24.9.2 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/webfontloader': specifier: 1.6.38 version: 1.6.38 @@ -1435,6 +1441,9 @@ packages: '@types/pbf@3.0.5': resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -2058,6 +2067,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001752: resolution: {integrity: sha512-vKUk7beoukxE47P5gcVNKkDRzXdVofotshHwfR9vmpeFKxmI5PBpgOMC18LUJUA/DvJ70Y7RveasIBraqsyO/g==} @@ -2125,6 +2138,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2266,6 +2282,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2315,6 +2335,9 @@ packages: resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3719,6 +3742,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3871,6 +3898,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3939,6 +3971,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -4053,6 +4088,9 @@ packages: resolution: {integrity: sha512-owllqNuDDEimQat7EPG0tH7JjO090xKNzUtYz6X+Sk2BXDnOCilDdNLwjWeFywG9xkJul1ULvtUQa9O4pUaY0w==} engines: {node: '>=4.0.0'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -4811,6 +4849,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -4851,6 +4892,9 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4870,10 +4914,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -6047,6 +6099,10 @@ snapshots: '@types/pbf@3.0.5': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.9.2 + '@types/semver@7.7.1': {} '@types/statuses@2.0.6': {} @@ -6822,6 +6878,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001752: {} case-police@0.6.1: {} @@ -6896,6 +6954,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -7022,6 +7086,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-is@0.1.4: {} deepmerge-ts@5.1.0: {} @@ -7061,6 +7127,8 @@ snapshots: diff-sequences@27.5.1: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -8752,6 +8820,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-html@1.8.0: @@ -8933,6 +9003,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + quansync@0.2.11: {} querystringify@2.2.0: {} @@ -9010,6 +9086,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requires-port@1.0.0: {} resolve-from@4.0.0: {} @@ -9137,6 +9215,8 @@ snapshots: serialize-to-js@3.1.2: {} + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -10065,6 +10145,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -10110,6 +10192,8 @@ snapshots: xml-name-validator@4.0.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -10123,8 +10207,27 @@ snapshots: yaml@2.8.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/apps/portal/src/components/auth/MfaChallengeCard.vue b/apps/portal/src/components/auth/MfaChallengeCard.vue new file mode 100644 index 00000000..7345e59f --- /dev/null +++ b/apps/portal/src/components/auth/MfaChallengeCard.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/apps/portal/src/components/settings/MfaDisableDialog.vue b/apps/portal/src/components/settings/MfaDisableDialog.vue new file mode 100644 index 00000000..4b3ab61b --- /dev/null +++ b/apps/portal/src/components/settings/MfaDisableDialog.vue @@ -0,0 +1,115 @@ + + + diff --git a/apps/portal/src/components/settings/MfaEmailSetupDialog.vue b/apps/portal/src/components/settings/MfaEmailSetupDialog.vue new file mode 100644 index 00000000..c76c481a --- /dev/null +++ b/apps/portal/src/components/settings/MfaEmailSetupDialog.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/apps/portal/src/components/settings/MfaTotpSetupDialog.vue b/apps/portal/src/components/settings/MfaTotpSetupDialog.vue new file mode 100644 index 00000000..26462c00 --- /dev/null +++ b/apps/portal/src/components/settings/MfaTotpSetupDialog.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/apps/portal/src/composables/api/useMfa.ts b/apps/portal/src/composables/api/useMfa.ts new file mode 100644 index 00000000..6cac3623 --- /dev/null +++ b/apps/portal/src/composables/api/useMfa.ts @@ -0,0 +1,173 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { apiClient } from '@/lib/axios' +import type { + MfaConfirmResponse, + MfaStatus, + MfaTotpSetup, + MfaVerifyPayload, + TrustedDevice, +} from '@/types/mfa' + +interface ApiResponse { + success: boolean + data: T + message: string +} + +// ─── Setup ─── + +export function useSetupTotp() { + return useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post>('/auth/mfa/setup/totp') + + return data.data + }, + }) +} + +export function useConfirmTotp() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (code: string) => { + const { data } = await apiClient.post>('/auth/mfa/setup/totp/confirm', { code }) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) + }, + }) +} + +export function useSetupEmail() { + return useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post>('/auth/mfa/setup/email') + + return data + }, + }) +} + +export function useConfirmEmail() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (code: string) => { + const { data } = await apiClient.post>('/auth/mfa/setup/email/confirm', { code }) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) + }, + }) +} + +// ─── Management ─── + +export function useDisableMfa() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: { code: string; method: string }) => { + const { data } = await apiClient.post>('/auth/mfa/disable', payload) + + return data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) + }, + }) +} + +export function useRegenerateBackupCodes() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: { code: string }) => { + const { data } = await apiClient.post>('/auth/mfa/backup-codes', payload) + + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mfa-status'] }) + }, + }) +} + +export function useMfaStatus() { + return useQuery({ + queryKey: ['mfa-status'], + queryFn: async () => { + const { data } = await apiClient.get>('/auth/mfa/status') + + return data.data + }, + }) +} + +// ─── Trusted devices ─── + +export function useTrustedDevices() { + return useQuery({ + queryKey: ['trusted-devices'], + queryFn: async () => { + const { data } = await apiClient.get>('/auth/trusted-devices') + + return data.data + }, + }) +} + +export function useRevokeDevice() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/auth/trusted-devices/${id}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trusted-devices'] }) + }, + }) +} + +export function useRevokeAllDevices() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + await apiClient.delete('/auth/trusted-devices') + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['trusted-devices'] }) + }, + }) +} + +// ─── Login flow (no auth needed — uses session token) ─── + +export function useVerifyMfa() { + return useMutation({ + mutationFn: async (payload: MfaVerifyPayload) => { + const { data } = await apiClient.post('/auth/mfa/verify', payload) + + return data + }, + }) +} + +export function useSendMfaEmailCode() { + return useMutation({ + mutationFn: async (mfaSessionToken: string) => { + const { data } = await apiClient.post('/auth/mfa/email/send', { + mfa_session_token: mfaSessionToken, + }) + + return data + }, + }) +} diff --git a/apps/portal/src/pages/login.vue b/apps/portal/src/pages/login.vue index 9f1748b7..53728686 100644 --- a/apps/portal/src/pages/login.vue +++ b/apps/portal/src/pages/login.vue @@ -1,6 +1,13 @@ + + diff --git a/apps/portal/src/pages/profiel.vue b/apps/portal/src/pages/profiel.vue index ed0df2bc..223755fa 100644 --- a/apps/portal/src/pages/profiel.vue +++ b/apps/portal/src/pages/profiel.vue @@ -2,7 +2,11 @@ import { useAuthStore } from '@/stores/useAuthStore' import { usePortalStore } from '@/stores/usePortalStore' import { useUpdateProfile, useUpdatePassword } from '@/composables/api/usePortalProfile' +import { useMfaStatus, useTrustedDevices, useRevokeDevice, useRevokeAllDevices } from '@/composables/api/useMfa' import { apiClient } from '@/lib/axios' +import MfaTotpSetupDialog from '@/components/settings/MfaTotpSetupDialog.vue' +import MfaEmailSetupDialog from '@/components/settings/MfaEmailSetupDialog.vue' +import MfaDisableDialog from '@/components/settings/MfaDisableDialog.vue' definePage({ name: 'portal-profiel', @@ -89,6 +93,36 @@ const showCurrentPassword = ref(false) const showNewPassword = ref(false) const showConfirmPassword = ref(false) +// MFA +const { data: mfaStatus, refetch: refetchMfaStatus } = useMfaStatus() +const { data: trustedDevices, refetch: refetchDevices } = useTrustedDevices() +const revokeDeviceMutation = useRevokeDevice() +const revokeAllMutation = useRevokeAllDevices() + +const showTotpSetup = ref(false) +const showEmailSetup = ref(false) +const showDisableDialog = ref(false) +const isMfaEnabled = computed(() => mfaStatus.value?.enabled ?? false) +const mfaMethodLabel = computed(() => { + if (mfaStatus.value?.method === 'totp') return 'Authenticator app' + if (mfaStatus.value?.method === 'email') return 'E-mailcode' + + return null +}) + +function onMfaSetupCompleted() { refetchMfaStatus() } +function onMfaDisabled() { refetchMfaStatus(); refetchDevices() } + +async function handleRevokeDevice(id: string) { + await revokeDeviceMutation.mutateAsync(id) + refetchDevices() +} + +async function handleRevokeAllDevices() { + await revokeAllMutation.mutateAsync() + refetchDevices() +} + // Populate profile form from auth user / current person data watch( [() => authStore.user, () => portal.currentPerson], @@ -477,6 +511,123 @@ async function savePassword() { + + + + + Beveiliging + + + + + + + + + + + + + + + Mijn evenementen diff --git a/apps/portal/src/pages/verify-email-change.vue b/apps/portal/src/pages/verify-email-change.vue index 0c66e767..80cbbd8a 100644 --- a/apps/portal/src/pages/verify-email-change.vue +++ b/apps/portal/src/pages/verify-email-change.vue @@ -1,4 +1,8 @@ + + diff --git a/apps/portal/src/pages/wachtwoord-resetten.vue b/apps/portal/src/pages/wachtwoord-resetten.vue index 6f2f51e9..fc06362c 100644 --- a/apps/portal/src/pages/wachtwoord-resetten.vue +++ b/apps/portal/src/pages/wachtwoord-resetten.vue @@ -1,4 +1,8 @@ + + diff --git a/apps/portal/src/pages/wachtwoord-vergeten.vue b/apps/portal/src/pages/wachtwoord-vergeten.vue index 51eeb2ed..3fd64f1c 100644 --- a/apps/portal/src/pages/wachtwoord-vergeten.vue +++ b/apps/portal/src/pages/wachtwoord-vergeten.vue @@ -1,4 +1,8 @@ + + diff --git a/apps/portal/src/types/mfa.ts b/apps/portal/src/types/mfa.ts new file mode 100644 index 00000000..905cdd8f --- /dev/null +++ b/apps/portal/src/types/mfa.ts @@ -0,0 +1,61 @@ +export const MfaMethod = { + TOTP: 'totp', + EMAIL: 'email', + BACKUP_CODE: 'backup_code', +} as const + +export type MfaMethod = typeof MfaMethod[keyof typeof MfaMethod] + +export interface MfaStatus { + enabled: boolean + method: 'totp' | 'email' | null + confirmed_at: string | null + backup_codes_remaining: number + is_required: boolean +} + +export interface MfaUserStatus { + enabled: boolean + method: 'totp' | 'email' | null + confirmed_at: string | null + setup_required: boolean +} + +export interface MfaTotpSetup { + secret: string + qr_code_url: string + provisioning_uri: string +} + +export interface MfaSessionResponse { + success: true + mfa_required: true + mfa_session_token: string + methods: MfaMethod[] + preferred_method: 'totp' | 'email' + expires_in: number +} + +export interface MfaConfirmResponse { + mfa_enabled: boolean + method: 'totp' | 'email' + backup_codes: string[] +} + +export interface TrustedDevice { + id: string + device_name: string | null + ip_address: string + trusted_until: string + last_used_at: string | null + created_at: string +} + +export interface MfaVerifyPayload { + mfa_session_token: string + code: string + method: MfaMethod + trust_device?: boolean + device_fingerprint?: string + device_name?: string +} diff --git a/apps/portal/src/types/portal.ts b/apps/portal/src/types/portal.ts index b230da84..acfadd84 100644 --- a/apps/portal/src/types/portal.ts +++ b/apps/portal/src/types/portal.ts @@ -37,6 +37,12 @@ export interface AuthMeUser { roles?: string[] permissions?: string[] portal_events?: PortalEvent[] + mfa?: { + enabled: boolean + method: 'totp' | 'email' | null + confirmed_at: string | null + setup_required: boolean + } } /** GET /portal/me?event_id= — person payload (subset used by dashboard) */ diff --git a/apps/portal/src/utils/deviceFingerprint.ts b/apps/portal/src/utils/deviceFingerprint.ts new file mode 100644 index 00000000..89f86154 --- /dev/null +++ b/apps/portal/src/utils/deviceFingerprint.ts @@ -0,0 +1,49 @@ +export function generateDeviceFingerprint(): string { + const components = [ + navigator.userAgent, + `${screen.width}x${screen.height}`, + Intl.DateTimeFormat().resolvedOptions().timeZone, + navigator.language, + navigator.hardwareConcurrency?.toString() ?? '', + ] + + const str = components.join('|') + let hash = 0 + + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + + hash = ((hash << 5) - hash) + char + hash = hash & hash + } + + return Math.abs(hash).toString(36) +} + +export function getDeviceName(): string { + const ua = navigator.userAgent + let browser = 'Onbekend' + let os = 'Onbekend' + + if (ua.includes('Chrome') && !ua.includes('Edg')) + browser = 'Chrome' + else if (ua.includes('Firefox')) + browser = 'Firefox' + else if (ua.includes('Safari') && !ua.includes('Chrome')) + browser = 'Safari' + else if (ua.includes('Edg')) + browser = 'Edge' + + if (ua.includes('Mac OS')) + os = 'macOS' + else if (ua.includes('Windows')) + os = 'Windows' + else if (ua.includes('Linux')) + os = 'Linux' + else if (ua.includes('Android')) + os = 'Android' + else if (ua.includes('iPhone') || ua.includes('iPad')) + os = 'iOS' + + return `${browser} op ${os}` +}