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/auth_handlers.go +103−7
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/go-webauthn/webauthn/protocol" | |
| 10 | 10 | "github.com/go-webauthn/webauthn/webauthn" | |
| 11 | 11 | ||
| 12 | "github.com/rodent-software/vole/core/id" | ||
| 12 | 13 | "github.com/rodent-software/vole/forge/identity" | |
| 13 | 14 | "github.com/rodent-software/vole/forge/session" | |
| 14 | 15 | ) | |
| @@ -19,14 +20,16 @@ func (w *Web) LoginPage(rw http.ResponseWriter, r *http.Request) { | |||
| 19 | 20 | next := safeNext(r.URL.Query().Get("next")) | |
| 20 | 21 | w.render(rw, "login", struct { | |
| 21 | 22 | Page | |
| 22 | Next string | ||
| 23 | Enabled bool | ||
| 24 | Reason string | ||
| 23 | Next string | ||
| 24 | Enabled bool | ||
| 25 | Registration bool | ||
| 26 | Reason string | ||
| 25 | 27 | }{ | |
| 26 | Page: w.page(r, "Log in"), | ||
| 27 | Next: next, | ||
| 28 | Enabled: w.enabled(), | ||
| 29 | Reason: authReason(w.Auth), | ||
| 28 | Page: w.page(r, "Log in"), | ||
| 29 | Next: next, | ||
| 30 | Enabled: w.enabled(), | ||
| 31 | Registration: w.registrationEnabled(), | ||
| 32 | Reason: authReason(w.Auth), | ||
| 30 | 33 | }, 0) | |
| 31 | 34 | } | |
| 32 | 35 | ||
| @@ -235,6 +238,99 @@ func (w *Web) InviteFinish(rw http.ResponseWriter, r *http.Request) { | |||
| 235 | 238 | writeJSON(rw, http.StatusOK, map[string]any{"ok": true, "redirect": "/"}) | |
| 236 | 239 | } | |
| 237 | 240 | ||
| 241 | // RegisterPage is only reachable when the operator explicitly enabled open | ||
| 242 | // registration. There is no half-open state where a page loads but the POST is | ||
| 243 | // refused for a different reason. | ||
| 244 | func (w *Web) RegisterPage(rw http.ResponseWriter, r *http.Request) { | ||
| 245 | if !w.registrationEnabled() { | ||
| 246 | http.NotFound(rw, r) | ||
| 247 | return | ||
| 248 | } | ||
| 249 | w.render(rw, "register", struct { | ||
| 250 | Page | ||
| 251 | Reason string | ||
| 252 | }{ | ||
| 253 | Page: w.page(r, "Create an account"), | ||
| 254 | Reason: authReason(w.Auth), | ||
| 255 | }, 0) | ||
| 256 | } | ||
| 257 | |||
| 258 | func (w *Web) RegisterBegin(rw http.ResponseWriter, r *http.Request) { | ||
| 259 | if !w.registrationEnabled() { | ||
| 260 | writeJSONError(rw, http.StatusServiceUnavailable, "open registration is disabled") | ||
| 261 | return | ||
| 262 | } | ||
| 263 | var body struct { | ||
| 264 | Username string `json:"username"` | ||
| 265 | } | ||
| 266 | if err := decodeJSONBody(r, &body); err != nil { | ||
| 267 | writeJSONError(rw, http.StatusBadRequest, err.Error()) | ||
| 268 | return | ||
| 269 | } | ||
| 270 | name := strings.TrimSpace(body.Username) | ||
| 271 | if err := identity.ValidateName(name); err != nil { | ||
| 272 | writeJSONError(rw, http.StatusBadRequest, err.Error()) | ||
| 273 | return | ||
| 274 | } | ||
| 275 | if _, err := w.Ident.Find(name); err == nil { | ||
| 276 | writeJSONError(rw, http.StatusConflict, "that account name is already taken") | ||
| 277 | return | ||
| 278 | } | ||
| 279 | accountID, err := id.New(time.Now().UTC()) | ||
| 280 | if err != nil { | ||
| 281 | writeJSONError(rw, http.StatusInternalServerError, "cannot create an account") | ||
| 282 | return | ||
| 283 | } | ||
| 284 | // The user handle is fixed now and written only when the ceremony succeeds. | ||
| 285 | // An abandoned registration therefore reserves neither a name nor an account. | ||
| 286 | user := &waUser{account: &identity.Account{ID: accountID, Name: name, Status: "active"}} | ||
| 287 | creation, data, err := w.Auth.WA.BeginRegistration(user, | ||
| 288 | webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred)) | ||
| 289 | if err != nil { | ||
| 290 | writeJSONError(rw, http.StatusBadRequest, "cannot begin registration: "+err.Error()) | ||
| 291 | return | ||
| 292 | } | ||
| 293 | if err := w.beginCeremony(rw, ceremony{ | ||
| 294 | Kind: "register", Data: *data, AccountID: accountID, Name: name, | ||
| 295 | }, creation); err != nil { | ||
| 296 | writeJSONError(rw, http.StatusInternalServerError, "cannot store the ceremony") | ||
| 297 | } | ||
| 298 | } | ||
| 299 | |||
| 300 | func (w *Web) RegisterFinish(rw http.ResponseWriter, r *http.Request) { | ||
| 301 | if !w.registrationEnabled() { | ||
| 302 | writeJSONError(rw, http.StatusServiceUnavailable, "open registration is disabled") | ||
| 303 | return | ||
| 304 | } | ||
| 305 | c, ok := w.takeCeremony(rw, r) | ||
| 306 | if !ok { | ||
| 307 | return | ||
| 308 | } | ||
| 309 | if c.Kind != "register" { | ||
| 310 | writeJSONError(rw, http.StatusBadRequest, "wrong ceremony") | ||
| 311 | return | ||
| 312 | } | ||
| 313 | user := &waUser{account: &identity.Account{ID: c.AccountID, Name: c.Name, Status: "active"}} | ||
| 314 | cred, err := w.Auth.WA.FinishRegistration(user, c.Data, r) | ||
| 315 | if err != nil { | ||
| 316 | writeJSONError(rw, http.StatusBadRequest, "registration failed: "+err.Error()) | ||
| 317 | return | ||
| 318 | } | ||
| 319 | acct, err := w.Ident.CreatePasskey(c.Name, c.AccountID, fromWebAuthn(cred, "first passkey"), "self-registration") | ||
| 320 | if err != nil { | ||
| 321 | writeJSONError(rw, http.StatusConflict, "cannot create the account: "+err.Error()) | ||
| 322 | return | ||
| 323 | } | ||
| 324 | sess, err := w.Sessions.Create(acct.ID, cred.ID, r.UserAgent()) | ||
| 325 | if err != nil { | ||
| 326 | writeJSONError(rw, http.StatusInternalServerError, "cannot create a session") | ||
| 327 | return | ||
| 328 | } | ||
| 329 | w.audit("web.register", acct.ID, acct.Name) | ||
| 330 | w.setSessionCookie(rw, sess) | ||
| 331 | writeJSON(rw, http.StatusOK, map[string]any{"ok": true, "redirect": "/"}) | ||
| 332 | } | ||
| 333 | |||
| 238 | 334 | func (w *Web) Logout(rw http.ResponseWriter, r *http.Request) { | |
| 239 | 335 | if !w.checkCSRF(r) { | |
| 240 | 336 | http.Error(rw, "bad request", http.StatusForbidden) | |