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{"register"}, Summary: "create an account (only meaningful for unregistered keys)", Usage: "register --username [--email
| --invite ]", Flags: []Flag{ {"--username", "", "the account's username", ""}, {"--email", "
", "for open registration", ""}, {"--invite", "", "for invite-only registration", ""}, }, Examples: []string{"register --username cmc --email cmc@example.org"}, Run: func(c *Ctx, args []string) int { return c.fail(protocol.ExitUsage, "this SSH key already belongs to %s. To register a new account, connect with the key it should use:\n ssh -F /dev/null -i git@ register ...", c.User.Username) }}) register(Command{Path: []string{"email", "add"}, Summary: "add an address and mail a verification code", Usage: "email add
", Examples: []string{"email add cmc@example.org"}, Run: runEmailAdd}) register(Command{Path: []string{"email", "verify"}, Summary: "confirm a verification code", Usage: "email verify ", MintsCredential: true, NeedsRecentSignIn: true, Examples: []string{"email verify abc123"}, Run: runEmailVerify}) register(Command{Path: []string{"email", "list"}, Summary: "list the addresses on your account", Usage: "email list", Examples: []string{"email list"}, ReadOnly: true, Run: runEmailList}) register(Command{Path: []string{"email", "remove"}, Summary: "remove an address; not the primary, nor the last verified one", Usage: "email remove
", Examples: []string{"email remove old@example.org"}, Run: runEmailRemove}) register(Command{Path: []string{"email", "primary"}, Summary: "make a verified address the primary", Usage: "email primary
", Examples: []string{"email primary cmc@example.org"}, Run: runEmailPrimary}) } func runEmailList(c *Ctx, args []string) int { if len(args) != 0 { return c.usage() } emails, err := c.Store.ListEmails(c.User.ID) if err != nil { return c.fail(protocol.ExitFailure, "listing addresses: %v", err) } type out struct { Address string `json:"address"` Verified bool `json:"verified"` VerifiedBy string `json:"verified_by,omitempty"` Primary bool `json:"primary"` } ds := make([]out, 0, len(emails)) for _, e := range emails { ds = append(ds, out{e.Address, e.Verified, e.VerifiedBy, e.Primary}) } return c.emit(ds, func(w io.Writer) { tb := c.table(w, "ADDRESS", "STATE") for _, d := range ds { state := "unverified" if d.Verified { state = "verified" } cells := []cell{cRef(d.Address), cState(state)} if d.Primary { cells = append(cells, cText("primary")) } tb.row(cells...) } tb.flush() }) } // emailErr maps the store's refusals onto exit codes: a missing address is // not found, a rule is denied, anything else is a failure. func emailErr(c *Ctx, verb string, err error) int { switch { case errors.Is(err, store.ErrNotFound): return c.fail(protocol.ExitNotFound, "no such address on your account") case errors.Is(err, store.ErrPrimaryEmail), errors.Is(err, store.ErrLastVerifiedEmail), errors.Is(err, store.ErrUnverifiedEmail): return c.fail(protocol.ExitDenied, "%v", err) } return c.fail(protocol.ExitFailure, "%s: %v", verb, err) } func runEmailRemove(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } if err := c.Store.RemoveEmail(c.User.ID, args[0]); err != nil { return emailErr(c, "removing address", err) } return c.emit(map[string]string{"address": args[0], "status": "removed"}, func(w io.Writer) { fmt.Fprintf(w, "%s removed\n", args[0]) }) } func runEmailPrimary(c *Ctx, args []string) int { if len(args) != 1 { return c.usage() } if err := c.Store.SetPrimaryEmail(c.User.ID, args[0]); err != nil { return emailErr(c, "setting primary", err) } return c.emit(map[string]string{"address": args[0], "status": "primary"}, func(w io.Writer) { fmt.Fprintf(w, "%s is now the primary address\n", args[0]) }) } 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"+ "Or sign in at https://%s/login with this address and paste the code under Settings.\n\n"+ "The code expires in 24 hours. If this wasn't you, ignore this mail.\n", siteHost(cfg), siteHost(cfg), code, siteHost(cfg)) return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body) } // notifyAdminsOfSignup tells the instance's admins that an account just // became active, when registration.notify_admin is on. It is queued like // any other notice, so a dead SMTP host shows up in the admin page's // Mail table rather than failing the registration that caused it: the // person signing up is not responsible for the operator's mail (#234). func notifyAdminsOfSignup(cfg config.Config, st *store.Store, username, mode string) { if !cfg.Registration.NotifyAdmin { return } addrs, err := st.AdminMailAddresses() if err != nil || len(addrs) == 0 { return } host := siteHost(cfg) subject := fmt.Sprintf("new account on %s: %s", host, username) body := fmt.Sprintf("%s registered on %s and the account is active (%s registration).\n\n"+ " https://%s/%s\n\nAccounts: ssh git@%s admin user list\n", username, host, mode, host, username, host) for _, a := range addrs { st.EnqueueMail(a, subject, body) } } const maxEmailAddsPerHour = 5 func runEmailAdd(c *Ctx, args []string) int { if len(args) != 1 || !strings.Contains(args[0], "@") { return c.usage() } 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)") } // An authenticated account is not a mail cannon: a handful of codes an // hour is plenty for a person and nothing for a script (#136). if n, err := c.Store.CountEmailTokensSince(c.User.ID, time.Now().Add(-time.Hour)); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } else if n >= maxEmailAddsPerHour { return c.fail(protocol.ExitDenied, "%d verification mails in the last hour; try again later", n) } 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.usage() } hash := store.HashToken(args[0]) address, err := c.Store.ConsumeEmailToken(c.User.ID, hash) if err != nil { if errors.Is(err, store.ErrNotFound) { // A code is scoped to the account that asked for it. Running // this with the wrong key authenticates as the wrong account // and looks exactly like a bad code, which is misleading when // the code is fine and the key is not. if other, e := c.Store.EmailTokenBelongsToAnotherUser(c.User.ID, hash); e == nil && other { return c.fail(protocol.ExitDenied, "that code belongs to a different account; this key authenticated you as %s. "+ "Re-run with the key registered to the account being verified: "+ "ssh -i git@ email verify ", c.User.Username) } 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) } wasPending := c.User.Pending if err := c.Store.ClearPending(c.User.ID); err != nil { return c.fail(protocol.ExitFailure, "%v", err) } // The open-mode account becomes real here, not when the form was // posted, so this is where the admins hear about it. if wasPending { notifyAdminsOfSignup(c.Cfg, c.Store, c.User.Username, "open") } 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 { f, err := parseFlags(argv[1:], flagSpec{Values: []string{"--username", "--email", "--invite"}, MaxPos: 0, Usage: "register --username --email | --invite "}) if err != nil { fmt.Fprintln(stderr, err) return protocol.ExitUsage } username, email, invite := f.Value("--username"), f.Value("--email"), f.Value("--invite") 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 --email
| register --username --invite ") } if err := policy.ValidateOwnerName(username); err != nil { return fail(protocol.ExitUsage, "%v", err) } msg, errMsg, code := RegisterAccount(cfg, st, pub, username, email, invite) if code != protocol.ExitOK { return fail(code, "%s", errMsg) } fmt.Fprint(stdout, msg) return protocol.ExitOK } // RegisterAccount creates an account for pub under the instance's // registration mode. On success it returns the human message and ExitOK; // otherwise an error message and the classifying exit code. Shared by the // SSH register command and the web signup form. func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, username, email, invite string) (string, string, int) { if err := policy.ValidateOwnerName(username); err != nil { return "", err.Error(), protocol.ExitUsage } fp := ssh.FingerprintSHA256(pub) switch cfg.Registration.Mode { case "invite": if invite == "" { return "", "this instance is invite-only: an invite code is required", protocol.ExitDenied } // One transaction: a failure at any step leaves the invite // redeemable and no partial account behind. _, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal()) if err != nil { if errors.Is(err, store.ErrNotFound) { return "", "that invite is invalid or already used", protocol.ExitDenied } return "", err.Error(), protocol.ExitUsage } st.Audit(0, "auth.registered", map[string]any{"user": username, "mode": "invite", "fingerprint": fp}) notifyAdminsOfSignup(cfg, st, username, "invite") return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK case "open": if email == "" || !strings.Contains(email, "@") { return "", "a valid email address is required", protocol.ExitUsage } uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal()) if err != nil { return "", err.Error(), protocol.ExitUsage } if err := sendVerification(cfg, st, uid, email); err != nil { return "", "sending verification mail: " + err.Error(), protocol.ExitFailure } st.Audit(uid, "auth.registered", map[string]any{"user": username, "mode": "open", "fingerprint": fp}) return fmt.Sprintf( "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify \n", username, email, siteHost(cfg)), "", protocol.ExitOK default: return "", "registration is closed on this instance", protocol.ExitDenied } }