From f62ffc8d92045a7ca88d13de51356ef3cfc6c512 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Thu, 3 Sep 2026 16:27:11 +0200 Subject: [PATCH] feat(2fa): show a scannable QR code when enabling two-factor auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup screen showed the secret and the otpauth URI as text and asked you to scan — with nothing to scan. It renders the URI as a QR now, an SVG (one dark path on a white card, so it reads in either theme) generated from the barcode encoder already in the module graph via pquerna/otp. The secret stays below it for a device that cannot scan. Co-Authored-By: Claude Opus 4.8 --- go.mod | 4 +-- internal/auth/totp.go | 36 ++++++++++++++++++++ internal/httpapi/totp_handlers.go | 10 +++++- web/src/routes/indstillinger/+page.svelte | 40 +++++++++++++++++++---- 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 1547dba..47c62f4 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/kristianwind/verdande go 1.26.3 require ( + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc github.com/coder/websocket v1.8.15 github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/emersion/go-message v0.18.2 github.com/go-chi/chi/v5 v5.3.1 github.com/go-webauthn/webauthn v0.17.4 github.com/pquerna/otp v1.5.0 @@ -14,9 +16,7 @@ require ( ) require ( - github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect diff --git a/internal/auth/totp.go b/internal/auth/totp.go index 4f30397..62c6ef8 100644 --- a/internal/auth/totp.go +++ b/internal/auth/totp.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/boombuler/barcode/qr" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" ) @@ -50,6 +51,41 @@ func NewTOTPSecret(issuer, account string) (secret, uri string, err error) { return key.Secret(), key.URL(), nil } +// TOTPQRSVG renders the otpauth:// URI as a QR code, as an SVG a phone camera can +// read. SVG rather than a PNG data URI so it stays crisp at any size and adds no +// base64 blob to the page — one dark path on a white square, which is what a scanner +// needs whatever theme the page is in. +// +// The barcode encoder is already in the module graph (pquerna/otp draws on it), so +// this borrows a dependency rather than adding one. +func TOTPQRSVG(uri string) (string, error) { + code, err := qr.Encode(uri, qr.M, qr.Auto) + if err != nil { + return "", fmt.Errorf("auth: encode totp qr: %w", err) + } + b := code.Bounds() + n := b.Dx() + // A four-module quiet zone all round, which the spec requires and scanners rely + // on — without it the code runs to the edge and a reader cannot find its border. + const quiet = 4 + size := n + quiet*2 + + var sb strings.Builder + fmt.Fprintf(&sb, ``, size, size) + sb.WriteString(``) + return sb.String(), nil +} + // TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR // code again before enrolment is confirmed. func TOTPURI(secret, issuer, account string) string { diff --git a/internal/httpapi/totp_handlers.go b/internal/httpapi/totp_handlers.go index 0458280..165ab5e 100644 --- a/internal/httpapi/totp_handlers.go +++ b/internal/httpapi/totp_handlers.go @@ -17,6 +17,9 @@ import ( type totpSetupResponse struct { Secret string `json:"secret"` URI string `json:"uri"` + // The URI as a scannable QR code (SVG). The secret and URI stay for typing in by + // hand on a device that cannot scan. + QR string `json:"qr"` } func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) { @@ -54,7 +57,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) { s.internal(w, r, "store totp secret", err) return } - writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri}) + qrSVG, err := auth.TOTPQRSVG(uri) + if err != nil { + s.internal(w, r, "render totp qr", err) + return + } + writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri, QR: qrSVG}) } type totpConfirmResponse struct { diff --git a/web/src/routes/indstillinger/+page.svelte b/web/src/routes/indstillinger/+page.svelte index 4a80ef9..ea14505 100644 --- a/web/src/routes/indstillinger/+page.svelte +++ b/web/src/routes/indstillinger/+page.svelte @@ -138,6 +138,7 @@ let totpSecret = $state(''); let totpURI = $state(''); + let totpQR = $state(''); let totpCode = $state(''); let totpErrors = $state({}); let recoveryCodes = $state([]); @@ -163,6 +164,7 @@ enablePassword = ''; totpSecret = r.secret; totpURI = r.uri; + totpQR = r.qr; } catch (e) { totpErrors = e.fields ?? {}; if (!Object.keys(totpErrors).length) app.toast(humanMessage(e)); @@ -177,6 +179,7 @@ recoveryCodes = r.recovery_codes; totpSecret = ''; totpURI = ''; + totpQR = ''; totpCode = ''; app.user = await api.me(); } catch (e) { @@ -492,8 +495,13 @@ {:else if totpSecret}

{t('account.scanHint')}

+ {#if totpQR} + +
{@html totpQR}
+ {/if}

{totpSecret}

-

{totpURI}

@@ -514,7 +522,14 @@
- +
{:else} @@ -859,6 +874,23 @@ color: var(--ink-faint); } + /* On its own white card whatever the theme: a QR is dark-on-light or it does not + scan, so the square keeps its own background rather than taking the page's. */ + .qr { + width: 176px; + height: 176px; + padding: var(--s2); + background: #ffffff; + border: 1px solid var(--line); + border-radius: var(--radius); + } + + .qr :global(svg) { + display: block; + width: 100%; + height: 100%; + } + .secret { margin: 0; padding: var(--s3); @@ -869,10 +901,6 @@ overflow-wrap: anywhere; } - .uri { - overflow-wrap: anywhere; - } - /* The understated variant: present, not loud. Bordered and in the muted ink so it reads as a footnote, brightening on hover. The cup is outline-only. */ .bmc {