feat(2fa): show a scannable QR code when enabling two-factor auth
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
73ee93deab
commit
f62ffc8d92
@@ -3,8 +3,10 @@ module github.com/kristianwind/verdande
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
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-chi/chi/v5 v5.3.1
|
||||||
github.com/go-webauthn/webauthn v0.17.4
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
@@ -14,9 +16,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/boombuler/barcode/qr"
|
||||||
"github.com/pquerna/otp"
|
"github.com/pquerna/otp"
|
||||||
"github.com/pquerna/otp/totp"
|
"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
|
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, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges">`, size, size)
|
||||||
|
sb.WriteString(`<rect width="100%" height="100%" fill="#ffffff"/><path fill="#111312" d="`)
|
||||||
|
for y := 0; y < n; y++ {
|
||||||
|
for x := 0; x < n; x++ {
|
||||||
|
// Dark modules are black; the red channel is 0 for those and full for the
|
||||||
|
// light ones. One 1×1 rect per dark module, merged into a single path.
|
||||||
|
if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 {
|
||||||
|
fmt.Fprintf(&sb, "M%d %dh1v1h-1z", x+quiet, y+quiet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(`"/></svg>`)
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
// TOTPURI rebuilds the otpauth:// URI for an existing secret, for showing the QR
|
||||||
// code again before enrolment is confirmed.
|
// code again before enrolment is confirmed.
|
||||||
func TOTPURI(secret, issuer, account string) string {
|
func TOTPURI(secret, issuer, account string) string {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import (
|
|||||||
type totpSetupResponse struct {
|
type totpSetupResponse struct {
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
URI string `json:"uri"`
|
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) {
|
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)
|
s.internal(w, r, "store totp secret", err)
|
||||||
return
|
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 {
|
type totpConfirmResponse struct {
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
|
|
||||||
let totpSecret = $state('');
|
let totpSecret = $state('');
|
||||||
let totpURI = $state('');
|
let totpURI = $state('');
|
||||||
|
let totpQR = $state('');
|
||||||
let totpCode = $state('');
|
let totpCode = $state('');
|
||||||
let totpErrors = $state({});
|
let totpErrors = $state({});
|
||||||
let recoveryCodes = $state([]);
|
let recoveryCodes = $state([]);
|
||||||
@@ -163,6 +164,7 @@
|
|||||||
enablePassword = '';
|
enablePassword = '';
|
||||||
totpSecret = r.secret;
|
totpSecret = r.secret;
|
||||||
totpURI = r.uri;
|
totpURI = r.uri;
|
||||||
|
totpQR = r.qr;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
totpErrors = e.fields ?? {};
|
totpErrors = e.fields ?? {};
|
||||||
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
|
||||||
@@ -177,6 +179,7 @@
|
|||||||
recoveryCodes = r.recovery_codes;
|
recoveryCodes = r.recovery_codes;
|
||||||
totpSecret = '';
|
totpSecret = '';
|
||||||
totpURI = '';
|
totpURI = '';
|
||||||
|
totpQR = '';
|
||||||
totpCode = '';
|
totpCode = '';
|
||||||
app.user = await api.me();
|
app.user = await api.me();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -492,8 +495,13 @@
|
|||||||
{:else if totpSecret}
|
{:else if totpSecret}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<p class="hint">{t('account.scanHint')}</p>
|
<p class="hint">{t('account.scanHint')}</p>
|
||||||
|
{#if totpQR}
|
||||||
|
<!-- The server renders the otpauth URI as an SVG QR — one dark path on a
|
||||||
|
white square, so it scans in either theme. It is the URI it already
|
||||||
|
sent, drawn; nothing here builds it. -->
|
||||||
|
<div class="qr">{@html totpQR}</div>
|
||||||
|
{/if}
|
||||||
<p class="mono secret">{totpSecret}</p>
|
<p class="mono secret">{totpSecret}</p>
|
||||||
<p class="hint mono uri">{totpURI}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onsubmit={confirmTOTP}>
|
<form onsubmit={confirmTOTP}>
|
||||||
@@ -514,7 +522,14 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="primary" type="submit">{t('account.confirm')}</button>
|
<button class="primary" type="submit">{t('account.confirm')}</button>
|
||||||
<button class="secondary" onclick={() => (totpSecret = '')}>{t('account.cancel')}</button>
|
<button
|
||||||
|
class="secondary"
|
||||||
|
onclick={() => {
|
||||||
|
totpSecret = '';
|
||||||
|
totpURI = '';
|
||||||
|
totpQR = '';
|
||||||
|
}}>{t('account.cancel')}</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -859,6 +874,23 @@
|
|||||||
color: var(--ink-faint);
|
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 {
|
.secret {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--s3);
|
padding: var(--s3);
|
||||||
@@ -869,10 +901,6 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uri {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The understated variant: present, not loud. Bordered and in the muted ink so
|
/* 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. */
|
it reads as a footnote, brightening on hover. The cup is outline-only. */
|
||||||
.bmc {
|
.bmc {
|
||||||
|
|||||||
Reference in New Issue
Block a user