Commit dce6f1b389
Verified · cmc
Layout: unified · split
cmd/gitbay/main.go +6
| @@ -32,6 +32,8 @@ func main() { | ||
| 32 | 32 | webCmd(), |
| 33 | 33 | remoteCmd(), |
| 34 | 34 | initCmd(), |
| 35 | pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", | |
| 36 | passOpts{server: []string{"register"}}), | |
| 35 | 37 | manCmd(root), |
| 36 | 38 | ) |
| 37 | 39 | |
| @@ -176,6 +178,10 @@ func authCmd() *cobra.Command { | ||
| 176 | 178 | keysAdd, |
| 177 | 179 | pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}), |
| 178 | 180 | ), |
| 181 | group("email", "manage email addresses", | |
| 182 | pass("add", "add an address and get a verification code by mail", passOpts{server: []string{"email", "add"}}), | |
| 183 | pass("verify", "confirm a verification code", passOpts{server: []string{"email", "verify"}}), | |
| 184 | ), | |
| 179 | 185 | group("pgp", "manage OpenPGP keys", |
| 180 | 186 | pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}), |
| 181 | 187 | pgpAdd, |
cmd/gitbayd/main.go +49 −1
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "net" |
| 9 | 9 | "net/http" |
| 10 | 10 | "os" |
| 11 | "strings" | |
| 11 | 12 | "path/filepath" |
| 12 | 13 | "strconv" |
| 13 | 14 | |
| @@ -16,6 +17,7 @@ import ( | ||
| 16 | 17 | |
| 17 | 18 | "gitbay.org/gitbay/internal/config" |
| 18 | 19 | "gitbay.org/gitbay/internal/control" |
| 20 | "gitbay.org/gitbay/internal/mail" | |
| 19 | 21 | "gitbay.org/gitbay/internal/gitd" |
| 20 | 22 | "gitbay.org/gitbay/internal/hookd" |
| 21 | 23 | "gitbay.org/gitbay/internal/httpd" |
| @@ -214,7 +216,7 @@ func adminCmd() *cobra.Command { | ||
| 214 | 216 | admin.AddCommand( |
| 215 | 217 | userCmd, |
| 216 | 218 | emailCmd, |
| 217 | notImplemented("invite", "issue registration invites"), | |
| 219 | adminInviteCmd(), | |
| 218 | 220 | backupCmd(), |
| 219 | 221 | notImplemented("gc", "run git gc across repositories"), |
| 220 | 222 | notImplemented("stats", "instance statistics"), |
| @@ -222,6 +224,52 @@ func adminCmd() *cobra.Command { | ||
| 222 | 224 | return admin |
| 223 | 225 | } |
| 224 | 226 | |
| 227 | func adminInviteCmd() *cobra.Command { | |
| 228 | var email string | |
| 229 | cmd := &cobra.Command{ | |
| 230 | Use: "invite", | |
| 231 | Short: "issue a registration invite and email its code", | |
| 232 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 233 | if email == "" { | |
| 234 | return fmt.Errorf("--email is required") | |
| 235 | } | |
| 236 | cfg, err := config.Load(configPath) | |
| 237 | if err != nil { | |
| 238 | return err | |
| 239 | } | |
| 240 | st, err := openStore(cfg) | |
| 241 | if err != nil { | |
| 242 | return err | |
| 243 | } | |
| 244 | defer st.Close() | |
| 245 | ||
| 246 | code, hash, err := store.NewToken() | |
| 247 | if err != nil { | |
| 248 | return err | |
| 249 | } | |
| 250 | if err := st.CreateInvite(hash, email); err != nil { | |
| 251 | return err | |
| 252 | } | |
| 253 | host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://"), "/") | |
| 254 | body := fmt.Sprintf( | |
| 255 | "You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+ | |
| 256 | " ssh git@%s register --username <name> --invite %s\n\n"+ | |
| 257 | "The invite is single-use and tied to this address.\n", host, host, code) | |
| 258 | if cfg.Mail.SMTPHost != "" { | |
| 259 | if err := mail.Send(cfg, email, "your invite to "+host, body); err != nil { | |
| 260 | return fmt.Errorf("invite stored but mail failed: %w (code: %s)", err, code) | |
| 261 | } | |
| 262 | fmt.Printf("invite emailed to %s\n", email) | |
| 263 | } else { | |
| 264 | fmt.Printf("invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code) | |
| 265 | } | |
| 266 | return nil | |
| 267 | }, | |
| 268 | } | |
| 269 | cmd.Flags().StringVar(&email, "email", "", "address to invite (the account's verified email)") | |
| 270 | return cmd | |
| 271 | } | |
| 272 | ||
| 225 | 273 | func adminUserCreateCmd() *cobra.Command { |
| 226 | 274 | var keyPath, email string |
| 227 | 275 | var verified, isAdmin bool |
e2e/registration_test.go added +221
| @@ -0,0 +1,221 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "bufio" | |
| 5 | "fmt" | |
| 6 | "net" | |
| 7 | "regexp" | |
| 8 | "strings" | |
| 9 | "sync" | |
| 10 | "testing" | |
| 11 | "time" | |
| 12 | ) | |
| 13 | ||
| 14 | // fakeSMTP is a minimal SMTP server capturing delivered messages. | |
| 15 | type fakeSMTP struct { | |
| 16 | addr string | |
| 17 | mu sync.Mutex | |
| 18 | mail []string // raw DATA payloads | |
| 19 | } | |
| 20 | ||
| 21 | func startFakeSMTP(t *testing.T) *fakeSMTP { | |
| 22 | t.Helper() | |
| 23 | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 24 | if err != nil { | |
| 25 | t.Fatal(err) | |
| 26 | } | |
| 27 | t.Cleanup(func() { ln.Close() }) | |
| 28 | f := &fakeSMTP{addr: ln.Addr().String()} | |
| 29 | go func() { | |
| 30 | for { | |
| 31 | conn, err := ln.Accept() | |
| 32 | if err != nil { | |
| 33 | return | |
| 34 | } | |
| 35 | go f.handle(conn) | |
| 36 | } | |
| 37 | }() | |
| 38 | return f | |
| 39 | } | |
| 40 | ||
| 41 | func (f *fakeSMTP) handle(conn net.Conn) { | |
| 42 | defer conn.Close() | |
| 43 | r := bufio.NewReader(conn) | |
| 44 | say := func(s string) { fmt.Fprintf(conn, "%s\r\n", s) } | |
| 45 | say("220 fake ESMTP") | |
| 46 | var data strings.Builder | |
| 47 | inData := false | |
| 48 | for { | |
| 49 | line, err := r.ReadString('\n') | |
| 50 | if err != nil { | |
| 51 | return | |
| 52 | } | |
| 53 | line = strings.TrimRight(line, "\r\n") | |
| 54 | if inData { | |
| 55 | if line == "." { | |
| 56 | f.mu.Lock() | |
| 57 | f.mail = append(f.mail, data.String()) | |
| 58 | f.mu.Unlock() | |
| 59 | data.Reset() | |
| 60 | inData = false | |
| 61 | say("250 ok") | |
| 62 | continue | |
| 63 | } | |
| 64 | data.WriteString(line + "\n") | |
| 65 | continue | |
| 66 | } | |
| 67 | switch { | |
| 68 | case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"): | |
| 69 | fmt.Fprintf(conn, "250-fake\r\n250 SIZE 1000000\r\n") | |
| 70 | case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"): | |
| 71 | say("250 ok") | |
| 72 | case line == "DATA": | |
| 73 | inData = true | |
| 74 | say("354 go") | |
| 75 | case line == "QUIT": | |
| 76 | say("221 bye") | |
| 77 | return | |
| 78 | default: | |
| 79 | say("250 ok") | |
| 80 | } | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | // waitMail returns the nth captured message. | |
| 85 | func (f *fakeSMTP) waitMail(t *testing.T, n int) string { | |
| 86 | t.Helper() | |
| 87 | deadline := time.Now().Add(5 * time.Second) | |
| 88 | for time.Now().Before(deadline) { | |
| 89 | f.mu.Lock() | |
| 90 | if len(f.mail) > n { | |
| 91 | m := f.mail[n] | |
| 92 | f.mu.Unlock() | |
| 93 | return m | |
| 94 | } | |
| 95 | f.mu.Unlock() | |
| 96 | time.Sleep(50 * time.Millisecond) | |
| 97 | } | |
| 98 | t.Fatalf("mail %d never arrived", n) | |
| 99 | return "" | |
| 100 | } | |
| 101 | ||
| 102 | var codePat = regexp.MustCompile(`(?:verify|--invite) ([0-9a-f]{64})`) | |
| 103 | ||
| 104 | func extractCode(t *testing.T, mail string) string { | |
| 105 | t.Helper() | |
| 106 | m := codePat.FindStringSubmatch(mail) | |
| 107 | if m == nil { | |
| 108 | t.Fatalf("no code in mail:\n%s", mail) | |
| 109 | } | |
| 110 | return m[1] | |
| 111 | } | |
| 112 | ||
| 113 | func TestOpenRegistration(t *testing.T) { | |
| 114 | smtp := startFakeSMTP(t) | |
| 115 | inst := startInstanceWith(t, fmt.Sprintf( | |
| 116 | "[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr)) | |
| 117 | ||
| 118 | // A stranger's key cannot run normal commands, and the denial explains | |
| 119 | // how to register. | |
| 120 | newKey := inst.newKey(t, "newcomer") | |
| 121 | _, errOut, code := inst.ssh(t, newKey, "", "whoami") | |
| 122 | if code != 4 || !strings.Contains(errOut, "register --username") { | |
| 123 | t.Fatalf("stranger whoami: exit %d, %s", code, errOut) | |
| 124 | } | |
| 125 | ||
| 126 | // Register: account created pending, verification mail sent. | |
| 127 | out, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "dana@example.test") | |
| 128 | if code != 0 { | |
| 129 | t.Fatalf("register: exit %d, %s", code, errOut) | |
| 130 | } | |
| 131 | if !strings.Contains(out, "verification code was sent") { | |
| 132 | t.Fatalf("register output: %s", out) | |
| 133 | } | |
| 134 | msg := smtp.waitMail(t, 0) | |
| 135 | if !strings.Contains(msg, "To: dana@example.test") || !strings.Contains(msg, "From: noreply@gitbay.test") { | |
| 136 | t.Fatalf("mail headers:\n%s", msg) | |
| 137 | } | |
| 138 | ||
| 139 | // Pending: the key authenticates, whoami works, but everything else is | |
| 140 | // gated — control commands and git alike. | |
| 141 | if out, _, code = inst.ssh(t, newKey, "", "whoami"); code != 0 || strings.TrimSpace(out) != "dana" { | |
| 142 | t.Fatalf("pending whoami: %d %q", code, out) | |
| 143 | } | |
| 144 | _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj") | |
| 145 | if code != 4 || !strings.Contains(errOut, "not active yet") { | |
| 146 | t.Fatalf("pending repo create: exit %d, %s", code, errOut) | |
| 147 | } | |
| 148 | cloneOut, cloneCode := gitRun(t, t.TempDir(), inst.gitEnv(newKey), "clone", inst.sshURL("dana/anything")) | |
| 149 | if cloneCode == 0 || !strings.Contains(cloneOut, "not active yet") { | |
| 150 | t.Fatalf("pending git: %d\n%s", cloneCode, cloneOut) | |
| 151 | } | |
| 152 | ||
| 153 | // A wrong code fails; the mailed code activates the account. | |
| 154 | if _, _, code = inst.ssh(t, newKey, "", "email", "verify", strings.Repeat("0", 64)); code != 2 { | |
| 155 | t.Fatalf("bad code: exit %d, want 2", code) | |
| 156 | } | |
| 157 | verifyCode := extractCode(t, msg) | |
| 158 | out, errOut, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode) | |
| 159 | if code != 0 || !strings.Contains(out, "account is active") { | |
| 160 | t.Fatalf("verify: exit %d, %s%s", code, out, errOut) | |
| 161 | } | |
| 162 | // Single use. | |
| 163 | if _, _, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode); code != 2 { | |
| 164 | t.Fatalf("code reuse: exit %d, want 2", code) | |
| 165 | } | |
| 166 | ||
| 167 | // Fully active: repo create works, and the verified email makes | |
| 168 | // signature verification meaningful (verified_by = smtp). | |
| 169 | if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj"); code != 0 { | |
| 170 | t.Fatalf("post-verify repo create: %s", errOut) | |
| 171 | } | |
| 172 | ||
| 173 | // Self-service email add on an existing account sends a second mail. | |
| 174 | if _, errOut, code = inst.ssh(t, newKey, "", "email", "add", "dana2@example.test"); code != 0 { | |
| 175 | t.Fatalf("email add: %s", errOut) | |
| 176 | } | |
| 177 | msg2 := smtp.waitMail(t, 1) | |
| 178 | if !strings.Contains(msg2, "To: dana2@example.test") { | |
| 179 | t.Fatalf("second mail:\n%s", msg2) | |
| 180 | } | |
| 181 | if _, _, code = inst.ssh(t, newKey, "", "email", "verify", extractCode(t, msg2)); code != 0 { | |
| 182 | t.Fatal("second verify failed") | |
| 183 | } | |
| 184 | } | |
| 185 | ||
| 186 | func TestInviteRegistration(t *testing.T) { | |
| 187 | smtp := startFakeSMTP(t) | |
| 188 | inst := startInstanceWith(t, fmt.Sprintf( | |
| 189 | "[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr)) | |
| 190 | ||
| 191 | // Registering without an invite is refused. | |
| 192 | newKey := inst.newKey(t, "guest") | |
| 193 | _, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "erin", "--email", "erin@example.test") | |
| 194 | if code != 4 || !strings.Contains(errOut, "invite-only") { | |
| 195 | t.Fatalf("uninvited register: exit %d, %s", code, errOut) | |
| 196 | } | |
| 197 | ||
| 198 | // Admin issues an invite; the code arrives by mail. | |
| 199 | out := inst.admin(t, "admin", "invite", "--email", "erin@example.test") | |
| 200 | if !strings.Contains(out, "invite emailed") { | |
| 201 | t.Fatalf("invite output: %s", out) | |
| 202 | } | |
| 203 | inviteCode := extractCode(t, smtp.waitMail(t, 0)) | |
| 204 | ||
| 205 | // Redeeming it creates an ACTIVE account: code possession proves the | |
| 206 | // mailbox, so the email is verified (by smtp) and nothing is pending. | |
| 207 | out, errOut, code = inst.ssh(t, newKey, "", "register", "--username", "erin", "--invite", inviteCode) | |
| 208 | if code != 0 || !strings.Contains(out, "account is active") { | |
| 209 | t.Fatalf("invite register: exit %d, %s%s", code, out, errOut) | |
| 210 | } | |
| 211 | if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "erin/proj"); code != 0 { | |
| 212 | t.Fatalf("invited user repo create: %s", errOut) | |
| 213 | } | |
| 214 | ||
| 215 | // Invites are single-use. | |
| 216 | otherKey := inst.newKey(t, "other") | |
| 217 | _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "fake", "--invite", inviteCode) | |
| 218 | if code != 4 || !strings.Contains(errOut, "already used") { | |
| 219 | t.Fatalf("invite reuse: exit %d, %s", code, errOut) | |
| 220 | } | |
| 221 | } | |
internal/config/config.go +6 −1
| @@ -62,8 +62,10 @@ type Limits struct { | ||
| 62 | 62 | } |
| 63 | 63 | |
| 64 | 64 | type Mail struct { |
| 65 | SMTPHost string `toml:"smtp_host"` | |
| 65 | SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587) | |
| 66 | 66 | From string `toml:"from"` |
| 67 | SMTPUser string `toml:"smtp_user,omitempty"` | |
| 68 | SMTPPass string `toml:"smtp_pass,omitempty"` | |
| 67 | 69 | } |
| 68 | 70 | |
| 69 | 71 | // Default returns the configuration used when a key is absent from the file. |
| @@ -139,6 +141,9 @@ func (c Config) Validate() error { | ||
| 139 | 141 | } |
| 140 | 142 | |
| 141 | 143 | // Contradictions. |
| 144 | if c.Mail.SMTPHost != "" && c.Mail.From == "" { | |
| 145 | errs = append(errs, errors.New("[mail] from is required when smtp_host is set")) | |
| 146 | } | |
| 142 | 147 | if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" { |
| 143 | 148 | errs = append(errs, fmt.Errorf( |
| 144 | 149 | "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP", |
internal/control/control.go +10
| @@ -70,6 +70,10 @@ func Dispatch(c *Ctx, argv []string) int { | ||
| 70 | 70 | if c.Scope != "full" { |
| 71 | 71 | return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) |
| 72 | 72 | } |
| 73 | if c.User.Pending && !pendingAllowed(cmd.Path) { | |
| 74 | return c.fail(protocol.ExitDenied, | |
| 75 | "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") | |
| 76 | } | |
| 73 | 77 | // Strip the global --json flag wherever it appears. |
| 74 | 78 | args := rest[:0:0] |
| 75 | 79 | for _, a := range rest { |
| @@ -85,6 +89,12 @@ func Dispatch(c *Ctx, argv []string) int { | ||
| 85 | 89 | return cmd.Run(c, args) |
| 86 | 90 | } |
| 87 | 91 | |
| 92 | // pendingAllowed lists what an unverified self-registered account may do. | |
| 93 | func pendingAllowed(path []string) bool { | |
| 94 | key := joinPath(path) | |
| 95 | return key == "email verify" || key == "email add" || key == "whoami" || key == "help" | |
| 96 | } | |
| 97 | ||
| 88 | 98 | type emptyReader struct{} |
| 89 | 99 | |
| 90 | 100 | func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } |
internal/control/register.go added +178
| @@ -0,0 +1,178 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | "strings" | |
| 8 | "time" | |
| 9 | ||
| 10 | "golang.org/x/crypto/ssh" | |
| 11 | ||
| 12 | "gitbay.org/gitbay/internal/config" | |
| 13 | "gitbay.org/gitbay/internal/mail" | |
| 14 | "gitbay.org/gitbay/internal/policy" | |
| 15 | "gitbay.org/gitbay/internal/protocol" | |
| 16 | "gitbay.org/gitbay/internal/store" | |
| 17 | ) | |
| 18 | ||
| 19 | func init() { | |
| 20 | register(Command{Path: []string{"email", "add"}, | |
| 21 | Summary: "add an address and mail a verification code: email add <address>", Run: runEmailAdd}) | |
| 22 | register(Command{Path: []string{"email", "verify"}, | |
| 23 | Summary: "confirm a verification code: email verify <code>", Run: runEmailVerify}) | |
| 24 | } | |
| 25 | ||
| 26 | func siteHost(cfg config.Config) string { | |
| 27 | h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://") | |
| 28 | return strings.TrimSuffix(h, "/") | |
| 29 | } | |
| 30 | ||
| 31 | func sendVerification(cfg config.Config, st *store.Store, userID int64, address string) error { | |
| 32 | code, hash, err := store.NewToken() | |
| 33 | if err != nil { | |
| 34 | return err | |
| 35 | } | |
| 36 | if err := st.CreateEmailToken(userID, address, hash, 24*time.Hour); err != nil { | |
| 37 | return err | |
| 38 | } | |
| 39 | body := fmt.Sprintf( | |
| 40 | "Someone (hopefully you) added this address to an account on %s.\n\n"+ | |
| 41 | "To verify it, run:\n\n ssh git@%s email verify %s\n\n"+ | |
| 42 | "The code expires in 24 hours. If this wasn't you, ignore this mail.\n", | |
| 43 | siteHost(cfg), siteHost(cfg), code) | |
| 44 | return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body) | |
| 45 | } | |
| 46 | ||
| 47 | func runEmailAdd(c *Ctx, args []string) int { | |
| 48 | if len(args) != 1 || !strings.Contains(args[0], "@") { | |
| 49 | return c.fail(protocol.ExitUsage, "usage: email add <address>") | |
| 50 | } | |
| 51 | if c.Cfg.Mail.SMTPHost == "" { | |
| 52 | return c.fail(protocol.ExitFailure, "this instance has no SMTP configured; ask an admin to verify the address (gitbayd admin email verify)") | |
| 53 | } | |
| 54 | if err := c.Store.AddEmail(c.User.ID, args[0], "", false); err != nil { | |
| 55 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 56 | } | |
| 57 | if err := sendVerification(c.Cfg, c.Store, c.User.ID, args[0]); err != nil { | |
| 58 | return c.fail(protocol.ExitFailure, "sending verification mail: %v", err) | |
| 59 | } | |
| 60 | return c.emit(map[string]string{"address": args[0], "status": "verification_sent"}, func(w io.Writer) { | |
| 61 | fmt.Fprintf(w, "verification code sent to %s\n", args[0]) | |
| 62 | }) | |
| 63 | } | |
| 64 | ||
| 65 | func runEmailVerify(c *Ctx, args []string) int { | |
| 66 | if len(args) != 1 { | |
| 67 | return c.fail(protocol.ExitUsage, "usage: email verify <code>") | |
| 68 | } | |
| 69 | address, err := c.Store.ConsumeEmailToken(c.User.ID, store.HashToken(args[0])) | |
| 70 | if err != nil { | |
| 71 | if errors.Is(err, store.ErrNotFound) { | |
| 72 | return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used") | |
| 73 | } | |
| 74 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 75 | } | |
| 76 | if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil { | |
| 77 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 78 | } | |
| 79 | if err := c.Store.ClearPending(c.User.ID); err != nil { | |
| 80 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 81 | } | |
| 82 | return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) { | |
| 83 | fmt.Fprintf(w, "%s verified; your account is active\n", address) | |
| 84 | }) | |
| 85 | } | |
| 86 | ||
| 87 | // RunRegister handles the one command an UNAUTHENTICATED key may run. It is | |
| 88 | // dispatched outside the normal registry: the caller has already checked | |
| 89 | // that registration is enabled and that argv[0] == "register". | |
| 90 | func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string, | |
| 91 | stdout, stderr io.Writer) int { | |
| 92 | var username, email, invite string | |
| 93 | args := argv[1:] | |
| 94 | for i := 0; i < len(args); i++ { | |
| 95 | switch args[i] { | |
| 96 | case "--username", "--email", "--invite": | |
| 97 | if i+1 >= len(args) { | |
| 98 | fmt.Fprintf(stderr, "%s requires a value\n", args[i]) | |
| 99 | return protocol.ExitUsage | |
| 100 | } | |
| 101 | switch args[i] { | |
| 102 | case "--username": | |
| 103 | username = args[i+1] | |
| 104 | case "--email": | |
| 105 | email = args[i+1] | |
| 106 | case "--invite": | |
| 107 | invite = args[i+1] | |
| 108 | } | |
| 109 | i++ | |
| 110 | default: | |
| 111 | fmt.Fprintf(stderr, "unexpected argument %q\n", args[i]) | |
| 112 | return protocol.ExitUsage | |
| 113 | } | |
| 114 | } | |
| 115 | fail := func(code int, format string, a ...any) int { | |
| 116 | fmt.Fprintf(stderr, format+"\n", a...) | |
| 117 | return code | |
| 118 | } | |
| 119 | if username == "" { | |
| 120 | return fail(protocol.ExitUsage, "usage: register --username <name> --email <address> | register --username <name> --invite <code>") | |
| 121 | } | |
| 122 | if err := policy.ValidateOwnerName(username); err != nil { | |
| 123 | return fail(protocol.ExitUsage, "%v", err) | |
| 124 | } | |
| 125 | ||
| 126 | switch cfg.Registration.Mode { | |
| 127 | case "invite": | |
| 128 | if invite == "" { | |
| 129 | return fail(protocol.ExitDenied, "this instance is invite-only: register --username <name> --invite <code>") | |
| 130 | } | |
| 131 | addr, err := st.ConsumeInvite(store.HashToken(invite)) | |
| 132 | if err != nil { | |
| 133 | return fail(protocol.ExitDenied, "that invite is invalid or already used") | |
| 134 | } | |
| 135 | uid, err := st.CreateRegisteredUser(username, false) | |
| 136 | if err != nil { | |
| 137 | return fail(protocol.ExitFailure, "%v", err) | |
| 138 | } | |
| 139 | // Possession of the emailed invite code proves the mailbox. | |
| 140 | if err := st.AddEmail(uid, addr, "smtp", true); err != nil { | |
| 141 | return fail(protocol.ExitFailure, "%v", err) | |
| 142 | } | |
| 143 | if err := addRegisteredKey(st, uid, pub); err != nil { | |
| 144 | return fail(protocol.ExitFailure, "%v", err) | |
| 145 | } | |
| 146 | fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username) | |
| 147 | return protocol.ExitOK | |
| 148 | ||
| 149 | case "open": | |
| 150 | if email == "" || !strings.Contains(email, "@") { | |
| 151 | return fail(protocol.ExitUsage, "usage: register --username <name> --email <address>") | |
| 152 | } | |
| 153 | uid, err := st.CreateRegisteredUser(username, true) | |
| 154 | if err != nil { | |
| 155 | return fail(protocol.ExitFailure, "%v", err) | |
| 156 | } | |
| 157 | if err := st.AddEmail(uid, email, "", true); err != nil { | |
| 158 | return fail(protocol.ExitFailure, "%v", err) | |
| 159 | } | |
| 160 | if err := addRegisteredKey(st, uid, pub); err != nil { | |
| 161 | return fail(protocol.ExitFailure, "%v", err) | |
| 162 | } | |
| 163 | if err := sendVerification(cfg, st, uid, email); err != nil { | |
| 164 | return fail(protocol.ExitFailure, "sending verification mail: %v", err) | |
| 165 | } | |
| 166 | fmt.Fprintf(stdout, | |
| 167 | "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify <code>\n", | |
| 168 | username, email, siteHost(cfg)) | |
| 169 | return protocol.ExitOK | |
| 170 | ||
| 171 | default: | |
| 172 | return fail(protocol.ExitDenied, "registration is closed on this instance") | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | func addRegisteredKey(st *store.Store, uid int64, pub ssh.PublicKey) error { | |
| 177 | return st.AddSSHKey(uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full") | |
| 178 | } | |
internal/mail/mail.go added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | // Package mail sends transactional email over SMTP: verification codes and | |
| 2 | // invites. STARTTLS is used when the server offers it; PLAIN auth when | |
| 3 | // credentials are configured. | |
| 4 | package mail | |
| 5 | ||
| 6 | import ( | |
| 7 | "fmt" | |
| 8 | "net" | |
| 9 | "net/smtp" | |
| 10 | "strings" | |
| 11 | "time" | |
| 12 | ||
| 13 | "gitbay.org/gitbay/internal/config" | |
| 14 | ) | |
| 15 | ||
| 16 | // Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port. | |
| 17 | func Send(cfg config.Config, to, subject, body string) error { | |
| 18 | m := cfg.Mail | |
| 19 | if m.SMTPHost == "" || m.From == "" { | |
| 20 | return fmt.Errorf("[mail] smtp_host and from must be configured") | |
| 21 | } | |
| 22 | host := m.SMTPHost | |
| 23 | if !strings.Contains(host, ":") { | |
| 24 | host += ":587" | |
| 25 | } | |
| 26 | hostname, _, _ := net.SplitHostPort(host) | |
| 27 | ||
| 28 | msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf( | |
| 29 | "From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n", | |
| 30 | m.From, to, subject, time.Now().Format(time.RFC1123Z), body)) | |
| 31 | ||
| 32 | c, err := smtp.Dial(host) | |
| 33 | if err != nil { | |
| 34 | return fmt.Errorf("smtp dial %s: %w", host, err) | |
| 35 | } | |
| 36 | defer c.Close() | |
| 37 | if ok, _ := c.Extension("STARTTLS"); ok { | |
| 38 | if err := c.StartTLS(nil); err != nil { | |
| 39 | return fmt.Errorf("starttls: %w", err) | |
| 40 | } | |
| 41 | } | |
| 42 | if m.SMTPUser != "" { | |
| 43 | if err := c.Auth(smtp.PlainAuth("", m.SMTPUser, m.SMTPPass, hostname)); err != nil { | |
| 44 | return fmt.Errorf("smtp auth: %w", err) | |
| 45 | } | |
| 46 | } | |
| 47 | if err := c.Mail(m.From); err != nil { | |
| 48 | return err | |
| 49 | } | |
| 50 | if err := c.Rcpt(to); err != nil { | |
| 51 | return err | |
| 52 | } | |
| 53 | w, err := c.Data() | |
| 54 | if err != nil { | |
| 55 | return err | |
| 56 | } | |
| 57 | if _, err := w.Write([]byte(msg)); err != nil { | |
| 58 | return err | |
| 59 | } | |
| 60 | if err := w.Close(); err != nil { | |
| 61 | return err | |
| 62 | } | |
| 63 | return c.Quit() | |
| 64 | } | |
internal/sshd/sshd.go +40 −1
| @@ -5,6 +5,7 @@ package sshd | ||
| 5 | 5 | import ( |
| 6 | 6 | "crypto/ed25519" |
| 7 | 7 | "crypto/rand" |
| 8 | "encoding/base64" | |
| 8 | 9 | "encoding/pem" |
| 9 | 10 | "errors" |
| 10 | 11 | "fmt" |
| @@ -95,11 +96,18 @@ func generateHostKey(path string) error { | ||
| 95 | 96 | } |
| 96 | 97 | |
| 97 | 98 | // authenticate resolves the presented key to a registered account. The SSH |
| 98 | // username is ignored; identity comes from the key alone. | |
| 99 | // username is ignored; identity comes from the key alone. When registration | |
| 100 | // is open or invite-based, unknown keys are admitted to run exactly one | |
| 101 | // command: register. | |
| 99 | 102 | func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) { |
| 100 | 103 | fp := ssh.FingerprintSHA256(pub) |
| 101 | 104 | key, err := s.st.SSHKeyByFingerprint(fp) |
| 102 | 105 | if err != nil { |
| 106 | if s.cfg.Registration.Mode != "closed" { | |
| 107 | return &ssh.Permissions{Extensions: map[string]string{ | |
| 108 | "anon-key": base64.StdEncoding.EncodeToString(pub.Marshal()), | |
| 109 | }}, nil | |
| 110 | } | |
| 103 | 111 | return nil, fmt.Errorf("unknown key %s", fp) |
| 104 | 112 | } |
| 105 | 113 | return &ssh.Permissions{Extensions: map[string]string{ |
| @@ -177,6 +185,9 @@ func sendExit(ch ssh.Channel, code int) { | ||
| 177 | 185 | |
| 178 | 186 | func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int { |
| 179 | 187 | ext := sconn.Permissions.Extensions |
| 188 | if blob := ext["anon-key"]; blob != "" { | |
| 189 | return s.runAnonymous(ch, blob, cmdline) | |
| 190 | } | |
| 180 | 191 | userID, _ := strconv.ParseInt(ext["user-id"], 10, 64) |
| 181 | 192 | keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64) |
| 182 | 193 | user, err := s.st.UserByID(userID) |
| @@ -188,6 +199,30 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) | ||
| 188 | 199 | return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr()) |
| 189 | 200 | } |
| 190 | 201 | |
| 202 | // runAnonymous handles a session from an unregistered key: the register | |
| 203 | // command and nothing else. | |
| 204 | func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int { | |
| 205 | raw, err := base64.StdEncoding.DecodeString(keyB64) | |
| 206 | if err != nil { | |
| 207 | return protocol.ExitFailure | |
| 208 | } | |
| 209 | pub, err := ssh.ParsePublicKey(raw) | |
| 210 | if err != nil { | |
| 211 | return protocol.ExitFailure | |
| 212 | } | |
| 213 | argv, err := protocol.Tokenize(cmdline) | |
| 214 | if err != nil { | |
| 215 | fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err) | |
| 216 | return protocol.ExitUsage | |
| 217 | } | |
| 218 | if len(argv) == 0 || argv[0] != "register" { | |
| 219 | fmt.Fprintf(ch.Stderr(), "this key is not registered here. Create an account with:\n ssh <host> register --username <name> %s\n", | |
| 220 | map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode]) | |
| 221 | return protocol.ExitDenied | |
| 222 | } | |
| 223 | return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr()) | |
| 224 | } | |
| 225 | ||
| 191 | 226 | // Exec runs one SSH exec command line for an authenticated key. It is the |
| 192 | 227 | // single dispatch path shared by the embedded listener and the system-sshd |
| 193 | 228 | // forced command (gitbayd shell). |
| @@ -201,6 +236,10 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline st | ||
| 201 | 236 | if len(argv) > 0 { |
| 202 | 237 | switch argv[0] { |
| 203 | 238 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": |
| 239 | if user.Pending { | |
| 240 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") | |
| 241 | return protocol.ExitDenied | |
| 242 | } | |
| 204 | 243 | return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr) |
| 205 | 244 | } |
| 206 | 245 | } |
internal/store/migrations/0003_registration.down.sql added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | ALTER TABLE users DROP COLUMN pending; | |
| 2 | DROP TABLE email_tokens; | |
internal/store/migrations/0003_registration.up.sql added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | CREATE TABLE email_tokens ( | |
| 2 | token_hash TEXT PRIMARY KEY, | |
| 3 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 4 | address TEXT NOT NULL, | |
| 5 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 6 | expires_at TEXT NOT NULL, | |
| 7 | used_at TEXT | |
| 8 | ); | |
| 9 | ||
| 10 | ALTER TABLE users ADD COLUMN pending INTEGER NOT NULL DEFAULT 0; | |
internal/store/registration.go added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "time" | |
| 6 | ) | |
| 7 | ||
| 8 | // CreateInvite stores an invite code hash bound to an email address. | |
| 9 | func (s *Store) CreateInvite(codeHash, email string) error { | |
| 10 | _, err := s.DB.Exec("INSERT INTO invites (code_hash, email) VALUES (?, ?)", codeHash, email) | |
| 11 | return err | |
| 12 | } | |
| 13 | ||
| 14 | // ConsumeInvite redeems an invite exactly once, returning the address it was | |
| 15 | // issued for. Used and unknown codes fail identically. | |
| 16 | func (s *Store) ConsumeInvite(codeHash string) (string, error) { | |
| 17 | res, err := s.DB.Exec( | |
| 18 | "UPDATE invites SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE code_hash = ? AND used_at IS NULL", | |
| 19 | codeHash) | |
| 20 | if err != nil { | |
| 21 | return "", err | |
| 22 | } | |
| 23 | if n, _ := res.RowsAffected(); n == 0 { | |
| 24 | return "", ErrNotFound | |
| 25 | } | |
| 26 | var email string | |
| 27 | err = s.DB.QueryRow("SELECT email FROM invites WHERE code_hash = ?", codeHash).Scan(&email) | |
| 28 | return email, err | |
| 29 | } | |
| 30 | ||
| 31 | // CreateEmailToken stores a verification code hash for one address. | |
| 32 | func (s *Store) CreateEmailToken(userID int64, address, tokenHash string, ttl time.Duration) error { | |
| 33 | _, err := s.DB.Exec( | |
| 34 | "INSERT INTO email_tokens (token_hash, user_id, address, expires_at) VALUES (?, ?, ?, ?)", | |
| 35 | tokenHash, userID, address, fmtTime(time.Now().Add(ttl))) | |
| 36 | return err | |
| 37 | } | |
| 38 | ||
| 39 | // ConsumeEmailToken redeems a verification code for the given user. | |
| 40 | func (s *Store) ConsumeEmailToken(userID int64, tokenHash string) (string, error) { | |
| 41 | res, err := s.DB.Exec(` | |
| 42 | UPDATE email_tokens SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') | |
| 43 | WHERE token_hash = ? AND user_id = ? AND used_at IS NULL AND expires_at > ?`, | |
| 44 | tokenHash, userID, fmtTime(time.Now())) | |
| 45 | if err != nil { | |
| 46 | return "", err | |
| 47 | } | |
| 48 | if n, _ := res.RowsAffected(); n == 0 { | |
| 49 | return "", ErrNotFound | |
| 50 | } | |
| 51 | var address string | |
| 52 | err = s.DB.QueryRow("SELECT address FROM email_tokens WHERE token_hash = ?", tokenHash).Scan(&address) | |
| 53 | return address, err | |
| 54 | } | |
| 55 | ||
| 56 | // CreateRegisteredUser makes a self-registered account, pending until its | |
| 57 | // email is verified. | |
| 58 | func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { | |
| 59 | res, err := s.DB.Exec("INSERT INTO users (username, pending) VALUES (?, ?)", username, boolInt(pending)) | |
| 60 | if err != nil { | |
| 61 | if isUniqueErr(err) { | |
| 62 | return 0, errors.New("that username is taken") | |
| 63 | } | |
| 64 | return 0, err | |
| 65 | } | |
| 66 | return res.LastInsertId() | |
| 67 | } | |
| 68 | ||
| 69 | // ClearPending activates a pending account. | |
| 70 | func (s *Store) ClearPending(userID int64) error { | |
| 71 | _, err := s.DB.Exec("UPDATE users SET pending = 0 WHERE id = ?", userID) | |
| 72 | return err | |
| 73 | } | |
internal/store/users.go +9 −6
| @@ -11,6 +11,7 @@ type User struct { | ||
| 11 | 11 | ID int64 |
| 12 | 12 | Username string |
| 13 | 13 | IsAdmin bool |
| 14 | Pending bool // self-registered, email not yet verified | |
| 14 | 15 | } |
| 15 | 16 | |
| 16 | 17 | type SSHKey struct { |
| @@ -41,25 +42,27 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { | ||
| 41 | 42 | |
| 42 | 43 | func (s *Store) UserByUsername(name string) (User, error) { |
| 43 | 44 | var u User |
| 44 | var admin int | |
| 45 | err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE username = ?", name). | |
| 46 | Scan(&u.ID, &u.Username, &admin) | |
| 45 | var admin, pending int | |
| 46 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE username = ?", name). | |
| 47 | Scan(&u.ID, &u.Username, &admin, &pending) | |
| 47 | 48 | if errors.Is(err, sql.ErrNoRows) { |
| 48 | 49 | return u, ErrNotFound |
| 49 | 50 | } |
| 50 | 51 | u.IsAdmin = admin != 0 |
| 52 | u.Pending = pending != 0 | |
| 51 | 53 | return u, err |
| 52 | 54 | } |
| 53 | 55 | |
| 54 | 56 | func (s *Store) UserByID(id int64) (User, error) { |
| 55 | 57 | var u User |
| 56 | var admin int | |
| 57 | err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE id = ?", id). | |
| 58 | Scan(&u.ID, &u.Username, &admin) | |
| 58 | var admin, pending int | |
| 59 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE id = ?", id). | |
| 60 | Scan(&u.ID, &u.Username, &admin, &pending) | |
| 59 | 61 | if errors.Is(err, sql.ErrNoRows) { |
| 60 | 62 | return u, ErrNotFound |
| 61 | 63 | } |
| 62 | 64 | u.IsAdmin = admin != 0 |
| 65 | u.Pending = pending != 0 | |
| 63 | 66 | return u, err |
| 64 | 67 | } |
| 65 | 68 | |