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