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.
Allow opt-in open registration
f920a9f12c4ff12e4f47497a084f0201d8914181 · Keenan Nemetz · 16d ago · parent 6c84d387
Allow opt-in open registration Add open_registration, off by default and effective only with an HTTPS public_url. /register creates an account and its first passkey without an administrator; the account row is written only after the WebAuthn ceremony succeeds, so an abandoned registration reserves no name. New accounts start with no repository-specific ACL entries. Add self-service SSH key management on the Account page so a registered contributor can add the key needed to push to their contributor namespace. Cover registration enablement, name validation, deferred account creation, and self-service key add/remove in tests, and document the policy.
server/web/account.go +71
| @@ -0,0 +1,71 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "strings" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // AddAccountKey lets a signed-in person attach an SSH public key without an | ||
| 9 | // administrator. Open registration is only useful for Git contributions if the | ||
| 10 | // account can turn its browser session into a key that pushes. | ||
| 11 | func (w *Web) AddAccountKey(rw http.ResponseWriter, r *http.Request) { | ||
| 12 | pr, ok := w.requireHTMLUser(rw, r) | ||
| 13 | if !ok { | ||
| 14 | return | ||
| 15 | } | ||
| 16 | r.Body = http.MaxBytesReader(rw, r.Body, 64<<10) | ||
| 17 | if !w.checkCSRF(r) { | ||
| 18 | http.Error(rw, "bad request", http.StatusForbidden) | ||
| 19 | return | ||
| 20 | } | ||
| 21 | if err := r.ParseForm(); err != nil { | ||
| 22 | http.Error(rw, "cannot read the form", http.StatusBadRequest) | ||
| 23 | return | ||
| 24 | } | ||
| 25 | key := strings.TrimSpace(r.PostFormValue("key")) | ||
| 26 | if key == "" { | ||
| 27 | http.Error(rw, "paste an SSH public key", http.StatusBadRequest) | ||
| 28 | return | ||
| 29 | } | ||
| 30 | fp, err := w.Ident.AddKey(pr.account.ID, key, pr.account.Name) | ||
| 31 | if err != nil { | ||
| 32 | msg := err.Error() | ||
| 33 | if strings.Contains(msg, "already registered") { | ||
| 34 | // Do not turn a known public key into a lookup of somebody else's name. | ||
| 35 | msg = "that SSH key is already registered" | ||
| 36 | } | ||
| 37 | http.Error(rw, msg, http.StatusBadRequest) | ||
| 38 | return | ||
| 39 | } | ||
| 40 | w.audit("web.key.add", pr.account.ID, fp) | ||
| 41 | http.Redirect(rw, r, "/account", http.StatusSeeOther) | ||
| 42 | } | ||
| 43 | |||
| 44 | // RemoveAccountKey detaches one of the signed-in account's own keys. It is the | ||
| 45 | // same operation an administrator can perform, scoped by the session account. | ||
| 46 | func (w *Web) RemoveAccountKey(rw http.ResponseWriter, r *http.Request) { | ||
| 47 | pr, ok := w.requireHTMLUser(rw, r) | ||
| 48 | if !ok { | ||
| 49 | return | ||
| 50 | } | ||
| 51 | r.Body = http.MaxBytesReader(rw, r.Body, 16<<10) | ||
| 52 | if !w.checkCSRF(r) { | ||
| 53 | http.Error(rw, "bad request", http.StatusForbidden) | ||
| 54 | return | ||
| 55 | } | ||
| 56 | if err := r.ParseForm(); err != nil { | ||
| 57 | http.Error(rw, "cannot read the form", http.StatusBadRequest) | ||
| 58 | return | ||
| 59 | } | ||
| 60 | fp := strings.TrimSpace(r.PostFormValue("fingerprint")) | ||
| 61 | if fp == "" { | ||
| 62 | http.Error(rw, "choose a key", http.StatusBadRequest) | ||
| 63 | return | ||
| 64 | } | ||
| 65 | if err := w.Ident.RemoveKey(pr.account.ID, fp, pr.account.Name); err != nil { | ||
| 66 | http.Error(rw, err.Error(), http.StatusBadRequest) | ||
| 67 | return | ||
| 68 | } | ||
| 69 | w.audit("web.key.remove", pr.account.ID, fp) | ||
| 70 | http.Redirect(rw, r, "/account", http.StatusSeeOther) | ||
| 71 | } | ||