From c42d5cd6f1bbc5a533c10d65b8a919135d8c548a Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 10 Oct 2026 21:05:27 +0200 Subject: [PATCH] fix(2fa): draw the authenticator QR code in the browser Both setup dialogs (profile and the enforced setup at sign-in) loaded the QR code as an image from api.qrserver.com. The CSP only allows `img-src 'self' data:`, so a production image showed a broken picture and the authenticator app could only be set up by typing the key. Without the CSP the request would have carried the TOTP secret to that service. The code is now an inline SVG built from the otpauth URI with `uqr` (no dependencies of its own): nothing leaves the page. It stays dark on white in dark mode and has the four-module quiet zone scanners expect. The 2FA e2e spec only spoke to the API, which is why this went unnoticed. It now opens the dialog against the production image and asserts that the code is drawn, that no request leaves the origin and that the CSP is not violated. The spec also signs in with the e-mail address: a created user's user name is the address, no longer the acronym, so the spec was red on develop before this. Co-Authored-By: Claude Opus 5.5 --- src/frontend-vue/e2e/50-2fa.spec.ts | 47 +++++++++++++++++-- src/frontend-vue/package.json | 1 + src/frontend-vue/pnpm-lock.yaml | 8 ++++ src/frontend-vue/src/components/QrCode.vue | 47 +++++++++++++++++++ .../src/views/auth/MfaSetupModal.vue | 9 +--- .../src/views/auth/SecureSetupModal.vue | 3 +- 6 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 src/frontend-vue/src/components/QrCode.vue 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 +