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 | } | ||