internal/control/register.go

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

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