diff --git a/README.md b/README.md
index 23be5ff..a5bfbc2 100644
--- a/README.md
+++ b/README.md
@@ -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.
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
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.
- `config.json` holds the server private key and is readable only by the
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
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
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
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)
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 /server PATCH /server POST /server/rotate-key GET /server/detect-ip
GET /peers POST /peers (returns the config and QR once)
diff --git a/api.go b/api.go
index 0670827..8197757 100644
--- a/api.go
+++ b/api.go
@@ -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"})
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 {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
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/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)
full("POST /api/v1/auth/password", a.changePassword)
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("POST /api/v1/users/{id}/reset-password", a.resetPassword)
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/stats", a.allStats)
@@ -203,7 +228,7 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
return
}
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 {
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
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()})
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)
slog.Info("login", "audit", true, "actor", in.Username, "remote", ip)
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)
out := map[string]any{
"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 != "" {
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,
"stats": cfg.Stats,
"decoy": cfg.Decoy,
+ "signin": cfg.SignIn,
"geo": a.geoStatus(),
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
"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 {
return err
}
+ if err := field(m, "signin", &c.SignIn); err != nil {
+ return err
+ }
return field(m, "log", &c.Log)
})
if err != nil {
diff --git a/app.css b/app.css
index 950b5de..9c28c48 100644
--- a/app.css
+++ b/app.css
@@ -307,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 .qr { width: 100%; height: auto; max-width: 280px; align-self: center; }
.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; }
diff --git a/app.js b/app.js
index f714ba3..8b40d54 100644
--- a/app.js
+++ b/app.js
@@ -48,6 +48,7 @@
server: '',
settings: '',
plus: '',
+ key: '',
logout: '',
};
@@ -138,6 +139,16 @@
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 "".
function fmtLocation(g) {
if (!g) return '';
@@ -216,7 +227,7 @@
const r = await fetch('/api/v1' + path, opt);
let data = {};
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;
showLogin();
throw new Error('Signed out');
@@ -225,6 +236,10 @@
showNewPassword();
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);
return data;
}
@@ -566,6 +581,7 @@
try { me = await api('GET', '/auth/me'); } catch { showLogin(); return; }
}
if (me.mustChangePassword) { showNewPassword(); return; }
+ if (me.mfaSetupRequired) { showMFASetup(); return; }
if (!main || !main.isConnected) buildShell();
every(30000, refreshSide);
const hash = location.hash || '#/';
@@ -604,9 +620,9 @@
err.textContent = '';
btn.disabled = true;
try {
- await api('POST', '/auth/login', { username: user.value, password: pw.value });
- me = await api('GET', '/auth/me');
- if (me.mustChangePassword) showNewPassword(pw.value); else render();
+ const res = await api('POST', '/auth/login', { username: user.value, password: pw.value });
+ if (res.mfa) { showSecondStep(res.ticket, res.methods, pw.value); return; }
+ await signedIn(pw.value);
} catch (x) {
err.textContent = x.message;
btn.disabled = false;
@@ -616,12 +632,305 @@
h('div', { class: 'field' }, h('label', { htmlFor: 'u' }, 'Username'), user),
h('div', { class: 'field' }, h('label', { htmlFor: 'p' }, 'Password'), pw),
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' },
brand(72),
- form)));
+ form, passkeyRow)));
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
// an admin chose. current is that password when the user just typed it.
function showNewPassword(current) {
@@ -1407,6 +1716,8 @@
pwErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Change password'))),
+ mfaCard(),
+
h('section', { class: 'card flush', 'aria-labelledby': 'mytk' },
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.')),
@@ -1429,6 +1740,7 @@
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, 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.tokens ? String(u.tokens) : h('span', { class: 'muted' }, 'None')),
h('td', null, fmtDate(u.created)),
@@ -1502,6 +1814,7 @@
must.el,
h('div', { class: 'actions' },
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…')),
e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))));
@@ -1525,6 +1838,10 @@
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'))));
};
+ 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 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;
@@ -1596,6 +1913,26 @@
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)));
+ // 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
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) => {
@@ -1675,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('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')),
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))),
+ 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('h2', { id: 'web' }, 'Web interface'),
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'),
diff --git a/auth.go b/auth.go
index a6e17dc..df3660f 100644
--- a/auth.go
+++ b/auth.go
@@ -102,7 +102,10 @@ type principal struct {
RemoteIP string
// MustChangePassword blocks everything but changing the password.
MustChangePassword bool
- Session *sessionInfo // nil for API tokens
+ // 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
}
// sessionInfo is when and from where a browser session started.
@@ -138,6 +141,7 @@ type Auth struct {
used map[string]tokenUse
logins map[string]tokenUse // last sign-in per user ID
fails map[string]*failState
+ mfa mfaState
}
const (
@@ -146,26 +150,27 @@ const (
)
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" }
var errLocked = errors.New("too many failed attempts, try again later")
-// Login checks the credentials and returns a new session id.
-func (a *Auth) Login(user, pw, ip string) (string, error) {
+// Login checks the credentials and returns a new session id, or, for a user
+// 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()
f := a.fails[ip]
if f != nil && time.Now().Before(f.until) {
a.mu.Unlock()
- return "", errLocked
+ return "", "", errLocked
}
a.mu.Unlock()
cfg := a.store.Get()
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
// 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.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)
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
@@ -290,7 +298,8 @@ func (a *Auth) Authenticate(r *http.Request) (*principal, bool) {
return nil, false
}
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 {
@@ -317,4 +326,16 @@ func (a *Auth) sweep() {
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)
+ }
+ }
+ }
}
diff --git a/config.go b/config.go
index 3ce8384..496a817 100644
--- a/config.go
+++ b/config.go
@@ -27,12 +27,20 @@ type Config struct {
APITokens []APIToken `json:"apiTokens"`
// Admin is the single account of config version 1; applyDefaults moves
// it into Users.
- Admin *Admin `json:"admin,omitempty"`
- Server Server `json:"server"`
- Peers []Peer `json:"peers"`
- Log LogConfig `json:"log"`
- Stats StatsConfig `json:"stats"`
- Decoy DecoyConfig `json:"decoy"`
+ Admin *Admin `json:"admin,omitempty"`
+ Server Server `json:"server"`
+ Peers []Peer `json:"peers"`
+ Log LogConfig `json:"log"`
+ Stats StatsConfig `json:"stats"`
+ 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.
@@ -92,6 +100,7 @@ type User struct {
// the user can do nothing else until they pick their own.
MustChangePassword bool `json:"mustChangePassword,omitempty"`
Created time.Time `json:"created"`
+ MFA *UserMFA `json:"mfa,omitempty"` // two-step sign-in, nil when never set up
}
type APIToken struct {
diff --git a/go.mod b/go.mod
index 0b5b4e2..7016e3a 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module ghostwire
go 1.27.1
require (
+ github.com/go-webauthn/webauthn v0.18.2
github.com/google/nftables v0.3.0
github.com/oschwald/maxminddb-golang v1.13.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
@@ -15,11 +16,20 @@ 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-tpm v0.9.8 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // 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/x448/float16 v0.8.4 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
diff --git a/go.sum b/go.sum
index 89b21ca..78dd293 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,23 @@
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
+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/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/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/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+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/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+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/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
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/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
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/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
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=
diff --git a/main_test.go b/main_test.go
index 8ecfabb..1b48822 100644
--- a/main_test.go
+++ b/main_test.go
@@ -980,3 +980,144 @@ func TestDecoy(t *testing.T) {
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")
+ }
+}
diff --git a/mfa.go b/mfa.go
new file mode 100644
index 0000000..62b1159
--- /dev/null
+++ b/mfa.go
@@ -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
+}
diff --git a/users.go b/users.go
index f2f5581..e3fa9a0 100644
--- a/users.go
+++ b/users.go
@@ -13,14 +13,15 @@ import (
// everyone changes their own password with the current one.
type userView struct {
- ID string `json:"id"`
- Username string `json:"username"`
- Note string `json:"note"`
- MustChangePassword bool `json:"mustChangePassword"`
- Created time.Time `json:"created"`
- LastLogin *tokenUse `json:"lastLogin"` // since the service started
- Tokens int `json:"tokens"`
- You bool `json:"you"`
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Note string `json:"note"`
+ MustChangePassword bool `json:"mustChangePassword"`
+ Created time.Time `json:"created"`
+ LastLogin *tokenUse `json:"lastLogin"` // since the service started
+ Tokens int `json:"tokens"`
+ 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 {
@@ -30,7 +31,7 @@ func (a *App) userView(c *Config, u *User, me string) userView {
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.