Delete stored security keys
This commit is contained in:
@@ -144,7 +144,6 @@
|
||||
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(', ');
|
||||
}
|
||||
@@ -907,7 +906,7 @@
|
||||
}
|
||||
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('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 danger small', onClick: () => removeKey(k) }, 'Remove')));
|
||||
}
|
||||
|
||||
@@ -204,6 +204,9 @@ func (c *Config) applyDefaults() {
|
||||
c.Users = []User{u}
|
||||
}
|
||||
c.Admin = nil
|
||||
for i := range c.Users {
|
||||
dropSecurityKeys(&c.Users[i])
|
||||
}
|
||||
for i := range c.APITokens {
|
||||
if c.APITokens[i].UserID == "" {
|
||||
c.APITokens[i].UserID = c.Users[0].ID // tokens from before users existed
|
||||
|
||||
@@ -1121,3 +1121,30 @@ func TestMFA(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,17 +42,29 @@ type UserMFA struct {
|
||||
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
|
||||
}
|
||||
|
||||
// MFAKey is a passkey. Keys added by v0.3.0 as plain security keys have
|
||||
// Passkey false; they still work as the second step.
|
||||
// MFAKey is a passkey.
|
||||
type MFAKey struct {
|
||||
ID string `json:"id"`
|
||||
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"`
|
||||
LastUsed *time.Time `json:"lastUsed,omitempty"`
|
||||
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 {
|
||||
return u.MFA != nil && (u.MFA.TOTPSecret != "" || len(u.MFA.Keys) > 0)
|
||||
}
|
||||
@@ -205,7 +217,6 @@ type ticket struct {
|
||||
|
||||
type ceremony struct {
|
||||
userID string // "" for a passkey sign-in
|
||||
passkey bool
|
||||
data *webauthn.SessionData
|
||||
expires time.Time
|
||||
}
|
||||
@@ -563,7 +574,7 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
if bytes.Equal(k.Credential.ID, rawID) {
|
||||
found = u
|
||||
return waUser{u}, nil
|
||||
}
|
||||
@@ -589,7 +600,6 @@ func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
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"`
|
||||
}
|
||||
@@ -606,7 +616,7 @@ func (a *App) mfaStatus(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
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)
|
||||
}
|
||||
@@ -752,7 +762,7 @@ func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
a.auth.mu.Lock()
|
||||
a.auth.mfa.enrolls[p.UserID] = &ceremony{userID: p.UserID, passkey: true, 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()
|
||||
writeJSON(w, http.StatusOK, opts)
|
||||
}
|
||||
@@ -792,7 +802,7 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
|
||||
name = name[:maxKeyName]
|
||||
}
|
||||
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 {
|
||||
_, u := c.userByID(p.UserID)
|
||||
if u == nil || u.MFA == nil {
|
||||
@@ -911,17 +921,9 @@ func (a *App) resetMFA(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// mfaSummary is what user lists show.
|
||||
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 {
|
||||
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
|
||||
out["totp"], out["passkeys"] = m.TOTPSecret != "", len(m.Keys)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type userView struct {
|
||||
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}
|
||||
MFA map[string]any `json:"mfa"` // {"totp": bool, "passkeys": n}
|
||||
}
|
||||
|
||||
func (a *App) userView(c *Config, u *User, me string) userView {
|
||||
|
||||
Reference in New Issue
Block a user