diff --git a/src/frontend-vue/e2e/50-2fa.spec.ts b/src/frontend-vue/e2e/50-2fa.spec.ts index 17255bce..6e45a490 100644 --- a/src/frontend-vue/e2e/50-2fa.spec.ts +++ b/src/frontend-vue/e2e/50-2fa.spec.ts @@ -27,6 +27,9 @@ const TEST_PASSWORD = 'TestPass1234!' const SUFFIX = uniqueSuffix() const userName = `totp-${SUFFIX}` +// A user signs in with the e-mail address: the account's user name is the address, the +// acronym is only a display handle. +const email = `${userName}@modgud.test` test.describe.configure({ mode: 'serial' }) @@ -39,7 +42,7 @@ test.beforeAll(async ({ request }) => { const created = await (await request.post('/api/user', { data: { Firstname: 'Totp', Lastname: 'User', Acronym: userName, - Email: `${userName}@modgud.test`, + Email: email, }, })).json() const passRes = await request.put(`/api/user/${created.Id}/password`, { @@ -49,9 +52,43 @@ test.beforeAll(async ({ request }) => { await request.post('/api/account/logout') }) +test('§3 the setup dialog draws the QR code itself', async ({ page, baseURL }) => { + // The QR code carries the TOTP secret, so the page has to draw it itself: an image from a + // QR service would hand the secret to that service, and the production CSP + // (`img-src 'self' data:`) refuses such an image anyway — a broken picture in the dialog. + // The specs below talk to the API only; this one opens the dialog. + await apiLogin(page, email, TEST_PASSWORD) + + const origin = new URL(baseURL!).origin + const foreignRequests: string[] = [] + const cspViolations: string[] = [] + page.on('request', (r) => { + const url = new URL(r.url()) + if (url.protocol.startsWith('http') && url.origin !== origin) foreignRequests.push(r.url()) + }) + page.on('console', (m) => { + if (m.type() === 'error' && /Content Security Policy/i.test(m.text())) cspViolations.push(m.text()) + }) + + await page.goto('/profile') + await page.getByRole('menuitem', { name: /Sicherheit|Security/i }).click() + const setupResponse = page.waitForResponse((r) => r.url().endsWith('/api/account/mfa/setup')) + await page.getByRole('button', { name: /MFA einrichten|Set up MFA/i }).click() + expect((await setupResponse).ok()).toBeTruthy() + + // Drawn in the page: an with the modules as one path, not an image from elsewhere. + const qr = page.locator('svg[role="img"][aria-label="QR Code"]') + await expect(qr).toBeVisible() + const modules = await qr.locator('path').getAttribute('d') + expect(modules?.length ?? 0).toBeGreaterThan(1000) + + expect(foreignRequests).toEqual([]) + expect(cspViolations).toEqual([]) +}) + test('§3 TOTP setup + sign-in with code', async ({ page, request }) => { // Authenticate the user (cookie via page.request). - await apiLogin(page, userName, TEST_PASSWORD) + await apiLogin(page, email, TEST_PASSWORD) // Step 1: Request authenticator setup. Backend resets the key + returns // the otpauth:// URI for the QR plus the human-format `SharedKey`. @@ -79,7 +116,7 @@ test('§3 TOTP setup + sign-in with code', async ({ page, request }) => { await page.request.post('/api/account/logout') const firstLogin = await request.post('/api/account/login', { - data: { UserName: userName, Password: TEST_PASSWORD, RememberMe: false }, + data: { UserName: email, Password: TEST_PASSWORD, RememberMe: false }, }) expect(firstLogin.ok()).toBeTruthy() const firstBody = await firstLogin.json() @@ -98,7 +135,7 @@ test('§3 TOTP setup + sign-in with code', async ({ page, request }) => { // Step 5: /me on the same request context now returns the user — full // sign-in completed. const me = await (await request.get('/api/account/me')).json() - expect(me.UserName).toBe(userName) + expect(me.UserName).toBe(email) expect(me.Has2FA).toBe(true) expect(me.TwoFactorMethods).toContain('totp') }) @@ -106,7 +143,7 @@ test('§3 TOTP setup + sign-in with code', async ({ page, request }) => { test('§3 invalid TOTP on second-factor login is rejected', async ({ request }) => { // The previous test enabled TOTP. Try a wrong code on a fresh login. const passRes = await request.post('/api/account/login', { - data: { UserName: userName, Password: TEST_PASSWORD, RememberMe: false }, + data: { UserName: email, Password: TEST_PASSWORD, RememberMe: false }, }) expect(passRes.ok()).toBeTruthy() expect((await passRes.json()).RequiresMfa).toBe(true) diff --git a/src/frontend-vue/package.json b/src/frontend-vue/package.json index 2443538d..f21aeaf3 100644 --- a/src/frontend-vue/package.json +++ b/src/frontend-vue/package.json @@ -25,6 +25,7 @@ "@fontsource-variable/instrument-sans": "5.2.8", "monaco-editor": "^0.55.1", "pinia": "^3.0.4", + "uqr": "0.1.3", "vue": "^3.5.43", "vue-router": "^5.1.0" }, diff --git a/src/frontend-vue/pnpm-lock.yaml b/src/frontend-vue/pnpm-lock.yaml index dc503b7a..3a48f056 100644 --- a/src/frontend-vue/pnpm-lock.yaml +++ b/src/frontend-vue/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: pinia: specifier: ^3.0.4 version: 3.0.4(typescript@6.0.3)(vue@3.5.43(typescript@6.0.3)) + uqr: + specifier: 0.1.3 + version: 0.1.3 vue: specifier: ^3.5.43 version: 3.5.43(typescript@6.0.3) @@ -980,6 +983,9 @@ packages: resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} engines: {node: ^20.19.0 || >=22.12.0} + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} @@ -1967,6 +1973,8 @@ snapshots: picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 + uqr@0.1.3: {} + url-parse@1.5.10: dependencies: querystringify: 2.2.0 diff --git a/src/frontend-vue/src/components/QrCode.vue b/src/frontend-vue/src/components/QrCode.vue new file mode 100644 index 00000000..651e5684 --- /dev/null +++ b/src/frontend-vue/src/components/QrCode.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/frontend-vue/src/views/auth/MfaSetupModal.vue b/src/frontend-vue/src/views/auth/MfaSetupModal.vue index 928c2e2f..2a564bd4 100644 --- a/src/frontend-vue/src/views/auth/MfaSetupModal.vue +++ b/src/frontend-vue/src/views/auth/MfaSetupModal.vue @@ -3,6 +3,7 @@ import { computed, ref, onMounted } from 'vue' import { useHttpClient } from '@/composables/useHttpClient' import { useI18n } from '@cocoar/vue-localization' import ModalLayout from '@/components/ModalLayout.vue' +import QrCode from '@/components/QrCode.vue' import { CoarNotice, CoarOtpInput, @@ -79,13 +80,7 @@ async function verifyCode() {
- QR Code +
diff --git a/src/frontend-vue/src/views/auth/SecureSetupModal.vue b/src/frontend-vue/src/views/auth/SecureSetupModal.vue index 72c15412..c54d2384 100644 --- a/src/frontend-vue/src/views/auth/SecureSetupModal.vue +++ b/src/frontend-vue/src/views/auth/SecureSetupModal.vue @@ -3,6 +3,7 @@ import { computed, ref } from 'vue' import { useHttpClient } from '@/composables/useHttpClient' import { useAuthStore } from '@/stores/auth.store' import { useI18n } from '@cocoar/vue-localization' +import QrCode from '@/components/QrCode.vue' import { CoarNotice, CoarCard, @@ -167,7 +168,7 @@ function bufferToBase64Url(b: ArrayBuffer): string {

{{ t('auth.secureSetup.totpScanQr', {}, 'Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.).') }}

- QR Code +