Delete stored security keys
This commit is contained in:
@@ -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(', ');
|
||||||
}
|
}
|
||||||
@@ -907,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')));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,17 +42,29 @@ type UserMFA struct {
|
|||||||
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
|
Handle []byte `json:"handle,omitempty"` // WebAuthn user handle
|
||||||
}
|
}
|
||||||
|
|
||||||
// MFAKey is a passkey. Keys added by v0.3.0 as plain security keys have
|
// MFAKey is a passkey.
|
||||||
// Passkey false; they still work as the second step.
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -205,7 +217,6 @@ type ticket struct {
|
|||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -563,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
|
||||||
}
|
}
|
||||||
@@ -589,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"`
|
||||||
}
|
}
|
||||||
@@ -606,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)
|
||||||
}
|
}
|
||||||
@@ -752,7 +762,7 @@ func (a *App) keyBegin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.auth.mu.Lock()
|
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()
|
a.auth.mu.Unlock()
|
||||||
writeJSON(w, http.StatusOK, opts)
|
writeJSON(w, http.StatusOK, opts)
|
||||||
}
|
}
|
||||||
@@ -792,7 +802,7 @@ func (a *App) keyFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
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 {
|
||||||
@@ -911,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user