internal/control/register.go
172 lines · 6099 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 Run: func(c *Ctx, args []string) int {
23 return c.fail(protocol.ExitUsage,
24 "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 ...",
25 c.User.Username)
26 }})
27 register(Command{Path: []string{"email", "add"},
28 Summary: "add an address and mail a verification code: email add <address>", Run: runEmailAdd})
29 register(Command{Path: []string{"email", "verify"},
30 Summary: "confirm a verification code: email verify <code>", Run: runEmailVerify})
31}
32
33func siteHost(cfg config.Config) string {
34 h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://")
35 return strings.TrimSuffix(h, "/")
36}
37
38func sendVerification(cfg config.Config, st *store.Store, userID int64, address string) error {
39 code, hash, err := store.NewToken()
40 if err != nil {
41 return err
42 }
43 if err := st.CreateEmailToken(userID, address, hash, 24*time.Hour); err != nil {
44 return err
45 }
46 body := fmt.Sprintf(
47 "Someone (hopefully you) added this address to an account on %s.\n\n"+
48 "To verify it, run:\n\n ssh git@%s email verify %s\n\n"+
49 "The code expires in 24 hours. If this wasn't you, ignore this mail.\n",
50 siteHost(cfg), siteHost(cfg), code)
51 return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body)
52}
53
54func runEmailAdd(c *Ctx, args []string) int {
55 if len(args) != 1 || !strings.Contains(args[0], "@") {
56 return c.fail(protocol.ExitUsage, "usage: email add <address>")
57 }
58 if c.Cfg.Mail.SMTPHost == "" {
59 return c.fail(protocol.ExitFailure, "this instance has no SMTP configured; ask an admin to verify the address (gitbayd admin email verify)")
60 }
61 if err := c.Store.AddEmail(c.User.ID, args[0], "", false); err != nil {
62 return c.fail(protocol.ExitFailure, "%v", err)
63 }
64 if err := sendVerification(c.Cfg, c.Store, c.User.ID, args[0]); err != nil {
65 return c.fail(protocol.ExitFailure, "sending verification mail: %v", err)
66 }
67 return c.emit(map[string]string{"address": args[0], "status": "verification_sent"}, func(w io.Writer) {
68 fmt.Fprintf(w, "verification code sent to %s\n", args[0])
69 })
70}
71
72func runEmailVerify(c *Ctx, args []string) int {
73 if len(args) != 1 {
74 return c.fail(protocol.ExitUsage, "usage: email verify <code>")
75 }
76 address, err := c.Store.ConsumeEmailToken(c.User.ID, store.HashToken(args[0]))
77 if err != nil {
78 if errors.Is(err, store.ErrNotFound) {
79 return c.fail(protocol.ExitUsage, "that code is invalid, expired, or already used")
80 }
81 return c.fail(protocol.ExitFailure, "%v", err)
82 }
83 if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil {
84 return c.fail(protocol.ExitFailure, "%v", err)
85 }
86 if err := c.Store.ClearPending(c.User.ID); err != nil {
87 return c.fail(protocol.ExitFailure, "%v", err)
88 }
89 return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) {
90 fmt.Fprintf(w, "%s verified; your account is active\n", address)
91 })
92}
93
94// RunRegister handles the one command an UNAUTHENTICATED key may run. It is
95// dispatched outside the normal registry: the caller has already checked
96// that registration is enabled and that argv[0] == "register".
97func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []string,
98 stdout, stderr io.Writer) int {
99 var username, email, invite string
100 args := argv[1:]
101 for i := 0; i < len(args); i++ {
102 switch args[i] {
103 case "--username", "--email", "--invite":
104 if i+1 >= len(args) {
105 fmt.Fprintf(stderr, "%s requires a value\n", args[i])
106 return protocol.ExitUsage
107 }
108 switch args[i] {
109 case "--username":
110 username = args[i+1]
111 case "--email":
112 email = args[i+1]
113 case "--invite":
114 invite = args[i+1]
115 }
116 i++
117 default:
118 fmt.Fprintf(stderr, "unexpected argument %q\n", args[i])
119 return protocol.ExitUsage
120 }
121 }
122 fail := func(code int, format string, a ...any) int {
123 fmt.Fprintf(stderr, format+"\n", a...)
124 return code
125 }
126 if username == "" {
127 return fail(protocol.ExitUsage, "usage: register --username <name> --email <address> | register --username <name> --invite <code>")
128 }
129 if err := policy.ValidateOwnerName(username); err != nil {
130 return fail(protocol.ExitUsage, "%v", err)
131 }
132
133 fp := ssh.FingerprintSHA256(pub)
134 switch cfg.Registration.Mode {
135 case "invite":
136 if invite == "" {
137 return fail(protocol.ExitDenied, "this instance is invite-only: register --username <name> --invite <code>")
138 }
139 // One transaction: a failure at any step leaves the invite
140 // redeemable and no partial account behind.
141 _, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal())
142 if err != nil {
143 if errors.Is(err, store.ErrNotFound) {
144 return fail(protocol.ExitDenied, "that invite is invalid or already used")
145 }
146 return fail(protocol.ExitUsage, "%v", err)
147 }
148 fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username)
149 return protocol.ExitOK
150
151 case "open":
152 if email == "" || !strings.Contains(email, "@") {
153 return fail(protocol.ExitUsage, "usage: register --username <name> --email <address>")
154 }
155 uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal())
156 if err != nil {
157 return fail(protocol.ExitUsage, "%v", err)
158 }
159 if err := sendVerification(cfg, st, uid, email); err != nil {
160 return fail(protocol.ExitFailure, "sending verification mail: %v", err)
161 }
162 fmt.Fprintf(stdout,
163 "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify <code>\n",
164 username, email, siteHost(cfg))
165 return protocol.ExitOK
166
167 default:
168 return fail(protocol.ExitDenied, "registration is closed on this instance")
169 }
170}
171
172