internal/control/register.go

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

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