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