5 Commits

Author SHA1 Message Date
Daniel Redetzke 1fe9b4e619 Two-step sign-in: authenticator app, security keys and passkeys 2026-10-04 21:55:53 +03:00
Daniel Redetzke ebbc282073 Drop the tagline from the sign-in page 2026-10-04 21:02:37 +03:00
Daniel Redetzke 8f13a37d92 Fingerprint app.js, setup.js and app.css 2026-10-04 20:56:35 +03:00
Daniel Redetzke 990aa55a3d Sortable peers table, shorter connection history 2026-10-04 20:53:56 +03:00
Daniel Redetzke 60426577a5 More decoy pages 2026-10-04 20:47:12 +03:00
13 changed files with 1776 additions and 65 deletions
+22 -1
View File
@@ -67,6 +67,14 @@ The screenshots show sample data from the built-in simulator.
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes. - **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
HttpOnly, SameSite=Strict cookie and last 12 hours by default. HttpOnly, SameSite=Strict cookie and last 12 hours by default.
- **Two-step sign-in:** each user can add an authenticator app (TOTP), security
keys such as a YubiKey, and passkeys that sign in without a password, under
My account. Turning it on gives 10 one-time recovery codes. An admin can
require it for everyone (Settings → Sign-in) and reset it for a user who lost
their phone or key. Security keys and passkeys use WebAuthn and need the
server's domain name with a trusted certificate (Let's Encrypt, certificate
files, or a reverse proxy); on a self-signed certificate or an IP address,
only the authenticator app is offered. API tokens never need a second step.
- **API tokens** are stored only as hashes and can be read-only or full access. - **API tokens** are stored only as hashes and can be read-only or full access.
- `config.json` holds the server private key and is readable only by the - `config.json` holds the server private key and is readable only by the
service (0600). service (0600).
@@ -235,6 +243,12 @@ GET. Full-access tokens can do everything the web interface does except backup
and restore. Users, passwords and API tokens need a full-access token even for and restore. Users, passwords and API tokens need a full-access token even for
reading. reading.
For a user with two-step sign-in, `POST /auth/login` answers
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
instead of starting a session; the ticket is good for 5 minutes, and one of
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user.
`POST /users` and `POST /users/{id}/reset-password` take `POST /users` and `POST /users/{id}/reset-password` take
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the `{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
user can do nothing but choose a new password at the next sign-in. user can do nothing but choose a new password at the next sign-in.
@@ -242,7 +256,14 @@ user can do nothing but choose a new password at the next sign-in.
``` ```
POST /auth/login · /auth/logout GET /auth/me POST /auth/password (own password) POST /auth/login · /auth/logout GET /auth/me POST /auth/password (own password)
GET /users POST /users PATCH /users/{id} DELETE /users/{id} GET /users POST /users PATCH /users/{id} DELETE /users/{id}
POST /users/{id}/reset-password POST /users/{id}/reset-password POST /users/{id}/reset-mfa
GET /auth/options (public: is passkey sign-in offered here)
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
POST /auth/login/passkey/begin · /auth/login/passkey/finish?id=
signed in: GET /auth/mfa · POST /auth/mfa/totp/setup · /auth/mfa/totp/confirm · DELETE /auth/mfa/totp
signed in: POST /auth/mfa/keys/begin {"passkey"} · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
signed in: POST /auth/mfa/recovery-codes
GET /status GET /stats?range=24h|7d|30d|90d GET /status GET /stats?range=24h|7d|30d|90d
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
GET /peers POST /peers (returns the config and QR once) GET /peers POST /peers (returns the config and QR once)
+37 -2
View File
@@ -84,6 +84,11 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "choose a new password first", "code": "password_change_required"}) writeJSON(w, http.StatusForbidden, map[string]string{"error": "choose a new password first", "code": "password_change_required"})
return return
} }
if p.MFASetupRequired && r.URL.Path != "/api/v1/auth/me" && r.URL.Path != "/api/v1/auth/password" &&
!strings.HasPrefix(r.URL.Path, "/api/v1/auth/mfa") {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "set up two-step sign-in first", "code": "mfa_setup_required"})
return
}
if p.Scope == "ro" && r.Method != http.MethodGet { if p.Scope == "ro" && r.Method != http.MethodGet {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"}) writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
return return
@@ -121,6 +126,25 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("POST /api/v1/auth/login", a.login) mux.HandleFunc("POST /api/v1/auth/login", a.login)
mux.HandleFunc("POST /api/v1/auth/logout", a.logout) mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
// The second step of signing in, and signing in with a passkey alone.
mux.HandleFunc("GET /api/v1/auth/options", a.signInOptions)
mux.HandleFunc("POST /api/v1/auth/login/totp", a.loginTOTP)
mux.HandleFunc("POST /api/v1/auth/login/recovery", a.loginRecovery)
mux.HandleFunc("POST /api/v1/auth/login/key/begin", a.loginKeyBegin)
mux.HandleFunc("POST /api/v1/auth/login/key/finish", a.loginKeyFinish)
mux.HandleFunc("POST /api/v1/auth/login/passkey/begin", a.loginPasskeyBegin)
mux.HandleFunc("POST /api/v1/auth/login/passkey/finish", a.loginPasskeyFinish)
// Your own two-step sign-in. Keys and passkeys need a browser, so these
// are for signed-in users only.
adm("GET /api/v1/auth/mfa", a.mfaStatus)
adm("POST /api/v1/auth/mfa/totp/setup", a.totpSetup)
adm("POST /api/v1/auth/mfa/totp/confirm", a.totpConfirm)
adm("DELETE /api/v1/auth/mfa/totp", a.totpRemove)
adm("POST /api/v1/auth/mfa/keys/begin", a.keyBegin)
adm("POST /api/v1/auth/mfa/keys/finish", a.keyFinish)
adm("PATCH /api/v1/auth/mfa/keys/{id}", a.keyRename)
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
g("GET /api/v1/auth/me", a.me) g("GET /api/v1/auth/me", a.me)
full("POST /api/v1/auth/password", a.changePassword) full("POST /api/v1/auth/password", a.changePassword)
full("GET /api/v1/users", a.listUsers) full("GET /api/v1/users", a.listUsers)
@@ -128,6 +152,7 @@ func (a *App) routes() http.Handler {
full("PATCH /api/v1/users/{id}", a.patchUser) full("PATCH /api/v1/users/{id}", a.patchUser)
full("POST /api/v1/users/{id}/reset-password", a.resetPassword) full("POST /api/v1/users/{id}/reset-password", a.resetPassword)
full("DELETE /api/v1/users/{id}", a.deleteUser) full("DELETE /api/v1/users/{id}", a.deleteUser)
full("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
g("GET /api/v1/status", a.status) g("GET /api/v1/status", a.status)
g("GET /api/v1/stats", a.allStats) g("GET /api/v1/stats", a.allStats)
@@ -203,7 +228,7 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
return return
} }
ip := remoteIP(r) ip := remoteIP(r)
id, err := a.auth.Login(in.Username, in.Password, ip) id, ticket, err := a.auth.Login(in.Username, in.Password, ip)
if err != nil { if err != nil {
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error()) slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
code := http.StatusUnauthorized code := http.StatusUnauthorized
@@ -213,6 +238,12 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
writeJSON(w, code, map[string]string{"error": err.Error()}) writeJSON(w, code, map[string]string{"error": err.Error()})
return return
} }
if ticket != "" {
// The password was right; the second step makes the session.
_, u := a.auth.ticketUserID(ticket)
writeJSON(w, http.StatusOK, map[string]any{"mfa": true, "ticket": ticket, "methods": mfaMethods(u)})
return
}
a.setSessionCookie(w, r, id) a.setSessionCookie(w, r, id)
slog.Info("login", "audit", true, "actor", in.Username, "remote", ip) slog.Info("login", "audit", true, "actor", in.Username, "remote", ip)
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
@@ -237,7 +268,7 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
p := who(r) p := who(r)
out := map[string]any{ out := map[string]any{
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope, "id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
"mustChangePassword": p.MustChangePassword, "version": version, "session": p.Session, "mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
} }
if p.TokenID != "" { if p.TokenID != "" {
out["tokenId"] = p.TokenID // lets an app find its own token in /tokens out["tokenId"] = p.TokenID // lets an app find its own token in /tokens
@@ -981,6 +1012,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
"log": cfg.Log, "log": cfg.Log,
"stats": cfg.Stats, "stats": cfg.Stats,
"decoy": cfg.Decoy, "decoy": cfg.Decoy,
"signin": cfg.SignIn,
"geo": a.geoStatus(), "geo": a.geoStatus(),
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions "adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
"fingerprint": a.tls.Fingerprint(), "fingerprint": a.tls.Fingerprint(),
@@ -1012,6 +1044,9 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
if err := field(m, "decoy", &c.Decoy); err != nil { if err := field(m, "decoy", &c.Decoy); err != nil {
return err return err
} }
if err := field(m, "signin", &c.SignIn); err != nil {
return err
}
return field(m, "log", &c.Log) return field(m, "log", &c.Log)
}) })
if err != nil { if err != nil {
+19 -1
View File
@@ -136,6 +136,10 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
table { width: 100%; border-collapse: collapse; min-width: 720px; } table { width: 100%; border-collapse: collapse; min-width: 720px; }
table.narrow { min-width: 520px; } table.narrow { min-width: 520px; }
th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; } th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th .sort { display: inline-flex; align-items: center; gap: 4px; background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; }
th .sort:hover, th .sort.on { color: var(--ink); }
th .sort .arrow { font-size: 11px; opacity: 0.35; }
th .sort:hover .arrow, th .sort.on .arrow { opacity: 1; }
td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; } td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
tr:last-child td { border-bottom: 0; } tr:last-child td { border-bottom: 0; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
@@ -272,7 +276,6 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.linkbtn { background: none; border: 0; padding: 4px; font: inherit; font-size: 13px; color: #9cc3f5; text-decoration: underline; cursor: pointer; align-self: center; } .linkbtn { background: none; border: 0; padding: 4px; font: inherit; font-size: 13px; color: #9cc3f5; text-decoration: underline; cursor: pointer; align-self: center; }
.linkbtn:hover { color: #fff; } .linkbtn:hover { color: #fff; }
.loginform .err-text:empty { display: none; } .loginform .err-text:empty { display: none; }
.loginfoot { margin: 0; font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #8d8e93; }
.err-text { color: var(--bad-ink); font-size: 13px; margin: 0; } .err-text { color: var(--bad-ink); font-size: 13px; margin: 0; }
/* setup link page (setup.html): same dark look as the login page */ /* setup link page (setup.html): same dark look as the login page */
.setuppage { min-height: 100vh; display: flex; justify-content: center; padding: 48px 16px; background: var(--ink); color: #f4f4f1; font-size: 15px; } .setuppage { min-height: 100vh; display: flex; justify-content: center; padding: 48px 16px; background: var(--ink); color: #f4f4f1; font-size: 15px; }
@@ -304,3 +307,18 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
.steps .btn.primary { background: #f4f4f1; border-color: #f4f4f1; color: var(--ink); font-weight: 600; } .steps .btn.primary { background: #f4f4f1; border-color: #f4f4f1; color: var(--ink); font-weight: 600; }
.steps .qr { width: 100%; height: auto; max-width: 280px; align-self: center; } .steps .qr { width: 100%; height: auto; max-width: 280px; align-self: center; }
.loading-page { padding: 40px; color: var(--ink-3); } .loading-page { padding: 40px; color: var(--ink-3); }
/* two-step sign-in */
.loginalt { width: 100%; display: flex; flex-direction: column; gap: 14px; margin-top: -24px; }
.loginalt .or, .loginform .or { display: flex; align-items: center; gap: 10px; color: #8d8e93; font-size: 12px; }
.loginalt .or::before, .loginalt .or::after { content: ""; flex: 1; height: 1px; background: #2c2d32; }
.loginpage .btn.altbtn { min-height: 44px; width: 100%; font-size: 15px; font-weight: 500; background: none; border-color: #3a3b41; color: #f4f4f1; margin-top: 0; }
.loginpage .btn.altbtn:hover { background: #222328; border-color: #55565c; color: #fff; }
.loginlinks { display: flex; flex-direction: column; align-items: center; gap: 2px; margin-top: 6px; }
.loginform .codeinput { text-align: center; font-size: 20px; letter-spacing: 0.2em; }
.mfalist { display: flex; flex-direction: column; }
.mfarow { display: flex; align-items: center; gap: 8px; padding: 12px 0; border-top: 1px solid var(--line-2); }
.mfarow:first-child { border-top: 0; padding-top: 0; }
.mfarow .grow { flex: 1; min-width: 0; }
.dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; }
.dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; }
+414 -21
View File
@@ -48,6 +48,7 @@
server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>', server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>', settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
plus: '<path d="M12 5v14M5 12h14"/>', plus: '<path d="M12 5v14M5 12h14"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>', logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>',
}; };
@@ -138,6 +139,16 @@
return ts + ' ' + String(l.level).padEnd(5) + ' ' + l.msg + (rest ? ' ' + rest : ''); return ts + ' ' + String(l.level).padEnd(5) + ' ' + l.msg + (rest ? ' ' + rest : '');
} }
// mfaText summarizes a user's two-step sign-in: "App, 2 keys" or "".
function mfaText(m) {
if (!m) return '';
const parts = [];
if (m.totp) parts.push('App');
if (m.keys) parts.push(m.keys === 1 ? '1 key' : m.keys + ' keys');
if (m.passkeys) parts.push(m.passkeys === 1 ? '1 passkey' : m.passkeys + ' passkeys');
return parts.join(', ');
}
// "Germany · Deutsche Telekom AG", "Local network" or "". // "Germany · Deutsche Telekom AG", "Local network" or "".
function fmtLocation(g) { function fmtLocation(g) {
if (!g) return ''; if (!g) return '';
@@ -216,7 +227,7 @@
const r = await fetch('/api/v1' + path, opt); const r = await fetch('/api/v1' + path, opt);
let data = {}; let data = {};
try { data = await r.json(); } catch { /* empty body */ } try { data = await r.json(); } catch { /* empty body */ }
if (r.status === 401 && path !== '/auth/login' && path !== '/auth/me') { if (r.status === 401 && !path.startsWith('/auth/login') && path !== '/auth/me') {
me = null; me = null;
showLogin(); showLogin();
throw new Error('Signed out'); throw new Error('Signed out');
@@ -225,6 +236,10 @@
showNewPassword(); showNewPassword();
throw new Error('Signed out'); throw new Error('Signed out');
} }
if (r.status === 403 && data.code === 'mfa_setup_required') {
showMFASetup();
throw new Error('Signed out');
}
if (!r.ok) throw new Error(data.error || r.statusText); if (!r.ok) throw new Error(data.error || r.statusText);
return data; return data;
} }
@@ -566,6 +581,7 @@
try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; } try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; }
} }
if (me.mustChangePassword) { showNewPassword(); return; } if (me.mustChangePassword) { showNewPassword(); return; }
if (me.mfaSetupRequired) { showMFASetup(); return; }
if (!main || !main.isConnected) buildShell(); if (!main || !main.isConnected) buildShell();
every(30000, refreshSide); every(30000, refreshSide);
const hash = location.hash || '#/'; const hash = location.hash || '#/';
@@ -604,9 +620,9 @@
err.textContent = ''; err.textContent = '';
btn.disabled = true; btn.disabled = true;
try { try {
await api('POST', '/auth/login', { username: user.value, password: pw.value }); const res = await api('POST', '/auth/login', { username: user.value, password: pw.value });
me = await api('GET', '/auth/me'); if (res.mfa) { showSecondStep(res.ticket, res.methods, pw.value); return; }
if (me.mustChangePassword) showNewPassword(pw.value); else render(); await signedIn(pw.value);
} catch (x) { } catch (x) {
err.textContent = x.message; err.textContent = x.message;
btn.disabled = false; btn.disabled = false;
@@ -616,13 +632,305 @@
h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), user), h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), user),
h('div', { class: 'field' }, h('label', { htmlFor: 'p' }, 'Password'), pw), h('div', { class: 'field' }, h('label', { htmlFor: 'p' }, 'Password'), pw),
err, btn); err, btn);
// A passkey signs in without username and password, where the address
// allows it.
const passkeyRow = h('div', { class: 'loginalt', hidden: true },
h('div', { class: 'or' }, 'or'),
h('button', { type: 'button', class: 'btn altbtn', onClick: async () => {
err.textContent = '';
try {
const b = await api('POST', '/auth/login/passkey/begin');
const cred = await webauthnGet(b.options);
await api('POST', '/auth/login/passkey/finish?id=' + encodeURIComponent(b.id), cred);
await signedIn();
} catch (x) { err.textContent = keyError(x); }
} }, icon('key', 18), 'Sign in with a passkey'));
if (window.PublicKeyCredential) {
api('GET', '/auth/options').then((o) => { passkeyRow.hidden = !o.passkeys; }).catch(() => {});
}
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' }, app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' },
brand(72), brand(72),
form), form, passkeyRow)));
h('p', { class: 'loginfoot' }, 'WireGuard server manager')));
user.focus(); user.focus();
} }
// signedIn continues after a successful sign-in. password is the one just
// typed, if any, so a temporary password need not be typed again.
async function signedIn(password) {
me = await api('GET', '/auth/me');
if (me.mustChangePassword) showNewPassword(password); else render();
}
// ---------- two-step sign-in ----------
const b64dec = (s) => {
const b = atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - s.length % 4) % 4));
return Uint8Array.from(b, (c) => c.charCodeAt(0)).buffer;
};
const b64enc = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// webauthnCreate and webauthnGet turn the server's options into the
// browser call and the browser's answer back into JSON.
async function webauthnCreate(opts) {
const pk = opts.publicKey;
pk.challenge = b64dec(pk.challenge);
pk.user.id = b64dec(pk.user.id);
(pk.excludeCredentials || []).forEach((c) => { c.id = b64dec(c.id); });
const c = await navigator.credentials.create({ publicKey: pk });
return {
id: c.id, rawId: b64enc(c.rawId), type: c.type, authenticatorAttachment: c.authenticatorAttachment,
response: {
clientDataJSON: b64enc(c.response.clientDataJSON), attestationObject: b64enc(c.response.attestationObject),
transports: c.response.getTransports ? c.response.getTransports() : [],
},
clientExtensionResults: c.getClientExtensionResults(),
};
}
async function webauthnGet(opts) {
const pk = opts.publicKey;
pk.challenge = b64dec(pk.challenge);
(pk.allowCredentials || []).forEach((c) => { c.id = b64dec(c.id); });
const c = await navigator.credentials.get({ publicKey: pk });
return {
id: c.id, rawId: b64enc(c.rawId), type: c.type, authenticatorAttachment: c.authenticatorAttachment,
response: {
clientDataJSON: b64enc(c.response.clientDataJSON), authenticatorData: b64enc(c.response.authenticatorData),
signature: b64enc(c.response.signature), userHandle: c.response.userHandle ? b64enc(c.response.userHandle) : null,
},
clientExtensionResults: c.getClientExtensionResults(),
};
}
// keyError explains a failed key or passkey prompt.
function keyError(x) {
if (x && x.name === 'NotAllowedError') return 'Cancelled or timed out. Try again.';
if (x && x.name === 'InvalidStateError') return 'This key is already set up for your account.';
if (x && x.name === 'SecurityError') return 'Security keys need this site on its domain name with a trusted certificate.';
return x.message;
}
// showSecondStep asks for a key, an authenticator code or a recovery code
// after a correct password.
function showSecondStep(ticket, methods, password) {
cleanups.forEach((f) => f());
cleanups = [];
main = null;
const canKey = methods.includes('key') && !!window.PublicKeyCredential;
let mode = canKey ? 'key' : methods.includes('totp') ? 'totp' : 'recovery';
const box = h('div', { class: 'loginform' });
const TITLES = {
key: ['Use your security key', 'Insert your key and touch it, or use the passkey on this device.'],
totp: ['Enter the code', 'The 6-digit code from your authenticator app.'],
recovery: ['Use a recovery code', 'One of the codes you saved when you set up two-step sign-in. Each works once.'],
};
const LINKS = { key: 'Use a security key instead', totp: 'Use an authenticator code instead', recovery: 'Use a recovery code' };
const head = h('div', { class: 'logintext' });
const draw = () => {
const err = h('p', { class: 'err-text', role: 'alert' });
head.replaceChildren(h('h1', null, TITLES[mode][0]), h('p', null, TITLES[mode][1]));
const others = ['key', 'totp', 'recovery'].filter((m) => m !== mode && methods.includes(m) && (m !== 'key' || canKey))
.map((m) => h('button', { type: 'button', class: 'linkbtn', onClick: () => { mode = m; draw(); } }, LINKS[m]));
const foot = h('div', { class: 'loginlinks' }, others, h('button', { type: 'button', class: 'linkbtn', onClick: showLogin }, 'Start over'));
if (mode === 'key') {
const btn = h('button', { type: 'button', class: 'btn primary' }, 'Use security key');
const go = async () => {
err.textContent = '';
btn.disabled = true;
try {
const opts = await api('POST', '/auth/login/key/begin', { ticket });
const cred = await webauthnGet(opts);
await api('POST', '/auth/login/key/finish?ticket=' + encodeURIComponent(ticket), cred);
await signedIn(password);
} catch (x) { err.textContent = keyError(x); btn.disabled = false; }
};
btn.addEventListener('click', go);
box.replaceChildren(err, btn, foot);
btn.focus();
return;
}
const code = h('input', { id: 'mc', autocomplete: 'one-time-code', autocapitalize: 'none', required: true,
inputMode: mode === 'totp' ? 'numeric' : 'text', class: 'mono codeinput', placeholder: mode === 'totp' ? '123 456' : 'XXXX-XXXX' });
const btn = h('button', { type: 'submit', class: 'btn primary' }, 'Verify');
box.replaceChildren(h('form', { class: 'loginform', onSubmit: async (e) => {
e.preventDefault();
err.textContent = '';
btn.disabled = true;
try {
await api('POST', '/auth/login/' + mode, { ticket, code: code.value });
await signedIn(password);
} catch (x) {
err.textContent = x.message;
btn.disabled = false;
code.select();
}
} }, h('div', { class: 'field' }, h('label', { htmlFor: 'mc', class: 'sr' }, TITLES[mode][0]), code), err, btn), foot);
code.focus();
};
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' }, brand(72), head, box)));
draw();
}
// showMFASetup is the screen for a user who must set up two-step sign-in
// before doing anything else.
async function showMFASetup() {
cleanups.forEach((f) => f());
cleanups = [];
main = null;
let st = { keysAvailable: false };
try { st = await api('GET', '/auth/mfa'); } catch { /* offer the app only */ }
const done = async () => { me = await api('GET', '/auth/me'); render(); };
const keys = st.keysAvailable && window.PublicKeyCredential;
app.replaceChildren(h('div', { class: 'loginpage' }, h('div', { class: 'loginbox' },
brand(72),
h('div', { class: 'logintext' },
h('h1', null, 'Set up two-step sign-in'),
h('p', null, 'This server asks for a second step after the password. Add one to continue.')),
h('div', { class: 'loginform' },
h('button', { type: 'button', class: 'btn primary', onClick: () => addTOTP(done) }, 'Use an authenticator app'),
keys ? h('button', { type: 'button', class: 'btn altbtn', onClick: () => addKey(false, done) }, 'Use a security key') : null,
keys ? h('button', { type: 'button', class: 'btn altbtn', onClick: () => addKey(true, done) }, 'Use a passkey') : null,
h('div', { class: 'loginlinks' }, h('button', { type: 'button', class: 'linkbtn', onClick: logout }, 'Sign out'))))));
}
// recoveryDialog shows new recovery codes once.
function recoveryDialog(codes, onClose) {
const text = codes.join('\n');
const d = dialog((close) => h('div', { class: 'dlg' },
h('h2', null, 'Your recovery codes'),
h('p', null, 'If you lose your phone or key, each of these signs you in once. Store them somewhere safe, such as your password manager. They are not shown again.'),
h('pre', { class: 'code codes' }, text),
h('div', { class: 'actions' },
h('button', { type: 'button', class: 'btn', onClick: () => copy(text) }, 'Copy'),
h('button', { type: 'button', class: 'btn', onClick: () => download(APP.toLowerCase() + '-recovery-codes.txt', text + '\n') }, 'Download')),
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn primary', onClick: close }, 'Done'))));
if (onClose) d.addEventListener('close', onClose);
}
// afterAdd shows recovery codes when the method was the first one.
const afterAdd = (res, onDone) => {
if (res.recoveryCodes && res.recoveryCodes.length) recoveryDialog(res.recoveryCodes, onDone);
else if (onDone) onDone();
};
async function addTOTP(onDone) {
let s;
try { s = await api('POST', '/auth/mfa/totp/setup'); } catch (x) { toast(x.message, true); return; }
const code = h('input', { id: 'tc', class: 'mono', autocomplete: 'one-time-code', inputMode: 'numeric', placeholder: '123 456', required: true });
const e = h('p', { class: 'err-text', role: 'alert' });
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
ev.preventDefault();
e.textContent = '';
try {
const res = await api('POST', '/auth/mfa/totp/confirm', { code: code.value });
close();
toast('Authenticator app turned on');
afterAdd(res, onDone);
} catch (x) { e.textContent = x.message; code.select(); }
} },
h('h2', null, 'Add an authenticator app'),
h('div', { class: 'qrrow' },
h('img', { class: 'qr', src: s.qr, alt: 'QR code for the authenticator app' }),
h('div', { class: 'col' },
h('p', null, 'Scan the code with your authenticator app, for example 1Password, Google Authenticator or Authy. Or enter this key by hand:'),
h('code', { class: 'mono secret' }, s.secret.match(/.{1,4}/g).join(' ')),
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(s.secret) }, 'Copy key')))),
h('div', { class: 'field' }, h('label', { htmlFor: 'tc' }, 'Code from the app'), code),
e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Turn on'))));
code.focus();
}
// addKey adds a security key, or with passkey a passkey that also signs
// in without a password.
function addKey(passkey, onDone) {
const nm = h('input', { id: 'kn', value: passkey ? 'Passkey' : 'YubiKey', autocomplete: 'off', maxLength: 64 });
const e = h('p', { class: 'err-text', role: 'alert' });
const btn = h('button', { type: 'submit', class: 'btn primary' }, passkey ? 'Add passkey' : 'Add security key');
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
ev.preventDefault();
e.textContent = '';
btn.disabled = true;
try {
const opts = await api('POST', '/auth/mfa/keys/begin', { passkey });
const cred = await webauthnCreate(opts);
const res = await api('POST', '/auth/mfa/keys/finish?name=' + encodeURIComponent(nm.value.trim()), cred);
close();
toast((passkey ? 'Passkey' : 'Security key') + ' added');
afterAdd(res, onDone);
} catch (x) { e.textContent = keyError(x); btn.disabled = false; }
} },
h('h2', null, passkey ? 'Add a passkey' : 'Add a security key'),
h('p', null, passkey
? 'A passkey signs you in on its own, without username and password. It can live in your password manager, on this device (Touch ID, Face ID, Windows Hello) or on a YubiKey.'
: 'A YubiKey or other FIDO2 key, asked for after your password. Have it ready: your browser asks you to insert and touch it.'),
h('div', { class: 'field' }, h('label', { htmlFor: 'kn' }, 'Name'), nm, h('span', { class: 'hint' }, 'So you can tell your keys apart')),
e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), btn)));
nm.select();
}
// mfaCard is the "Two-step sign-in" section of My account.
function mfaCard() {
const body = h('div', null, h('p', { class: 'muted' }, 'Loading…'));
const card = h('section', { class: 'card', 'aria-labelledby': 'mfa' },
h('h2', { id: 'mfa' }, 'Two-step sign-in'),
h('p', { class: 'lead' }, 'Asks for a second proof after your password. App tokens, like the iOS app\'s, are not affected.'),
body);
const draw = async () => {
let s;
try { s = await api('GET', '/auth/mfa'); } catch (x) { body.replaceChildren(h('p', { class: 'err-text' }, x.message)); return; }
const keys = s.keysAvailable && window.PublicKeyCredential;
const removeKey = async (k) => {
if (!await confirmDialog({ title: 'Remove ' + k.name + '?', text: 'It can no longer be used to sign in.', ok: 'Remove', danger: true })) return;
try { await api('DELETE', '/auth/mfa/keys/' + k.id); toast('Removed ' + k.name); draw(); } catch (x) { toast(x.message, true); }
};
const renameKey = (k) => {
const nm = h('input', { id: 'rk', value: k.name, maxLength: 64, required: true });
const e = h('p', { class: 'err-text', role: 'alert' });
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
ev.preventDefault();
try { await api('PATCH', '/auth/mfa/keys/' + k.id, { name: nm.value.trim() }); close(); draw(); } catch (x) { e.textContent = x.message; }
} }, h('h2', null, 'Rename key'), h('div', { class: 'field' }, h('label', { htmlFor: 'rk' }, 'Name'), nm), e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
nm.select();
};
const removeTOTP = async () => {
if (!await confirmDialog({ title: 'Remove the authenticator app?', text: 'Its codes stop working for this account.', ok: 'Remove', danger: true })) return;
try { await api('DELETE', '/auth/mfa/totp'); toast('Authenticator app removed'); draw(); } catch (x) { toast(x.message, true); }
};
const newCodes = async () => {
if (!await confirmDialog({ title: 'Make new recovery codes?', text: 'Your old codes stop working.', ok: 'Make new codes' })) return;
try { recoveryDialog((await api('POST', '/auth/mfa/recovery-codes')).recoveryCodes, draw); } catch (x) { toast(x.message, true); }
};
const rows = [];
if (s.totp) {
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, 'Authenticator app'), h('div', { class: 'hint' }, 'Added ' + fmtDate(s.totpAdded))),
h('button', { type: 'button', class: 'btn danger small', onClick: removeTOTP }, 'Remove')));
}
for (const k of s.keys) {
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, k.name),
h('div', { class: 'hint' }, (k.passkey ? 'Passkey' : 'Security key') + ' · added ' + fmtDate(k.created) + ' · ' + (k.lastUsed ? 'last used ' + ago(k.lastUsed) : 'not used yet'))),
h('button', { type: 'button', class: 'btn small', onClick: () => renameKey(k) }, 'Rename'),
h('button', { type: 'button', class: 'btn danger small', onClick: () => removeKey(k) }, 'Remove')));
}
if (rows.length) {
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, 'Recovery codes'), h('div', { class: 'hint' }, s.recoveryLeft + ' of 10 left')),
h('button', { type: 'button', class: 'btn small', onClick: newCodes }, 'New codes')));
}
body.replaceChildren(...[
rows.length ? h('div', { class: 'mfalist' }, rows) : h('div', { class: 'notice' }, s.required ? 'Two-step sign-in is required on this server.' : 'Two-step sign-in is off for your account.'),
h('div', { class: 'actions section' },
s.totp ? null : h('button', { type: 'button', class: 'btn primary', onClick: () => addTOTP(draw) }, 'Add authenticator app'),
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addKey(false, draw) }, 'Add security key') : null,
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addKey(true, draw) }, 'Add passkey') : null),
keys ? null : h('p', { class: 'hint section' }, 'Security keys and passkeys need this site on its domain name with a trusted certificate (Let\'s Encrypt or certificate files).'),
].filter(Boolean));
};
draw();
return card;
}
// showNewPassword is the screen after signing in with a temporary password // showNewPassword is the screen after signing in with a temporary password
// an admin chose. current is that password when the user just typed it. // an admin chose. current is that password when the user just typed it.
function showNewPassword(current) { function showNewPassword(current) {
@@ -658,8 +966,7 @@
h('div', { class: 'logintext' }, h('div', { class: 'logintext' },
h('h1', null, me ? 'Welcome, ' + me.name : 'Choose a new password'), h('h1', null, me ? 'Welcome, ' + me.name : 'Choose a new password'),
h('p', null, 'An admin gave you a temporary password. Choose your own to continue.')), h('p', null, 'An admin gave you a temporary password. Choose your own to continue.')),
form), form)));
h('p', { class: 'loginfoot' }, 'WireGuard server manager')));
(cur || p1).focus(); (cur || p1).focus();
} }
@@ -740,9 +1047,50 @@
// ---------- peers ---------- // ---------- peers ----------
// PEER_SORT holds the sort keys of the peers table. Each returns a value
// where smaller sorts first; null always sorts last. Numbers start
// descending, text ascending.
const STATE_ORDER = ['online', 'offline', 'never', 'setup', 'nokey', 'disabled'];
const ipNum = (ip) => ip.split('.').reduce((n, o) => n * 256 + Number(o), 0);
const PEER_SORT = {
name: { label: 'Name', key: (p) => p.name.toLowerCase() },
address: { label: 'Address', key: (p) => ipNum(p.ipv4) },
status: { label: 'Status', key: (p) => STATE_ORDER.indexOf(peerState(p).key) * 1e13 - (p.stats.lastHandshake ? Date.parse(p.stats.lastHandshake) : 0) },
endpoint: { label: 'Endpoint', key: (p) => p.stats.endpoint ? ((p.stats.location && p.stats.location.country) || '~') + ' ' + p.stats.endpoint : null },
latency: { label: 'Latency', num: true, asc: true, key: (p) => { const st = latState(p); return st && st.ms != null ? st.ms : null; } },
down: { label: 'Download, 30 d', num: true, key: (p) => p.stats.down30d },
up: { label: 'Upload, 30 d', num: true, key: (p) => p.stats.up30d },
enabled: { label: 'Enabled', key: (p) => (p.enabled ? 0 : 1) },
};
let peerSort = { by: null, desc: false }; // kept while the app is open
async function viewPeers(wrap) { async function viewPeers(wrap) {
let q = '', filter = 'all', data = await api('GET', '/peers'); let q = '', filter = 'all', data = await api('GET', '/peers');
const tbody = h('tbody'); const tbody = h('tbody');
const headRow = h('tr');
const sortBy = (k) => {
const c = PEER_SORT[k];
peerSort = peerSort.by === k ? { by: k, desc: !peerSort.desc } : { by: k, desc: c.num && !c.asc };
drawHead();
drawRows();
};
const drawHead = () => headRow.replaceChildren(
...Object.entries(PEER_SORT).map(([k, c]) => {
const on = peerSort.by === k;
return h('th', { class: c.num ? 'num' : null, 'aria-sort': on ? (peerSort.desc ? 'descending' : 'ascending') : 'none' },
h('button', { type: 'button', class: on ? 'sort on' : 'sort', onClick: () => sortBy(k) }, c.label,
h('span', { class: 'arrow', 'aria-hidden': 'true' }, on ? (peerSort.desc ? '↓' : '↑') : '↕')));
}),
h('th', null, h('span', { class: 'sr' }, 'Actions')));
const sorted = (rows) => {
if (!peerSort.by) return rows;
const key = PEER_SORT[peerSort.by].key, dir = peerSort.desc ? -1 : 1;
return rows.map((p) => [p, key(p)]).sort(([a, ka], [b, kb]) => {
if (ka == null || kb == null) return ka == null && kb == null ? 0 : ka == null ? 1 : -1;
const c = typeof ka === 'string' ? ka.localeCompare(kb) : ka - kb;
return c * dir || a.name.localeCompare(b.name);
}).map(([p]) => p);
};
const empty = h('p', { class: 'empty', hidden: true }, 'No peers match this filter.'); const empty = h('p', { class: 'empty', hidden: true }, 'No peers match this filter.');
const sub = h('p', { class: 'sub' }); const sub = h('p', { class: 'sub' });
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Status filter' }); const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Status filter' });
@@ -767,7 +1115,7 @@
const keep = filter === 'all' || filter === st || (filter === 'offline' && ['offline', 'never', 'setup', 'nokey'].includes(st)); const keep = filter === 'all' || filter === st || (filter === 'offline' && ['offline', 'never', 'setup', 'nokey'].includes(st));
return hit && keep; return hit && keep;
}); });
tbody.replaceChildren(...rows.map((p) => h('tr', null, tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null), h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null),
h('td', { class: 'mono' }, p.ipv4), h('td', { class: 'mono' }, p.ipv4),
h('td', null, badge(peerState(p))), h('td', null, badge(peerState(p))),
@@ -782,6 +1130,7 @@
}; };
drawPills(); drawPills();
drawHead();
drawRows(); drawRows();
fill(wrap, fill(wrap,
h('div', { class: 'head' }, h('div', { class: 'head' },
@@ -792,8 +1141,7 @@
h('input', { id: 'q', type: 'search', placeholder: 'Search name, address or note', style: { flex: '1 1 260px', maxWidth: '360px' }, onInput: (e) => { q = e.target.value.toLowerCase(); drawRows(); } }), h('input', { id: 'q', type: 'search', placeholder: 'Search name, address or note', style: { flex: '1 1 260px', maxWidth: '360px' }, onInput: (e) => { q = e.target.value.toLowerCase(); drawRows(); } }),
pills), pills),
h('section', { class: 'card flush' }, h('div', { class: 'tbl' }, h('table', null, h('section', { class: 'card flush' }, h('div', { class: 'tbl' }, h('table', null,
h('thead', null, h('tr', null, ['Name', 'Address', 'Status', 'Endpoint'].map((t) => h('th', null, t)), h('thead', null, headRow),
h('th', { class: 'num' }, 'Latency'), h('th', { class: 'num' }, 'Download, 30 d'), h('th', { class: 'num' }, 'Upload, 30 d'), h('th', null, 'Enabled'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
tbody), empty)), tbody), empty)),
h('p', { class: 'muted', style: { margin: '0', fontSize: '13px' } }, 'Online means a handshake in the last 3 minutes. Latency is the round trip from the server through the tunnel to the device and back, median of the last 5 minutes; turn it on in a peer\'s settings. Download and Upload are measured from the peer\'s side. Changes apply live without disconnecting other peers.')); h('p', { class: 'muted', style: { margin: '0', fontSize: '13px' } }, 'Online means a handshake in the last 3 minutes. Latency is the round trip from the server through the tunnel to the device and back, median of the last 5 minutes; turn it on in a peer\'s settings. Download and Upload are measured from the peer\'s side. Changes apply live without disconnecting other peers.'));
every(15000, async () => { try { data = await api('GET', '/peers'); drawRows(); } catch { /* keep last */ } }); every(15000, async () => { try { data = await api('GET', '/peers'); drawRows(); } catch { /* keep last */ } });
@@ -937,8 +1285,24 @@
// ---------- peer detail ---------- // ---------- peer detail ----------
async function viewPeer(wrap, id) { async function viewPeer(wrap, id) {
const [p, srv, sess] = await Promise.all([api('GET', '/peers/' + id), api('GET', '/server'), api('GET', '/peers/' + id + '/sessions?limit=50')]); const [p, srv, sess] = await Promise.all([api('GET', '/peers/' + id), api('GET', '/server'), api('GET', '/peers/' + id + '/sessions?limit=100')]);
const sessions = sess.sessions; const sessions = sess.sessions;
// The history shows the newest rows; the rest open on request.
const SHORT = 8;
let allSessions = false;
const sessBody = h('tbody');
const sessMore = h('button', { type: 'button', class: 'btn small', onClick: () => { allSessions = !allSessions; drawSessions(); } });
const drawSessions = () => {
sessBody.replaceChildren(...(allSessions ? sessions : sessions.slice(0, SHORT)).map((se) => h('tr', null,
h('td', null, fmtStamp(se.start)),
h('td', null, se.open ? [h('span', { class: 'badge' }, h('span', { class: 'dot ok' }), 'Online now'), ' ', fmtDuration(se.seconds)] : fmtDuration(se.seconds)),
h('td', null, fmtLocation(se.geo) || h('span', { class: 'muted' }, 'Unknown')),
h('td', { class: 'mono muted' }, se.ip),
h('td', { class: 'num' }, fmtBytes(se.down)),
h('td', { class: 'num' }, fmtBytes(se.up)))));
sessMore.textContent = allSessions ? 'Show fewer' : 'Show all ' + sessions.length;
};
drawSessions();
let range = '7d'; let range = '7d';
const st = peerState(p); const st = peerState(p);
const traffic = h('div'); const traffic = h('div');
@@ -1111,14 +1475,9 @@
sessions.length ? h('div', { class: 'tbl' }, h('table', null, sessions.length ? h('div', { class: 'tbl' }, h('table', null,
h('thead', null, h('tr', null, h('th', null, 'Started'), h('th', null, 'Duration'), h('th', null, 'From'), h('th', null, 'Address'), h('thead', null, h('tr', null, h('th', null, 'Started'), h('th', null, 'Duration'), h('th', null, 'From'), h('th', null, 'Address'),
h('th', { class: 'num' }, 'Download'), h('th', { class: 'num' }, 'Upload'))), h('th', { class: 'num' }, 'Download'), h('th', { class: 'num' }, 'Upload'))),
h('tbody', null, sessions.map((se) => h('tr', null, sessBody))
h('td', null, fmtStamp(se.start)),
h('td', null, se.open ? [h('span', { class: 'badge' }, h('span', { class: 'dot ok' }), 'Online now'), ' ', fmtDuration(se.seconds)] : fmtDuration(se.seconds)),
h('td', null, fmtLocation(se.geo) || h('span', { class: 'muted' }, 'Unknown')),
h('td', { class: 'mono muted' }, se.ip),
h('td', { class: 'num' }, fmtBytes(se.down)),
h('td', { class: 'num' }, fmtBytes(se.up)))))))
: h('p', { class: 'empty' }, 'No connections recorded yet.'), : h('p', { class: 'empty' }, 'No connections recorded yet.'),
sessions.length > SHORT ? h('div', { style: { margin: '8px 12px 0' } }, sessMore) : null,
h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ', h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ',
h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'), h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'),
'. Kept as long as the daily traffic history.')), '. Kept as long as the daily traffic history.')),
@@ -1357,6 +1716,8 @@
pwErr, pwErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Change password'))), h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Change password'))),
mfaCard(),
h('section', { class: 'card flush', 'aria-labelledby': 'mytk' }, h('section', { class: 'card flush', 'aria-labelledby': 'mytk' },
h('div', { class: 'cardhead' }, h('div', { class: 'cardhead' },
h('div', null, h('h2', { id: 'mytk' }, 'My app tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Tokens you created for the iOS app and scripts. All tokens are listed under Settings → API tokens.')), h('div', null, h('h2', { id: 'mytk' }, 'My app tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Tokens you created for the iOS app and scripts. All tokens are listed under Settings → API tokens.')),
@@ -1379,6 +1740,7 @@
const drawUsers = (users) => userBody.replaceChildren(...users.map((u) => h('tr', null, const drawUsers = (users) => userBody.replaceChildren(...users.map((u) => h('tr', null,
h('td', null, h('strong', null, u.username), u.you ? h('span', { class: 'tag plain' }, 'You') : null, u.note ? h('div', { class: 'note' }, u.note) : null), h('td', null, h('strong', null, u.username), u.you ? h('span', { class: 'tag plain' }, 'You') : null, u.note ? h('div', { class: 'note' }, u.note) : null),
h('td', null, u.mustChangePassword ? h('span', { class: 'badge warn' }, 'Must choose a password') : h('span', { class: 'muted' }, 'Active')), h('td', null, u.mustChangePassword ? h('span', { class: 'badge warn' }, 'Must choose a password') : h('span', { class: 'muted' }, 'Active')),
h('td', null, mfaText(u.mfa) ? h('span', { class: 'badge' }, mfaText(u.mfa)) : h('span', { class: s.signin.requireMfa ? 'badge warn' : 'muted' }, 'Off')),
h('td', null, u.lastLogin ? ago(u.lastLogin.at) + ' · ' + u.lastLogin.ip : h('span', { class: 'muted' }, 'Not since restart')), h('td', null, u.lastLogin ? ago(u.lastLogin.at) + ' · ' + u.lastLogin.ip : h('span', { class: 'muted' }, 'Not since restart')),
h('td', null, u.tokens ? String(u.tokens) : h('span', { class: 'muted' }, 'None')), h('td', null, u.tokens ? String(u.tokens) : h('span', { class: 'muted' }, 'None')),
h('td', null, fmtDate(u.created)), h('td', null, fmtDate(u.created)),
@@ -1452,6 +1814,7 @@
must.el, must.el,
h('div', { class: 'actions' }, h('div', { class: 'actions' },
h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetUser(u); } }, 'Reset password…'), h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetUser(u); } }, 'Reset password…'),
mfaText(u.mfa) ? h('button', { type: 'button', class: 'btn', onClick: () => { close(); resetMFA(u); } }, 'Reset two-step sign-in…') : null,
h('button', { type: 'button', class: 'btn danger', onClick: () => { close(); deleteUser(u); } }, 'Delete user…')), h('button', { type: 'button', class: 'btn danger', onClick: () => { close(); deleteUser(u); } }, 'Delete user…')),
e, e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save')))); h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
@@ -1475,6 +1838,10 @@
pw.el, must.el, e, pw.el, must.el, e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Reset password')))); h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Reset password'))));
}; };
const resetMFA = async (u) => {
if (!await confirmDialog({ title: 'Reset two-step sign-in for ' + u.username + '?', text: 'Their authenticator app, security keys, passkeys and recovery codes are removed. They sign in with their password and can set it up again.', ok: 'Reset', danger: true })) return;
try { await api('POST', '/users/' + u.id + '/reset-mfa'); toast('Two-step sign-in reset for ' + u.username); reloadUsers(); } catch (x) { toast(x.message, true); }
};
const deleteUser = async (u) => { const deleteUser = async (u) => {
const tokens = u.tokens ? ' Their ' + (u.tokens === 1 ? 'app token is' : u.tokens + ' app tokens are') + ' revoked too.' : ''; const tokens = u.tokens ? ' Their ' + (u.tokens === 1 ? 'app token is' : u.tokens + ' app tokens are') + ' revoked too.' : '';
if (!await confirmDialog({ title: 'Delete ' + u.username + '?', text: u.username + ' is signed out and can no longer sign in.' + tokens, ok: 'Delete user', danger: true })) return; if (!await confirmDialog({ title: 'Delete ' + u.username + '?', text: u.username + ' is signed out and can no longer sign in.' + tokens, ok: 'Delete user', danger: true })) return;
@@ -1546,8 +1913,28 @@
try { await api('PATCH', '/settings', { log: { ...s.log, level: e.target.value } }); s.log.level = e.target.value; toast('Log level: ' + e.target.value); } catch (x) { toast(x.message, true); } try { await api('PATCH', '/settings', { log: { ...s.log, level: e.target.value } }); s.log.level = e.target.value; toast('Log level: ' + e.target.value); } catch (x) { toast(x.message, true); }
} }, ['debug', 'info', 'warn', 'error'].map((l) => h('option', { value: l, selected: s.log.level === l }, l))); } }, ['debug', 'info', 'warn', 'error'].map((l) => h('option', { value: l, selected: s.log.level === l }, l)));
// sign-in rules
const requireBox = h('input', { type: 'checkbox', id: 'rq', checked: s.signin.requireMfa, onChange: async (e) => {
const on = e.target.checked;
if (on) {
const mine = us.users.find((u) => u.you);
const without = us.users.filter((u) => !mfaText(u.mfa)).map((u) => u.username);
const text = 'Users without two-step sign-in must set it up right after their next sign-in, before they can do anything else. API tokens are not affected.' +
(without.length ? ' Not set up yet: ' + without.join(', ') + '.' : '') +
(mine && !mfaText(mine.mfa) ? ' That includes you: you are asked to set it up now.' : '');
if (!await confirmDialog({ title: 'Require two-step sign-in?', text, ok: 'Require it' })) { e.target.checked = false; return; }
}
try {
await api('PATCH', '/settings', { signin: { ...s.signin, requireMfa: on } });
s.signin.requireMfa = on;
toast(on ? 'Two-step sign-in required' : 'Two-step sign-in optional');
me = await api('GET', '/auth/me');
if (me.mfaSetupRequired) showMFASetup(); else reloadUsers();
} catch (x) { e.target.checked = !on; toast(x.message, true); }
} });
// decoy // decoy
const decoyPages = [['nginx', 'nginx welcome page'], ['apache', 'Apache "It works!" page'], ['soon', '"Coming soon" page']]; const decoyPages = [['nginx', 'nginx welcome page'], ['apache', 'Apache "It works!" page'], ['soon', '"Coming soon" page'], ['blank', 'Blank page'], ['forbidden', '"Forbidden" page'], ['private', '"Private server" page']];
const decoyBox = h('input', { type: 'checkbox', id: 'dc', checked: s.decoy.enabled, onChange: async (e) => { const decoyBox = h('input', { type: 'checkbox', id: 'dc', checked: s.decoy.enabled, onChange: async (e) => {
const on = e.target.checked; const on = e.target.checked;
if (on) { if (on) {
@@ -1625,9 +2012,15 @@
h('div', null, h('h2', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')), h('div', null, h('h2', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')),
h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')), h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')),
h('div', { class: 'tbl' }, h('table', null, h('div', { class: 'tbl' }, h('table', null,
h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))), h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Two-step'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
userBody))), userBody))),
h('section', { class: 'card', 'aria-labelledby': 'sgn' },
h('h2', { id: 'sgn' }, 'Sign-in'),
h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app, security keys such as a YubiKey, or passkeys. Changes apply immediately.'),
h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'),
h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.')))),
h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' }, h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' },
h('h2', { id: 'web' }, 'Web interface'), h('h2', { id: 'web' }, 'Web interface'),
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'), h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'),
+29 -8
View File
@@ -102,6 +102,9 @@ type principal struct {
RemoteIP string RemoteIP string
// MustChangePassword blocks everything but changing the password. // MustChangePassword blocks everything but changing the password.
MustChangePassword bool MustChangePassword bool
// MFASetupRequired blocks everything but setting up two-step sign-in,
// when it is required and the user has none.
MFASetupRequired bool
Session *sessionInfo // nil for API tokens Session *sessionInfo // nil for API tokens
} }
@@ -138,6 +141,7 @@ type Auth struct {
used map[string]tokenUse used map[string]tokenUse
logins map[string]tokenUse // last sign-in per user ID logins map[string]tokenUse // last sign-in per user ID
fails map[string]*failState fails map[string]*failState
mfa mfaState
} }
const ( const (
@@ -146,26 +150,27 @@ const (
) )
func newAuth(s *Store) *Auth { func newAuth(s *Store) *Auth {
return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}} return &Auth{store: s, sessions: map[string]*session{}, used: map[string]tokenUse{}, logins: map[string]tokenUse{}, fails: map[string]*failState{}, mfa: newMFAState()}
} }
func cookieName() string { return appName + "_session" } func cookieName() string { return appName + "_session" }
var errLocked = errors.New("too many failed attempts, try again later") var errLocked = errors.New("too many failed attempts, try again later")
// Login checks the credentials and returns a new session id. // Login checks the credentials and returns a new session id, or, for a user
func (a *Auth) Login(user, pw, ip string) (string, error) { // with two-step sign-in, a ticket for the second step.
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
a.mu.Lock() a.mu.Lock()
f := a.fails[ip] f := a.fails[ip]
if f != nil && time.Now().Before(f.until) { if f != nil && time.Now().Before(f.until) {
a.mu.Unlock() a.mu.Unlock()
return "", errLocked return "", "", errLocked
} }
a.mu.Unlock() a.mu.Unlock()
cfg := a.store.Get() cfg := a.store.Get()
if !cfg.passwordSet() { if !cfg.passwordSet() {
return "", errors.New("no password is set; run: " + appName + " passwd") return "", "", errors.New("no password is set; run: " + appName + " passwd")
} }
// An unknown username costs as much time as a wrong password, so the // An unknown username costs as much time as a wrong password, so the
// answer time does not tell which usernames exist. // answer time does not tell which usernames exist.
@@ -189,11 +194,14 @@ func (a *Auth) Login(user, pw, ip string) (string, error) {
f.count = 0 f.count = 0
f.until = time.Now().Add(lockoutTime) f.until = time.Now().Add(lockoutTime)
} }
return "", errors.New("wrong username or password") return "", "", errors.New("wrong username or password")
}
if u.hasMFA() {
return "", a.newTicketLocked(u, ip), nil
} }
delete(a.fails, ip) delete(a.fails, ip)
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip} a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), nil return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
} }
// NewSession replaces a session after the user changed their password; it // NewSession replaces a session after the user changed their password; it
@@ -290,7 +298,8 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
return nil, false return nil, false
} }
info := s.info info := s.info
return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword, Session: &info}, true return &principal{Name: u.Username, UserID: u.ID, Scope: "rw", IsAdmin: true, RemoteIP: ip, MustChangePassword: u.MustChangePassword,
MFASetupRequired: cfg.SignIn.RequireMFA && !u.hasMFA(), Session: &info}, true
} }
func (a *Auth) TokenUse(id string) *tokenUse { func (a *Auth) TokenUse(id string) *tokenUse {
@@ -317,4 +326,16 @@ func (a *Auth) sweep() {
delete(a.fails, ip) delete(a.fails, ip)
} }
} }
for id, t := range a.mfa.tickets {
if now.After(t.expires) {
delete(a.mfa.tickets, id)
}
}
for _, m := range []map[string]*ceremony{a.mfa.logins, a.mfa.enrolls} {
for id, c := range m {
if now.After(c.expires) {
delete(m, id)
}
}
}
} }
+9
View File
@@ -33,6 +33,14 @@ type Config struct {
Log LogConfig `json:"log"` Log LogConfig `json:"log"`
Stats StatsConfig `json:"stats"` Stats StatsConfig `json:"stats"`
Decoy DecoyConfig `json:"decoy"` Decoy DecoyConfig `json:"decoy"`
SignIn SignInConfig `json:"signin"`
}
// SignInConfig holds the rules for signing in to the web interface.
type SignInConfig struct {
// RequireMFA sends users without two-step sign-in to set it up before
// they can do anything else. API tokens are not affected.
RequireMFA bool `json:"requireMfa"`
} }
// DecoyConfig replaces the web interface with a stock web server page. // DecoyConfig replaces the web interface with a stock web server page.
@@ -92,6 +100,7 @@ type User struct {
// the user can do nothing else until they pick their own. // the user can do nothing else until they pick their own.
MustChangePassword bool `json:"mustChangePassword,omitempty"` MustChangePassword bool `json:"mustChangePassword,omitempty"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
MFA *UserMFA `json:"mfa,omitempty"` // two-step sign-in, nil when never set up
} }
type APIToken struct { type APIToken struct {
+41
View File
@@ -13,6 +13,7 @@ import (
type decoyPage struct { type decoyPage struct {
server string // Server header, "" for none server string // Server header, "" for none
index func(host string) string // the front page index func(host string) string // the front page
indexCode int // status of the front page, 0 for 200
error func(code int, r *http.Request) string // body for 404 and 405 error func(code int, r *http.Request) string // body for 404 and 405
} }
@@ -20,6 +21,10 @@ var decoyPages = map[string]decoyPage{
"nginx": {server: nginxServer, index: func(string) string { return nginxIndex }, error: nginxError}, "nginx": {server: nginxServer, index: func(string) string { return nginxIndex }, error: nginxError},
"apache": {server: apacheServer, index: func(string) string { return apacheIndex }, error: apacheError}, "apache": {server: apacheServer, index: func(string) string { return apacheIndex }, error: apacheError},
"soon": {index: soonIndex, error: soonError}, "soon": {index: soonIndex, error: soonError},
// Generic pages that name no server software.
"blank": {index: func(string) string { return "" }, error: func(int, *http.Request) string { return "" }},
"forbidden": {index: func(string) string { return forbiddenIndex }, indexCode: http.StatusForbidden, error: soonError},
"private": {index: func(string) string { return privateIndex }, error: soonError},
} }
// serveDecoy writes the decoy's answer for r. It drops the headers the web // serveDecoy writes the decoy's answer for r. It drops the headers the web
@@ -43,6 +48,9 @@ func serveDecoy(w http.ResponseWriter, r *http.Request, name string) {
code, body = http.StatusMethodNotAllowed, d.error(http.StatusMethodNotAllowed, r) code, body = http.StatusMethodNotAllowed, d.error(http.StatusMethodNotAllowed, r)
case r.URL.Path == "/" || r.URL.Path == "/index.html": case r.URL.Path == "/" || r.URL.Path == "/index.html":
body = d.index(hostOnly(r.Host)) body = d.index(hostOnly(r.Host))
if d.indexCode != 0 {
code = d.indexCode
}
default: default:
code, body = http.StatusNotFound, d.error(http.StatusNotFound, r) code, body = http.StatusNotFound, d.error(http.StatusNotFound, r)
} }
@@ -537,3 +545,36 @@ const apacheIndex = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//
</body> </body>
</html> </html>
` `
const forbiddenIndex = `<!DOCTYPE html>
<html>
<head><title>403 Forbidden</title></head>
<body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
</body>
</html>
`
const privateIndex = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Private</title>
<style>
html, body { height: 100%; margin: 0; }
body { display: flex; align-items: center; justify-content: center; background: #111; color: #999;
font-family: Georgia, serif; text-align: center; }
h1 { font-size: 28px; font-weight: normal; letter-spacing: 0.04em; color: #fff; margin: 0 0 10px; }
p { margin: 0; font-size: 15px; }
</style>
</head>
<body>
<main>
<h1>Private server</h1>
<p>Nothing to see here.</p>
</main>
</body>
</html>
`
+10
View File
@@ -3,6 +3,7 @@ module ghostwire
go 1.27.1 go 1.27.1
require ( require (
github.com/go-webauthn/webauthn v0.18.2
github.com/google/nftables v0.3.0 github.com/google/nftables v0.3.0
github.com/oschwald/maxminddb-golang v1.13.1 github.com/oschwald/maxminddb-golang v1.13.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
@@ -15,11 +16,20 @@ require (
) )
require ( require (
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.5.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/vishvananda/netns v0.0.5 // indirect github.com/vishvananda/netns v0.0.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sync v0.23.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
+28 -8
View File
@@ -1,9 +1,23 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A=
github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
@@ -14,16 +28,24 @@ github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE9
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
@@ -42,5 +64,3 @@ golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uI
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+166 -3
View File
@@ -921,8 +921,15 @@ func TestDecoy(t *testing.T) {
} }
} }
if b, _ := get("/", 200); !strings.Contains(b, "/app.js") { b, _ := get("/", 200)
t.Fatal("web interface not served with the decoy off") if !strings.Contains(b, `"/app.js?v=`+assetHash["app.js"]+`"`) || !strings.Contains(b, `"/app.css?v=`+assetHash["app.css"]+`"`) {
t.Fatalf("web interface not served with fingerprinted files: %q", b)
}
if _, h := get("/app.js?v="+assetHash["app.js"], 200); !strings.Contains(h.Get("Cache-Control"), "immutable") {
t.Fatalf("fingerprinted app.js: %v", h)
}
if _, h := get("/app.js?v=old", 200); h.Get("Cache-Control") != "no-cache" {
t.Fatalf("stale app.js cached: %v", h)
} }
set(func(c *Config) { set(func(c *Config) {
v4 := netip.MustParsePrefix(c.Server.IPv4) v4 := netip.MustParsePrefix(c.Server.IPv4)
@@ -939,7 +946,7 @@ func TestDecoy(t *testing.T) {
t.Fatalf("%s leaks: %q", p, b) t.Fatalf("%s leaks: %q", p, b)
} }
} }
if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js"`) { if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js?v=`+assetHash["setup.js"]+`"`) {
t.Fatalf("setup page files not under the link: %q", b) t.Fatalf("setup page files not under the link: %q", b)
} }
get("/setup/live-token/app.css", 200) get("/setup/live-token/app.css", 200)
@@ -954,7 +961,163 @@ func TestDecoy(t *testing.T) {
if b, h := get("/", 200); !strings.Contains(b, "<p class=\"host\">127.0.0.1</p>") || h.Get("Server") != "" { if b, h := get("/", 200); !strings.Contains(b, "<p class=\"host\">127.0.0.1</p>") || h.Get("Server") != "" {
t.Fatalf("soon decoy: %q", b) t.Fatalf("soon decoy: %q", b)
} }
set(func(c *Config) { c.Decoy.Page = "blank" })
if b, _ := get("/", 200); b != "" {
t.Fatalf("blank decoy: %q", b)
}
if b, _ := get("/app.js", 404); b != "" {
t.Fatalf("blank 404: %q", b)
}
set(func(c *Config) { c.Decoy.Page = "forbidden" })
if b, _ := get("/", 403); !strings.Contains(b, "Forbidden") {
t.Fatalf("forbidden decoy: %q", b)
}
set(func(c *Config) { c.Decoy.Page = "private" })
if b, _ := get("/", 200); !strings.Contains(b, "Private server") {
t.Fatalf("private decoy: %q", b)
}
if err := store.Update(func(c *Config) error { c.Decoy.Page = "iis"; return nil }); err == nil { if err := store.Update(func(c *Config) error { c.Decoy.Page = "iis"; return nil }); err == nil {
t.Fatal("unknown decoy page accepted") t.Fatal("unknown decoy page accepted")
} }
} }
func TestTOTPCode(t *testing.T) {
// RFC 6238, appendix B (SHA-1), cut to 6 digits.
key := []byte("12345678901234567890")
for _, c := range []struct {
unix int64
want string
}{{59, "287082"}, {1111111109, "081804"}, {1234567890, "005924"}, {2000000000, "279037"}} {
if got := totpCode(key, uint64(c.unix/30)); got != c.want {
t.Errorf("time %d: %s, want %s", c.unix, got, c.want)
}
}
secret := b32.EncodeToString(key)
now := time.Unix(1111111109, 0)
if _, ok := totpMatch(secret, "081 804", now); !ok {
t.Error("code with a space refused")
}
if _, ok := totpMatch(secret, "081804", now.Add(90*time.Second)); ok {
t.Error("code three steps late accepted")
}
}
// TestMFA signs in with an authenticator code and a recovery code, and
// checks the "require" switch and the admin reset.
func TestMFA(t *testing.T) {
dir := t.TempDir()
store, err := openStore(filepath.Join(dir, "config.json"))
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("a long test password")
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
k := &fakeKernel{}
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store),
tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}}
srv := httptest.NewServer(app.routes())
defer srv.Close()
client := func() func(method, path string, body any, want int) map[string]any {
jar, _ := cookiejar.New(nil)
cl := &http.Client{Jar: jar}
return func(method, path string, body any, want int) map[string]any {
t.Helper()
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
req.Header.Set("Content-Type", "application/json")
resp, err := cl.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
if resp.StatusCode != want {
t.Fatalf("%s %s: status %d, want %d: %v", method, path, resp.StatusCode, want, out)
}
return out
}
}
login := map[string]string{"username": "admin", "password": "a long test password"}
adm := client()
adm("POST", "/auth/login", login, 200)
if o := adm("GET", "/auth/options", nil, 200); o["passkeys"] != false {
t.Fatalf("passkeys offered on an IP address: %v", o)
}
adm("POST", "/auth/mfa/keys/begin", map[string]bool{"passkey": true}, 400)
// Turn on the authenticator app; the first method brings recovery codes.
setup := adm("POST", "/auth/mfa/totp/setup", nil, 200)
secret := setup["secret"].(string)
if !strings.HasPrefix(setup["uri"].(string), "otpauth://totp/") || setup["qr"] == "" {
t.Fatalf("setup: %v", setup)
}
adm("POST", "/auth/mfa/totp/confirm", map[string]string{"code": "000000"}, 400)
key, _ := b32.DecodeString(secret)
code := func(offset int) string { return totpCode(key, uint64(time.Now().Unix()/30)+uint64(offset)) }
conf := adm("POST", "/auth/mfa/totp/confirm", map[string]string{"code": code(0)}, 200)
codes := conf["recoveryCodes"].([]any)
if len(codes) != recoveryCount {
t.Fatalf("recovery codes: %v", conf)
}
if s := adm("GET", "/auth/mfa", nil, 200); s["totp"] != true || s["recoveryLeft"] != float64(recoveryCount) {
t.Fatalf("status: %v", s)
}
// A password alone now gives a ticket, not a session.
c := client()
r := c("POST", "/auth/login", login, 200)
ticket, _ := r["ticket"].(string)
if r["mfa"] != true || ticket == "" {
t.Fatalf("login without second step: %v", r)
}
c("GET", "/peers", nil, 401)
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": "123456"}, 401)
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": code(0)}, 401) // used during setup
c("POST", "/auth/login/totp", map[string]string{"ticket": ticket, "code": code(1)}, 200)
c("GET", "/peers", nil, 200)
// A recovery code works once.
c2 := client()
ticket = c2("POST", "/auth/login", login, 200)["ticket"].(string)
c2("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": strings.ToLower(codes[0].(string))}, 200)
c3 := client()
ticket = c3("POST", "/auth/login", login, 200)["ticket"].(string)
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401)
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[1].(string)}, 200)
// Required for everyone: a user without it can only set it up.
adm("PATCH", "/settings", map[string]any{"signin": map[string]bool{"requireMfa": true}}, 200)
u := adm("POST", "/users", map[string]any{"username": "eve", "password": "eve's password 1", "mustChangePassword": false}, 201)["user"].(map[string]any)
e := client()
e("POST", "/auth/login", map[string]string{"username": "eve", "password": "eve's password 1"}, 200)
if me := e("GET", "/auth/me", nil, 200); me["mfaSetupRequired"] != true {
t.Fatalf("me: %v", me)
}
e("GET", "/peers", nil, 403)
e("GET", "/auth/mfa", nil, 200)
// The last method cannot be removed while it is required.
adm("DELETE", "/auth/mfa/totp", nil, 400)
// An admin resets another user's two-step sign-in, not their own.
_ = store.Update(func(c *Config) error {
_, eu := c.userByID(u["id"].(string))
eu.MFA = &UserMFA{TOTPSecret: newTOTPSecret(), RecoveryCodes: []string{"x"}}
return nil
})
if l := adm("GET", "/users", nil, 200)["users"].([]any); l[1].(map[string]any)["mfa"].(map[string]any)["totp"] != true {
t.Fatalf("users list: %v", l)
}
me := adm("GET", "/auth/me", nil, 200)
adm("POST", "/users/"+me["id"].(string)+"/reset-mfa", nil, 400)
adm("POST", "/users/"+u["id"].(string)+"/reset-mfa", nil, 200)
if _, eu := store.Get().userByID(u["id"].(string)); eu.hasMFA() || len(eu.MFA.RecoveryCodes) != 0 {
t.Fatal("reset left methods behind")
}
}
+933
View File
@@ -0,0 +1,933 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// Two-step sign-in for the web interface: an authenticator app (TOTP),
// security keys such as a YubiKey and passkeys (both WebAuthn), plus
// one-time recovery codes. API tokens never need a second step.
//
// After a correct password, a user with two-step sign-in gets a short-lived
// ticket instead of a session; the ticket and a code or key turn into the
// session. A passkey signs in on its own, without username and password.
// UserMFA is a user's two-step sign-in setup, stored in config.json.
type UserMFA struct {
TOTPSecret string `json:"totpSecret,omitempty"` // base32
TOTPAdded *time.Time `json:"totpAdded,omitempty"`
Keys []MFAKey `json:"keys,omitempty"`
RecoveryCodes []string `json:"recoveryCodes,omitempty"` // SHA-256 of the unused codes
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
}
// MFAKey is a security key or passkey.
type MFAKey struct {
ID string `json:"id"`
Name string `json:"name"`
Passkey bool `json:"passkey"` // discoverable: signs in without a password
Created time.Time `json:"created"`
LastUsed *time.Time `json:"lastUsed,omitempty"`
Credential webauthn.Credential `json:"credential"`
}
func (u *User) hasMFA() bool {
return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0)
}
const (
ticketTTL = 5 * time.Minute
recoveryCount = 10
totpPeriod = 30
totpDigits = 6
maxKeyName = 64
)
// --- TOTP (RFC 6238, SHA-1, 6 digits, 30 s) ---
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
func newTOTPSecret() string {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b32.EncodeToString(b)
}
func totpCode(key []byte, counter uint64) string {
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], counter)
m := hmac.New(sha1.New, key)
m.Write(msg[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0x0f
v := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
return fmt.Sprintf("%0*d", totpDigits, v%1_000_000)
}
// totpMatch returns the time step the code belongs to, allowing one step of
// clock drift either way.
func totpMatch(secret, code string, now time.Time) (uint64, bool) {
key, err := b32.DecodeString(strings.ToUpper(secret))
code = strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, code)
if err != nil || len(code) != totpDigits {
return 0, false
}
step := uint64(now.Unix() / totpPeriod)
for _, c := range []uint64{step, step - 1, step + 1} {
if subtle.ConstantTimeCompare([]byte(totpCode(key, c)), []byte(code)) == 1 {
return c, true
}
}
return 0, false
}
func totpURI(secret, username string) string {
label := url.PathEscape(appName + ":" + username)
return "otpauth://totp/" + label + "?secret=" + secret + "&issuer=" + url.QueryEscape(appName) + "&algorithm=SHA1&digits=6&period=30"
}
// --- recovery codes ---
const recoveryAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
// newRecoveryCodes returns codes to show once and their hashes to store.
func newRecoveryCodes() (codes, hashes []string) {
for range recoveryCount {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
panic(err)
}
var s strings.Builder
for i, x := range b {
if i == 4 {
s.WriteByte('-')
}
s.WriteByte(recoveryAlphabet[int(x)%len(recoveryAlphabet)])
}
codes = append(codes, s.String())
hashes = append(hashes, hashRecovery(s.String()))
}
return codes, hashes
}
func hashRecovery(code string) string {
norm := strings.Map(func(r rune) rune {
if r == '-' || r == ' ' {
return -1
}
return r
}, strings.ToUpper(code))
sum := sha256.Sum256([]byte(norm))
return hex.EncodeToString(sum[:])
}
// --- WebAuthn ---
// waUser adapts a User to the webauthn library.
type waUser struct{ u *User }
func (w waUser) WebAuthnID() []byte { return w.u.MFA.Handle }
func (w waUser) WebAuthnName() string { return w.u.Username }
func (w waUser) WebAuthnDisplayName() string { return w.u.Username }
func (w waUser) WebAuthnCredentials() []webauthn.Credential {
var out []webauthn.Credential
if w.u.MFA != nil {
for _, k := range w.u.MFA.Keys {
out = append(out, k.Credential)
}
}
return out
}
// keysAvailable reports whether security keys and passkeys can work on this
// address: WebAuthn needs a domain name (not an IP address) and a
// certificate the browser trusts, or localhost.
func (a *App) keysAvailable(r *http.Request) bool {
host := hostOnly(r.Host)
if host == "localhost" {
return true
}
return host != "" && net.ParseIP(host) == nil && a.store.Get().Web.TLS.Mode != "selfsigned"
}
func (a *App) webAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
if !a.keysAvailable(r) {
return nil, badRequest("security keys and passkeys need a domain name with a trusted certificate")
}
scheme := "https"
if r.TLS == nil && hostOnly(r.Host) == "localhost" {
scheme = "http"
}
return webauthn.New(&webauthn.Config{
RPID: hostOnly(r.Host), RPDisplayName: appName, RPOrigins: []string{scheme + "://" + r.Host},
})
}
// --- pending ceremonies, kept in memory ---
// ticket is a sign-in waiting for its second step.
type ticket struct {
userID string
ip string
expires time.Time
fails int
key *webauthn.SessionData // a security key challenge, once asked for
}
type ceremony struct {
userID string // "" for a passkey sign-in
passkey bool
data *webauthn.SessionData
expires time.Time
}
type mfaState struct {
tickets map[string]*ticket
logins map[string]*ceremony // passkey sign-ins by id
enrolls map[string]*ceremony // key registrations by user ID
totpSetup map[string]string // TOTP secrets waiting for their first code, by user ID
totpLast map[string]uint64 // last time step used per user, so a code works once
}
func newMFAState() mfaState {
return mfaState{tickets: map[string]*ticket{}, logins: map[string]*ceremony{}, enrolls: map[string]*ceremony{},
totpSetup: map[string]string{}, totpLast: map[string]uint64{}}
}
var errBadTicket = errors.New("the sign-in expired; enter your password again")
// failLocked counts a failed attempt from ip toward the lockout. a.mu must
// be held.
func (a *Auth) failLocked(ip string) {
f := a.fails[ip]
if f == nil {
f = &failState{}
a.fails[ip] = f
}
f.count++
if f.count >= maxFailures {
f.count = 0
f.until = time.Now().Add(lockoutTime)
}
}
func (a *Auth) lockedLocked(ip string) bool {
f := a.fails[ip]
return f != nil && time.Now().Before(f.until)
}
// newTicket starts the second step for a user whose password was right.
// a.mu must be held.
func (a *Auth) newTicketLocked(u *User, ip string) string {
id := randomString(32)
a.mfa.tickets[id] = &ticket{userID: u.ID, ip: ip, expires: time.Now().Add(ticketTTL)}
return id
}
// ticketUser returns the live ticket and its user.
func (a *Auth) ticketUser(id, ip string) (*ticket, *User, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.lockedLocked(ip) {
return nil, nil, errLocked
}
t := a.mfa.tickets[id]
if t == nil || time.Now().After(t.expires) {
delete(a.mfa.tickets, id)
return nil, nil, errBadTicket
}
_, u := a.store.Get().userByID(t.userID)
if u == nil {
delete(a.mfa.tickets, id)
return nil, nil, errBadTicket
}
return t, u, nil
}
// ticketFailed counts a wrong code; five end the ticket.
func (a *Auth) ticketFailed(id, ip string) {
a.mu.Lock()
defer a.mu.Unlock()
a.failLocked(ip)
if t := a.mfa.tickets[id]; t != nil {
t.fails++
if t.fails >= maxFailures {
delete(a.mfa.tickets, id)
}
}
}
// finishSignIn turns a passed second step into a session.
func (a *Auth) finishSignIn(u *User, ip string) string {
cfg := a.store.Get()
a.mu.Lock()
defer a.mu.Unlock()
delete(a.fails, ip)
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
}
// --- sign-in endpoints (public) ---
func (a *App) signedIn(w http.ResponseWriter, r *http.Request, u *User, how string) {
ip := remoteIP(r)
a.setSessionCookie(w, r, a.auth.finishSignIn(u, ip))
slog.Info("login", "audit", true, "actor", u.Username, "remote", ip, "method", how)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) signInFailed(w http.ResponseWriter, err error) {
code := http.StatusUnauthorized
if errors.Is(err, errLocked) {
code = http.StatusTooManyRequests
}
writeJSON(w, code, map[string]string{"error": err.Error()})
}
// signInOptions tells the sign-in page whether to offer a passkey.
func (a *App) signInOptions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"passkeys": a.keysAvailable(r)})
}
func (a *App) loginTOTP(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket, Code string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
ip := remoteIP(r)
_, u, err := a.auth.ticketUser(in.Ticket, ip)
if err != nil {
a.signInFailed(w, err)
return
}
if u.MFA == nil || u.MFA.TOTPSecret == "" || !a.auth.useTOTP(u.ID, u.MFA.TOTPSecret, in.Code) {
a.auth.ticketFailed(in.Ticket, ip)
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "wrong authenticator code")
a.signInFailed(w, errors.New("wrong code"))
return
}
a.auth.dropTicket(in.Ticket)
a.signedIn(w, r, u, "totp")
}
// useTOTP checks a code and makes sure it is not used twice.
func (a *Auth) useTOTP(userID, secret, code string) bool {
step, ok := totpMatch(secret, code, time.Now())
if !ok {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
if last, seen := a.mfa.totpLast[userID]; seen && step <= last {
return false
}
a.mfa.totpLast[userID] = step
return true
}
// ticketUserID returns the ticket's user without checking the lockout.
func (a *Auth) ticketUserID(id string) (string, *User) {
a.mu.Lock()
t := a.mfa.tickets[id]
a.mu.Unlock()
if t == nil {
return "", nil
}
_, u := a.store.Get().userByID(t.userID)
return t.userID, u
}
// mfaMethods lists what the second step can use: "key", "totp", "recovery".
func mfaMethods(u *User) []string {
out := []string{}
if u == nil || u.MFA == nil {
return out
}
if len(u.MFA.Keys) > 0 {
out = append(out, "key")
}
if u.MFA.TOTPSecret != "" {
out = append(out, "totp")
}
if len(u.MFA.RecoveryCodes) > 0 {
out = append(out, "recovery")
}
return out
}
func (a *Auth) dropTicket(id string) {
a.mu.Lock()
delete(a.mfa.tickets, id)
a.mu.Unlock()
}
func (a *App) loginRecovery(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket, Code string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
ip := remoteIP(r)
_, u, err := a.auth.ticketUser(in.Ticket, ip)
if err != nil {
a.signInFailed(w, err)
return
}
h := hashRecovery(in.Code)
var left int
used := false
_ = a.store.Update(func(c *Config) error {
_, cu := c.userByID(u.ID)
if cu == nil || cu.MFA == nil {
return nil
}
for i, x := range cu.MFA.RecoveryCodes {
if subtle.ConstantTimeCompare([]byte(x), []byte(h)) == 1 {
cu.MFA.RecoveryCodes = slices.Delete(cu.MFA.RecoveryCodes, i, i+1)
used = true
break
}
}
left = len(cu.MFA.RecoveryCodes)
return nil
})
if !used {
a.auth.ticketFailed(in.Ticket, ip)
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "wrong recovery code")
a.signInFailed(w, errors.New("wrong or used recovery code"))
return
}
a.auth.dropTicket(in.Ticket)
slog.Info("recovery code used", "audit", true, "actor", u.Username, "remote", ip, "left", left)
a.signedIn(w, r, u, "recovery code")
}
// loginKeyBegin asks for one of the user's security keys or passkeys.
func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
t, u, err := a.auth.ticketUser(in.Ticket, remoteIP(r))
if err != nil {
a.signInFailed(w, err)
return
}
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
if u.MFA == nil || len(u.MFA.Keys) == 0 {
writeErr(w, badRequest("no security key is set up"))
return
}
opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged))
if err != nil {
writeErr(w, err)
return
}
a.auth.mu.Lock()
t.key = data
a.auth.mu.Unlock()
writeJSON(w, http.StatusOK, opts)
}
// loginKeyFinish checks the key's answer. The ticket is in the query, the
// body is the browser's credential.
func (a *App) loginKeyFinish(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("ticket")
ip := remoteIP(r)
t, u, err := a.auth.ticketUser(id, ip)
if err != nil {
a.signInFailed(w, err)
return
}
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
a.auth.mu.Lock()
data := t.key
t.key = nil
a.auth.mu.Unlock()
if data == nil {
writeErr(w, badRequest("ask for the key first"))
return
}
cred, err := wa.FinishLogin(waUser{u}, *data, r)
if err != nil {
a.auth.ticketFailed(id, ip)
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "security key: "+err.Error())
a.signInFailed(w, errors.New("the security key was not accepted"))
return
}
a.keyUsed(u.ID, cred)
a.auth.dropTicket(id)
a.signedIn(w, r, u, "security key")
}
// keyUsed stores the key's new signature counter and when it was used.
func (a *App) keyUsed(userID string, cred *webauthn.Credential) {
now := time.Now().UTC()
_ = a.store.Update(func(c *Config) error {
if _, u := c.userByID(userID); u != nil && u.MFA != nil {
for i := range u.MFA.Keys {
if k := &u.MFA.Keys[i]; bytes.Equal(k.Credential.ID, cred.ID) {
k.Credential.Authenticator = cred.Authenticator
k.Credential.Flags = cred.Flags
k.LastUsed = &now
}
}
}
return nil
})
}
// loginPasskeyBegin starts a sign-in with a passkey alone.
func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
opts, data, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
writeErr(w, err)
return
}
id := randomString(24)
a.auth.mu.Lock()
a.auth.mfa.logins[id] = &ceremony{data: data, expires: time.Now().Add(ticketTTL)}
a.auth.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
}
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
ip := remoteIP(r)
a.auth.mu.Lock()
cer := a.auth.mfa.logins[id]
delete(a.auth.mfa.logins, id)
locked := a.auth.lockedLocked(ip)
a.auth.mu.Unlock()
if locked {
a.signInFailed(w, errLocked)
return
}
if cer == nil || time.Now().After(cer.expires) {
a.signInFailed(w, errors.New("the sign-in expired; try again"))
return
}
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
cfg := a.store.Get()
var found *User
cred, err := wa.FinishDiscoverableLogin(func(rawID, handle []byte) (webauthn.User, error) {
for i := range cfg.Users {
u := &cfg.Users[i]
if u.MFA != nil && len(u.MFA.Handle) > 0 && bytes.Equal(u.MFA.Handle, handle) {
for _, k := range u.MFA.Keys {
if k.Passkey && bytes.Equal(k.Credential.ID, rawID) {
found = u
return waUser{u}, nil
}
}
}
}
return nil, errors.New("unknown passkey")
}, *cer.data, r)
if err != nil || found == nil {
a.auth.mu.Lock()
a.auth.failLocked(ip)
a.auth.mu.Unlock()
slog.Warn("login failed", "remote", ip, "reason", "passkey not accepted")
a.signInFailed(w, errors.New("this passkey is not known here"))
return
}
a.keyUsed(found.ID, cred)
a.signedIn(w, r, found, "passkey")
}
// --- managing your own two-step sign-in (signed-in users) ---
type keyView struct {
ID string `json:"id"`
Name string `json:"name"`
Passkey bool `json:"passkey"`
Created time.Time `json:"created"`
LastUsed *time.Time `json:"lastUsed"`
}
func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) {
cfg := a.store.Get()
_, u := cfg.userByID(who(r).UserID)
if u == nil {
writeErr(w, badRequest("no such user"))
return
}
out := map[string]any{"totp": false, "totpAdded": nil, "keys": []keyView{}, "recoveryLeft": 0,
"keysAvailable": a.keysAvailable(r), "required": cfg.SignIn.RequireMFA}
if m := u.MFA; m != nil {
keys := []keyView{}
for _, k := range m.Keys {
keys = append(keys, keyView{k.ID, k.Name, k.Passkey, k.Created, k.LastUsed})
}
out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes)
}
writeJSON(w, http.StatusOK, out)
}
// addFirstCodes gives a user recovery codes with their first method. It
// returns the codes to show, or nil when the user already has codes. It runs
// inside a store update.
func addFirstCodes(u *User) []string {
if len(u.MFA.RecoveryCodes) > 0 {
return nil
}
codes, hashes := newRecoveryCodes()
u.MFA.RecoveryCodes = hashes
return codes
}
func (a *App) totpSetup(w http.ResponseWriter, r *http.Request) {
p := who(r)
secret := newTOTPSecret()
a.auth.mu.Lock()
a.auth.mfa.totpSetup[p.UserID] = secret
a.auth.mu.Unlock()
_, u := a.store.Get().userByID(p.UserID)
if u == nil {
writeErr(w, badRequest("no such user"))
return
}
uri := totpURI(secret, u.Username)
qr, _ := qrDataURL(uri)
writeJSON(w, http.StatusOK, map[string]any{"secret": secret, "uri": uri, "qr": qr})
}
func (a *App) totpConfirm(w http.ResponseWriter, r *http.Request) {
var in struct{ Code string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
p := who(r)
a.auth.mu.Lock()
secret := a.auth.mfa.totpSetup[p.UserID]
a.auth.mu.Unlock()
if secret == "" {
writeErr(w, badRequest("start the setup again"))
return
}
if !a.auth.useTOTP(p.UserID, secret, in.Code) {
writeErr(w, badRequest("wrong code; check the time on your phone and try the next one"))
return
}
var codes []string
now := time.Now().UTC()
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID)
if u == nil {
return badRequest("no such user")
}
if u.MFA == nil {
u.MFA = &UserMFA{}
}
u.MFA.TOTPSecret, u.MFA.TOTPAdded = secret, &now
codes = addFirstCodes(u)
return nil
}); err != nil {
writeErr(w, err)
return
}
a.auth.mu.Lock()
delete(a.auth.mfa.totpSetup, p.UserID)
a.auth.mu.Unlock()
a.audit(r, "authenticator app added")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
}
// lastMethodCheck refuses to remove the last method while two-step sign-in
// is required.
func lastMethodCheck(c *Config, u *User) error {
if c.SignIn.RequireMFA && !u.hasMFA() {
return badRequest("two-step sign-in is required here; add another method first")
}
if !u.hasMFA() && u.MFA != nil {
u.MFA.RecoveryCodes = nil
}
return nil
}
func (a *App) totpRemove(w http.ResponseWriter, r *http.Request) {
p := who(r)
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID)
if u == nil || u.MFA == nil || u.MFA.TOTPSecret == "" {
return badRequest("no authenticator app is set up")
}
u.MFA.TOTPSecret, u.MFA.TOTPAdded = "", nil
return lastMethodCheck(c, u)
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "authenticator app removed")
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// keyBegin starts adding a security key ({"passkey": false}) or a passkey.
func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
var in struct{ Passkey bool }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
p := who(r)
// The user handle is made once and never changes.
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID)
if u == nil {
return badRequest("no such user")
}
if u.MFA == nil {
u.MFA = &UserMFA{}
}
if len(u.MFA.Handle) == 0 {
u.MFA.Handle = make([]byte, 32)
if _, err := rand.Read(u.MFA.Handle); err != nil {
return err
}
}
return nil
}); err != nil {
writeErr(w, err)
return
}
_, u := a.store.Get().userByID(p.UserID)
var exclude []protocol.CredentialDescriptor
for _, k := range u.MFA.Keys {
exclude = append(exclude, k.Credential.Descriptor())
}
sel := protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementDiscouraged, UserVerification: protocol.VerificationDiscouraged}
if in.Passkey {
sel = protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired}
}
opts, data, err := wa.BeginRegistration(waUser{u}, webauthn.WithAuthenticatorSelection(sel), webauthn.WithExclusions(exclude))
if err != nil {
writeErr(w, err)
return
}
a.auth.mu.Lock()
a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, passkey: in.Passkey, data: data, expires: time.Now().Add(ticketTTL)}
a.auth.mu.Unlock()
writeJSON(w, http.StatusOK, opts)
}
// keyFinish stores the new key. The name is in the query, the body is the
// browser's credential.
func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
p := who(r)
name := strings.TrimSpace(r.URL.Query().Get("name"))
a.auth.mu.Lock()
cer := a.auth.mfa.enrolls[p.UserID]
delete(a.auth.mfa.enrolls, p.UserID)
a.auth.mu.Unlock()
if cer == nil || time.Now().After(cer.expires) {
writeErr(w, badRequest("adding the key took too long; try again"))
return
}
wa, err := a.webAuthn(r)
if err != nil {
writeErr(w, err)
return
}
_, u := a.store.Get().userByID(p.UserID)
if u == nil {
writeErr(w, badRequest("no such user"))
return
}
cred, err := wa.FinishRegistration(waUser{u}, *cer.data, r)
if err != nil {
writeErr(w, badRequest("the key was not accepted: %v", err))
return
}
if name == "" {
name = map[bool]string{false: "Security key", true: "Passkey"}[cer.passkey]
}
if len(name) > maxKeyName {
name = name[:maxKeyName]
}
var codes []string
key := MFAKey{ID: newID(), Name: name, Passkey: cer.passkey, Created: time.Now().UTC(), Credential: *cred}
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID)
if u == nil || u.MFA == nil {
return badRequest("no such user")
}
u.MFA.Keys = append(u.MFA.Keys, key)
codes = addFirstCodes(u)
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, map[bool]string{false: "security key added", true: "passkey added"}[cer.passkey], "key", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
}
func (a *App) keyRename(w http.ResponseWriter, r *http.Request) {
var in struct{ Name string }
if err := readJSON(r, &in); err != nil {
writeErr(w, err)
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || len(in.Name) > maxKeyName {
writeErr(w, badRequest("name must be 1–%d characters", maxKeyName))
return
}
id := r.PathValue("id")
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(who(r).UserID)
if u == nil || u.MFA == nil {
return badRequest("no such key")
}
for i := range u.MFA.Keys {
if u.MFA.Keys[i].ID == id {
u.MFA.Keys[i].Name = in.Name
return nil
}
}
return badRequest("no such key")
}); err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) keyRemove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var name string
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(who(r).UserID)
if u == nil || u.MFA == nil {
return badRequest("no such key")
}
i := slices.IndexFunc(u.MFA.Keys, func(k MFAKey) bool { return k.ID == id })
if i < 0 {
return badRequest("no such key")
}
name = u.MFA.Keys[i].Name
u.MFA.Keys = slices.Delete(u.MFA.Keys, i, i+1)
return lastMethodCheck(c, u)
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "security key removed", "key", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) newRecoveryCodesHandler(w http.ResponseWriter, r *http.Request) {
var codes []string
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(who(r).UserID)
if u == nil || !u.hasMFA() {
return badRequest("turn on two-step sign-in first")
}
var hashes []string
codes, hashes = newRecoveryCodes()
u.MFA.RecoveryCodes = hashes
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "recovery codes replaced")
writeJSON(w, http.StatusOK, map[string]any{"recoveryCodes": codes})
}
// resetMFA removes another user's two-step sign-in, for a lost phone or key.
// Their user handle stays, so passkeys they still hold are just unknown.
func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == who(r).UserID {
writeErr(w, badRequest("manage your own two-step sign-in under My account"))
return
}
var name string
if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(id)
if u == nil {
return badRequest("no such user")
}
name = u.Username
if u.MFA != nil {
u.MFA = &UserMFA{Handle: u.MFA.Handle}
}
return nil
}); err != nil {
writeErr(w, err)
return
}
a.audit(r, "two-step sign-in reset", "user", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// mfaSummary is what user lists show.
func mfaSummary(u *User) map[string]any {
out := map[string]any{"totp": false, "keys": 0, "passkeys": 0}
if m := u.MFA; m != nil {
keys, passkeys := 0, 0
for _, k := range m.Keys {
if k.Passkey {
passkeys++
} else {
keys++
}
}
out["totp"], out["keys"], out["passkeys"] = m.TOTPSecret != "", keys, passkeys
}
return out
}
+2 -1
View File
@@ -21,6 +21,7 @@ type userView struct {
LastLogin *tokenUse `json:"lastLogin"` // since the service started LastLogin *tokenUse `json:"lastLogin"` // since the service started
Tokens int `json:"tokens"` Tokens int `json:"tokens"`
You bool `json:"you"` You bool `json:"you"`
MFA map[string]any `json:"mfa"` // {"totp": bool, "keys": n, "passkeys": n}
} }
func (a *App) userView(c *Config, u *User, me string) userView { func (a *App) userView(c *Config, u *User, me string) userView {
@@ -30,7 +31,7 @@ func (a *App) userView(c *Config, u *User, me string) userView {
n++ n++
} }
} }
return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me} return userView{u.ID, u.Username, u.Note, u.MustChangePassword, u.Created, a.auth.LastLogin(u.ID), n, u.ID == me, mfaSummary(u)}
} }
// username names a user for lists, or "" if the ID is unknown. // username names a user for lists, or "" if the ID is unknown.
+48 -2
View File
@@ -1,7 +1,9 @@
package main package main
import ( import (
"crypto/sha256"
"embed" "embed"
"encoding/hex"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -14,6 +16,39 @@ import (
//go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2 //go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2
var webFiles embed.FS var webFiles embed.FS
// The pages load app.js, setup.js and app.css with ?v=<hash of the file>, so
// a new binary makes browsers fetch the new files, and a fingerprinted file
// can be cached for good.
var (
assetHash = map[string]string{}
indexPage []byte
)
func init() {
for _, name := range []string{"app.js", "setup.js", "app.css"} {
b, err := webFiles.ReadFile(name)
if err != nil {
panic(err)
}
sum := sha256.Sum256(b)
assetHash[name] = hex.EncodeToString(sum[:5])
}
b, err := webFiles.ReadFile("index.html")
if err != nil {
panic(err)
}
indexPage = fingerprint(b, "/")
}
// fingerprint adds ?v=<hash> to the page's references to base + file.
func fingerprint(page []byte, base string) []byte {
s := string(page)
for name, h := range assetHash {
s = strings.ReplaceAll(s, `"`+base+name+`"`, `"`+base+name+"?v="+h+`"`)
}
return []byte(s)
}
func (a *App) webHandler() http.Handler { func (a *App) webHandler() http.Handler {
files := http.FileServerFS(webFiles) files := http.FileServerFS(webFiles)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -22,7 +57,18 @@ func (a *App) webHandler() http.Handler {
return return
} }
switch r.URL.Path { switch r.URL.Path {
case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png": case "/":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(indexPage)
case "/app.js", "/setup.js", "/app.css":
if v := r.URL.Query().Get("v"); v != "" && v == assetHash[r.URL.Path[1:]] {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
files.ServeHTTP(w, r)
case "/favicon.svg", "/apple-touch-icon.png":
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
files.ServeHTTP(w, r) files.ServeHTTP(w, r)
case "/ShipporiMinchoB1-ExtraBold.woff2": case "/ShipporiMinchoB1-ExtraBold.woff2":
@@ -66,7 +112,7 @@ func (a *App) setupPage(w http.ResponseWriter, r *http.Request) {
page := strings.NewReplacer(`href="/`, `href="`+base, `src="/`, `src="`+base).Replace(string(b)) page := strings.NewReplacer(`href="/`, `href="`+base, `src="/`, `src="`+base).Replace(string(b))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(page)) _, _ = w.Write(fingerprint([]byte(page), base))
} }
func (a *App) setupAsset(w http.ResponseWriter, r *http.Request) { func (a *App) setupAsset(w http.ResponseWriter, r *http.Request) {