From 1fe9b4e6191445498067dc20a83abc7039c4d40d Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Sun, 4 Oct 2026 21:55:53 +0300 Subject: [PATCH] Two-step sign-in: authenticator app, security keys and passkeys --- README.md | 23 +- api.go | 39 ++- app.css | 15 + app.js | 355 +++++++++++++++++++- auth.go | 39 ++- config.go | 21 +- go.mod | 10 + go.sum | 36 +- main_test.go | 141 ++++++++ mfa.go | 933 +++++++++++++++++++++++++++++++++++++++++++++++++++ users.go | 19 +- 11 files changed, 1590 insertions(+), 41 deletions(-) create mode 100644 mfa.go 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.