Commit dce6f1b389

dce6f1b3897a636e2cb1c99f5a33db9e19503395

parent: 4c11a67a9f

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 00:25 UTC

registration: invite and open modes with SMTP verification

- internal/mail: minimal SMTP sender (STARTTLS when offered, PLAIN auth
  when configured); [mail] gains smtp_user/smtp_pass; from required
  whenever smtp_host is set
- migration 0003: email_tokens table, users.pending column
- open mode: unknown keys are admitted to run exactly one command —
  register --username --email — which creates a pending account,
  registers the presented key, and mails a 24h single-use code; pending
  accounts may only run whoami/help/email add/email verify, and git
  transport is refused until verification (verified_by = smtp)
- invite mode: gitbayd admin invite --email mails a single-use code
  (printed instead when no SMTP); register --invite redeems it into an
  immediately active account — code possession proves the mailbox;
  uninvited registration refused
- email add/verify as control commands and CLI passthroughs; gitbay
  register passthrough for first contact
- e2e: in-test SMTP server capturing mail; full open flow (denial hint,
  pending gates on control+git, wrong/reused codes, activation, second
  address) and invite flow (uninvited refusal, mailed code, active on
  redeem, single-use)

Layout: unified · split

cmd/gitbay/main.go +6
@@ -32,6 +32,8 @@ func main() {
32 webCmd(), 32 webCmd(),
33 remoteCmd(), 33 remoteCmd(),
34 initCmd(), 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 manCmd(root), 37 manCmd(root),
36 ) 38 )
37 39
@@ -176,6 +178,10 @@ func authCmd() *cobra.Command {
176 keysAdd, 178 keysAdd,
177 pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}), 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 group("pgp", "manage OpenPGP keys", 185 group("pgp", "manage OpenPGP keys",
180 pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}), 186 pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}),
181 pgpAdd, 187 pgpAdd,
cmd/gitbayd/main.go +49 −1
@@ -8,6 +8,7 @@ import (
8 "net" 8 "net"
9 "net/http" 9 "net/http"
10 "os" 10 "os"
11 "strings"
11 "path/filepath" 12 "path/filepath"
12 "strconv" 13 "strconv"
13 14
@@ -16,6 +17,7 @@ import (
16 17
17 "gitbay.org/gitbay/internal/config" 18 "gitbay.org/gitbay/internal/config"
18 "gitbay.org/gitbay/internal/control" 19 "gitbay.org/gitbay/internal/control"
20 "gitbay.org/gitbay/internal/mail"
19 "gitbay.org/gitbay/internal/gitd" 21 "gitbay.org/gitbay/internal/gitd"
20 "gitbay.org/gitbay/internal/hookd" 22 "gitbay.org/gitbay/internal/hookd"
21 "gitbay.org/gitbay/internal/httpd" 23 "gitbay.org/gitbay/internal/httpd"
@@ -214,7 +216,7 @@ func adminCmd() *cobra.Command {
214 admin.AddCommand( 216 admin.AddCommand(
215 userCmd, 217 userCmd,
216 emailCmd, 218 emailCmd,
217 notImplemented("invite", "issue registration invites"), 219 adminInviteCmd(),
218 backupCmd(), 220 backupCmd(),
219 notImplemented("gc", "run git gc across repositories"), 221 notImplemented("gc", "run git gc across repositories"),
220 notImplemented("stats", "instance statistics"), 222 notImplemented("stats", "instance statistics"),
@@ -222,6 +224,52 @@ func adminCmd() *cobra.Command {
222 return admin 224 return admin
223} 225}
224 226
227func 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
225func adminUserCreateCmd() *cobra.Command { 273func adminUserCreateCmd() *cobra.Command {
226 var keyPath, email string 274 var keyPath, email string
227 var verified, isAdmin bool 275 var verified, isAdmin bool
e2e/registration_test.go added +221
@@ -0,0 +1,221 @@
1package e2e
2
3import (
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.
15type fakeSMTP struct {
16 addr string
17 mu sync.Mutex
18 mail []string // raw DATA payloads
19}
20
21func 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
41func (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.
85func (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
102var codePat = regexp.MustCompile(`(?:verify|--invite) ([0-9a-f]{64})`)
103
104func 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
113func 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
186func 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
64type Mail struct { 64type Mail struct {
65 SMTPHost string `toml:"smtp_host"` 65 SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587)
66 From string `toml:"from"` 66 From string `toml:"from"`
67 SMTPUser string `toml:"smtp_user,omitempty"`
68 SMTPPass string `toml:"smtp_pass,omitempty"`
67} 69}
68 70
69// Default returns the configuration used when a key is absent from the file. 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 // Contradictions. 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 if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" { 147 if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
143 errs = append(errs, fmt.Errorf( 148 errs = append(errs, fmt.Errorf(
144 "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP", 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 if c.Scope != "full" { 70 if c.Scope != "full" {
71 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) 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 // Strip the global --json flag wherever it appears. 77 // Strip the global --json flag wherever it appears.
74 args := rest[:0:0] 78 args := rest[:0:0]
75 for _, a := range rest { 79 for _, a := range rest {
@@ -85,6 +89,12 @@ func Dispatch(c *Ctx, argv []string) int {
85 return cmd.Run(c, args) 89 return cmd.Run(c, args)
86} 90}
87 91
92// pendingAllowed lists what an unverified self-registered account may do.
93func pendingAllowed(path []string) bool {
94 key := joinPath(path)
95 return key == "email verify" || key == "email add" || key == "whoami" || key == "help"
96}
97
88type emptyReader struct{} 98type emptyReader struct{}
89 99
90func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } 100func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF }
internal/control/register.go added +178
@@ -0,0 +1,178 @@
1package control
2
3import (
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
19func 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
26func siteHost(cfg config.Config) string {
27 h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://")
28 return strings.TrimSuffix(h, "/")
29}
30
31func 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
47func 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
65func 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".
90func 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
176func 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.
4package mail
5
6import (
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.
17func 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
5import ( 5import (
6 "crypto/ed25519" 6 "crypto/ed25519"
7 "crypto/rand" 7 "crypto/rand"
8 "encoding/base64"
8 "encoding/pem" 9 "encoding/pem"
9 "errors" 10 "errors"
10 "fmt" 11 "fmt"
@@ -95,11 +96,18 @@ func generateHostKey(path string) error {
95} 96}
96 97
97// authenticate resolves the presented key to a registered account. The SSH 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.
99func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) { 102func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
100 fp := ssh.FingerprintSHA256(pub) 103 fp := ssh.FingerprintSHA256(pub)
101 key, err := s.st.SSHKeyByFingerprint(fp) 104 key, err := s.st.SSHKeyByFingerprint(fp)
102 if err != nil { 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 return nil, fmt.Errorf("unknown key %s", fp) 111 return nil, fmt.Errorf("unknown key %s", fp)
104 } 112 }
105 return &ssh.Permissions{Extensions: map[string]string{ 113 return &ssh.Permissions{Extensions: map[string]string{
@@ -177,6 +185,9 @@ func sendExit(ch ssh.Channel, code int) {
177 185
178func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int { 186func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int {
179 ext := sconn.Permissions.Extensions 187 ext := sconn.Permissions.Extensions
188 if blob := ext["anon-key"]; blob != "" {
189 return s.runAnonymous(ch, blob, cmdline)
190 }
180 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64) 191 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
181 keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64) 192 keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
182 user, err := s.st.UserByID(userID) 193 user, err := s.st.UserByID(userID)
@@ -188,6 +199,30 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string)
188 return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr()) 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.
204func (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// Exec runs one SSH exec command line for an authenticated key. It is the 226// Exec runs one SSH exec command line for an authenticated key. It is the
192// single dispatch path shared by the embedded listener and the system-sshd 227// single dispatch path shared by the embedded listener and the system-sshd
193// forced command (gitbayd shell). 228// forced command (gitbayd shell).
@@ -201,6 +236,10 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline st
201 if len(argv) > 0 { 236 if len(argv) > 0 {
202 switch argv[0] { 237 switch argv[0] {
203 case "git-upload-pack", "git-receive-pack", "git-upload-archive": 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 return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr) 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 @@
1ALTER TABLE users DROP COLUMN pending;
2DROP TABLE email_tokens;
internal/store/migrations/0003_registration.up.sql added +10
@@ -0,0 +1,10 @@
1CREATE 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
10ALTER TABLE users ADD COLUMN pending INTEGER NOT NULL DEFAULT 0;
internal/store/registration.go added +73
@@ -0,0 +1,73 @@
1package store
2
3import (
4 "errors"
5 "time"
6)
7
8// CreateInvite stores an invite code hash bound to an email address.
9func (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.
16func (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.
32func (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.
40func (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.
58func (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.
70func (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 ID int64 11 ID int64
12 Username string 12 Username string
13 IsAdmin bool 13 IsAdmin bool
14 Pending bool // self-registered, email not yet verified
14} 15}
15 16
16type SSHKey struct { 17type SSHKey struct {
@@ -41,25 +42,27 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) {
41 42
42func (s *Store) UserByUsername(name string) (User, error) { 43func (s *Store) UserByUsername(name string) (User, error) {
43 var u User 44 var u User
44 var admin int 45 var admin, pending int
45 err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE username = ?", name). 46 err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE username = ?", name).
46 Scan(&u.ID, &u.Username, &admin) 47 Scan(&u.ID, &u.Username, &admin, &pending)
47 if errors.Is(err, sql.ErrNoRows) { 48 if errors.Is(err, sql.ErrNoRows) {
48 return u, ErrNotFound 49 return u, ErrNotFound
49 } 50 }
50 u.IsAdmin = admin != 0 51 u.IsAdmin = admin != 0
52 u.Pending = pending != 0
51 return u, err 53 return u, err
52} 54}
53 55
54func (s *Store) UserByID(id int64) (User, error) { 56func (s *Store) UserByID(id int64) (User, error) {
55 var u User 57 var u User
56 var admin int 58 var admin, pending int
57 err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE id = ?", id). 59 err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE id = ?", id).
58 Scan(&u.ID, &u.Username, &admin) 60 Scan(&u.ID, &u.Username, &admin, &pending)
59 if errors.Is(err, sql.ErrNoRows) { 61 if errors.Is(err, sql.ErrNoRows) {
60 return u, ErrNotFound 62 return u, ErrNotFound
61 } 63 }
62 u.IsAdmin = admin != 0 64 u.IsAdmin = admin != 0
65 u.Pending = pending != 0
63 return u, err 66 return u, err
64} 67}
65 68