Commit c327fbdf04
Verified · cmc
Layout: unified · split
docs/users.org +4 −1
| @@ -19,7 +19,10 @@ Or build from source: =go build ./cmd/gitbay= in a clone of | |||
| 19 | 19 | ||
| 20 | * Getting an account | 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 | - closed :: an admin creates your account on the host and registers your | 27 | - closed :: an admin creates your account on the host and registers your |
| 25 | first SSH key. Nothing for you to do but hand over your public key. | 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 | return fail(protocol.ExitUsage, "%v", err) | 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 | fp := ssh.FingerprintSHA256(pub) | 149 | fp := ssh.FingerprintSHA256(pub) |
| 134 | switch cfg.Registration.Mode { | 150 | switch cfg.Registration.Mode { |
| 135 | case "invite": | 151 | case "invite": |
| 136 | if invite == "" { | 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 | // One transaction: a failure at any step leaves the invite | 155 | // One transaction: a failure at any step leaves the invite |
| 140 | // redeemable and no partial account behind. | 156 | // redeemable and no partial account behind. |
| 141 | _, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal()) | 157 | _, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal()) |
| 142 | if err != nil { | 158 | if err != nil { |
| 143 | if errors.Is(err, store.ErrNotFound) { | 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) | 164 | return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK |
| 149 | return protocol.ExitOK | ||
| 150 | 165 | ||
| 151 | case "open": | 166 | case "open": |
| 152 | if email == "" || !strings.Contains(email, "@") { | 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 | uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal()) | 170 | uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal()) |
| 156 | if err != nil { | 171 | if err != nil { |
| 157 | return fail(protocol.ExitUsage, "%v", err) | 172 | return "", err.Error(), protocol.ExitUsage |
| 158 | } | 173 | } |
| 159 | if err := sendVerification(cfg, st, uid, email); err != nil { | 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 | "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify <code>\n", | 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)) | 179 | username, email, siteHost(cfg)), "", protocol.ExitOK |
| 165 | return protocol.ExitOK | ||
| 166 | 180 | ||
| 167 | default: | 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 | "strings" | 7 | "strings" |
| 8 | "time" | 8 | "time" |
| 9 | 9 | ||
| 10 | gossh "golang.org/x/crypto/ssh" | ||
| 11 | |||
| 10 | "gitbay.org/gitbay/internal/control" | 12 | "gitbay.org/gitbay/internal/control" |
| 11 | "gitbay.org/gitbay/internal/gitutil" | 13 | "gitbay.org/gitbay/internal/gitutil" |
| 12 | "gitbay.org/gitbay/internal/policy" | 14 | "gitbay.org/gitbay/internal/policy" |
| @@ -164,6 +166,47 @@ func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.Use | |||
| 164 | return repo, true | 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 | // issueCreateForm renders the new-issue form, prefilled from the repo's | 210 | // issueCreateForm renders the new-issue form, prefilled from the repo's |
| 168 | // default issue template when one exists. | 211 | // default issue template when one exists. |
| 169 | func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) { | 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 | Route{Method: "POST", Pattern: "/logout", Mutating: true, | 71 | Route{Method: "POST", Pattern: "/logout", Mutating: true, |
| 72 | Handler: s.checkOrigin(s.logout)}, | 72 | Handler: s.checkOrigin(s.logout)}, |
| 73 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, | 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 | Route{Method: "POST", Pattern: "/new", Mutating: true, | 85 | Route{Method: "POST", Pattern: "/new", Mutating: true, |
| 75 | Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, | 86 | Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, |
| 76 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new", | 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 | Site string | 101 | Site string |
| 102 | Host string | 102 | Host string |
| 103 | Accounts bool | 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 | func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) { | 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 | <pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed | 8 | <pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed |
| 9 | git clone ssh://git@{{.Host}}/owner/repo.git</pre> | 9 | git clone ssh://git@{{.Host}}/owner/repo.git</pre> |
| 10 | <p><a class="explorelink" href="/explore">explore public repositories →</a></p> | 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 | </div> | 12 | </div> |
| 13 | {{end}} | 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}} | ||