Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions src/frontend-vue/e2e/50-2fa.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })

Expand All @@ -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`, {
Expand All @@ -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 <svg> 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`.
Expand Down Expand Up @@ -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()
Expand All @@ -98,15 +135,15 @@ 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')
})

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)
Expand Down
1 change: 1 addition & 0 deletions src/frontend-vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
8 changes: 8 additions & 0 deletions src/frontend-vue/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions src/frontend-vue/src/components/QrCode.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup lang="ts">
// A QR code drawn in the browser. What it encodes never leaves the page: the authenticator
// setup puts the TOTP secret in here, so the code must not be fetched as an image from a
// QR service — and the CSP (`img-src 'self' data:`) would refuse that image anyway.
import { computed } from 'vue'
import { encode } from 'uqr'

const props = withDefaults(defineProps<{
value: string
/** Edge length in CSS pixels. */
size?: number
label?: string
}>(), {
size: 200,
label: 'QR Code',
})

// Four modules of quiet zone, as the QR specification asks for — scanners need it to find
// the code when the page behind it is dark.
const qr = computed(() => encode(props.value, { ecc: 'M', border: 4 }))

const path = computed(() => {
const { data, size } = qr.value
let d = ''
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
if (data[row]?.[col]) d += `M${col},${row}h1v1h-1z`
}
}
return d
})
</script>

<template>
<!-- Always dark on white, in dark mode too: an inverted code is not readable by every scanner. -->
<svg
xmlns="http://www.w3.org/2000/svg"
:viewBox="`0 0 ${qr.size} ${qr.size}`"
:width="size"
:height="size"
shape-rendering="crispEdges"
role="img"
:aria-label="label">
<rect :width="qr.size" :height="qr.size" fill="#fff" />
<path :d="path" fill="#000" />
</svg>
</template>
9 changes: 2 additions & 7 deletions src/frontend-vue/src/views/auth/MfaSetupModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -79,13 +80,7 @@ async function verifyCode() {

<!-- QR Code -->
<div class="flex justify-center py-4">
<img
:src="`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(authenticatorUri)}`"
alt="QR Code"
width="200"
height="200"
class="rounded border"
/>
<QrCode v-if="authenticatorUri" :value="authenticatorUri" class="rounded border" />
</div>

<!-- Shared Key -->
Expand Down
3 changes: 2 additions & 1 deletion src/frontend-vue/src/views/auth/SecureSetupModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -167,7 +168,7 @@ function bufferToBase64Url(b: ArrayBuffer): string {
<p class="text-sm text-surface-600">{{ t('auth.secureSetup.totpScanQr', {}, 'Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.).') }}</p>

<div class="flex justify-center">
<img :src="`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpAuthUri)}`" alt="QR Code" class="rounded" />
<QrCode v-if="totpAuthUri" :value="totpAuthUri" class="rounded" />
</div>

<details class="text-xs text-surface-500">
Expand Down
Loading