internal/control/register.go

70dc0648f931f6f7112c6b71b0be485eeae4077f
gitbay/internal/control/register.go history · blame · raw

194 lines · 7951 bytes

  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{"register"},
 21		Summary: "create an account (only meaningful for unregistered keys)",
 22		Usage:   "register --username <name> [--email <address> | --invite <code>]",
 23		Run: func(c *Ctx, args []string) int {
 24			return c.fail(protocol.ExitUsage,
 25				"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 <newkey> git@<host> register ...",
 26				c.User.Username)
 27		}})
 28	register(Command{Path: []string{"email", "add"},
 29		Summary: "add an address and mail a verification code",
 30		Usage:   "email add <address>", Run: runEmailAdd})
 31	register(Command{Path: []string{"email", "verify"},
 32		Summary: "confirm a verification code",
 33		Usage:   "email verify <code>", Run: runEmailVerify})
 34}
 35
 36func siteHost(cfg config.Config) string {
 37	h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://")
 38	return strings.TrimSuffix(h, "/")
 39}
 40
 41func sendVerification(cfg config.Config, st *store.Store, userID int64, address string) error {
 42	code, hash, err := store.NewToken()
 43	if err != nil {
 44		return err
 45	}
 46	if err := st.CreateEmailToken(userID, address, hash, 24*time.Hour); err != nil {
 47		return err
 48	}
 49	body := fmt.Sprintf(
 50		"Someone (hopefully you) added this address to an account on %s.\n\n"+
 51			"To verify it, run:\n\n    ssh git@%s email verify %s\n\n"+
 52			"The code expires in 24 hours. If this wasn't you, ignore this mail.\n",
 53		siteHost(cfg), siteHost(cfg), code)
 54	return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body)
 55}
 56
 57const maxEmailAddsPerHour = 5
 58
 59func runEmailAdd(c *Ctx, args []string) int {
 60	if len(args) != 1 || !strings.Contains(args[0], "@") {
 61		return c.fail(protocol.ExitUsage, "usage: email add <address>")
 62	}
 63	if c.Cfg.Mail.SMTPHost == "" {
 64		return c.fail(protocol.ExitFailure, "this instance has no SMTP configured; ask an admin to verify the address (gitbayd admin email verify)")
 65	}
 66	// An authenticated account is not a mail cannon: a handful of codes an
 67	// hour is plenty for a person and nothing for a script (#136).
 68	if n, err := c.Store.CountEmailTokensSince(c.User.ID, time.Now().Add(-time.Hour)); err != nil {
 69		return c.fail(protocol.ExitFailure, "%v", err)
 70	} else if n >= maxEmailAddsPerHour {
 71		return c.fail(protocol.ExitDenied, "%d verification mails in the last hour; try again later", n)
 72	}
 73	if err := c.Store.AddEmail(c.User.ID, args[0], "", false); err != nil {
 74		return c.fail(protocol.ExitFailure, "%v", err)
 75	}
 76	if err := sendVerification(c.Cfg, c.Store, c.User.ID, args[0]); err != nil {
 77		return c.fail(protocol.ExitFailure, "sending verification mail: %v", err)
 78	}
 79	return c.emit(map[string]string{"address": args[0], "status": "verification_sent"}, func(w io.Writer) {
 80		fmt.Fprintf(w, "verification code sent to %s\n", args[0])
 81	})
 82}
 83
 84func runEmailVerify(c *Ctx, args []string) int {
 85	if len(args) != 1 {
 86		return c.fail(protocol.ExitUsage, "usage: email verify <code>")
 87	}
 88	hash := store.HashToken(args[0])
 89	address, err := c.Store.ConsumeEmailToken(c.User.ID, hash)
 90	if err != nil {
 91		if errors.Is(err, store.ErrNotFound) {
 92			// A code is scoped to the account that asked for it. Running
 93			// this with the wrong key authenticates as the wrong account
 94			// and looks exactly like a bad code, which is misleading when
 95			// the code is fine and the key is not.
 96			if other, e := c.Store.EmailTokenBelongsToAnotherUser(c.User.ID, hash); e == nil && other {
 97				return c.fail(protocol.ExitDenied,
 98					"that code belongs to a different account; this key authenticated you as %s. "+
 99						"Re-run with the key registered to the account being verified: "+
100						"ssh -i <that key> git@<host> email verify <code>",
101					c.User.Username)
102			}
103			return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used")
104		}
105		return c.fail(protocol.ExitFailure, "%v", err)
106	}
107	if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil {
108		return c.fail(protocol.ExitFailure, "%v", err)
109	}
110	if err := c.Store.ClearPending(c.User.ID); err != nil {
111		return c.fail(protocol.ExitFailure, "%v", err)
112	}
113	return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) {
114		fmt.Fprintf(w, "%s verified; your account is active\n", address)
115	})
116}
117
118// RunRegister handles the one command an UNAUTHENTICATED key may run. It is
119// dispatched outside the normal registry: the caller has already checked
120// that registration is enabled and that argv[0] == "register".
121func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string,
122	stdout, stderr io.Writer) int {
123	f, err := parseFlags(argv[1:], flagSpec{Values: []string{"--username", "--email", "--invite"}, MaxPos: 0,
124		Usage: "register --username <n> --email <a> | --invite <code>"})
125	if err != nil {
126		fmt.Fprintln(stderr, err)
127		return protocol.ExitUsage
128	}
129	username, email, invite := f.Value("--username"), f.Value("--email"), f.Value("--invite")
130	fail := func(code int, format string, a ...any) int {
131		fmt.Fprintf(stderr, format+"\n", a...)
132		return code
133	}
134	if username == "" {
135		return fail(protocol.ExitUsage, "usage: register --username <name> --email <address> | register --username <name> --invite <code>")
136	}
137	if err := policy.ValidateOwnerName(username); err != nil {
138		return fail(protocol.ExitUsage, "%v", err)
139	}
140
141	msg, errMsg, code := RegisterAccount(cfg, st, pub, username, email, invite)
142	if code != protocol.ExitOK {
143		return fail(code, "%s", errMsg)
144	}
145	fmt.Fprint(stdout, msg)
146	return protocol.ExitOK
147}
148
149// RegisterAccount creates an account for pub under the instance's
150// registration mode. On success it returns the human message and ExitOK;
151// otherwise an error message and the classifying exit code. Shared by the
152// SSH register command and the web signup form.
153func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, username, email, invite string) (string, string, int) {
154	if err := policy.ValidateOwnerName(username); err != nil {
155		return "", err.Error(), protocol.ExitUsage
156	}
157	fp := ssh.FingerprintSHA256(pub)
158	switch cfg.Registration.Mode {
159	case "invite":
160		if invite == "" {
161			return "", "this instance is invite-only: an invite code is required", protocol.ExitDenied
162		}
163		// One transaction: a failure at any step leaves the invite
164		// redeemable and no partial account behind.
165		_, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal())
166		if err != nil {
167			if errors.Is(err, store.ErrNotFound) {
168				return "", "that invite is invalid or already used", protocol.ExitDenied
169			}
170			return "", err.Error(), protocol.ExitUsage
171		}
172		st.Audit(0, "auth.registered", map[string]any{"user": username, "mode": "invite", "fingerprint": fp})
173		return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK
174
175	case "open":
176		if email == "" || !strings.Contains(email, "@") {
177			return "", "a valid email address is required", protocol.ExitUsage
178		}
179		uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal())
180		if err != nil {
181			return "", err.Error(), protocol.ExitUsage
182		}
183		if err := sendVerification(cfg, st, uid, email); err != nil {
184			return "", "sending verification mail: " + err.Error(), protocol.ExitFailure
185		}
186		st.Audit(uid, "auth.registered", map[string]any{"user": username, "mode": "open", "fingerprint": fp})
187		return fmt.Sprintf(
188			"account %s created. A verification code was sent to %s.\nActivate with:\n\n    ssh git@%s email verify <code>\n",
189			username, email, siteHost(cfg)), "", protocol.ExitOK
190
191	default:
192		return "", "registration is closed on this instance", protocol.ExitDenied
193	}
194}