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