krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
dce6f1b3897a636e2cb1c99f5a33db9e19503395
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T00:25:40Z
cmd/gitbay/main.go | 6 + cmd/gitbayd/main.go | 50 ++++- e2e/registration_test.go | 221 +++++++++++++++++++++ internal/config/config.go | 7 +- internal/control/control.go | 10 + internal/control/register.go | 178 +++++++++++++++++ internal/mail/mail.go | 64 ++++++ internal/sshd/sshd.go | 41 +++- .../store/migrations/0003_registration.down.sql | 2 + internal/store/migrations/0003_registration.up.sql | 10 + internal/store/registration.go | 73 +++++++ internal/store/users.go | 15 +- 12 files changed, 668 insertions(+), 9 deletions(-) @@ -32,6 +32,8 @@ func main() { webCmd(), remoteCmd(), initCmd(), + pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", + passOpts{server: []string{"register"}}), manCmd(root), ) @@ -176,6 +178,10 @@ func authCmd() *cobra.Command { keysAdd, pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}), ), + group("email", "manage email addresses", + pass("add", "add an address and get a verification code by mail", passOpts{server: []string{"email", "add"}}), + pass("verify", "confirm a verification code", passOpts{server: []string{"email", "verify"}}), + ), group("pgp", "manage OpenPGP keys", pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}), pgpAdd, @@ -8,6 +8,7 @@ import ( "net" "net/http" "os" + "strings" "path/filepath" "strconv" @@ -16,6 +17,7 @@ import ( "gitbay.org/gitbay/internal/config" "gitbay.org/gitbay/internal/control" + "gitbay.org/gitbay/internal/mail" "gitbay.org/gitbay/internal/gitd" "gitbay.org/gitbay/internal/hookd" "gitbay.org/gitbay/internal/httpd" @@ -214,7 +216,7 @@ func adminCmd() *cobra.Command { admin.AddCommand( userCmd, emailCmd, - notImplemented("invite", "issue registration invites"), + adminInviteCmd(), backupCmd(), notImplemented("gc", "run git gc across repositories"), notImplemented("stats", "instance statistics"), @@ -222,6 +224,52 @@ func adminCmd() *cobra.Command { return admin } +func adminInviteCmd() *cobra.Command { + var email string + cmd := &cobra.Command{ + Use: "invite", + Short: "issue a registration invite and email its code", + RunE: func(cmd *cobra.Command, args []string) error { + if email == "" { + return fmt.Errorf("--email is required") + } + cfg, err := config.Load(configPath) + if err != nil { + return err + } + st, err := openStore(cfg) + if err != nil { + return err + } + defer st.Close() + + code, hash, err := store.NewToken() + if err != nil { + return err + } + if err := st.CreateInvite(hash, email); err != nil { + return err + } + host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://"), "/") + body := fmt.Sprintf( + "You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+ + " ssh git@%s register --username <name> --invite %s\n\n"+ + "The invite is single-use and tied to this address.\n", host, host, code) + if cfg.Mail.SMTPHost != "" { + if err := mail.Send(cfg, email, "your invite to "+host, body); err != nil { + return fmt.Errorf("invite stored but mail failed: %w (code: %s)", err, code) + } + fmt.Printf("invite emailed to %s\n", email) + } else { + fmt.Printf("invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code) + } + return nil + }, + } + cmd.Flags().StringVar(&email, "email", "", "address to invite (the account's verified email)") + return cmd +} + func adminUserCreateCmd() *cobra.Command { var keyPath, email string var verified, isAdmin bool new file mode 100644 @@ -0,0 +1,221 @@ +package e2e + +import ( + "bufio" + "fmt" + "net" + "regexp" + "strings" + "sync" + "testing" + "time" +) + +// fakeSMTP is a minimal SMTP server capturing delivered messages. +type fakeSMTP struct { + addr string + mu sync.Mutex + mail []string // raw DATA payloads +} + +func startFakeSMTP(t *testing.T) *fakeSMTP { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + f := &fakeSMTP{addr: ln.Addr().String()} + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go f.handle(conn) + } + }() + return f +} + +func (f *fakeSMTP) handle(conn net.Conn) { + defer conn.Close() + r := bufio.NewReader(conn) + say := func(s string) { fmt.Fprintf(conn, "%s\r\n", s) } + say("220 fake ESMTP") + var data strings.Builder + inData := false + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + if inData { + if line == "." { + f.mu.Lock() + f.mail = append(f.mail, data.String()) + f.mu.Unlock() + data.Reset() + inData = false + say("250 ok") + continue + } + data.WriteString(line + "\n") + continue + } + switch { + case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"): + fmt.Fprintf(conn, "250-fake\r\n250 SIZE 1000000\r\n") + case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"): + say("250 ok") + case line == "DATA": + inData = true + say("354 go") + case line == "QUIT": + say("221 bye") + return + default: + say("250 ok") + } + } +} + +// waitMail returns the nth captured message. +func (f *fakeSMTP) waitMail(t *testing.T, n int) string { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + f.mu.Lock() + if len(f.mail) > n { + m := f.mail[n] + f.mu.Unlock() + return m + } + f.mu.Unlock() + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("mail %d never arrived", n) + return "" +} + +var codePat = regexp.MustCompile(`(?:verify|--invite) ([0-9a-f]{64})`) + +func extractCode(t *testing.T, mail string) string { + t.Helper() + m := codePat.FindStringSubmatch(mail) + if m == nil { + t.Fatalf("no code in mail:\n%s", mail) + } + return m[1] +} + +func TestOpenRegistration(t *testing.T) { + smtp := startFakeSMTP(t) + inst := startInstanceWith(t, fmt.Sprintf( + "[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr)) + + // A stranger's key cannot run normal commands, and the denial explains + // how to register. + newKey := inst.newKey(t, "newcomer") + _, errOut, code := inst.ssh(t, newKey, "", "whoami") + if code != 4 || !strings.Contains(errOut, "register --username") { + t.Fatalf("stranger whoami: exit %d, %s", code, errOut) + } + + // Register: account created pending, verification mail sent. + out, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "dana@example.test") + if code != 0 { + t.Fatalf("register: exit %d, %s", code, errOut) + } + if !strings.Contains(out, "verification code was sent") { + t.Fatalf("register output: %s", out) + } + msg := smtp.waitMail(t, 0) + if !strings.Contains(msg, "To: dana@example.test") || !strings.Contains(msg, "From: noreply@gitbay.test") { + t.Fatalf("mail headers:\n%s", msg) + } + + // Pending: the key authenticates, whoami works, but everything else is + // gated — control commands and git alike. + if out, _, code = inst.ssh(t, newKey, "", "whoami"); code != 0 || strings.TrimSpace(out) != "dana" { + t.Fatalf("pending whoami: %d %q", code, out) + } + _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj") + if code != 4 || !strings.Contains(errOut, "not active yet") { + t.Fatalf("pending repo create: exit %d, %s", code, errOut) + } + cloneOut, cloneCode := gitRun(t, t.TempDir(), inst.gitEnv(newKey), "clone", inst.sshURL("dana/anything")) + if cloneCode == 0 || !strings.Contains(cloneOut, "not active yet") { + t.Fatalf("pending git: %d\n%s", cloneCode, cloneOut) + } + + // A wrong code fails; the mailed code activates the account. + if _, _, code = inst.ssh(t, newKey, "", "email", "verify", strings.Repeat("0", 64)); code != 2 { + t.Fatalf("bad code: exit %d, want 2", code) + } + verifyCode := extractCode(t, msg) + out, errOut, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode) + if code != 0 || !strings.Contains(out, "account is active") { + t.Fatalf("verify: exit %d, %s%s", code, out, errOut) + } + // Single use. + if _, _, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode); code != 2 { + t.Fatalf("code reuse: exit %d, want 2", code) + } + + // Fully active: repo create works, and the verified email makes + // signature verification meaningful (verified_by = smtp). + if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj"); code != 0 { + t.Fatalf("post-verify repo create: %s", errOut) + } + + // Self-service email add on an existing account sends a second mail. + if _, errOut, code = inst.ssh(t, newKey, "", "email", "add", "dana2@example.test"); code != 0 { + t.Fatalf("email add: %s", errOut) + } + msg2 := smtp.waitMail(t, 1) + if !strings.Contains(msg2, "To: dana2@example.test") { + t.Fatalf("second mail:\n%s", msg2) + } + if _, _, code = inst.ssh(t, newKey, "", "email", "verify", extractCode(t, msg2)); code != 0 { + t.Fatal("second verify failed") + } +} + +func TestInviteRegistration(t *testing.T) { + smtp := startFakeSMTP(t) + inst := startInstanceWith(t, fmt.Sprintf( + "[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr)) + + // Registering without an invite is refused. + newKey := inst.newKey(t, "guest") + _, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "erin", "--email", "erin@example.test") + if code != 4 || !strings.Contains(errOut, "invite-only") { + t.Fatalf("uninvited register: exit %d, %s", code, errOut) + } + + // Admin issues an invite; the code arrives by mail. + out := inst.admin(t, "admin", "invite", "--email", "erin@example.test") + if !strings.Contains(out, "invite emailed") { + t.Fatalf("invite output: %s", out) + } + inviteCode := extractCode(t, smtp.waitMail(t, 0)) + + // Redeeming it creates an ACTIVE account: code possession proves the + // mailbox, so the email is verified (by smtp) and nothing is pending. + out, errOut, code = inst.ssh(t, newKey, "", "register", "--username", "erin", "--invite", inviteCode) + if code != 0 || !strings.Contains(out, "account is active") { + t.Fatalf("invite register: exit %d, %s%s", code, out, errOut) + } + if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "erin/proj"); code != 0 { + t.Fatalf("invited user repo create: %s", errOut) + } + + // Invites are single-use. + otherKey := inst.newKey(t, "other") + _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "fake", "--invite", inviteCode) + if code != 4 || !strings.Contains(errOut, "already used") { + t.Fatalf("invite reuse: exit %d, %s", code, errOut) + } +} @@ -62,8 +62,10 @@ type Limits struct { } type Mail struct { - SMTPHost string `toml:"smtp_host"` + SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587) From string `toml:"from"` + SMTPUser string `toml:"smtp_user,omitempty"` + SMTPPass string `toml:"smtp_pass,omitempty"` } // Default returns the configuration used when a key is absent from the file. @@ -139,6 +141,9 @@ func (c Config) Validate() error { } // Contradictions. + if c.Mail.SMTPHost != "" && c.Mail.From == "" { + errs = append(errs, errors.New("[mail] from is required when smtp_host is set")) + } if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" { errs = append(errs, fmt.Errorf( "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP", @@ -70,6 +70,10 @@ func Dispatch(c *Ctx, argv []string) int { if c.Scope != "full" { return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) } + if c.User.Pending && !pendingAllowed(cmd.Path) { + return c.fail(protocol.ExitDenied, + "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") + } // Strip the global --json flag wherever it appears. args := rest[:0:0] for _, a := range rest { @@ -85,6 +89,12 @@ func Dispatch(c *Ctx, argv []string) int { return cmd.Run(c, args) } +// pendingAllowed lists what an unverified self-registered account may do. +func pendingAllowed(path []string) bool { + key := joinPath(path) + return key == "email verify" || key == "email add" || key == "whoami" || key == "help" +} + type emptyReader struct{} func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } new file mode 100644 @@ -0,0 +1,178 @@ +package control + +import ( + "errors" + "fmt" + "io" + "strings" + "time" + + "golang.org/x/crypto/ssh" + + "gitbay.org/gitbay/internal/config" + "gitbay.org/gitbay/internal/mail" + "gitbay.org/gitbay/internal/policy" + "gitbay.org/gitbay/internal/protocol" + "gitbay.org/gitbay/internal/store" +) + +func init() { + register(Command{Path: []string{"email", "add"}, + Summary: "add an address and mail a verification code: email add <address>", Run: runEmailAdd}) + register(Command{Path: []string{"email", "verify"}, + Summary: "confirm a verification code: email verify <code>", Run: runEmailVerify}) +} + +func siteHost(cfg config.Config) string { + h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://") + return strings.TrimSuffix(h, "/") +} + +func sendVerification(cfg config.Config, st *store.Store, userID int64, address string) error { + code, hash, err := store.NewToken() + if err != nil { + return err + } + if err := st.CreateEmailToken(userID, address, hash, 24*time.Hour); err != nil { + return err + } + body := fmt.Sprintf( + "Someone (hopefully you) added this address to an account on %s.\n\n"+ + "To verify it, run:\n\n ssh git@%s email verify %s\n\n"+ + "The code expires in 24 hours. If this wasn't you, ignore this mail.\n", + siteHost(cfg), siteHost(cfg), code) + return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body) +} + +func runEmailAdd(c *Ctx, args []string) int { + if len(args) != 1 || !strings.Contains(args[0], "@") { + return c.fail(protocol.ExitUsage, "usage: email add <address>") + } + if c.Cfg.Mail.SMTPHost == "" { + return c.fail(protocol.ExitFailure, "this instance has no SMTP configured; ask an admin to verify the address (gitbayd admin email verify)") + } + if err := c.Store.AddEmail(c.User.ID, args[0], "", false); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if err := sendVerification(c.Cfg, c.Store, c.User.ID, args[0]); err != nil { + return c.fail(protocol.ExitFailure, "sending verification mail: %v", err) + } + return c.emit(map[string]string{"address": args[0], "status": "verification_sent"}, func(w io.Writer) { + fmt.Fprintf(w, "verification code sent to %s\n", args[0]) + }) +} + +func runEmailVerify(c *Ctx, args []string) int { + if len(args) != 1 { + return c.fail(protocol.ExitUsage, "usage: email verify <code>") + } + address, err := c.Store.ConsumeEmailToken(c.User.ID, store.HashToken(args[0])) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used") + } + return c.fail(protocol.ExitFailure, "%v", err) + } + if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if err := c.Store.ClearPending(c.User.ID); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) { + fmt.Fprintf(w, "%s verified; your account is active\n", address) + }) +} + +// RunRegister handles the one command an UNAUTHENTICATED key may run. It is +// dispatched outside the normal registry: the caller has already checked +// that registration is enabled and that argv[0] == "register". +func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string, + stdout, stderr io.Writer) int { + var username, email, invite string + args := argv[1:] + for i := 0; i < len(args); i++ { + switch args[i] { + case "--username", "--email", "--invite": + if i+1 >= len(args) { + fmt.Fprintf(stderr, "%s requires a value\n", args[i]) + return protocol.ExitUsage + } + switch args[i] { + case "--username": + username = args[i+1] + case "--email": + email = args[i+1] + case "--invite": + invite = args[i+1] + } + i++ + default: + fmt.Fprintf(stderr, "unexpected argument %q\n", args[i]) + return protocol.ExitUsage + } + } + fail := func(code int, format string, a ...any) int { + fmt.Fprintf(stderr, format+"\n", a...) + return code + } + if username == "" { + return fail(protocol.ExitUsage, "usage: register --username <name> --email <address> | register --username <name> --invite <code>") + } + if err := policy.ValidateOwnerName(username); err != nil { + return fail(protocol.ExitUsage, "%v", err) + } + + switch cfg.Registration.Mode { + case "invite": + if invite == "" { + return fail(protocol.ExitDenied, "this instance is invite-only: register --username <name> --invite <code>") + } + addr, err := st.ConsumeInvite(store.HashToken(invite)) + if err != nil { + return fail(protocol.ExitDenied, "that invite is invalid or already used") + } + uid, err := st.CreateRegisteredUser(username, false) + if err != nil { + return fail(protocol.ExitFailure, "%v", err) + } + // Possession of the emailed invite code proves the mailbox. + if err := st.AddEmail(uid, addr, "smtp", true); err != nil { + return fail(protocol.ExitFailure, "%v", err) + } + if err := addRegisteredKey(st, uid, pub); err != nil { + return fail(protocol.ExitFailure, "%v", err) + } + fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username) + return protocol.ExitOK + + case "open": + if email == "" || !strings.Contains(email, "@") { + return fail(protocol.ExitUsage, "usage: register --username <name> --email <address>") + } + uid, err := st.CreateRegisteredUser(username, true) + if err != nil { + return fail(protocol.ExitFailure, "%v", err) + } + if err := st.AddEmail(uid, email, "", true); err != nil { + return fail(protocol.ExitFailure, "%v", err) + } + if err := addRegisteredKey(st, uid, pub); err != nil { + return fail(protocol.ExitFailure, "%v", err) + } + if err := sendVerification(cfg, st, uid, email); err != nil { + return fail(protocol.ExitFailure, "sending verification mail: %v", err) + } + fmt.Fprintf(stdout, + "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify <code>\n", + username, email, siteHost(cfg)) + return protocol.ExitOK + + default: + return fail(protocol.ExitDenied, "registration is closed on this instance") + } +} + +func addRegisteredKey(st *store.Store, uid int64, pub ssh.PublicKey) error { + return st.AddSSHKey(uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full") +} new file mode 100644 @@ -0,0 +1,64 @@ +// Package mail sends transactional email over SMTP: verification codes and +// invites. STARTTLS is used when the server offers it; PLAIN auth when +// credentials are configured. +package mail + +import ( + "fmt" + "net" + "net/smtp" + "strings" + "time" + + "gitbay.org/gitbay/internal/config" +) + +// Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port. +func Send(cfg config.Config, to, subject, body string) error { + m := cfg.Mail + if m.SMTPHost == "" || m.From == "" { + return fmt.Errorf("[mail] smtp_host and from must be configured") + } + host := m.SMTPHost + if !strings.Contains(host, ":") { + host += ":587" + } + hostname, _, _ := net.SplitHostPort(host) + + msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf( + "From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n", + m.From, to, subject, time.Now().Format(time.RFC1123Z), body)) + + c, err := smtp.Dial(host) + if err != nil { + return fmt.Errorf("smtp dial %s: %w", host, err) + } + defer c.Close() + if ok, _ := c.Extension("STARTTLS"); ok { + if err := c.StartTLS(nil); err != nil { + return fmt.Errorf("starttls: %w", err) + } + } + if m.SMTPUser != "" { + if err := c.Auth(smtp.PlainAuth("", m.SMTPUser, m.SMTPPass, hostname)); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + if err := c.Mail(m.From); err != nil { + return err + } + if err := c.Rcpt(to); err != nil { + return err + } + w, err := c.Data() + if err != nil { + return err + } + if _, err := w.Write([]byte(msg)); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + return c.Quit() +} @@ -5,6 +5,7 @@ package sshd import ( "crypto/ed25519" "crypto/rand" + "encoding/base64" "encoding/pem" "errors" "fmt" @@ -95,11 +96,18 @@ func generateHostKey(path string) error { } // authenticate resolves the presented key to a registered account. The SSH -// username is ignored; identity comes from the key alone. +// username is ignored; identity comes from the key alone. When registration +// is open or invite-based, unknown keys are admitted to run exactly one +// command: register. func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) { fp := ssh.FingerprintSHA256(pub) key, err := s.st.SSHKeyByFingerprint(fp) if err != nil { + if s.cfg.Registration.Mode != "closed" { + return &ssh.Permissions{Extensions: map[string]string{ + "anon-key": base64.StdEncoding.EncodeToString(pub.Marshal()), + }}, nil + } return nil, fmt.Errorf("unknown key %s", fp) } return &ssh.Permissions{Extensions: map[string]string{ @@ -177,6 +185,9 @@ func sendExit(ch ssh.Channel, code int) { func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int { ext := sconn.Permissions.Extensions + if blob := ext["anon-key"]; blob != "" { + return s.runAnonymous(ch, blob, cmdline) + } userID, _ := strconv.ParseInt(ext["user-id"], 10, 64) keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64) user, err := s.st.UserByID(userID) @@ -188,6 +199,30 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr()) } +// runAnonymous handles a session from an unregistered key: the register +// command and nothing else. +func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int { + raw, err := base64.StdEncoding.DecodeString(keyB64) + if err != nil { + return protocol.ExitFailure + } + pub, err := ssh.ParsePublicKey(raw) + if err != nil { + return protocol.ExitFailure + } + argv, err := protocol.Tokenize(cmdline) + if err != nil { + fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err) + return protocol.ExitUsage + } + if len(argv) == 0 || argv[0] != "register" { + fmt.Fprintf(ch.Stderr(), "this key is not registered here. Create an account with:\n ssh <host> register --username <name> %s\n", + map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode]) + return protocol.ExitDenied + } + return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr()) +} + // Exec runs one SSH exec command line for an authenticated key. It is the // single dispatch path shared by the embedded listener and the system-sshd // forced command (gitbayd shell). @@ -201,6 +236,10 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline st if len(argv) > 0 { switch argv[0] { case "git-upload-pack", "git-receive-pack", "git-upload-archive": + if user.Pending { + fmt.Fprintln(stderr, "your account is not active yet: verify your email first") + return protocol.ExitDenied + } return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr) } } new file mode 100644 @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN pending; +DROP TABLE email_tokens; new file mode 100644 @@ -0,0 +1,10 @@ +CREATE TABLE email_tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + address TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at TEXT NOT NULL, + used_at TEXT +); + +ALTER TABLE users ADD COLUMN pending INTEGER NOT NULL DEFAULT 0; new file mode 100644 @@ -0,0 +1,73 @@ +package store + +import ( + "errors" + "time" +) + +// CreateInvite stores an invite code hash bound to an email address. +func (s *Store) CreateInvite(codeHash, email string) error { + _, err := s.DB.Exec("INSERT INTO invites (code_hash, email) VALUES (?, ?)", codeHash, email) + return err +} + +// ConsumeInvite redeems an invite exactly once, returning the address it was +// issued for. Used and unknown codes fail identically. +func (s *Store) ConsumeInvite(codeHash string) (string, error) { + res, err := s.DB.Exec( + "UPDATE invites SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE code_hash = ? AND used_at IS NULL", + codeHash) + if err != nil { + return "", err + } + if n, _ := res.RowsAffected(); n == 0 { + return "", ErrNotFound + } + var email string + err = s.DB.QueryRow("SELECT email FROM invites WHERE code_hash = ?", codeHash).Scan(&email) + return email, err +} + +// CreateEmailToken stores a verification code hash for one address. +func (s *Store) CreateEmailToken(userID int64, address, tokenHash string, ttl time.Duration) error { + _, err := s.DB.Exec( + "INSERT INTO email_tokens (token_hash, user_id, address, expires_at) VALUES (?, ?, ?, ?)", + tokenHash, userID, address, fmtTime(time.Now().Add(ttl))) + return err +} + +// ConsumeEmailToken redeems a verification code for the given user. +func (s *Store) ConsumeEmailToken(userID int64, tokenHash string) (string, error) { + res, err := s.DB.Exec(` + UPDATE email_tokens SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE token_hash = ? AND user_id = ? AND used_at IS NULL AND expires_at > ?`, + tokenHash, userID, fmtTime(time.Now())) + if err != nil { + return "", err + } + if n, _ := res.RowsAffected(); n == 0 { + return "", ErrNotFound + } + var address string + err = s.DB.QueryRow("SELECT address FROM email_tokens WHERE token_hash = ?", tokenHash).Scan(&address) + return address, err +} + +// CreateRegisteredUser makes a self-registered account, pending until its +// email is verified. +func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { + res, err := s.DB.Exec("INSERT INTO users (username, pending) VALUES (?, ?)", username, boolInt(pending)) + if err != nil { + if isUniqueErr(err) { + return 0, errors.New("that username is taken") + } + return 0, err + } + return res.LastInsertId() +} + +// ClearPending activates a pending account. +func (s *Store) ClearPending(userID int64) error { + _, err := s.DB.Exec("UPDATE users SET pending = 0 WHERE id = ?", userID) + return err +} @@ -11,6 +11,7 @@ type User struct { ID int64 Username string IsAdmin bool + Pending bool // self-registered, email not yet verified } type SSHKey struct { @@ -41,25 +42,27 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { func (s *Store) UserByUsername(name string) (User, error) { var u User - var admin int - err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE username = ?", name). - Scan(&u.ID, &u.Username, &admin) + var admin, pending int + err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE username = ?", name). + Scan(&u.ID, &u.Username, &admin, &pending) if errors.Is(err, sql.ErrNoRows) { return u, ErrNotFound } u.IsAdmin = admin != 0 + u.Pending = pending != 0 return u, err } func (s *Store) UserByID(id int64) (User, error) { var u User - var admin int - err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE id = ?", id). - Scan(&u.ID, &u.Username, &admin) + var admin, pending int + err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE id = ?", id). + Scan(&u.ID, &u.Username, &admin, &pending) if errors.Is(err, sql.ErrNoRows) { return u, ErrNotFound } u.IsAdmin = admin != 0 + u.Pending = pending != 0 return u, err }