Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Let people rename and remove their own passkeys

7d7718fac3edfc9b8e27d1e55721a6f9ca9106af · Keenan Nemetz · 7h ago · parent 5f6163bb

Let people rename and remove their own passkeys

The Account page listed and added passkeys and could do nothing else: a lost or
shared device, or a label typo, needed an instance admin and the CLI. Both are the
person's own credential, so both belong on the page.

- Rename: `identity.RenameCredential` writes the label in one transaction and audits
  `identity.credential-rename` with the passkey and its new label. A label is display
  text, so the credential id and key are untouched and the cap is the one the
  ceremony already uses (`MaxCredentialLabel`). An empty label is allowed and the
  page calls it a passkey.
- Remove: `identity.RemoveCredential` now deletes the sessions of that passkey in the
  same transaction. The foreign key cascades already, and saying it out loud is what
  makes it the intent rather than a schema accident: the session the person is
  reading the page from ends with the passkey it signed in with.
- The last passkey is refused, with an explanation, while the account has no SSH key
  either. With one it is a way in for Git, so an account that has one loses only its
  browser sign-in; with neither, the only way back is an admin invite, and losing
  that should never be a click that silently succeeded. Two passkeys are enough to
  rotate: the last one is protected, not every one.
- The page gets a rename form and a remove form per passkey, each with the CSRF token
  and a confirmation on the removal, and a line saying what removing one does. The
  handlers are scoped by the session account, so the form cannot name somebody else's
  credential, and an unknown one is a 404 rather than a silent no-op.

Docs: `docs/accounts-and-access.md` covers renaming, the last-passkey rule, and the
audit actions for both.

Tests: the label changes and the credential does not, an empty label clears it, an
over-long one is refused where it is accepted, and another account's passkey is not
renameable; a removed passkey takes its sessions with it (identity); and end to end
over the account page — rename one of two, remove one of several, a post without the
token is refused, and the last passkey is refused until an SSH key is added.

Closes 01M6SASJYFVH6Z3P6BXTEMA83

server/web/account.go +71

@@ -1,8 +1,11 @@
1 1 package web
2 2
3 3 import (
4 "errors"
4 5 "net/http"
5 6 "strings"
7
8 "rodent.software/vole/forge/identity"
6 9 )
7 10
8 11 // AddAccountKey lets a signed-in person attach an SSH public key without an
@@ -34,6 +37,74 @@ func (w *Web) AddAccountKey(rw http.ResponseWriter, r *http.Request) {
34 37
35 38 // RemoveAccountKey detaches one of the signed-in account's own keys. It is the
36 39 // same operation an instance admin can perform, scoped by the session account.
40 // RenameAccountPasskey relabels one of the signed-in account's own passkeys. A label
41 // is display text: the credential id is what signs in, so this is a small update and
42 // not a re-registration.
43 func (w *Web) RenameAccountPasskey(rw http.ResponseWriter, r *http.Request) {
44 pr, ok := w.beginPost(rw, r, maxTinyForm)
45 if !ok {
46 return
47 }
48 id, err := identity.ParseCredentialID(strings.TrimSpace(r.PostFormValue("credential")))
49 if err != nil {
50 http.Error(rw, "choose a passkey", http.StatusBadRequest)
51 return
52 }
53 label := strings.TrimSpace(r.PostFormValue("label"))
54 if err := w.Ident.RenameCredential(pr.account.Name, id, label, pr.account.Name); err != nil {
55 if errors.Is(err, identity.ErrCredentialNotFound) {
56 http.Error(rw, err.Error(), http.StatusNotFound)
57 return
58 }
59 http.Error(rw, err.Error(), http.StatusBadRequest)
60 return
61 }
62 w.audit("web.passkey.rename", pr.account.Name, "", identity.CredentialIDString(id)+" "+label)
63 http.Redirect(rw, r, "/-/account", http.StatusSeeOther)
64 }
65
66 // RemoveAccountPasskey revokes one of the signed-in account's own passkeys, and with
67 // it the sessions that signed in with it.
68 //
69 // The last passkey is the last way into the browser, so it is refused when the account
70 // has no SSH key either: an account with neither can only be brought back by an
71 // instance admin, and losing the only way in should never be a click that succeeded.
72 // An SSH key is a way in — it pushes and clones — so an account that has one loses only
73 // its browser sign-in.
74 func (w *Web) RemoveAccountPasskey(rw http.ResponseWriter, r *http.Request) {
75 pr, ok := w.beginPost(rw, r, maxTinyForm)
76 if !ok {
77 return
78 }
79 id, err := identity.ParseCredentialID(strings.TrimSpace(r.PostFormValue("credential")))
80 if err != nil {
81 http.Error(rw, "choose a passkey", http.StatusBadRequest)
82 return
83 }
84 creds, err := w.Ident.Credentials(pr.account.Name)
85 if err != nil {
86 w.serverError(rw, r, "cannot read passkeys: "+err.Error())
87 return
88 }
89 last := len(creds) == 1 && identity.CredentialIDString(creds[0].ID) == identity.CredentialIDString(id)
90 if last && len(pr.account.Keys) == 0 {
91 http.Error(rw,
92 "this is the only way into your account: register another passkey or add an SSH key "+
93 "first, or ask an instance admin for an invite", http.StatusConflict)
94 return
95 }
96 if err := w.Ident.RemoveCredential(pr.account.Name, id, pr.account.Name); err != nil {
97 if errors.Is(err, identity.ErrCredentialNotFound) {
98 http.Error(rw, err.Error(), http.StatusNotFound)
99 return
100 }
101 http.Error(rw, err.Error(), http.StatusBadRequest)
102 return
103 }
104 w.audit("web.passkey.remove", pr.account.Name, "", identity.CredentialIDString(id))
105 http.Redirect(rw, r, "/-/account", http.StatusSeeOther)
106 }
107
37 108 func (w *Web) RemoveAccountKey(rw http.ResponseWriter, r *http.Request) {
38 109 pr, ok := w.beginPost(rw, r, maxTinyForm)
39 110 if !ok {