3 Commits

Author SHA1 Message Date
Daniel Redetzke 0a8dc8f7af Delete stored security keys 2026-10-04 22:35:19 +03:00
Daniel Redetzke 6570611ed8 Passkeys only: drop adding security keys 2026-10-04 22:13:31 +03:00
Daniel Redetzke ea13593925 Japanese hover label on the passkey button 2026-10-04 22:03:12 +03:00
6 changed files with 103 additions and 80 deletions
+11 -9
View File
@@ -67,14 +67,16 @@ The screenshots show sample data from the built-in simulator.
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes. - **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
HttpOnly, SameSite=Strict cookie and last 12 hours by default. HttpOnly, SameSite=Strict cookie and last 12 hours by default.
- **Two-step sign-in:** each user can add an authenticator app (TOTP), security - **Two-step sign-in:** each user can add an authenticator app (TOTP) and
keys such as a YubiKey, and passkeys that sign in without a password, under passkeys under My account. A passkey signs in on its own, without username
My account. Turning it on gives 10 one-time recovery codes. An admin can and password, and also works as the second step after a password. It can live
require it for everyone (Settings → Sign-in) and reset it for a user who lost on the device (Touch ID, Face ID, Windows Hello), in a password manager, or on
their phone or key. Security keys and passkeys use WebAuthn and need the a YubiKey with a PIN set. Turning it on gives 10 one-time recovery codes. An
server's domain name with a trusted certificate (Let's Encrypt, certificate admin can require it for everyone (Settings → Sign-in) and reset it for a user
files, or a reverse proxy); on a self-signed certificate or an IP address, who lost their phone or key. Passkeys use WebAuthn and need the server's
only the authenticator app is offered. API tokens never need a second step. domain name with a trusted certificate (Let's Encrypt, certificate files, or a
reverse proxy); on a self-signed certificate or an IP address, only the
authenticator app is offered. API tokens never need a second step.
- **API tokens** are stored only as hashes and can be read-only or full access. - **API tokens** are stored only as hashes and can be read-only or full access.
- `config.json` holds the server private key and is readable only by the - `config.json` holds the server private key and is readable only by the
service (0600). service (0600).
@@ -262,7 +264,7 @@ 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/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
POST /auth/login/passkey/begin · /auth/login/passkey/finish?id= 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: 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/keys/begin · /auth/mfa/keys/finish?name= · PATCH|DELETE /auth/mfa/keys/{id}
signed in: POST /auth/mfa/recovery-codes signed in: POST /auth/mfa/recovery-codes
GET /status GET /stats?range=24h|7d|30d|90d GET /status GET /stats?range=24h|7d|30d|90d
GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip GET /server PATCH /server POST /server/rotate-key GET /server/detect-ip
+22 -27
View File
@@ -144,7 +144,6 @@
if (!m) return ''; if (!m) return '';
const parts = []; const parts = [];
if (m.totp) parts.push('App'); 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'); if (m.passkeys) parts.push(m.passkeys === 1 ? '1 passkey' : m.passkeys + ' passkeys');
return parts.join(', '); return parts.join(', ');
} }
@@ -636,7 +635,7 @@
// allows it. // allows it.
const passkeyRow = h('div', { class: 'loginalt', hidden: true }, const passkeyRow = h('div', { class: 'loginalt', hidden: true },
h('div', { class: 'or' }, 'or'), h('div', { class: 'or' }, 'or'),
h('button', { type: 'button', class: 'btn altbtn', onClick: async () => { h('button', { type: 'button', class: 'btn altbtn signin', onClick: async () => {
err.textContent = ''; err.textContent = '';
try { try {
const b = await api('POST', '/auth/login/passkey/begin'); const b = await api('POST', '/auth/login/passkey/begin');
@@ -644,7 +643,7 @@
await api('POST', '/auth/login/passkey/finish?id=' + encodeURIComponent(b.id), cred); await api('POST', '/auth/login/passkey/finish?id=' + encodeURIComponent(b.id), cred);
await signedIn(); await signedIn();
} catch (x) { err.textContent = keyError(x); } } catch (x) { err.textContent = keyError(x); }
} }, icon('key', 18), 'Sign in with a passkey')); } }, icon('key', 18), h('span', { class: 'en' }, 'Sign in with a passkey'), h('span', { class: 'ja', lang: 'ja', 'aria-hidden': 'true' }, 'パスキーでサインイン')));
if (window.PublicKeyCredential) { if (window.PublicKeyCredential) {
api('GET', '/auth/options').then((o) => { passkeyRow.hidden = !o.passkeys; }).catch(() => {}); api('GET', '/auth/options').then((o) => { passkeyRow.hidden = !o.passkeys; }).catch(() => {});
} }
@@ -706,7 +705,7 @@
function keyError(x) { function keyError(x) {
if (x && x.name === 'NotAllowedError') return 'Cancelled or timed out. Try again.'; 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 === '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.'; if (x && x.name === 'SecurityError') return 'Passkeys need this site on its domain name with a trusted certificate.';
return x.message; return x.message;
} }
@@ -720,11 +719,11 @@
let mode = canKey ? 'key' : methods.includes('totp') ? 'totp' : 'recovery'; let mode = canKey ? 'key' : methods.includes('totp') ? 'totp' : 'recovery';
const box = h('div', { class: 'loginform' }); const box = h('div', { class: 'loginform' });
const TITLES = { const TITLES = {
key: ['Use your security key', 'Insert your key and touch it, or use the passkey on this device.'], key: ['Use your passkey', 'Confirm with Touch ID, Face ID, Windows Hello or your password manager, or insert your YubiKey and touch it.'],
totp: ['Enter the code', 'The 6-digit code from your authenticator app.'], 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.'], 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 LINKS = { key: 'Use a passkey instead', totp: 'Use an authenticator code instead', recovery: 'Use a recovery code' };
const head = h('div', { class: 'logintext' }); const head = h('div', { class: 'logintext' });
const draw = () => { const draw = () => {
const err = h('p', { class: 'err-text', role: 'alert' }); const err = h('p', { class: 'err-text', role: 'alert' });
@@ -733,7 +732,7 @@
.map((m) => h('button', { type: 'button', class: 'linkbtn', onClick: () => { mode = m; draw(); } }, LINKS[m])); .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')); const foot = h('div', { class: 'loginlinks' }, others, h('button', { type: 'button', class: 'linkbtn', onClick: showLogin }, 'Start over'));
if (mode === 'key') { if (mode === 'key') {
const btn = h('button', { type: 'button', class: 'btn primary' }, 'Use security key'); const btn = h('button', { type: 'button', class: 'btn primary' }, 'Use passkey');
const go = async () => { const go = async () => {
err.textContent = ''; err.textContent = '';
btn.disabled = true; btn.disabled = true;
@@ -788,8 +787,7 @@
h('p', null, 'This server asks for a second step after the password. Add one to continue.')), h('p', null, 'This server asks for a second step after the password. Add one to continue.')),
h('div', { class: 'loginform' }, h('div', { class: 'loginform' },
h('button', { type: 'button', class: 'btn primary', onClick: () => addTOTP(done) }, 'Use an authenticator app'), 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: () => addPasskey(done) }, 'Use a passkey') : 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')))))); h('div', { class: 'loginlinks' }, h('button', { type: 'button', class: 'linkbtn', onClick: logout }, 'Sign out'))))));
} }
@@ -841,30 +839,28 @@
code.focus(); code.focus();
} }
// addKey adds a security key, or with passkey a passkey that also signs // addPasskey adds a passkey. It signs in on its own, and also serves as
// in without a password. // the second step after a password.
function addKey(passkey, onDone) { function addPasskey(onDone) {
const nm = h('input', { id: 'kn', value: passkey ? 'Passkey' : 'YubiKey', autocomplete: 'off', maxLength: 64 }); const nm = h('input', { id: 'kn', value: 'Passkey', autocomplete: 'off', maxLength: 64 });
const e = h('p', { class: 'err-text', role: 'alert' }); const e = h('p', { class: 'err-text', role: 'alert' });
const btn = h('button', { type: 'submit', class: 'btn primary' }, passkey ? 'Add passkey' : 'Add security key'); const btn = h('button', { type: 'submit', class: 'btn primary' }, 'Add passkey');
dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => { dialog((close) => h('form', { class: 'dlg', onSubmit: async (ev) => {
ev.preventDefault(); ev.preventDefault();
e.textContent = ''; e.textContent = '';
btn.disabled = true; btn.disabled = true;
try { try {
const opts = await api('POST', '/auth/mfa/keys/begin', { passkey }); const opts = await api('POST', '/auth/mfa/keys/begin');
const cred = await webauthnCreate(opts); const cred = await webauthnCreate(opts);
const res = await api('POST', '/auth/mfa/keys/finish?name=' + encodeURIComponent(nm.value.trim()), cred); const res = await api('POST', '/auth/mfa/keys/finish?name=' + encodeURIComponent(nm.value.trim()), cred);
close(); close();
toast((passkey ? 'Passkey' : 'Security key') + ' added'); toast('Passkey added');
afterAdd(res, onDone); afterAdd(res, onDone);
} catch (x) { e.textContent = keyError(x); btn.disabled = false; } } catch (x) { e.textContent = keyError(x); btn.disabled = false; }
} }, } },
h('h2', null, passkey ? 'Add a passkey' : 'Add a security key'), h('h2', null, 'Add a passkey'),
h('p', null, passkey h('p', null, 'A passkey signs you in on its own, without username and password, and also works as the second step after your password. It can live on this device (Touch ID, Face ID, Windows Hello), in your password manager, or on a YubiKey with a PIN set.'),
? '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.' h('div', { class: 'field' }, h('label', { htmlFor: 'kn' }, 'Name'), nm, h('span', { class: 'hint' }, 'So you can tell your passkeys apart, for example "MacBook" or "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, e,
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), btn))); h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), btn)));
nm.select(); nm.select();
@@ -910,7 +906,7 @@
} }
for (const k of s.keys) { for (const k of s.keys) {
rows.push(h('div', { class: 'mfarow' }, h('div', { class: 'grow' }, h('strong', null, k.name), 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('div', { class: 'hint' }, '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 small', onClick: () => renameKey(k) }, 'Rename'),
h('button', { type: 'button', class: 'btn danger small', onClick: () => removeKey(k) }, 'Remove'))); h('button', { type: 'button', class: 'btn danger small', onClick: () => removeKey(k) }, 'Remove')));
} }
@@ -922,9 +918,8 @@
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.'), 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' }, h('div', { class: 'actions section' },
s.totp ? null : h('button', { type: 'button', class: 'btn primary', onClick: () => addTOTP(draw) }, 'Add authenticator app'), 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: () => addPasskey(draw) }, 'Add passkey') : null),
keys ? h('button', { type: 'button', class: 'btn', onClick: () => addKey(true, draw) }, 'Add passkey') : null), keys ? null : h('p', { class: 'hint section' }, 'Passkeys need this site on its domain name with a trusted certificate (Let\'s Encrypt or certificate files).'),
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)); ].filter(Boolean));
}; };
draw(); draw();
@@ -1839,7 +1834,7 @@
h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Reset password')))); h('div', { class: 'foot' }, h('button', { type: 'button', class: 'btn', onClick: close }, 'Cancel'), h('button', { type: 'submit', class: 'btn primary' }, 'Reset password'))));
}; };
const resetMFA = async (u) => { 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; if (!await confirmDialog({ title: 'Reset two-step sign-in for ' + u.username + '?', text: 'Their authenticator app, 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); } try { await api('POST', '/users/' + u.id + '/reset-mfa'); toast('Two-step sign-in reset for ' + u.username); reloadUsers(); } catch (x) { toast(x.message, true); }
}; };
const deleteUser = async (u) => { const deleteUser = async (u) => {
@@ -2017,7 +2012,7 @@
h('section', { class: 'card', 'aria-labelledby': 'sgn' }, h('section', { class: 'card', 'aria-labelledby': 'sgn' },
h('h2', { id: 'sgn' }, 'Sign-in'), 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('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app or passkeys, including on a YubiKey. Changes apply immediately.'),
h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'), 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('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.')))),
+3
View File
@@ -204,6 +204,9 @@ func (c *Config) applyDefaults() {
c.Users = []User{u} c.Users = []User{u}
} }
c.Admin = nil c.Admin = nil
for i := range c.Users {
dropSecurityKeys(&c.Users[i])
}
for i := range c.APITokens { for i := range c.APITokens {
if c.APITokens[i].UserID == "" { if c.APITokens[i].UserID == "" {
c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed
+28 -1
View File
@@ -1050,7 +1050,7 @@ func TestMFA(t *testing.T) {
if o := adm("GET", "/auth/options", nil, 200); o["passkeys"] != false { if o := adm("GET", "/auth/options", nil, 200); o["passkeys"] != false {
t.Fatalf("passkeys offered on an IP address: %v", o) t.Fatalf("passkeys offered on an IP address: %v", o)
} }
adm("POST", "/auth/mfa/keys/begin", map[string]bool{"passkey": true}, 400) adm("POST", "/auth/mfa/keys/begin", nil, 400)
// Turn on the authenticator app; the first method brings recovery codes. // Turn on the authenticator app; the first method brings recovery codes.
setup := adm("POST", "/auth/mfa/totp/setup", nil, 200) setup := adm("POST", "/auth/mfa/totp/setup", nil, 200)
@@ -1121,3 +1121,30 @@ func TestMFA(t *testing.T) {
t.Fatal("reset left methods behind") t.Fatal("reset left methods behind")
} }
} }
// TestDropSecurityKeys checks that security keys from v0.3.0 are deleted on
// load, and recovery codes with them when nothing else is left.
func TestDropSecurityKeys(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
cfg := `{"users": [
{"id": "a", "username": "a", "passwordHash": "x", "mfa": {"keys": [{"id": "k", "name": "YubiKey", "passkey": false}], "recoveryCodes": ["h"]}},
{"id": "b", "username": "b", "passwordHash": "x", "mfa": {"keys": [{"id": "k1", "name": "YubiKey", "passkey": false}, {"id": "k2", "name": "Mac", "passkey": true}], "recoveryCodes": ["h"]}}
]}`
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}
store, err := openStore(path)
if err != nil {
t.Fatal(err)
}
c := store.Get()
if a := c.Users[0].MFA; len(a.Keys) != 0 || len(a.RecoveryCodes) != 0 {
t.Fatalf("user a kept %v", a)
}
if b := c.Users[1].MFA; len(b.Keys) != 1 || b.Keys[0].Name != "Mac" || len(b.RecoveryCodes) != 1 {
t.Fatalf("user b: %v", b)
}
if b, _ := os.ReadFile(path); strings.Contains(string(b), "YubiKey") {
t.Fatal("security key still in config.json")
}
}
+38 -42
View File
@@ -24,9 +24,10 @@ import (
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
) )
// Two-step sign-in for the web interface: an authenticator app (TOTP), // Two-step sign-in for the web interface: an authenticator app (TOTP) and
// security keys such as a YubiKey and passkeys (both WebAuthn), plus // passkeys (WebAuthn, also on a YubiKey), plus one-time recovery codes. A
// one-time recovery codes. API tokens never need a second step. // passkey signs in on its own and also serves as the second step after a
// password. API tokens never need a second step.
// //
// After a correct password, a user with two-step sign-in gets a short-lived // 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 // ticket instead of a session; the ticket and a code or key turn into the
@@ -41,16 +42,29 @@ type UserMFA struct {
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
} }
// MFAKey is a security key or passkey. // MFAKey is a passkey.
type MFAKey struct { type MFAKey struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Passkey bool `json:"passkey"` // discoverable: signs in without a password Passkey bool `json:"passkey"` // false only for security keys added by v0.3.0, which are deleted
Created time.Time `json:"created"` Created time.Time `json:"created"`
LastUsed *time.Time `json:"lastUsed,omitempty"` LastUsed *time.Time `json:"lastUsed,omitempty"`
Credential webauthn.Credential `json:"credential"` Credential webauthn.Credential `json:"credential"`
} }
// dropSecurityKeys deletes the security keys v0.3.0 could add; only
// passkeys are supported. A user left without a method loses their
// recovery codes too.
func dropSecurityKeys(u *User) {
if u.MFA == nil {
return
}
u.MFA.Keys = slices.DeleteFunc(u.MFA.Keys, func(k MFAKey) bool { return !k.Passkey })
if !u.hasMFA() {
u.MFA.RecoveryCodes = nil
}
}
func (u *User) hasMFA() bool { func (u *User) hasMFA() bool {
return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0) return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0)
} }
@@ -166,7 +180,7 @@ func (w waUser) WebAuthnCredentials() []webauthn.Credential {
return out return out
} }
// keysAvailable reports whether security keys and passkeys can work on this // keysAvailable reports whether passkeys can work on this
// address: WebAuthn needs a domain name (not an IP address) and a // address: WebAuthn needs a domain name (not an IP address) and a
// certificate the browser trusts, or localhost. // certificate the browser trusts, or localhost.
func (a *App) keysAvailable(r *http.Request) bool { func (a *App) keysAvailable(r *http.Request) bool {
@@ -179,7 +193,7 @@ func (a *App) keysAvailable(r *http.Request) bool {
func (a *App) webAuthn(r *http.Request) (*webauthn.WebAuthn, error) { func (a *App) webAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
if !a.keysAvailable(r) { if !a.keysAvailable(r) {
return nil, badRequest("security keys and passkeys need a domain name with a trusted certificate") return nil, badRequest("passkeys need a domain name with a trusted certificate")
} }
scheme := "https" scheme := "https"
if r.TLS == nil && hostOnly(r.Host) == "localhost" { if r.TLS == nil && hostOnly(r.Host) == "localhost" {
@@ -198,12 +212,11 @@ type ticket struct {
ip string ip string
expires time.Time expires time.Time
fails int fails int
key *webauthn.SessionData // a security key challenge, once asked for key *webauthn.SessionData // a passkey challenge, once asked for
} }
type ceremony struct { type ceremony struct {
userID string // "" for a passkey sign-in userID string // "" for a passkey sign-in
passkey bool
data *webauthn.SessionData data *webauthn.SessionData
expires time.Time expires time.Time
} }
@@ -430,7 +443,7 @@ func (a *App) loginRecovery(w http.ResponseWriter, r *http.Request) {
a.signedIn(w, r, u, "recovery code") a.signedIn(w, r, u, "recovery code")
} }
// loginKeyBegin asks for one of the user's security keys or passkeys. // loginKeyBegin asks for one of the user's passkeys, as the second step.
func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) { func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket string } var in struct{ Ticket string }
if err := readJSON(r, &in); err != nil { if err := readJSON(r, &in); err != nil {
@@ -448,7 +461,7 @@ func (a *App) loginKeyBegin(w http.ResponseWriter, r *http.Request) {
return return
} }
if u.MFA == nil || len(u.MFA.Keys) == 0 { if u.MFA == nil || len(u.MFA.Keys) == 0 {
writeErr(w, badRequest("no security key is set up")) writeErr(w, badRequest("no passkey is set up"))
return return
} }
opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged)) opts, data, err := wa.BeginLogin(waUser{u}, webauthn.WithUserVerification(protocol.VerificationDiscouraged))
@@ -488,13 +501,13 @@ func (a *App) loginKeyFinish(w http.ResponseWriter, r *http.Request) {
cred, err := wa.FinishLogin(waUser{u}, *data, r) cred, err := wa.FinishLogin(waUser{u}, *data, r)
if err != nil { if err != nil {
a.auth.ticketFailed(id, ip) a.auth.ticketFailed(id, ip)
slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "security key: "+err.Error()) slog.Warn("login failed", "user", u.Username, "remote", ip, "reason", "passkey: "+err.Error())
a.signInFailed(w, errors.New("the security key was not accepted")) a.signInFailed(w, errors.New("the passkey was not accepted"))
return return
} }
a.keyUsed(u.ID, cred) a.keyUsed(u.ID, cred)
a.auth.dropTicket(id) a.auth.dropTicket(id)
a.signedIn(w, r, u, "security key") a.signedIn(w, r, u, "passkey")
} }
// keyUsed stores the key's new signature counter and when it was used. // keyUsed stores the key's new signature counter and when it was used.
@@ -561,7 +574,7 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
u := &cfg.Users[i] u := &cfg.Users[i]
if u.MFA != nil && len(u.MFA.Handle) > 0 && bytes.Equal(u.MFA.Handle, handle) { if u.MFA != nil && len(u.MFA.Handle) > 0 && bytes.Equal(u.MFA.Handle, handle) {
for _, k := range u.MFA.Keys { for _, k := range u.MFA.Keys {
if k.Passkey && bytes.Equal(k.Credential.ID, rawID) { if bytes.Equal(k.Credential.ID, rawID) {
found = u found = u
return waUser{u}, nil return waUser{u}, nil
} }
@@ -587,7 +600,6 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
type keyView struct { type keyView struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Passkey bool `json:"passkey"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
LastUsed *time.Time `json:"lastUsed"` LastUsed *time.Time `json:"lastUsed"`
} }
@@ -604,7 +616,7 @@ func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) {
if m := u.MFA; m != nil { if m := u.MFA; m != nil {
keys := []keyView{} keys := []keyView{}
for _, k := range m.Keys { for _, k := range m.Keys {
keys = append(keys, keyView{k.ID, k.Name, k.Passkey, k.Created, k.LastUsed}) keys = append(keys, keyView{k.ID, k.Name, k.Created, k.LastUsed})
} }
out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes) out["totp"], out["totpAdded"], out["keys"], out["recoveryLeft"] = m.TOTPSecret != "", m.TOTPAdded, keys, len(m.RecoveryCodes)
} }
@@ -710,13 +722,8 @@ func (a *App) totpRemove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
// keyBegin starts adding a security key ({"passkey": false}) or a passkey. // keyBegin starts adding a passkey.
func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) { 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) wa, err := a.webAuthn(r)
if err != nil { if err != nil {
writeErr(w, err) writeErr(w, err)
@@ -748,17 +755,14 @@ func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
for _, k := range u.MFA.Keys { for _, k := range u.MFA.Keys {
exclude = append(exclude, k.Credential.Descriptor()) exclude = append(exclude, k.Credential.Descriptor())
} }
sel := protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementDiscouraged, UserVerification: protocol.VerificationDiscouraged} sel := protocol.AuthenticatorSelection{ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired}
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)) opts, data, err := wa.BeginRegistration(waUser{u}, webauthn.WithAuthenticatorSelection(sel), webauthn.WithExclusions(exclude))
if err != nil { if err != nil {
writeErr(w, err) writeErr(w, err)
return return
} }
a.auth.mu.Lock() 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.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, data: data, expires: time.Now().Add(ticketTTL)}
a.auth.mu.Unlock() a.auth.mu.Unlock()
writeJSON(w, http.StatusOK, opts) writeJSON(w, http.StatusOK, opts)
} }
@@ -792,13 +796,13 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
return return
} }
if name == "" { if name == "" {
name = map[bool]string{false: "Security key", true: "Passkey"}[cer.passkey] name = "Passkey"
} }
if len(name) > maxKeyName { if len(name) > maxKeyName {
name = name[:maxKeyName] name = name[:maxKeyName]
} }
var codes []string var codes []string
key := MFAKey{ID: newID(), Name: name, Passkey: cer.passkey, Created: time.Now().UTC(), Credential: *cred} key := MFAKey{ID: newID(), Name: name, Passkey: true, Created: time.Now().UTC(), Credential: *cred}
if err := a.store.Update(func(c *Config) error { if err := a.store.Update(func(c *Config) error {
_, u := c.userByID(p.UserID) _, u := c.userByID(p.UserID)
if u == nil || u.MFA == nil { if u == nil || u.MFA == nil {
@@ -811,7 +815,7 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
writeErr(w, err) writeErr(w, err)
return return
} }
a.audit(r, map[bool]string{false: "security key added", true: "passkey added"}[cer.passkey], "key", name) a.audit(r, "passkey added", "key", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recoveryCodes": codes})
} }
@@ -865,7 +869,7 @@ func (a *App) keyRemove(w http.ResponseWriter, r *http.Request) {
writeErr(w, err) writeErr(w, err)
return return
} }
a.audit(r, "security key removed", "key", name) a.audit(r, "passkey removed", "key", name)
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
@@ -917,17 +921,9 @@ func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) {
// mfaSummary is what user lists show. // mfaSummary is what user lists show.
func mfaSummary(u *User) map[string]any { func mfaSummary(u *User) map[string]any {
out := map[string]any{"totp": false, "keys": 0, "passkeys": 0} out := map[string]any{"totp": false, "passkeys": 0}
if m := u.MFA; m != nil { if m := u.MFA; m != nil {
keys, passkeys := 0, 0 out["totp"], out["passkeys"] = m.TOTPSecret != "", len(m.Keys)
for _, k := range m.Keys {
if k.Passkey {
passkeys++
} else {
keys++
}
}
out["totp"], out["keys"], out["passkeys"] = m.TOTPSecret != "", keys, passkeys
} }
return out return out
} }
+1 -1
View File
@@ -21,7 +21,7 @@ type userView struct {
LastLogin *tokenUse `json:"lastLogin"` // since the service started LastLogin *tokenUse `json:"lastLogin"` // since the service started
Tokens int `json:"tokens"` Tokens int `json:"tokens"`
You bool `json:"you"` You bool `json:"you"`
MFA map[string]any `json:"mfa"` // {"totp": bool, "keys": n, "passkeys": n} MFA map[string]any `json:"mfa"` // {"totp": bool, "passkeys": n}
} }
func (a *App) userView(c *Config, u *User, me string) userView { func (a *App) userView(c *Config, u *User, me string) userView {