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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Allow opt-in open registration

f920a9f12c4ff12e4f47497a084f0201d8914181 · Keenan Nemetz · 15d 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/auth_test.go +97

@@ -17,6 +17,7 @@ import (
17 17 "github.com/rodent-software/vole/forge/index"
18 18 "github.com/rodent-software/vole/forge/session"
19 19 "github.com/rodent-software/vole/metadata"
20 "golang.org/x/crypto/ssh"
20 21 )
21 22
22 23 var noRedirectAuth = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
@@ -457,3 +458,99 @@ jobs:
457 458 t.Fatalf("queued run = %+v", run)
458 459 }
459 460 }
461
462 func TestOpenRegistrationPagesAndBegin(t *testing.T) {
463 t.Parallel()
464 f := newFixtureWith(t, func(c *config.Config) {
465 c.PublicURL = "https://forge.test"
466 c.OpenRegistration = true
467 })
468
469 resp, body := f.browserGet(t, "/login", "")
470 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create one") {
471 t.Fatalf("login page: status=%d body=%s", resp.StatusCode, body)
472 }
473 resp, body = f.browserGet(t, "/register", "")
474 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create account and passkey") {
475 t.Fatalf("register page: status=%d body=%s", resp.StatusCode, body)
476 }
477 resp, out := f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "newbie"})
478 if resp.StatusCode != http.StatusOK || out["publicKey"] == nil {
479 t.Fatalf("register begin: status=%d body=%v", resp.StatusCode, out)
480 }
481 resp, _ = f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "keenan"})
482 if resp.StatusCode != http.StatusConflict {
483 t.Fatalf("taken name status = %d, want 409", resp.StatusCode)
484 }
485 resp, _ = f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "Not A Name"})
486 if resp.StatusCode != http.StatusBadRequest {
487 t.Fatalf("invalid name status = %d, want 400", resp.StatusCode)
488 }
489
490 // An account is not created until the authenticator completes the ceremony.
491 if _, err := f.srv.Identity.Find("newbie"); err == nil {
492 t.Fatal("registration begin created an account before a credential existed")
493 }
494 }
495
496 func TestOpenRegistrationIsOffByDefault(t *testing.T) {
497 t.Parallel()
498 f := browserFixture(t)
499 resp, body := f.browserGet(t, "/login", "")
500 if resp.StatusCode != http.StatusOK || strings.Contains(body, "Create one") {
501 t.Fatalf("login page offered registration while disabled: status=%d body=%s", resp.StatusCode, body)
502 }
503 resp, _ = f.browserGet(t, "/register", "")
504 if resp.StatusCode != http.StatusNotFound {
505 t.Fatalf("register page status = %d, want 404", resp.StatusCode)
506 }
507 resp, _ = f.browserJSON(t, "/auth/register/begin", "", "", map[string]string{"username": "newbie"})
508 if resp.StatusCode != http.StatusServiceUnavailable {
509 t.Fatalf("register begin status = %d, want 503", resp.StatusCode)
510 }
511 }
512
513 func TestAccountAddsAndRemovesItsOwnSSHKey(t *testing.T) {
514 t.Parallel()
515 f := browserFixture(t)
516 sess, cookie := f.browserSession(t, f.ownerID)
517 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_self_service"))
518 k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pub))
519 if err != nil {
520 t.Fatal(err)
521 }
522 fp := ssh.FingerprintSHA256(k)
523
524 resp, body := f.browserPost(t, "/account/keys", cookie, url.Values{
525 "csrf": {sess.CSRF}, "key": {pub},
526 })
527 if resp.StatusCode != http.StatusSeeOther {
528 t.Fatalf("add key: status=%d body=%s", resp.StatusCode, body)
529 }
530 a, err := f.srv.Identity.Find(f.ownerID)
531 if err != nil {
532 t.Fatal(err)
533 }
534 found := false
535 for _, key := range a.Keys {
536 if key.Fingerprint == fp {
537 found = true
538 }
539 }
540 if !found {
541 t.Fatalf("added key %s is not on the account: %+v", fp, a.Keys)
542 }
543
544 resp, body = f.browserPost(t, "/account/keys/remove", cookie, url.Values{
545 "csrf": {sess.CSRF}, "fingerprint": {fp},
546 })
547 if resp.StatusCode != http.StatusSeeOther {
548 t.Fatalf("remove key: status=%d body=%s", resp.StatusCode, body)
549 }
550 a, _ = f.srv.Identity.Find(f.ownerID)
551 for _, key := range a.Keys {
552 if key.Fingerprint == fp {
553 t.Fatal("removed key is still on the account")
554 }
555 }
556 }