internal/control/register.go

v1.35.0
gitbay/internal/control/register.go history · blame · raw

303 lines · 11782 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	register(Command{Path: []string{"email", "list"},
 35		Summary:  "list the addresses on your account",
 36		Usage:    "email list",
 37		ReadOnly: true, Run: runEmailList})
 38	register(Command{Path: []string{"email", "remove"},
 39		Summary: "remove an address; not the primary, nor the last verified one",
 40		Usage:   "email remove <address>", Run: runEmailRemove})
 41	register(Command{Path: []string{"email", "primary"},
 42		Summary: "make a verified address the primary",
 43		Usage:   "email primary <address>", Run: runEmailPrimary})
 44}
 45
 46func runEmailList(c *Ctx, args []string) int {
 47	if len(args) != 0 {
 48		return c.usage()
 49	}
 50	emails, err := c.Store.ListEmails(c.User.ID)
 51	if err != nil {
 52		return c.fail(protocol.ExitFailure, "listing addresses: %v", err)
 53	}
 54	type out struct {
 55		Address    string `json:"address"`
 56		Verified   bool   `json:"verified"`
 57		VerifiedBy string `json:"verified_by,omitempty"`
 58		Primary    bool   `json:"primary"`
 59	}
 60	ds := make([]out, 0, len(emails))
 61	for _, e := range emails {
 62		ds = append(ds, out{e.Address, e.Verified, e.VerifiedBy, e.Primary})
 63	}
 64	return c.emit(ds, func(w io.Writer) {
 65		for _, d := range ds {
 66			state := "unverified"
 67			if d.Verified {
 68				state = "verified"
 69			}
 70			if d.Primary {
 71				state += "\tprimary"
 72			}
 73			fmt.Fprintf(w, "%s\t%s\n", d.Address, state)
 74		}
 75	})
 76}
 77
 78// emailErr maps the store's refusals onto exit codes: a missing address is
 79// not found, a rule is denied, anything else is a failure.
 80func emailErr(c *Ctx, verb string, err error) int {
 81	switch {
 82	case errors.Is(err, store.ErrNotFound):
 83		return c.fail(protocol.ExitNotFound, "no such address on your account")
 84	case errors.Is(err, store.ErrPrimaryEmail), errors.Is(err, store.ErrLastVerifiedEmail), errors.Is(err, store.ErrUnverifiedEmail):
 85		return c.fail(protocol.ExitDenied, "%v", err)
 86	}
 87	return c.fail(protocol.ExitFailure, "%s: %v", verb, err)
 88}
 89
 90func runEmailRemove(c *Ctx, args []string) int {
 91	if len(args) != 1 {
 92		return c.usage()
 93	}
 94	if err := c.Store.RemoveEmail(c.User.ID, args[0]); err != nil {
 95		return emailErr(c, "removing address", err)
 96	}
 97	return c.emit(map[string]string{"address": args[0], "status": "removed"}, func(w io.Writer) {
 98		fmt.Fprintf(w, "%s removed\n", args[0])
 99	})
100}
101
102func runEmailPrimary(c *Ctx, args []string) int {
103	if len(args) != 1 {
104		return c.usage()
105	}
106	if err := c.Store.SetPrimaryEmail(c.User.ID, args[0]); err != nil {
107		return emailErr(c, "setting primary", err)
108	}
109	return c.emit(map[string]string{"address": args[0], "status": "primary"}, func(w io.Writer) {
110		fmt.Fprintf(w, "%s is now the primary address\n", args[0])
111	})
112}
113
114func siteHost(cfg config.Config) string {
115	h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://")
116	return strings.TrimSuffix(h, "/")
117}
118
119func sendVerification(cfg config.Config, st *store.Store, userID int64, address string) error {
120	code, hash, err := store.NewToken()
121	if err != nil {
122		return err
123	}
124	if err := st.CreateEmailToken(userID, address, hash, 24*time.Hour); err != nil {
125		return err
126	}
127	body := fmt.Sprintf(
128		"Someone (hopefully you) added this address to an account on %s.\n\n"+
129			"To verify it, run:\n\n    ssh git@%s email verify %s\n\n"+
130			"Or sign in at https://%s/login with this address and paste the code under Settings.\n\n"+
131			"The code expires in 24 hours. If this wasn't you, ignore this mail.\n",
132		siteHost(cfg), siteHost(cfg), code, siteHost(cfg))
133	return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body)
134}
135
136// notifyAdminsOfSignup tells the instance's admins that an account just
137// became active, when registration.notify_admin is on. It is queued like
138// any other notice, so a dead SMTP host shows up in the admin page's
139// Mail table rather than failing the registration that caused it: the
140// person signing up is not responsible for the operator's mail (#234).
141func notifyAdminsOfSignup(cfg config.Config, st *store.Store, username, mode string) {
142	if !cfg.Registration.NotifyAdmin {
143		return
144	}
145	addrs, err := st.AdminMailAddresses()
146	if err != nil || len(addrs) == 0 {
147		return
148	}
149	host := siteHost(cfg)
150	subject := fmt.Sprintf("new account on %s: %s", host, username)
151	body := fmt.Sprintf("%s registered on %s and the account is active (%s registration).\n\n"+
152		"    https://%s/%s\n\nAccounts: ssh git@%s admin user list\n",
153		username, host, mode, host, username, host)
154	for _, a := range addrs {
155		st.EnqueueMail(a, subject, body)
156	}
157}
158
159const maxEmailAddsPerHour = 5
160
161func runEmailAdd(c *Ctx, args []string) int {
162	if len(args) != 1 || !strings.Contains(args[0], "@") {
163		return c.usage()
164	}
165	if c.Cfg.Mail.SMTPHost == "" {
166		return c.fail(protocol.ExitFailure, "this instance has no SMTP configured; ask an admin to verify the address (gitbayd admin email verify)")
167	}
168	// An authenticated account is not a mail cannon: a handful of codes an
169	// hour is plenty for a person and nothing for a script (#136).
170	if n, err := c.Store.CountEmailTokensSince(c.User.ID, time.Now().Add(-time.Hour)); err != nil {
171		return c.fail(protocol.ExitFailure, "%v", err)
172	} else if n >= maxEmailAddsPerHour {
173		return c.fail(protocol.ExitDenied, "%d verification mails in the last hour; try again later", n)
174	}
175	if err := c.Store.AddEmail(c.User.ID, args[0], "", false); err != nil {
176		return c.fail(protocol.ExitFailure, "%v", err)
177	}
178	if err := sendVerification(c.Cfg, c.Store, c.User.ID, args[0]); err != nil {
179		return c.fail(protocol.ExitFailure, "sending verification mail: %v", err)
180	}
181	return c.emit(map[string]string{"address": args[0], "status": "verification_sent"}, func(w io.Writer) {
182		fmt.Fprintf(w, "verification code sent to %s\n", args[0])
183	})
184}
185
186func runEmailVerify(c *Ctx, args []string) int {
187	if len(args) != 1 {
188		return c.usage()
189	}
190	hash := store.HashToken(args[0])
191	address, err := c.Store.ConsumeEmailToken(c.User.ID, hash)
192	if err != nil {
193		if errors.Is(err, store.ErrNotFound) {
194			// A code is scoped to the account that asked for it. Running
195			// this with the wrong key authenticates as the wrong account
196			// and looks exactly like a bad code, which is misleading when
197			// the code is fine and the key is not.
198			if other, e := c.Store.EmailTokenBelongsToAnotherUser(c.User.ID, hash); e == nil && other {
199				return c.fail(protocol.ExitDenied,
200					"that code belongs to a different account; this key authenticated you as %s. "+
201						"Re-run with the key registered to the account being verified: "+
202						"ssh -i <that key> git@<host> email verify <code>",
203					c.User.Username)
204			}
205			return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used")
206		}
207		return c.fail(protocol.ExitFailure, "%v", err)
208	}
209	if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil {
210		return c.fail(protocol.ExitFailure, "%v", err)
211	}
212	wasPending := c.User.Pending
213	if err := c.Store.ClearPending(c.User.ID); err != nil {
214		return c.fail(protocol.ExitFailure, "%v", err)
215	}
216	// The open-mode account becomes real here, not when the form was
217	// posted, so this is where the admins hear about it.
218	if wasPending {
219		notifyAdminsOfSignup(c.Cfg, c.Store, c.User.Username, "open")
220	}
221	return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) {
222		fmt.Fprintf(w, "%s verified; your account is active\n", address)
223	})
224}
225
226// RunRegister handles the one command an UNAUTHENTICATED key may run. It is
227// dispatched outside the normal registry: the caller has already checked
228// that registration is enabled and that argv[0] == "register".
229func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string,
230	stdout, stderr io.Writer) int {
231	f, err := parseFlags(argv[1:], flagSpec{Values: []string{"--username", "--email", "--invite"}, MaxPos: 0,
232		Usage: "register --username <n> --email <a> | --invite <code>"})
233	if err != nil {
234		fmt.Fprintln(stderr, err)
235		return protocol.ExitUsage
236	}
237	username, email, invite := f.Value("--username"), f.Value("--email"), f.Value("--invite")
238	fail := func(code int, format string, a ...any) int {
239		fmt.Fprintf(stderr, format+"\n", a...)
240		return code
241	}
242	if username == "" {
243		return fail(protocol.ExitUsage, "usage: register --username <name> --email <address> | register --username <name> --invite <code>")
244	}
245	if err := policy.ValidateOwnerName(username); err != nil {
246		return fail(protocol.ExitUsage, "%v", err)
247	}
248
249	msg, errMsg, code := RegisterAccount(cfg, st, pub, username, email, invite)
250	if code != protocol.ExitOK {
251		return fail(code, "%s", errMsg)
252	}
253	fmt.Fprint(stdout, msg)
254	return protocol.ExitOK
255}
256
257// RegisterAccount creates an account for pub under the instance's
258// registration mode. On success it returns the human message and ExitOK;
259// otherwise an error message and the classifying exit code. Shared by the
260// SSH register command and the web signup form.
261func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, username, email, invite string) (string, string, int) {
262	if err := policy.ValidateOwnerName(username); err != nil {
263		return "", err.Error(), protocol.ExitUsage
264	}
265	fp := ssh.FingerprintSHA256(pub)
266	switch cfg.Registration.Mode {
267	case "invite":
268		if invite == "" {
269			return "", "this instance is invite-only: an invite code is required", protocol.ExitDenied
270		}
271		// One transaction: a failure at any step leaves the invite
272		// redeemable and no partial account behind.
273		_, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal())
274		if err != nil {
275			if errors.Is(err, store.ErrNotFound) {
276				return "", "that invite is invalid or already used", protocol.ExitDenied
277			}
278			return "", err.Error(), protocol.ExitUsage
279		}
280		st.Audit(0, "auth.registered", map[string]any{"user": username, "mode": "invite", "fingerprint": fp})
281		notifyAdminsOfSignup(cfg, st, username, "invite")
282		return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK
283
284	case "open":
285		if email == "" || !strings.Contains(email, "@") {
286			return "", "a valid email address is required", protocol.ExitUsage
287		}
288		uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal())
289		if err != nil {
290			return "", err.Error(), protocol.ExitUsage
291		}
292		if err := sendVerification(cfg, st, uid, email); err != nil {
293			return "", "sending verification mail: " + err.Error(), protocol.ExitFailure
294		}
295		st.Audit(uid, "auth.registered", map[string]any{"user": username, "mode": "open", "fingerprint": fp})
296		return fmt.Sprintf(
297			"account %s created. A verification code was sent to %s.\nActivate with:\n\n    ssh git@%s email verify <code>\n",
298			username, email, siteHost(cfg)), "", protocol.ExitOK
299
300	default:
301		return "", "registration is closed on this instance", protocol.ExitDenied
302	}
303}