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() {
3232 webCmd(),
3333 remoteCmd(),
3434 initCmd(),
35 pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>",
36 passOpts{server: []string{"register"}}),
3537 manCmd(root),
3638 )
3739
@@ -176,6 +178,10 @@ func authCmd() *cobra.Command {
176178 keysAdd,
177179 pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}),
178180 ),
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 ),
179185 group("pgp", "manage OpenPGP keys",
180186 pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}),
181187 pgpAdd,
cmd/gitbayd/main.go +49 −1
@@ -8,6 +8,7 @@ import (
88 "net"
99 "net/http"
1010 "os"
11 "strings"
1112 "path/filepath"
1213 "strconv"
1314
@@ -16,6 +17,7 @@ import (
1617
1718 "gitbay.org/gitbay/internal/config"
1819 "gitbay.org/gitbay/internal/control"
20 "gitbay.org/gitbay/internal/mail"
1921 "gitbay.org/gitbay/internal/gitd"
2022 "gitbay.org/gitbay/internal/hookd"
2123 "gitbay.org/gitbay/internal/httpd"
@@ -214,7 +216,7 @@ func adminCmd() *cobra.Command {
214216 admin.AddCommand(
215217 userCmd,
216218 emailCmd,
217 notImplemented("invite", "issue registration invites"),
219 adminInviteCmd(),
218220 backupCmd(),
219221 notImplemented("gc", "run git gc across repositories"),
220222 notImplemented("stats", "instance statistics"),
@@ -222,6 +224,52 @@ func adminCmd() *cobra.Command {
222224 return admin
223225}
224226
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
225273func adminUserCreateCmd() *cobra.Command {
226274 var keyPath, email string
227275 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 {
6262}
6363
6464type Mail struct {
65 SMTPHost string `toml:"smtp_host"`
65 SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587)
6666 From string `toml:"from"`
67 SMTPUser string `toml:"smtp_user,omitempty"`
68 SMTPPass string `toml:"smtp_pass,omitempty"`
6769}
6870
6971// Default returns the configuration used when a key is absent from the file.
@@ -139,6 +141,9 @@ func (c Config) Validate() error {
139141 }
140142
141143 // 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 }
142147 if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" {
143148 errs = append(errs, fmt.Errorf(
144149 "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 {
7070 if c.Scope != "full" {
7171 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope)
7272 }
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 }
7377 // Strip the global --json flag wherever it appears.
7478 args := rest[:0:0]
7579 for _, a := range rest {
@@ -85,6 +89,12 @@ func Dispatch(c *Ctx, argv []string) int {
8589 return cmd.Run(c, args)
8690}
8791
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
8898type emptyReader struct{}
8999
90100func (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
55import (
66 "crypto/ed25519"
77 "crypto/rand"
8 "encoding/base64"
89 "encoding/pem"
910 "errors"
1011 "fmt"
@@ -95,11 +96,18 @@ func generateHostKey(path string) error {
9596}
9697
9798// 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.
99102func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
100103 fp := ssh.FingerprintSHA256(pub)
101104 key, err := s.st.SSHKeyByFingerprint(fp)
102105 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 }
103111 return nil, fmt.Errorf("unknown key %s", fp)
104112 }
105113 return &ssh.Permissions{Extensions: map[string]string{
@@ -177,6 +185,9 @@ func sendExit(ch ssh.Channel, code int) {
177185
178186func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int {
179187 ext := sconn.Permissions.Extensions
188 if blob := ext["anon-key"]; blob != "" {
189 return s.runAnonymous(ch, blob, cmdline)
190 }
180191 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
181192 keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
182193 user, err := s.st.UserByID(userID)
@@ -188,6 +199,30 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string)
188199 return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr())
189200}
190201
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
191226// Exec runs one SSH exec command line for an authenticated key. It is the
192227// single dispatch path shared by the embedded listener and the system-sshd
193228// forced command (gitbayd shell).
@@ -201,6 +236,10 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline st
201236 if len(argv) > 0 {
202237 switch argv[0] {
203238 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 }
204243 return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr)
205244 }
206245 }
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 {
1111 ID int64
1212 Username string
1313 IsAdmin bool
14 Pending bool // self-registered, email not yet verified
1415}
1516
1617type SSHKey struct {
@@ -41,25 +42,27 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) {
4142
4243func (s *Store) UserByUsername(name string) (User, error) {
4344 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)
4748 if errors.Is(err, sql.ErrNoRows) {
4849 return u, ErrNotFound
4950 }
5051 u.IsAdmin = admin != 0
52 u.Pending = pending != 0
5153 return u, err
5254}
5355
5456func (s *Store) UserByID(id int64) (User, error) {
5557 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)
5961 if errors.Is(err, sql.ErrNoRows) {
6062 return u, ErrNotFound
6163 }
6264 u.IsAdmin = admin != 0
65 u.Pending = pending != 0
6366 return u, err
6467}
6568