Skip to content

Commit 77be8c5

Browse files
committed
feat: [OCISDEV-249] add MFA capability
We've added a capability to check if MFA is enabled. If the capability is enabled, we will require MFA when accessing the admin settings page.
1 parent 5fd9f48 commit 77be8c5

File tree

10 files changed

+217
-24
lines changed

10 files changed

+217
-24
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Enhancement: Add MFA capability
2+
3+
We've added a capability to check if MFA is enabled.
4+
If the capability is enabled, we will require MFA when accessing the admin settings page.
5+
6+
https://github.com/owncloud/web/pull/12925

dev/docker/ocis.web.config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"metadata_url": "https://host.docker.internal:9200/.well-known/openid-configuration",
66
"authority": "https://host.docker.internal:9200",
77
"client_id": "web",
8-
"response_type": "code"
8+
"response_type": "code",
9+
"scope": "openid profile email acr"
910
},
1011
"options": {
1112
"contextHelpersReadMore": true

packages/web-app-admin-settings/src/index.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
AppNavigationItem,
1111
defineWebApplication,
1212
useAbility,
13+
useAuthService,
14+
useCapabilityStore,
1315
useUserStore
1416
} from '@ownclouders/web-pkg'
1517
import { RouteRecordRaw } from 'vue-router'
@@ -22,7 +24,7 @@ function $gettext(msg: string) {
2224

2325
const appId = 'admin-settings'
2426

25-
function getAvailableRoute(ability: Ability) {
27+
function getNextAvailableRoute(ability: Ability) {
2628
if (ability.can('read-all', 'Setting')) {
2729
return { name: 'admin-settings-general' }
2830
}
@@ -42,6 +44,16 @@ function getAvailableRoute(ability: Ability) {
4244
throw Error('Insufficient permissions')
4345
}
4446

47+
async function requireAcr(redirectUrl: string) {
48+
const capabilityStore = useCapabilityStore()
49+
if (!capabilityStore.authMfaEnabled) {
50+
return
51+
}
52+
53+
const authService = useAuthService()
54+
await authService.requireAcr(capabilityStore.authMfaRequiredLevelname, redirectUrl)
55+
}
56+
4557
export const routes = ({ $ability }: { $ability: Ability }): RouteRecordRaw[] => [
4658
{
4759
path: '/',
@@ -53,10 +65,13 @@ export const routes = ({ $ability }: { $ability: Ability }): RouteRecordRaw[] =>
5365
path: '/general',
5466
name: 'admin-settings-general',
5567
component: General,
56-
beforeEnter: (to, from, next) => {
68+
beforeEnter: async (to, from, next) => {
5769
if (!$ability.can('read-all', 'Setting')) {
58-
return next(getAvailableRoute($ability))
70+
return next(getNextAvailableRoute($ability))
5971
}
72+
73+
await requireAcr(to.fullPath)
74+
6075
next()
6176
},
6277
meta: {
@@ -68,10 +83,13 @@ export const routes = ({ $ability }: { $ability: Ability }): RouteRecordRaw[] =>
6883
path: '/users',
6984
name: 'admin-settings-users',
7085
component: Users,
71-
beforeEnter: (to, from, next) => {
86+
beforeEnter: async (to, from, next) => {
7287
if (!$ability.can('read-all', 'Account')) {
73-
return next(getAvailableRoute($ability))
88+
return next(getNextAvailableRoute($ability))
7489
}
90+
91+
await requireAcr(to.fullPath)
92+
7593
next()
7694
},
7795
meta: {
@@ -83,10 +101,13 @@ export const routes = ({ $ability }: { $ability: Ability }): RouteRecordRaw[] =>
83101
path: '/groups',
84102
name: 'admin-settings-groups',
85103
component: Groups,
86-
beforeEnter: (to, from, next) => {
104+
beforeEnter: async (to, from, next) => {
87105
if (!$ability.can('read-all', 'Group')) {
88-
return next(getAvailableRoute($ability))
106+
return next(getNextAvailableRoute($ability))
89107
}
108+
109+
await requireAcr(to.fullPath)
110+
90111
next()
91112
},
92113
meta: {
@@ -98,10 +119,13 @@ export const routes = ({ $ability }: { $ability: Ability }): RouteRecordRaw[] =>
98119
path: '/spaces',
99120
name: 'admin-settings-spaces',
100121
component: Spaces,
101-
beforeEnter: (to, from, next) => {
122+
beforeEnter: async (to, from, next) => {
102123
if (!$ability.can('read-all', 'Drive')) {
103-
return next(getAvailableRoute($ability))
124+
return next(getNextAvailableRoute($ability))
104125
}
126+
127+
await requireAcr(to.fullPath)
128+
105129
next()
106130
},
107131
meta: {

packages/web-app-admin-settings/tests/unit/index.spec.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
11
import { navItems, routes } from '../../src/index'
22
import { Ability } from '@ownclouders/web-client'
3+
import { createTestingPinia } from '@ownclouders/web-test-helpers'
34
import { mock } from 'vitest-mock-extended'
5+
import * as pkg from '@ownclouders/web-pkg'
6+
import { AuthService } from '../../../web-runtime/src/services/auth/authService'
7+
8+
const mockRequireAcr = vi.fn()
9+
vi.spyOn(pkg, 'useAuthService').mockReturnValue(mock<AuthService>({ requireAcr: mockRequireAcr }))
410

511
const getAbilityMock = (hasPermission: boolean) => mock<Ability>({ can: () => hasPermission })
612

713
describe('admin settings index', () => {
14+
beforeEach(() => {
15+
createTestingPinia({
16+
initialState: {
17+
capabilities: {
18+
capabilities: { auth: { mfa: { enabled: true, levelnames: ['advanced'] } } }
19+
}
20+
}
21+
})
22+
})
23+
824
describe('navItems', () => {
925
describe('general', () => {
1026
it.each([true, false])('should be enabled according to the permissions', (enabled) => {
@@ -64,11 +80,11 @@ describe('admin settings index', () => {
6480
}),
6581
redirect: { name: 'admin-settings-groups' }
6682
}
67-
])('redirects "/general" with sufficient permissions', ({ can, redirect }) => {
83+
])('redirects "/general" with sufficient permissions', async ({ can, redirect }) => {
6884
const ability = mock<Ability>({ can })
6985
const route = routes({ $ability: ability }).find((n) => n.path === '/general')
7086
const nextMock = vi.fn()
71-
;(route.beforeEnter as any)({}, {}, nextMock)
87+
await (route.beforeEnter as any)({}, {}, nextMock)
7288
const args = [...(redirect ? [redirect] : [])]
7389
expect(nextMock).toHaveBeenCalledWith(...args)
7490
})
@@ -84,11 +100,11 @@ describe('admin settings index', () => {
84100
}),
85101
redirect: { name: 'admin-settings-spaces' }
86102
}
87-
])('redirects "/users" with sufficient permissions', ({ can, redirect }) => {
103+
])('redirects "/users" with sufficient permissions', async ({ can, redirect }) => {
88104
const ability = mock<Ability>({ can })
89105
const route = routes({ $ability: ability }).find((n) => n.path === '/users')
90106
const nextMock = vi.fn()
91-
;(route.beforeEnter as any)({}, {}, nextMock)
107+
await (route.beforeEnter as any)({}, {}, nextMock)
92108
const args = [...(redirect ? [redirect] : [])]
93109
expect(nextMock).toHaveBeenCalledWith(...args)
94110
})
@@ -104,11 +120,11 @@ describe('admin settings index', () => {
104120
}),
105121
redirect: { name: 'admin-settings-general' }
106122
}
107-
])('redirects "/groups" with sufficient permissions', ({ can, redirect }) => {
123+
])('redirects "/groups" with sufficient permissions', async ({ can, redirect }) => {
108124
const ability = mock<Ability>({ can })
109125
const route = routes({ $ability: ability }).find((n) => n.path === '/groups')
110126
const nextMock = vi.fn()
111-
;(route.beforeEnter as any)({}, {}, nextMock)
127+
await (route.beforeEnter as any)({}, {}, nextMock)
112128
const args = [...(redirect ? [redirect] : [])]
113129
expect(nextMock).toHaveBeenCalledWith(...args)
114130
})
@@ -124,24 +140,56 @@ describe('admin settings index', () => {
124140
}),
125141
redirect: { name: 'admin-settings-users' }
126142
}
127-
])('redirects "/spaces" with sufficient permissions', ({ can, redirect }) => {
143+
])('redirects "/spaces" with sufficient permissions', async ({ can, redirect }) => {
128144
const ability = mock<Ability>({ can })
129145
const route = routes({ $ability: ability }).find((n) => n.path === '/spaces')
130146
const nextMock = vi.fn()
131-
;(route.beforeEnter as any)({}, {}, nextMock)
147+
await (route.beforeEnter as any)({}, {}, nextMock)
132148
const args = [...(redirect ? [redirect] : [])]
133149
expect(nextMock).toHaveBeenCalledWith(...args)
134150
})
135151
it.each(['/general', '/users', '/groups', '/spaces'])(
136152
'should throw an error if permissions are insufficient',
137-
(path) => {
153+
async (path) => {
138154
const ability = mock<Ability>({ can: vi.fn(() => false) })
139155
const route = routes({ $ability: ability }).find((n) => n.path === path)
140156
const nextMock = vi.fn()
141-
expect(() => {
142-
;(route.beforeEnter as any)({}, {}, nextMock)
143-
}).toThrowError('Insufficient permissions')
157+
await expect(() => (route.beforeEnter as any)({}, {}, nextMock)).rejects.toThrowError(
158+
'Insufficient permissions'
159+
)
144160
}
145161
)
162+
163+
describe('requireAcr', () => {
164+
it.each(['/general', '/users', '/groups', '/spaces'])(
165+
'should call requireAcr if MFA is enabled when path is %s',
166+
async (path) => {
167+
const ability = mock<Ability>({ can: vi.fn(() => true) })
168+
const route = routes({ $ability: ability }).find((n) => n.path === path)
169+
await (route.beforeEnter as any)({ fullPath: path }, {}, vi.fn())
170+
expect(mockRequireAcr).toHaveBeenCalledWith('advanced', path)
171+
}
172+
)
173+
})
174+
175+
describe('requireAcr', () => {
176+
it.each(['/general', '/users', '/groups', '/spaces'])(
177+
'should not call requireAcr if MFA is disabled when path is %s',
178+
async (path) => {
179+
createTestingPinia({
180+
initialState: {
181+
capabilities: {
182+
capabilities: { auth: { mfa: { enabled: false, levelnames: ['advanced'] } } }
183+
}
184+
}
185+
})
186+
187+
const ability = mock<Ability>({ can: vi.fn(() => true) })
188+
const route = routes({ $ability: ability }).find((n) => n.path === path)
189+
await (route.beforeEnter as any)({}, {}, vi.fn())
190+
expect(mockRequireAcr).not.toHaveBeenCalled()
191+
}
192+
)
193+
})
146194
})
147195
})

packages/web-client/src/ocs/capabilities.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,16 @@ export interface ArchiverCapability {
5050
max_size?: string
5151
}
5252

53+
interface AuthCapability {
54+
mfa: {
55+
enabled?: boolean
56+
levelnames?: string[]
57+
}
58+
}
59+
5360
export interface Capabilities {
5461
capabilities: {
62+
auth: AuthCapability
5563
checksums?: {
5664
preferredUploadType?: string
5765
supportedTypes?: string[]

packages/web-pkg/src/composables/authContext/useAuthService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface AuthServiceInterface {
66
signinSilent(): Promise<unknown>
77
logoutUser(): Promise<void | NavigationFailure>
88
getRefreshToken(): Promise<string>
9+
requireAcr(acrValue: string, redirectUrl: string): Promise<void>
910
}
1011

1112
export const useAuthService = (): AuthServiceInterface => {

packages/web-pkg/src/composables/piniaStores/capabilities.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import merge from 'lodash-es/merge'
55
import { SharePermissionBit } from '@ownclouders/web-client'
66

77
const defaultValues = {
8+
auth: {
9+
mfa: {
10+
enabled: false,
11+
levelnames: ['advanced']
12+
}
13+
},
814
core: {
915
'support-sse': false,
1016
'support-url-signing': false
@@ -145,6 +151,9 @@ export const useCapabilityStore = defineStore('capabilities', () => {
145151
const searchMediaType = computed(() => unref(capabilities).search.property?.mediatype)
146152
const searchContent = computed(() => unref(capabilities).search.property?.content)
147153

154+
const authMfaEnabled = computed(() => unref(capabilities).auth.mfa.enabled)
155+
const authMfaRequiredLevelname = computed(() => unref(capabilities).auth.mfa.levelnames.at(0))
156+
148157
return {
149158
isInitialized,
150159
capabilities,
@@ -191,7 +200,9 @@ export const useCapabilityStore = defineStore('capabilities', () => {
191200
passwordPolicy,
192201
searchLastMofifiedDate,
193202
searchMediaType,
194-
searchContent
203+
searchContent,
204+
authMfaEnabled,
205+
authMfaRequiredLevelname
195206
}
196207
})
197208

packages/web-runtime/src/services/auth/authService.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,30 @@ export class AuthService implements AuthServiceInterface {
384384
console.debug('[authService:handleDelegatedTokenUpdate] - going to update the access_token')
385385
return this.userManager.updateContext(event.data, false)
386386
}
387+
388+
/**
389+
* Redirects to the login page if the user is not authenticated or if the ACR value is not the one required.
390+
*
391+
* @param acrValue - The ACR value to require.
392+
* @param redirectUrl - The URL to redirect to after login.
393+
*
394+
* @throws {Error} In cases of wrong authentication.
395+
*/
396+
public async requireAcr(acrValue: string, redirectUrl: string) {
397+
const user = await this.userManager.getUser()
398+
if (!user || user.expired) {
399+
this.userManager.setPostLoginRedirectUrl(redirectUrl)
400+
return this.userManager.signinRedirect({ acr_values: acrValue })
401+
}
402+
403+
const { acr } = user.profile
404+
if (acr === acrValue) {
405+
return
406+
}
407+
408+
this.userManager.setPostLoginRedirectUrl(redirectUrl)
409+
return this.userManager.signinRedirect({ acr_values: acrValue })
410+
}
387411
}
388412

389413
export const authService = new AuthService()

packages/web-runtime/src/services/auth/userManager.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ export class UserManager extends OidcUserManager {
7373
client_id: '',
7474

7575
// we trigger the token renewal manually via a timer running in a web worker
76-
automaticSilentRenew: false
76+
automaticSilentRenew: false,
77+
78+
// do not filter acr
79+
filterProtocolClaims: ['nbf', 'jti', 'auth_time', 'nonce', 'amr', 'azp', 'at_hash']
7780
}
7881

7982
if (options.configStore.isOIDC) {

0 commit comments

Comments
 (0)