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.

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)