internal/control/register.go
210 lines · 8149 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}
35
36func siteHost(cfg config.Config) string {
37 h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://")
38 return strings.TrimSuffix(h, "/")
39}
40
41func sendVerification(cfg config.Config, st *store.Store, userID int64, address string) error {
42 code, hash, err := store.NewToken()
43 if err != nil {
44 return err
45 }
46 if err := st.CreateEmailToken(userID, address, hash, 24*time.Hour); err != nil {
47 return err
48 }
49 body := fmt.Sprintf(
50 "Someone (hopefully you) added this address to an account on %s.\n\n"+
51 "To verify it, run:\n\n ssh git@%s email verify %s\n\n"+
52 "The code expires in 24 hours. If this wasn't you, ignore this mail.\n",
53 siteHost(cfg), siteHost(cfg), code)
54 return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body)
55}
56
57const maxEmailAddsPerHour = 5
58
59func runEmailAdd(c *Ctx, args []string) int {
60 if len(args) != 1 || !strings.Contains(args[0], "@") {
61 return c.fail(protocol.ExitUsage, "usage: email add <address>")
62 }
63 if c.Cfg.Mail.SMTPHost == "" {
64 return c.fail(protocol.ExitFailure, "this instance has no SMTP configured; ask an admin to verify the address (gitbayd admin email verify)")
65 }
66 // An authenticated account is not a mail cannon: a handful of codes an
67 // hour is plenty for a person and nothing for a script (#136).
68 if n, err := c.Store.CountEmailTokensSince(c.User.ID, time.Now().Add(-time.Hour)); err != nil {
69 return c.fail(protocol.ExitFailure, "%v", err)
70 } else if n >= maxEmailAddsPerHour {
71 return c.fail(protocol.ExitDenied, "%d verification mails in the last hour; try again later", n)
72 }
73 if err := c.Store.AddEmail(c.User.ID, args[0], "", false); err != nil {
74 return c.fail(protocol.ExitFailure, "%v", err)
75 }
76 if err := sendVerification(c.Cfg, c.Store, c.User.ID, args[0]); err != nil {
77 return c.fail(protocol.ExitFailure, "sending verification mail: %v", err)
78 }
79 return c.emit(map[string]string{"address": args[0], "status": "verification_sent"}, func(w io.Writer) {
80 fmt.Fprintf(w, "verification code sent to %s\n", args[0])
81 })
82}
83
84func runEmailVerify(c *Ctx, args []string) int {
85 if len(args) != 1 {
86 return c.fail(protocol.ExitUsage, "usage: email verify <code>")
87 }
88 hash := store.HashToken(args[0])
89 address, err := c.Store.ConsumeEmailToken(c.User.ID, hash)
90 if err != nil {
91 if errors.Is(err, store.ErrNotFound) {
92 // A code is scoped to the account that asked for it. Running
93 // this with the wrong key authenticates as the wrong account
94 // and looks exactly like a bad code, which is misleading when
95 // the code is fine and the key is not.
96 if other, e := c.Store.EmailTokenBelongsToAnotherUser(c.User.ID, hash); e == nil && other {
97 return c.fail(protocol.ExitDenied,
98 "that code belongs to a different account; this key authenticated you as %s. "+
99 "Re-run with the key registered to the account being verified: "+
100 "ssh -i <that key> git@<host> email verify <code>",
101 c.User.Username)
102 }
103 return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used")
104 }
105 return c.fail(protocol.ExitFailure, "%v", err)
106 }
107 if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil {
108 return c.fail(protocol.ExitFailure, "%v", err)
109 }
110 if err := c.Store.ClearPending(c.User.ID); err != nil {
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) {
114 fmt.Fprintf(w, "%s verified; your account is active\n", address)
115 })
116}
117
118// RunRegister handles the one command an UNAUTHENTICATED key may run. It is
119// dispatched outside the normal registry: the caller has already checked
120// that registration is enabled and that argv[0] == "register".
121func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string,
122 stdout, stderr io.Writer) int {
123 var username, email, invite string
124 args := argv[1:]
125 for i := 0; i < len(args); i++ {
126 switch args[i] {
127 case "--username", "--email", "--invite":
128 if i+1 >= len(args) {
129 fmt.Fprintf(stderr, "%s requires a value\n", args[i])
130 return protocol.ExitUsage
131 }
132 switch args[i] {
133 case "--username":
134 username = args[i+1]
135 case "--email":
136 email = args[i+1]
137 case "--invite":
138 invite = args[i+1]
139 }
140 i++
141 default:
142 fmt.Fprintf(stderr, "unexpected argument %q\n", args[i])
143 return protocol.ExitUsage
144 }
145 }
146 fail := func(code int, format string, a ...any) int {
147 fmt.Fprintf(stderr, format+"\n", a...)
148 return code
149 }
150 if username == "" {
151 return fail(protocol.ExitUsage, "usage: register --username <name> --email <address> | register --username <name> --invite <code>")
152 }
153 if err := policy.ValidateOwnerName(username); err != nil {
154 return fail(protocol.ExitUsage, "%v", err)
155 }
156
157 msg, errMsg, code := RegisterAccount(cfg, st, pub, username, email, invite)
158 if code != protocol.ExitOK {
159 return fail(code, "%s", errMsg)
160 }
161 fmt.Fprint(stdout, msg)
162 return protocol.ExitOK
163}
164
165// RegisterAccount creates an account for pub under the instance's
166// registration mode. On success it returns the human message and ExitOK;
167// otherwise an error message and the classifying exit code. Shared by the
168// SSH register command and the web signup form.
169func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, username, email, invite string) (string, string, int) {
170 if err := policy.ValidateOwnerName(username); err != nil {
171 return "", err.Error(), protocol.ExitUsage
172 }
173 fp := ssh.FingerprintSHA256(pub)
174 switch cfg.Registration.Mode {
175 case "invite":
176 if invite == "" {
177 return "", "this instance is invite-only: an invite code is required", protocol.ExitDenied
178 }
179 // One transaction: a failure at any step leaves the invite
180 // redeemable and no partial account behind.
181 _, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal())
182 if err != nil {
183 if errors.Is(err, store.ErrNotFound) {
184 return "", "that invite is invalid or already used", protocol.ExitDenied
185 }
186 return "", err.Error(), protocol.ExitUsage
187 }
188 st.Audit(0, "auth.registered", map[string]any{"user": username, "mode": "invite", "fingerprint": fp})
189 return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK
190
191 case "open":
192 if email == "" || !strings.Contains(email, "@") {
193 return "", "a valid email address is required", protocol.ExitUsage
194 }
195 uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal())
196 if err != nil {
197 return "", err.Error(), protocol.ExitUsage
198 }
199 if err := sendVerification(cfg, st, uid, email); err != nil {
200 return "", "sending verification mail: " + err.Error(), protocol.ExitFailure
201 }
202 st.Audit(uid, "auth.registered", map[string]any{"user": username, "mode": "open", "fingerprint": fp})
203 return fmt.Sprintf(
204 "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify <code>\n",
205 username, email, siteHost(cfg)), "", protocol.ExitOK
206
207 default:
208 return "", "registration is closed on this instance", protocol.ExitDenied
209 }
210}