internal/control/register.go

9df917e73a67d15adecc3f45976690f6fcd4e47a
gitbay/internal/control/register.go history · blame · raw

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