Commit c327fbdf04
Verified · cmc
docs/users.org +4 −1
| @@ -19,7 +19,10 @@ Or build from source: =go build ./cmd/gitbay= in a clone of | ||
| 19 | 19 | |
| 20 | 20 | * Getting an account |
| 21 | 21 | |
| 22 | How you join depends on the instance's registration mode: | |
| 22 | How you join depends on the instance's registration mode. On instances | |
| 23 | with web accounts enabled, =/register= offers the same signup as a | |
| 24 | browser form (paste your SSH public key); everything below works from | |
| 25 | the terminal alone: | |
| 23 | 26 | |
| 24 | 27 | - closed :: an admin creates your account on the host and registers your |
| 25 | 28 | first SSH key. Nothing for you to do but hand over your public key. |
e2e/websignup_test.go added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "net/url" | |
| 6 | "os" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ) | |
| 10 | ||
| 11 | func TestWebSignup(t *testing.T) { | |
| 12 | smtp := startFakeSMTP(t) | |
| 13 | inst := startInstanceWith(t, fmt.Sprintf( | |
| 14 | "[web]\nmode = \"accounts\"\n[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", | |
| 15 | smtp.addr)) | |
| 16 | ||
| 17 | // The landing page advertises signup; the form renders. | |
| 18 | status, body := inst.get(t, "/") | |
| 19 | if status != 200 || !strings.Contains(body, `href="/register"`) { | |
| 20 | t.Fatalf("landing signup link: %d", status) | |
| 21 | } | |
| 22 | status, body = inst.get(t, "/register") | |
| 23 | if status != 200 || !strings.Contains(body, "invite-only") || !strings.Contains(body, `name="key"`) { | |
| 24 | t.Fatalf("register form: %d\n%s", status, body) | |
| 25 | } | |
| 26 | ||
| 27 | // Invite issued over the admin path; redeemed through the browser. | |
| 28 | inst.admin(t, "admin", "invite", "--email", "erin@example.test") | |
| 29 | inviteCode := extractCode(t, smtp.waitMail(t, 0)) | |
| 30 | key := inst.newKey(t, "erin") | |
| 31 | pub, err := os.ReadFile(key + ".pub") | |
| 32 | if err != nil { | |
| 33 | t.Fatal(err) | |
| 34 | } | |
| 35 | ||
| 36 | browser := newBrowser(t) | |
| 37 | // A garbage key re-renders the form with the error, keeping the input. | |
| 38 | _, body = browserPost(t, browser, inst.base()+"/register", url.Values{ | |
| 39 | "username": {"erin"}, "invite": {inviteCode}, "key": {"not a key"}}) | |
| 40 | if !strings.Contains(body, "does not parse as an SSH public key") || !strings.Contains(body, `value="erin"`) { | |
| 41 | t.Fatalf("bad key handling:\n%s", body) | |
| 42 | } | |
| 43 | // A bad invite fails without burning anything. | |
| 44 | _, body = browserPost(t, browser, inst.base()+"/register", url.Values{ | |
| 45 | "username": {"erin"}, "invite": {"deadbeef"}, "key": {string(pub)}}) | |
| 46 | if !strings.Contains(body, "invalid or already used") { | |
| 47 | t.Fatalf("bad invite:\n%s", body) | |
| 48 | } | |
| 49 | // The real thing: account is active immediately (invite proves the mailbox). | |
| 50 | status, body = browserPost(t, browser, inst.base()+"/register", url.Values{ | |
| 51 | "username": {"erin"}, "invite": {inviteCode}, "key": {string(pub)}}) | |
| 52 | if status != 200 || !strings.Contains(body, "welcome, erin") { | |
| 53 | t.Fatalf("signup: %d\n%s", status, body) | |
| 54 | } | |
| 55 | out, errOut, code := inst.ssh(t, key, "", "whoami") | |
| 56 | if code != 0 || !strings.Contains(out, "erin") { | |
| 57 | t.Fatalf("ssh after web signup: exit %d, %s%s", code, out, errOut) | |
| 58 | } | |
| 59 | // The invite is burned: reusing it (fresh browser, fresh key) fails. | |
| 60 | key2 := inst.newKey(t, "mallory") | |
| 61 | pub2, _ := os.ReadFile(key2 + ".pub") | |
| 62 | _, body = browserPost(t, newBrowser(t), inst.base()+"/register", url.Values{ | |
| 63 | "username": {"mallory"}, "invite": {inviteCode}, "key": {string(pub2)}}) | |
| 64 | if !strings.Contains(body, "invalid or already used") { | |
| 65 | t.Fatalf("invite reuse:\n%s", body) | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | func TestWebSignupClosedInstance(t *testing.T) { | |
| 70 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 71 | // Closed registration: no signup route at all, and no landing hint. | |
| 72 | if status, _ := inst.get(t, "/register"); status != 404 { | |
| 73 | t.Fatalf("register on closed instance: %d", status) | |
| 74 | } | |
| 75 | _, body := inst.get(t, "/") | |
| 76 | if strings.Contains(body, `href="/register"`) { | |
| 77 | t.Fatal("closed landing advertises signup") | |
| 78 | } | |
| 79 | } | |
internal/control/register.go +26 −12
| @@ -130,42 +130,56 @@ func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []s | ||
| 130 | 130 | return fail(protocol.ExitUsage, "%v", err) |
| 131 | 131 | } |
| 132 | 132 | |
| 133 | msg, errMsg, code := RegisterAccount(cfg, st, pub, username, email, invite) | |
| 134 | if code != protocol.ExitOK { | |
| 135 | return fail(code, "%s", errMsg) | |
| 136 | } | |
| 137 | fmt.Fprint(stdout, msg) | |
| 138 | return protocol.ExitOK | |
| 139 | } | |
| 140 | ||
| 141 | // RegisterAccount creates an account for pub under the instance's | |
| 142 | // registration mode. On success it returns the human message and ExitOK; | |
| 143 | // otherwise an error message and the classifying exit code. Shared by the | |
| 144 | // SSH register command and the web signup form. | |
| 145 | func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, username, email, invite string) (string, string, int) { | |
| 146 | if err := policy.ValidateOwnerName(username); err != nil { | |
| 147 | return "", err.Error(), protocol.ExitUsage | |
| 148 | } | |
| 133 | 149 | fp := ssh.FingerprintSHA256(pub) |
| 134 | 150 | switch cfg.Registration.Mode { |
| 135 | 151 | case "invite": |
| 136 | 152 | if invite == "" { |
| 137 | return fail(protocol.ExitDenied, "this instance is invite-only: register --username <name> --invite <code>") | |
| 153 | return "", "this instance is invite-only: an invite code is required", protocol.ExitDenied | |
| 138 | 154 | } |
| 139 | 155 | // One transaction: a failure at any step leaves the invite |
| 140 | 156 | // redeemable and no partial account behind. |
| 141 | 157 | _, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal()) |
| 142 | 158 | if err != nil { |
| 143 | 159 | if errors.Is(err, store.ErrNotFound) { |
| 144 | return fail(protocol.ExitDenied, "that invite is invalid or already used") | |
| 160 | return "", "that invite is invalid or already used", protocol.ExitDenied | |
| 145 | 161 | } |
| 146 | return fail(protocol.ExitUsage, "%v", err) | |
| 162 | return "", err.Error(), protocol.ExitUsage | |
| 147 | 163 | } |
| 148 | fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username) | |
| 149 | return protocol.ExitOK | |
| 164 | return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK | |
| 150 | 165 | |
| 151 | 166 | case "open": |
| 152 | 167 | if email == "" || !strings.Contains(email, "@") { |
| 153 | return fail(protocol.ExitUsage, "usage: register --username <name> --email <address>") | |
| 168 | return "", "a valid email address is required", protocol.ExitUsage | |
| 154 | 169 | } |
| 155 | 170 | uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal()) |
| 156 | 171 | if err != nil { |
| 157 | return fail(protocol.ExitUsage, "%v", err) | |
| 172 | return "", err.Error(), protocol.ExitUsage | |
| 158 | 173 | } |
| 159 | 174 | if err := sendVerification(cfg, st, uid, email); err != nil { |
| 160 | return fail(protocol.ExitFailure, "sending verification mail: %v", err) | |
| 175 | return "", "sending verification mail: " + err.Error(), protocol.ExitFailure | |
| 161 | 176 | } |
| 162 | fmt.Fprintf(stdout, | |
| 177 | return fmt.Sprintf( | |
| 163 | 178 | "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify <code>\n", |
| 164 | username, email, siteHost(cfg)) | |
| 165 | return protocol.ExitOK | |
| 179 | username, email, siteHost(cfg)), "", protocol.ExitOK | |
| 166 | 180 | |
| 167 | 181 | default: |
| 168 | return fail(protocol.ExitDenied, "registration is closed on this instance") | |
| 182 | return "", "registration is closed on this instance", protocol.ExitDenied | |
| 169 | 183 | } |
| 170 | 184 | } |
| 171 | 185 | |
internal/httpd/accounts.go +43
| @@ -7,6 +7,8 @@ import ( | ||
| 7 | 7 | "strings" |
| 8 | 8 | "time" |
| 9 | 9 | |
| 10 | gossh "golang.org/x/crypto/ssh" | |
| 11 | ||
| 10 | 12 | "gitbay.org/gitbay/internal/control" |
| 11 | 13 | "gitbay.org/gitbay/internal/gitutil" |
| 12 | 14 | "gitbay.org/gitbay/internal/policy" |
| @@ -164,6 +166,47 @@ func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.Use | ||
| 164 | 166 | return repo, true |
| 165 | 167 | } |
| 166 | 168 | |
| 169 | // signupForm and signupSubmit front the SSH registration path for open | |
| 170 | // and invite instances: same store transactions, same rules, a pasted | |
| 171 | // public key instead of the connecting one. | |
| 172 | func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) { | |
| 173 | s.renderSignup(w, "", "") | |
| 174 | } | |
| 175 | ||
| 176 | func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) { | |
| 177 | s.render(w, "register.html", struct { | |
| 178 | Site string | |
| 179 | Viewer string | |
| 180 | Host string | |
| 181 | Mode string // open | invite | |
| 182 | Error string | |
| 183 | Username string | |
| 184 | }{s.siteName(), "", s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username}) | |
| 185 | } | |
| 186 | ||
| 187 | func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) { | |
| 188 | username := strings.TrimSpace(r.FormValue("username")) | |
| 189 | keyText := strings.TrimSpace(r.FormValue("key")) | |
| 190 | pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText)) | |
| 191 | if err != nil { | |
| 192 | s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username) | |
| 193 | return | |
| 194 | } | |
| 195 | msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username, | |
| 196 | strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite"))) | |
| 197 | if code != 0 { | |
| 198 | s.renderSignup(w, errMsg, username) | |
| 199 | return | |
| 200 | } | |
| 201 | s.render(w, "registered.html", struct { | |
| 202 | Site string | |
| 203 | Viewer string | |
| 204 | Username string | |
| 205 | Message string | |
| 206 | Host string | |
| 207 | }{s.siteName(), "", username, msg, s.cfg.SiteHost()}) | |
| 208 | } | |
| 209 | ||
| 167 | 210 | // issueCreateForm renders the new-issue form, prefilled from the repo's |
| 168 | 211 | // default issue template when one exists. |
| 169 | 212 | func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) { |
internal/httpd/routes.go +11
| @@ -71,6 +71,17 @@ func (s *Server) Routes() []Route { | ||
| 71 | 71 | Route{Method: "POST", Pattern: "/logout", Mutating: true, |
| 72 | 72 | Handler: s.checkOrigin(s.logout)}, |
| 73 | 73 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, |
| 74 | ) | |
| 75 | // Web signup fronts the same registration path as SSH register, | |
| 76 | // so it exists only when registration is open or invite. | |
| 77 | if s.cfg.Registration.Mode != "closed" { | |
| 78 | routes = append(routes, | |
| 79 | Route{Method: "GET", Pattern: "/register", Handler: s.signupForm}, | |
| 80 | Route{Method: "POST", Pattern: "/register", Mutating: true, | |
| 81 | Handler: s.checkOrigin(s.signupSubmit)}, | |
| 82 | ) | |
| 83 | } | |
| 84 | routes = append(routes, | |
| 74 | 85 | Route{Method: "POST", Pattern: "/new", Mutating: true, |
| 75 | 86 | Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, |
| 76 | 87 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new", |
internal/httpd/web.go +3 −1
| @@ -101,7 +101,9 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | ||
| 101 | 101 | Site string |
| 102 | 102 | Host string |
| 103 | 103 | Accounts bool |
| 104 | }{s.siteName(), host, s.cfg.Web.Mode == "accounts"}) | |
| 104 | Signup bool | |
| 105 | }{s.siteName(), host, s.cfg.Web.Mode == "accounts", | |
| 106 | s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"}) | |
| 105 | 107 | } |
| 106 | 108 | |
| 107 | 109 | func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) { |
internal/web/templates/landing.html +1 −1
| @@ -8,6 +8,6 @@ the web is a fast, readable rendering of that state.</p> | ||
| 8 | 8 | <pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed |
| 9 | 9 | git clone ssh://git@{{.Host}}/owner/repo.git</pre> |
| 10 | 10 | <p><a class="explorelink" href="/explore">explore public repositories →</a></p> |
| 11 | {{if .Accounts}}<p class="meta">have an account? mint a browser session from your terminal: <code>gitbay web login</code></p>{{end}} | |
| 11 | {{if .Accounts}}<p class="meta">have an account? mint a browser session from your terminal: <code>gitbay web login</code>{{if .Signup}} · new here? <a href="/register">create an account</a>{{end}}</p>{{end}} | |
| 12 | 12 | </div> |
| 13 | 13 | {{end}} |
internal/web/templates/register.html added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | {{define "title"}}register · {{.Site}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | <div class="landing"> | |
| 4 | <h1>create an account</h1> | |
| 5 | {{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p> | |
| 6 | {{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}} | |
| 7 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} | |
| 8 | <form method="post" action="/register" class="signupform"> | |
| 9 | <p><label>username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p> | |
| 10 | {{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p> | |
| 11 | {{else}}<p><label>email<br><input type="text" name="email" required></label></p>{{end}} | |
| 12 | <p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br> | |
| 13 | <textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p> | |
| 14 | <p><button type="submit">create account</button></p> | |
| 15 | </form> | |
| 16 | <p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite <code>{{else}}--email you@example.org{{end}}</code></p> | |
| 17 | </div> | |
| 18 | {{end}} | |
internal/web/templates/registered.html added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | {{define "title"}}welcome · {{.Site}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | <div class="landing"> | |
| 4 | <h1>welcome, {{.Username}}</h1> | |
| 5 | <pre class="quickstart">{{.Message}}</pre> | |
| 6 | <p>Everything runs over SSH with the key you registered:</p> | |
| 7 | <pre class="quickstart">ssh git@{{.Host}} whoami | |
| 8 | ssh git@{{.Host}} repo create {{.Username}}/hello | |
| 9 | gitbay web login # mints a browser session from your terminal</pre> | |
| 10 | </div> | |
| 11 | {{end}} | |