Skip to content

Commit e4c83dc

Browse files
Add login setup page
Introduces AtLoginProvider and a dedicated login-setup page/view allowing two-factor gateway configuration during login. Signed-off-by: LEROUGE Pierre <pierre.lerouge@cegedim.com>
1 parent 755da90 commit e4c83dc

8 files changed

Lines changed: 436 additions & 4 deletions

File tree

‎lib/Controller/SettingsController.php‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OCP\AppFramework\Http;
1515
use OCP\AppFramework\Http\Attribute\ApiRoute;
1616
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
17+
use OCP\AppFramework\Http\Attribute\NoTwoFactorRequired;
1718
use OCP\AppFramework\Http\JSONResponse;
1819
use OCP\AppFramework\OCSController;
1920
use OCP\IRequest;
@@ -44,6 +45,7 @@ public function __construct(
4445
* 503: Gateway wasn't configured yed
4546
*/
4647
#[NoAdminRequired]
48+
#[NoTwoFactorRequired]
4749
#[ApiRoute(verb: 'GET', url: '/settings/{gateway}/verification')]
4850
public function getVerificationState(string $gateway): JSONResponse {
4951
$user = $this->userSession->getUser();
@@ -71,6 +73,7 @@ public function getVerificationState(string $gateway): JSONResponse {
7173
* 400: User not found
7274
*/
7375
#[NoAdminRequired]
76+
#[NoTwoFactorRequired]
7477
#[ApiRoute(verb: 'POST', url: '/settings/{gateway}/verification/start')]
7578
public function startVerification(string $gateway, string $identifier): JSONResponse {
7679
$user = $this->userSession->getUser();
@@ -102,6 +105,7 @@ public function startVerification(string $gateway, string $identifier): JSONResp
102105
* 400: User not found
103106
*/
104107
#[NoAdminRequired]
108+
#[NoTwoFactorRequired]
105109
#[ApiRoute(verb: 'POST', url: '/settings/{gateway}/verification/finish')]
106110
public function finishVerification(string $gateway, string $verificationCode): JSONResponse {
107111
$user = $this->userSession->getUser();
@@ -112,8 +116,8 @@ public function finishVerification(string $gateway, string $verificationCode): J
112116

113117
try {
114118
$this->setup->finishSetup($user, $gateway, $verificationCode);
115-
} catch (VerificationException) {
116-
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
119+
} catch (VerificationException $e) {
120+
return new JSONResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
117121
}
118122

119123
return new JSONResponse([]);

‎lib/Provider/AProvider.php‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
use OCA\TwoFactorGateway\Service\StateStorage;
1818
use OCA\TwoFactorGateway\Settings\PersonalSettings;
1919
use OCP\AppFramework\Services\IInitialState;
20+
use OCP\Authentication\TwoFactorAuth\IActivatableAtLogin;
2021
use OCP\Authentication\TwoFactorAuth\IDeactivatableByAdmin;
22+
use OCP\Authentication\TwoFactorAuth\ILoginSetupProvider;
2123
use OCP\Authentication\TwoFactorAuth\IPersonalProviderSettings;
2224
use OCP\Authentication\TwoFactorAuth\IProvider;
2325
use OCP\Authentication\TwoFactorAuth\IProvidesIcons;
@@ -31,7 +33,7 @@
3133
use OCP\Template\ITemplate;
3234
use OCP\Template\ITemplateManager;
3335

34-
abstract class AProvider implements IProvider, IProvidesIcons, IDeactivatableByAdmin, IProvidesPersonalSettings {
36+
abstract class AProvider implements IProvider, IProvidesIcons, IDeactivatableByAdmin, IProvidesPersonalSettings, IActivatableAtLogin {
3537

3638
protected string $gatewayName = '';
3739
protected IGateway $gateway;
@@ -130,6 +132,15 @@ public function getPersonalSettings(IUser $user): IPersonalProviderSettings {
130132
);
131133
}
132134

135+
#[\Override]
136+
public function getLoginSetup(IUser $user): ILoginSetupProvider {
137+
$this->initialState->provideInitialState('settings-' . $this->gateway->getProviderId(), $this->gateway->getSettings());
138+
return new AtLoginProvider(
139+
$this->getGatewayName(),
140+
$this->gateway->isComplete(),
141+
);
142+
}
143+
133144
#[\Override]
134145
public function getLightIcon(): String {
135146
return Server::get(IURLGenerator::class)->imagePath(Application::APP_ID, 'app.svg');

‎lib/Provider/AtLoginProvider.php‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2024 Christoph Wurst <christoph@winzerhof-wurst.at>
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\TwoFactorGateway\Provider;
11+
12+
use OCP\Authentication\TwoFactorAuth\ILoginSetupProvider;
13+
use OCP\Server;
14+
use OCP\Template\ITemplate;
15+
use OCP\Template\ITemplateManager;
16+
17+
class AtLoginProvider implements ILoginSetupProvider {
18+
19+
public function __construct(
20+
private string $gateway,
21+
private bool $isComplete,
22+
) {
23+
}
24+
25+
#[\Override]
26+
public function getBody(): ITemplate {
27+
$template = Server::get(ITemplateManager::class)->getTemplate('twofactor_gateway', 'loginsetup');
28+
$template->assign('gateway', $this->gateway);
29+
$template->assign('isComplete', $this->isComplete);
30+
return $template;
31+
}
32+
}

‎src/login-setup.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createApp } from 'vue'
7+
import { loadState } from '@nextcloud/initial-state'
8+
import LoginSetup from './views/LoginSetup.vue'
9+
10+
const el = document.getElementById('twofactor-gateway-login-setup')
11+
if (el) {
12+
const gateway = (document.getElementById('twofactor-gateway-login-setup-gateway') as HTMLInputElement | null)?.value ?? ''
13+
const isComplete = (document.getElementById('twofactor-gateway-login-setup-is-complete') as HTMLInputElement | null)?.value === '1'
14+
15+
const state = loadState('twofactor_gateway', `settings-${gateway}`, {
16+
name: '',
17+
})
18+
19+
createApp(LoginSetup, {
20+
gatewayName: gateway,
21+
displayName: state.name,
22+
isComplete,
23+
}).mount(el)
24+
}

‎src/tests/views/LoginSetup.spec.ts‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
2+
// SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { flushPromises, mount } from '@vue/test-utils'
6+
import { defineComponent } from 'vue'
7+
import LoginSetup from '../../views/LoginSetup.vue'
8+
9+
Object.defineProperty(window, 'matchMedia', {
10+
writable: true,
11+
value: vi.fn().mockImplementation(() => ({
12+
matches: false,
13+
addEventListener: vi.fn(),
14+
removeEventListener: vi.fn(),
15+
})),
16+
})
17+
18+
vi.mock('@nextcloud/l10n', () => ({
19+
t: (_app: string, text: string, parameters?: Record<string, string | number>) => {
20+
if (parameters === undefined) {
21+
return `tr:${text}`
22+
}
23+
return Object.entries(parameters).reduce(
24+
(translated, [key, value]) => translated.replace(`{${key}}`, String(value)),
25+
`tr:${text}`,
26+
)
27+
},
28+
}))
29+
30+
vi.mock('@nextcloud/axios', () => ({
31+
default: {
32+
get: vi.fn(),
33+
post: vi.fn(),
34+
},
35+
}))
36+
37+
vi.mock('@nextcloud/router', () => ({
38+
generateOcsUrl: (url: string, params: Record<string, string> = {}) => Object.entries(params).reduce(
39+
(acc, [key, value]) => acc.replace(`{${key}}`, value),
40+
url,
41+
),
42+
}))
43+
44+
vi.mock('dompurify', () => ({
45+
default: { sanitize: (value: string) => value },
46+
}))
47+
48+
vi.mock('@nextcloud/vue/components/NcButton', () => ({
49+
default: defineComponent({
50+
emits: ['click'],
51+
template: '<button type="button" @click="$emit(\'click\', $event)"><slot /></button>',
52+
}),
53+
}))
54+
55+
vi.mock('@nextcloud/vue/components/NcLoadingIcon', () => ({
56+
default: defineComponent({ template: '<div class="nc-loading-icon" />' }),
57+
}))
58+
59+
vi.mock('@nextcloud/vue/components/NcTextField', () => ({
60+
default: defineComponent({
61+
props: ['modelValue', 'error', 'helperText'],
62+
emits: ['update:modelValue'],
63+
template: '<input type="text" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)">',
64+
}),
65+
}))
66+
67+
const makeProps = (overrides: Record<string, unknown> = {}) => ({
68+
gatewayName: 'signal',
69+
displayName: 'Signal',
70+
instructions: 'Install Signal first',
71+
isComplete: true,
72+
...overrides,
73+
})
74+
75+
describe('LoginSetup', () => {
76+
it('shows the not-available message when the gateway is not configured', async () => {
77+
const wrapper = mount(LoginSetup, { props: makeProps({ isComplete: false }) })
78+
await flushPromises()
79+
80+
expect(wrapper.text()).toContain('tr:Signal is not available. Please ask your administrator to finish setting it up.')
81+
expect(wrapper.find('.nc-loading-icon').exists()).toBe(false)
82+
})
83+
84+
it('starts at the identifier step when the user has no in-flight verification', async () => {
85+
const axios = (await import('@nextcloud/axios')).default
86+
vi.mocked(axios.get).mockResolvedValueOnce({ data: { state: 0, phoneNumber: null } })
87+
88+
const wrapper = mount(LoginSetup, { props: makeProps() })
89+
await flushPromises()
90+
91+
expect((wrapper.vm as unknown as { state: number }).state).toBe(1)
92+
expect(wrapper.text()).toContain('tr:Enter your identification (e.g. phone number to start the verification):')
93+
})
94+
95+
it('resumes at the confirmation step when the server reports state 2', async () => {
96+
const axios = (await import('@nextcloud/axios')).default
97+
vi.mocked(axios.get).mockResolvedValueOnce({ data: { state: 2, phoneNumber: '+33 6 ** ** ** 12' } })
98+
99+
const wrapper = mount(LoginSetup, { props: makeProps() })
100+
await flushPromises()
101+
102+
expect((wrapper.vm as unknown as { state: number }).state).toBe(2)
103+
expect(wrapper.text()).toContain('+33 6 ** ** ** 12')
104+
})
105+
106+
it('moves to the confirmation step after a successful verify call', async () => {
107+
const axios = (await import('@nextcloud/axios')).default
108+
vi.mocked(axios.get).mockResolvedValueOnce({ data: { state: 0, phoneNumber: null } })
109+
vi.mocked(axios.post).mockResolvedValueOnce({ data: { phoneNumber: '+33 6 ** ** ** 12' } })
110+
111+
const wrapper = mount(LoginSetup, { props: makeProps() })
112+
await flushPromises()
113+
114+
const vm = wrapper.vm as unknown as { identifier: string; verify: () => Promise<void>; state: number; phoneNumber: string }
115+
vm.identifier = '+33612345612'
116+
await vm.verify()
117+
await flushPromises()
118+
119+
expect(vm.state).toBe(2)
120+
expect(vm.phoneNumber).toBe('+33 6 ** ** ** 12')
121+
})
122+
123+
it('surfaces a verification error when the confirm call fails', async () => {
124+
const axios = (await import('@nextcloud/axios')).default
125+
vi.mocked(axios.get).mockResolvedValueOnce({ data: { state: 2, phoneNumber: '+33' } })
126+
vi.mocked(axios.post).mockRejectedValueOnce({ response: { data: { ocs: { data: { message: 'Wrong code' } } } } })
127+
128+
const wrapper = mount(LoginSetup, { props: makeProps() })
129+
await flushPromises()
130+
131+
const vm = wrapper.vm as unknown as { confirmationCode: string; confirm: () => Promise<void>; state: number; verificationError: string }
132+
vm.confirmationCode = '000000'
133+
await vm.confirm()
134+
await flushPromises()
135+
136+
expect(vm.state).toBe(1)
137+
expect(vm.verificationError).toBe('Wrong code')
138+
})
139+
140+
it('submits the redirect form after a successful confirmation', async () => {
141+
const axios = (await import('@nextcloud/axios')).default
142+
vi.mocked(axios.get).mockResolvedValueOnce({ data: { state: 2, phoneNumber: '+33' } })
143+
vi.mocked(axios.post).mockResolvedValueOnce({ data: {} })
144+
145+
const wrapper = mount(LoginSetup, { props: makeProps() })
146+
await flushPromises()
147+
148+
const submit = vi.fn()
149+
const vm = wrapper.vm as unknown as {
150+
confirmationCode: string
151+
confirm: () => Promise<void>
152+
state: number
153+
$refs: { redirectForm: HTMLFormElement }
154+
}
155+
vm.$refs.redirectForm.submit = submit
156+
vm.confirmationCode = '123456'
157+
await vm.confirm()
158+
await flushPromises()
159+
160+
expect(vm.state).toBe(3)
161+
expect(submit).toHaveBeenCalledTimes(1)
162+
})
163+
})

0 commit comments

Comments
 (0)