internal/control/register.go
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}