Commit dce6f1b389
Verified · cmc
Layout: unified · split
cmd/gitbay/main.go +6
| @@ -32,6 +32,8 @@ func main() { | |||
| 32 | webCmd(), | 32 | webCmd(), |
| 33 | remoteCmd(), | 33 | remoteCmd(), |
| 34 | initCmd(), | 34 | initCmd(), |
| 35 | pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", | ||
| 36 | passOpts{server: []string{"register"}}), | ||
| 35 | manCmd(root), | 37 | manCmd(root), |
| 36 | ) | 38 | ) |
| 37 | 39 | ||
| @@ -176,6 +178,10 @@ func authCmd() *cobra.Command { | |||
| 176 | keysAdd, | 178 | keysAdd, |
| 177 | pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}), | 179 | pass("remove", "remove an SSH key by fingerprint", passOpts{server: []string{"keys", "remove"}}), |
| 178 | ), | 180 | ), |
| 181 | group("email", "manage email addresses", | ||
| 182 | pass("add", "add an address and get a verification code by mail", passOpts{server: []string{"email", "add"}}), | ||
| 183 | pass("verify", "confirm a verification code", passOpts{server: []string{"email", "verify"}}), | ||
| 184 | ), | ||
| 179 | group("pgp", "manage OpenPGP keys", | 185 | group("pgp", "manage OpenPGP keys", |
| 180 | pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}), | 186 | pass("list", "list registered PGP keys", passOpts{server: []string{"pgp", "list"}}), |
| 181 | pgpAdd, | 187 | pgpAdd, |
cmd/gitbayd/main.go +49 −1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | "net" | 8 | "net" |
| 9 | "net/http" | 9 | "net/http" |
| 10 | "os" | 10 | "os" |
| 11 | "strings" | ||
| 11 | "path/filepath" | 12 | "path/filepath" |
| 12 | "strconv" | 13 | "strconv" |
| 13 | 14 | ||
| @@ -16,6 +17,7 @@ import ( | |||
| 16 | 17 | ||
| 17 | "gitbay.org/gitbay/internal/config" | 18 | "gitbay.org/gitbay/internal/config" |
| 18 | "gitbay.org/gitbay/internal/control" | 19 | "gitbay.org/gitbay/internal/control" |
| 20 | "gitbay.org/gitbay/internal/mail" | ||
| 19 | "gitbay.org/gitbay/internal/gitd" | 21 | "gitbay.org/gitbay/internal/gitd" |
| 20 | "gitbay.org/gitbay/internal/hookd" | 22 | "gitbay.org/gitbay/internal/hookd" |
| 21 | "gitbay.org/gitbay/internal/httpd" | 23 | "gitbay.org/gitbay/internal/httpd" |
| @@ -214,7 +216,7 @@ func adminCmd() *cobra.Command { | |||
| 214 | admin.AddCommand( | 216 | admin.AddCommand( |
| 215 | userCmd, | 217 | userCmd, |
| 216 | emailCmd, | 218 | emailCmd, |
| 217 | notImplemented("invite", "issue registration invites"), | 219 | adminInviteCmd(), |
| 218 | backupCmd(), | 220 | backupCmd(), |
| 219 | notImplemented("gc", "run git gc across repositories"), | 221 | notImplemented("gc", "run git gc across repositories"), |
| 220 | notImplemented("stats", "instance statistics"), | 222 | notImplemented("stats", "instance statistics"), |
| @@ -222,6 +224,52 @@ func adminCmd() *cobra.Command { | |||
| 222 | return admin | 224 | return admin |
| 223 | } | 225 | } |
| 224 | 226 | ||
| 227 | func adminInviteCmd() *cobra.Command { | ||
| 228 | var email string | ||
| 229 | cmd := &cobra.Command{ | ||
| 230 | Use: "invite", | ||
| 231 | Short: "issue a registration invite and email its code", | ||
| 232 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 233 | if email == "" { | ||
| 234 | return fmt.Errorf("--email is required") | ||
| 235 | } | ||
| 236 | cfg, err := config.Load(configPath) | ||
| 237 | if err != nil { | ||
| 238 | return err | ||
| 239 | } | ||
| 240 | st, err := openStore(cfg) | ||
| 241 | if err != nil { | ||
| 242 | return err | ||
| 243 | } | ||
| 244 | defer st.Close() | ||
| 245 | |||
| 246 | code, hash, err := store.NewToken() | ||
| 247 | if err != nil { | ||
| 248 | return err | ||
| 249 | } | ||
| 250 | if err := st.CreateInvite(hash, email); err != nil { | ||
| 251 | return err | ||
| 252 | } | ||
| 253 | host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://"), "/") | ||
| 254 | body := fmt.Sprintf( | ||
| 255 | "You have been invited to %s.\n\nCreate your account by running (with the SSH key you want to use):\n\n"+ | ||
| 256 | " ssh git@%s register --username <name> --invite %s\n\n"+ | ||
| 257 | "The invite is single-use and tied to this address.\n", host, host, code) | ||
| 258 | if cfg.Mail.SMTPHost != "" { | ||
| 259 | if err := mail.Send(cfg, email, "your invite to "+host, body); err != nil { | ||
| 260 | return fmt.Errorf("invite stored but mail failed: %w (code: %s)", err, code) | ||
| 261 | } | ||
| 262 | fmt.Printf("invite emailed to %s\n", email) | ||
| 263 | } else { | ||
| 264 | fmt.Printf("invite for %s (no SMTP configured; deliver it yourself):\n%s\n", email, code) | ||
| 265 | } | ||
| 266 | return nil | ||
| 267 | }, | ||
| 268 | } | ||
| 269 | cmd.Flags().StringVar(&email, "email", "", "address to invite (the account's verified email)") | ||
| 270 | return cmd | ||
| 271 | } | ||
| 272 | |||
| 225 | func adminUserCreateCmd() *cobra.Command { | 273 | func adminUserCreateCmd() *cobra.Command { |
| 226 | var keyPath, email string | 274 | var keyPath, email string |
| 227 | var verified, isAdmin bool | 275 | var verified, isAdmin bool |
e2e/registration_test.go added +221
| @@ -0,0 +1,221 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "fmt" | ||
| 6 | "net" | ||
| 7 | "regexp" | ||
| 8 | "strings" | ||
| 9 | "sync" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // fakeSMTP is a minimal SMTP server capturing delivered messages. | ||
| 15 | type fakeSMTP struct { | ||
| 16 | addr string | ||
| 17 | mu sync.Mutex | ||
| 18 | mail []string // raw DATA payloads | ||
| 19 | } | ||
| 20 | |||
| 21 | func startFakeSMTP(t *testing.T) *fakeSMTP { | ||
| 22 | t.Helper() | ||
| 23 | ln, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 24 | if err != nil { | ||
| 25 | t.Fatal(err) | ||
| 26 | } | ||
| 27 | t.Cleanup(func() { ln.Close() }) | ||
| 28 | f := &fakeSMTP{addr: ln.Addr().String()} | ||
| 29 | go func() { | ||
| 30 | for { | ||
| 31 | conn, err := ln.Accept() | ||
| 32 | if err != nil { | ||
| 33 | return | ||
| 34 | } | ||
| 35 | go f.handle(conn) | ||
| 36 | } | ||
| 37 | }() | ||
| 38 | return f | ||
| 39 | } | ||
| 40 | |||
| 41 | func (f *fakeSMTP) handle(conn net.Conn) { | ||
| 42 | defer conn.Close() | ||
| 43 | r := bufio.NewReader(conn) | ||
| 44 | say := func(s string) { fmt.Fprintf(conn, "%s\r\n", s) } | ||
| 45 | say("220 fake ESMTP") | ||
| 46 | var data strings.Builder | ||
| 47 | inData := false | ||
| 48 | for { | ||
| 49 | line, err := r.ReadString('\n') | ||
| 50 | if err != nil { | ||
| 51 | return | ||
| 52 | } | ||
| 53 | line = strings.TrimRight(line, "\r\n") | ||
| 54 | if inData { | ||
| 55 | if line == "." { | ||
| 56 | f.mu.Lock() | ||
| 57 | f.mail = append(f.mail, data.String()) | ||
| 58 | f.mu.Unlock() | ||
| 59 | data.Reset() | ||
| 60 | inData = false | ||
| 61 | say("250 ok") | ||
| 62 | continue | ||
| 63 | } | ||
| 64 | data.WriteString(line + "\n") | ||
| 65 | continue | ||
| 66 | } | ||
| 67 | switch { | ||
| 68 | case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"): | ||
| 69 | fmt.Fprintf(conn, "250-fake\r\n250 SIZE 1000000\r\n") | ||
| 70 | case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"): | ||
| 71 | say("250 ok") | ||
| 72 | case line == "DATA": | ||
| 73 | inData = true | ||
| 74 | say("354 go") | ||
| 75 | case line == "QUIT": | ||
| 76 | say("221 bye") | ||
| 77 | return | ||
| 78 | default: | ||
| 79 | say("250 ok") | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | // waitMail returns the nth captured message. | ||
| 85 | func (f *fakeSMTP) waitMail(t *testing.T, n int) string { | ||
| 86 | t.Helper() | ||
| 87 | deadline := time.Now().Add(5 * time.Second) | ||
| 88 | for time.Now().Before(deadline) { | ||
| 89 | f.mu.Lock() | ||
| 90 | if len(f.mail) > n { | ||
| 91 | m := f.mail[n] | ||
| 92 | f.mu.Unlock() | ||
| 93 | return m | ||
| 94 | } | ||
| 95 | f.mu.Unlock() | ||
| 96 | time.Sleep(50 * time.Millisecond) | ||
| 97 | } | ||
| 98 | t.Fatalf("mail %d never arrived", n) | ||
| 99 | return "" | ||
| 100 | } | ||
| 101 | |||
| 102 | var codePat = regexp.MustCompile(`(?:verify|--invite) ([0-9a-f]{64})`) | ||
| 103 | |||
| 104 | func extractCode(t *testing.T, mail string) string { | ||
| 105 | t.Helper() | ||
| 106 | m := codePat.FindStringSubmatch(mail) | ||
| 107 | if m == nil { | ||
| 108 | t.Fatalf("no code in mail:\n%s", mail) | ||
| 109 | } | ||
| 110 | return m[1] | ||
| 111 | } | ||
| 112 | |||
| 113 | func TestOpenRegistration(t *testing.T) { | ||
| 114 | smtp := startFakeSMTP(t) | ||
| 115 | inst := startInstanceWith(t, fmt.Sprintf( | ||
| 116 | "[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr)) | ||
| 117 | |||
| 118 | // A stranger's key cannot run normal commands, and the denial explains | ||
| 119 | // how to register. | ||
| 120 | newKey := inst.newKey(t, "newcomer") | ||
| 121 | _, errOut, code := inst.ssh(t, newKey, "", "whoami") | ||
| 122 | if code != 4 || !strings.Contains(errOut, "register --username") { | ||
| 123 | t.Fatalf("stranger whoami: exit %d, %s", code, errOut) | ||
| 124 | } | ||
| 125 | |||
| 126 | // Register: account created pending, verification mail sent. | ||
| 127 | out, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "dana@example.test") | ||
| 128 | if code != 0 { | ||
| 129 | t.Fatalf("register: exit %d, %s", code, errOut) | ||
| 130 | } | ||
| 131 | if !strings.Contains(out, "verification code was sent") { | ||
| 132 | t.Fatalf("register output: %s", out) | ||
| 133 | } | ||
| 134 | msg := smtp.waitMail(t, 0) | ||
| 135 | if !strings.Contains(msg, "To: dana@example.test") || !strings.Contains(msg, "From: noreply@gitbay.test") { | ||
| 136 | t.Fatalf("mail headers:\n%s", msg) | ||
| 137 | } | ||
| 138 | |||
| 139 | // Pending: the key authenticates, whoami works, but everything else is | ||
| 140 | // gated — control commands and git alike. | ||
| 141 | if out, _, code = inst.ssh(t, newKey, "", "whoami"); code != 0 || strings.TrimSpace(out) != "dana" { | ||
| 142 | t.Fatalf("pending whoami: %d %q", code, out) | ||
| 143 | } | ||
| 144 | _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj") | ||
| 145 | if code != 4 || !strings.Contains(errOut, "not active yet") { | ||
| 146 | t.Fatalf("pending repo create: exit %d, %s", code, errOut) | ||
| 147 | } | ||
| 148 | cloneOut, cloneCode := gitRun(t, t.TempDir(), inst.gitEnv(newKey), "clone", inst.sshURL("dana/anything")) | ||
| 149 | if cloneCode == 0 || !strings.Contains(cloneOut, "not active yet") { | ||
| 150 | t.Fatalf("pending git: %d\n%s", cloneCode, cloneOut) | ||
| 151 | } | ||
| 152 | |||
| 153 | // A wrong code fails; the mailed code activates the account. | ||
| 154 | if _, _, code = inst.ssh(t, newKey, "", "email", "verify", strings.Repeat("0", 64)); code != 2 { | ||
| 155 | t.Fatalf("bad code: exit %d, want 2", code) | ||
| 156 | } | ||
| 157 | verifyCode := extractCode(t, msg) | ||
| 158 | out, errOut, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode) | ||
| 159 | if code != 0 || !strings.Contains(out, "account is active") { | ||
| 160 | t.Fatalf("verify: exit %d, %s%s", code, out, errOut) | ||
| 161 | } | ||
| 162 | // Single use. | ||
| 163 | if _, _, code = inst.ssh(t, newKey, "", "email", "verify", verifyCode); code != 2 { | ||
| 164 | t.Fatalf("code reuse: exit %d, want 2", code) | ||
| 165 | } | ||
| 166 | |||
| 167 | // Fully active: repo create works, and the verified email makes | ||
| 168 | // signature verification meaningful (verified_by = smtp). | ||
| 169 | if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "dana/proj"); code != 0 { | ||
| 170 | t.Fatalf("post-verify repo create: %s", errOut) | ||
| 171 | } | ||
| 172 | |||
| 173 | // Self-service email add on an existing account sends a second mail. | ||
| 174 | if _, errOut, code = inst.ssh(t, newKey, "", "email", "add", "dana2@example.test"); code != 0 { | ||
| 175 | t.Fatalf("email add: %s", errOut) | ||
| 176 | } | ||
| 177 | msg2 := smtp.waitMail(t, 1) | ||
| 178 | if !strings.Contains(msg2, "To: dana2@example.test") { | ||
| 179 | t.Fatalf("second mail:\n%s", msg2) | ||
| 180 | } | ||
| 181 | if _, _, code = inst.ssh(t, newKey, "", "email", "verify", extractCode(t, msg2)); code != 0 { | ||
| 182 | t.Fatal("second verify failed") | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | func TestInviteRegistration(t *testing.T) { | ||
| 187 | smtp := startFakeSMTP(t) | ||
| 188 | inst := startInstanceWith(t, fmt.Sprintf( | ||
| 189 | "[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr)) | ||
| 190 | |||
| 191 | // Registering without an invite is refused. | ||
| 192 | newKey := inst.newKey(t, "guest") | ||
| 193 | _, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "erin", "--email", "erin@example.test") | ||
| 194 | if code != 4 || !strings.Contains(errOut, "invite-only") { | ||
| 195 | t.Fatalf("uninvited register: exit %d, %s", code, errOut) | ||
| 196 | } | ||
| 197 | |||
| 198 | // Admin issues an invite; the code arrives by mail. | ||
| 199 | out := inst.admin(t, "admin", "invite", "--email", "erin@example.test") | ||
| 200 | if !strings.Contains(out, "invite emailed") { | ||
| 201 | t.Fatalf("invite output: %s", out) | ||
| 202 | } | ||
| 203 | inviteCode := extractCode(t, smtp.waitMail(t, 0)) | ||
| 204 | |||
| 205 | // Redeeming it creates an ACTIVE account: code possession proves the | ||
| 206 | // mailbox, so the email is verified (by smtp) and nothing is pending. | ||
| 207 | out, errOut, code = inst.ssh(t, newKey, "", "register", "--username", "erin", "--invite", inviteCode) | ||
| 208 | if code != 0 || !strings.Contains(out, "account is active") { | ||
| 209 | t.Fatalf("invite register: exit %d, %s%s", code, out, errOut) | ||
| 210 | } | ||
| 211 | if _, errOut, code = inst.ssh(t, newKey, "", "repo", "create", "erin/proj"); code != 0 { | ||
| 212 | t.Fatalf("invited user repo create: %s", errOut) | ||
| 213 | } | ||
| 214 | |||
| 215 | // Invites are single-use. | ||
| 216 | otherKey := inst.newKey(t, "other") | ||
| 217 | _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "fake", "--invite", inviteCode) | ||
| 218 | if code != 4 || !strings.Contains(errOut, "already used") { | ||
| 219 | t.Fatalf("invite reuse: exit %d, %s", code, errOut) | ||
| 220 | } | ||
| 221 | } | ||
internal/config/config.go +6 −1
| @@ -62,8 +62,10 @@ type Limits struct { | |||
| 62 | } | 62 | } |
| 63 | 63 | ||
| 64 | type Mail struct { | 64 | type Mail struct { |
| 65 | SMTPHost string `toml:"smtp_host"` | 65 | SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587) |
| 66 | From string `toml:"from"` | 66 | From string `toml:"from"` |
| 67 | SMTPUser string `toml:"smtp_user,omitempty"` | ||
| 68 | SMTPPass string `toml:"smtp_pass,omitempty"` | ||
| 67 | } | 69 | } |
| 68 | 70 | ||
| 69 | // Default returns the configuration used when a key is absent from the file. | 71 | // Default returns the configuration used when a key is absent from the file. |
| @@ -139,6 +141,9 @@ func (c Config) Validate() error { | |||
| 139 | } | 141 | } |
| 140 | 142 | ||
| 141 | // Contradictions. | 143 | // Contradictions. |
| 144 | if c.Mail.SMTPHost != "" && c.Mail.From == "" { | ||
| 145 | errs = append(errs, errors.New("[mail] from is required when smtp_host is set")) | ||
| 146 | } | ||
| 142 | if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" { | 147 | if c.Registration.Mode != "closed" && c.Mail.SMTPHost == "" { |
| 143 | errs = append(errs, fmt.Errorf( | 148 | errs = append(errs, fmt.Errorf( |
| 144 | "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP", | 149 | "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP", |
internal/control/control.go +10
| @@ -70,6 +70,10 @@ func Dispatch(c *Ctx, argv []string) int { | |||
| 70 | if c.Scope != "full" { | 70 | if c.Scope != "full" { |
| 71 | return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) | 71 | return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) |
| 72 | } | 72 | } |
| 73 | if c.User.Pending && !pendingAllowed(cmd.Path) { | ||
| 74 | return c.fail(protocol.ExitDenied, | ||
| 75 | "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") | ||
| 76 | } | ||
| 73 | // Strip the global --json flag wherever it appears. | 77 | // Strip the global --json flag wherever it appears. |
| 74 | args := rest[:0:0] | 78 | args := rest[:0:0] |
| 75 | for _, a := range rest { | 79 | for _, a := range rest { |
| @@ -85,6 +89,12 @@ func Dispatch(c *Ctx, argv []string) int { | |||
| 85 | return cmd.Run(c, args) | 89 | return cmd.Run(c, args) |
| 86 | } | 90 | } |
| 87 | 91 | ||
| 92 | // pendingAllowed lists what an unverified self-registered account may do. | ||
| 93 | func pendingAllowed(path []string) bool { | ||
| 94 | key := joinPath(path) | ||
| 95 | return key == "email verify" || key == "email add" || key == "whoami" || key == "help" | ||
| 96 | } | ||
| 97 | |||
| 88 | type emptyReader struct{} | 98 | type emptyReader struct{} |
| 89 | 99 | ||
| 90 | func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } | 100 | func (emptyReader) Read([]byte) (int, error) { return 0, io.EOF } |
internal/control/register.go added +178
| @@ -0,0 +1,178 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 19 | func 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 | |||
| 26 | func siteHost(cfg config.Config) string { | ||
| 27 | h := strings.TrimPrefix(strings.TrimPrefix(cfg.Server.SiteURL, "https://"), "http://") | ||
| 28 | return strings.TrimSuffix(h, "/") | ||
| 29 | } | ||
| 30 | |||
| 31 | func 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 | |||
| 47 | func 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 | |||
| 65 | func 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". | ||
| 90 | func 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 | |||
| 176 | func addRegisteredKey(st *store.Store, uid int64, pub ssh.PublicKey) error { | ||
| 177 | return st.AddSSHKey(uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full") | ||
| 178 | } | ||
internal/mail/mail.go added +64
| @@ -0,0 +1,64 @@ | |||
| 1 | // Package mail sends transactional email over SMTP: verification codes and | ||
| 2 | // invites. STARTTLS is used when the server offers it; PLAIN auth when | ||
| 3 | // credentials are configured. | ||
| 4 | package mail | ||
| 5 | |||
| 6 | import ( | ||
| 7 | "fmt" | ||
| 8 | "net" | ||
| 9 | "net/smtp" | ||
| 10 | "strings" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "gitbay.org/gitbay/internal/config" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port. | ||
| 17 | func Send(cfg config.Config, to, subject, body string) error { | ||
| 18 | m := cfg.Mail | ||
| 19 | if m.SMTPHost == "" || m.From == "" { | ||
| 20 | return fmt.Errorf("[mail] smtp_host and from must be configured") | ||
| 21 | } | ||
| 22 | host := m.SMTPHost | ||
| 23 | if !strings.Contains(host, ":") { | ||
| 24 | host += ":587" | ||
| 25 | } | ||
| 26 | hostname, _, _ := net.SplitHostPort(host) | ||
| 27 | |||
| 28 | msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf( | ||
| 29 | "From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n", | ||
| 30 | m.From, to, subject, time.Now().Format(time.RFC1123Z), body)) | ||
| 31 | |||
| 32 | c, err := smtp.Dial(host) | ||
| 33 | if err != nil { | ||
| 34 | return fmt.Errorf("smtp dial %s: %w", host, err) | ||
| 35 | } | ||
| 36 | defer c.Close() | ||
| 37 | if ok, _ := c.Extension("STARTTLS"); ok { | ||
| 38 | if err := c.StartTLS(nil); err != nil { | ||
| 39 | return fmt.Errorf("starttls: %w", err) | ||
| 40 | } | ||
| 41 | } | ||
| 42 | if m.SMTPUser != "" { | ||
| 43 | if err := c.Auth(smtp.PlainAuth("", m.SMTPUser, m.SMTPPass, hostname)); err != nil { | ||
| 44 | return fmt.Errorf("smtp auth: %w", err) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | if err := c.Mail(m.From); err != nil { | ||
| 48 | return err | ||
| 49 | } | ||
| 50 | if err := c.Rcpt(to); err != nil { | ||
| 51 | return err | ||
| 52 | } | ||
| 53 | w, err := c.Data() | ||
| 54 | if err != nil { | ||
| 55 | return err | ||
| 56 | } | ||
| 57 | if _, err := w.Write([]byte(msg)); err != nil { | ||
| 58 | return err | ||
| 59 | } | ||
| 60 | if err := w.Close(); err != nil { | ||
| 61 | return err | ||
| 62 | } | ||
| 63 | return c.Quit() | ||
| 64 | } | ||
internal/sshd/sshd.go +40 −1
| @@ -5,6 +5,7 @@ package sshd | |||
| 5 | import ( | 5 | import ( |
| 6 | "crypto/ed25519" | 6 | "crypto/ed25519" |
| 7 | "crypto/rand" | 7 | "crypto/rand" |
| 8 | "encoding/base64" | ||
| 8 | "encoding/pem" | 9 | "encoding/pem" |
| 9 | "errors" | 10 | "errors" |
| 10 | "fmt" | 11 | "fmt" |
| @@ -95,11 +96,18 @@ func generateHostKey(path string) error { | |||
| 95 | } | 96 | } |
| 96 | 97 | ||
| 97 | // authenticate resolves the presented key to a registered account. The SSH | 98 | // authenticate resolves the presented key to a registered account. The SSH |
| 98 | // username is ignored; identity comes from the key alone. | 99 | // username is ignored; identity comes from the key alone. When registration |
| 100 | // is open or invite-based, unknown keys are admitted to run exactly one | ||
| 101 | // command: register. | ||
| 99 | func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) { | 102 | func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) { |
| 100 | fp := ssh.FingerprintSHA256(pub) | 103 | fp := ssh.FingerprintSHA256(pub) |
| 101 | key, err := s.st.SSHKeyByFingerprint(fp) | 104 | key, err := s.st.SSHKeyByFingerprint(fp) |
| 102 | if err != nil { | 105 | if err != nil { |
| 106 | if s.cfg.Registration.Mode != "closed" { | ||
| 107 | return &ssh.Permissions{Extensions: map[string]string{ | ||
| 108 | "anon-key": base64.StdEncoding.EncodeToString(pub.Marshal()), | ||
| 109 | }}, nil | ||
| 110 | } | ||
| 103 | return nil, fmt.Errorf("unknown key %s", fp) | 111 | return nil, fmt.Errorf("unknown key %s", fp) |
| 104 | } | 112 | } |
| 105 | return &ssh.Permissions{Extensions: map[string]string{ | 113 | return &ssh.Permissions{Extensions: map[string]string{ |
| @@ -177,6 +185,9 @@ func sendExit(ch ssh.Channel, code int) { | |||
| 177 | 185 | ||
| 178 | func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int { | 186 | func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int { |
| 179 | ext := sconn.Permissions.Extensions | 187 | ext := sconn.Permissions.Extensions |
| 188 | if blob := ext["anon-key"]; blob != "" { | ||
| 189 | return s.runAnonymous(ch, blob, cmdline) | ||
| 190 | } | ||
| 180 | userID, _ := strconv.ParseInt(ext["user-id"], 10, 64) | 191 | userID, _ := strconv.ParseInt(ext["user-id"], 10, 64) |
| 181 | keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64) | 192 | keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64) |
| 182 | user, err := s.st.UserByID(userID) | 193 | user, err := s.st.UserByID(userID) |
| @@ -188,6 +199,30 @@ func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) | |||
| 188 | return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr()) | 199 | return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr()) |
| 189 | } | 200 | } |
| 190 | 201 | ||
| 202 | // runAnonymous handles a session from an unregistered key: the register | ||
| 203 | // command and nothing else. | ||
| 204 | func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int { | ||
| 205 | raw, err := base64.StdEncoding.DecodeString(keyB64) | ||
| 206 | if err != nil { | ||
| 207 | return protocol.ExitFailure | ||
| 208 | } | ||
| 209 | pub, err := ssh.ParsePublicKey(raw) | ||
| 210 | if err != nil { | ||
| 211 | return protocol.ExitFailure | ||
| 212 | } | ||
| 213 | argv, err := protocol.Tokenize(cmdline) | ||
| 214 | if err != nil { | ||
| 215 | fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err) | ||
| 216 | return protocol.ExitUsage | ||
| 217 | } | ||
| 218 | if len(argv) == 0 || argv[0] != "register" { | ||
| 219 | fmt.Fprintf(ch.Stderr(), "this key is not registered here. Create an account with:\n ssh <host> register --username <name> %s\n", | ||
| 220 | map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode]) | ||
| 221 | return protocol.ExitDenied | ||
| 222 | } | ||
| 223 | return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr()) | ||
| 224 | } | ||
| 225 | |||
| 191 | // Exec runs one SSH exec command line for an authenticated key. It is the | 226 | // Exec runs one SSH exec command line for an authenticated key. It is the |
| 192 | // single dispatch path shared by the embedded listener and the system-sshd | 227 | // single dispatch path shared by the embedded listener and the system-sshd |
| 193 | // forced command (gitbayd shell). | 228 | // forced command (gitbayd shell). |
| @@ -201,6 +236,10 @@ func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline st | |||
| 201 | if len(argv) > 0 { | 236 | if len(argv) > 0 { |
| 202 | switch argv[0] { | 237 | switch argv[0] { |
| 203 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": | 238 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": |
| 239 | if user.Pending { | ||
| 240 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") | ||
| 241 | return protocol.ExitDenied | ||
| 242 | } | ||
| 204 | return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr) | 243 | return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr) |
| 205 | } | 244 | } |
| 206 | } | 245 | } |
internal/store/migrations/0003_registration.down.sql added +2
| @@ -0,0 +1,2 @@ | |||
| 1 | ALTER TABLE users DROP COLUMN pending; | ||
| 2 | DROP TABLE email_tokens; | ||
internal/store/migrations/0003_registration.up.sql added +10
| @@ -0,0 +1,10 @@ | |||
| 1 | CREATE TABLE email_tokens ( | ||
| 2 | token_hash TEXT PRIMARY KEY, | ||
| 3 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 4 | address TEXT NOT NULL, | ||
| 5 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | ||
| 6 | expires_at TEXT NOT NULL, | ||
| 7 | used_at TEXT | ||
| 8 | ); | ||
| 9 | |||
| 10 | ALTER TABLE users ADD COLUMN pending INTEGER NOT NULL DEFAULT 0; | ||
internal/store/registration.go added +73
| @@ -0,0 +1,73 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "time" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // CreateInvite stores an invite code hash bound to an email address. | ||
| 9 | func (s *Store) CreateInvite(codeHash, email string) error { | ||
| 10 | _, err := s.DB.Exec("INSERT INTO invites (code_hash, email) VALUES (?, ?)", codeHash, email) | ||
| 11 | return err | ||
| 12 | } | ||
| 13 | |||
| 14 | // ConsumeInvite redeems an invite exactly once, returning the address it was | ||
| 15 | // issued for. Used and unknown codes fail identically. | ||
| 16 | func (s *Store) ConsumeInvite(codeHash string) (string, error) { | ||
| 17 | res, err := s.DB.Exec( | ||
| 18 | "UPDATE invites SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE code_hash = ? AND used_at IS NULL", | ||
| 19 | codeHash) | ||
| 20 | if err != nil { | ||
| 21 | return "", err | ||
| 22 | } | ||
| 23 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 24 | return "", ErrNotFound | ||
| 25 | } | ||
| 26 | var email string | ||
| 27 | err = s.DB.QueryRow("SELECT email FROM invites WHERE code_hash = ?", codeHash).Scan(&email) | ||
| 28 | return email, err | ||
| 29 | } | ||
| 30 | |||
| 31 | // CreateEmailToken stores a verification code hash for one address. | ||
| 32 | func (s *Store) CreateEmailToken(userID int64, address, tokenHash string, ttl time.Duration) error { | ||
| 33 | _, err := s.DB.Exec( | ||
| 34 | "INSERT INTO email_tokens (token_hash, user_id, address, expires_at) VALUES (?, ?, ?, ?)", | ||
| 35 | tokenHash, userID, address, fmtTime(time.Now().Add(ttl))) | ||
| 36 | return err | ||
| 37 | } | ||
| 38 | |||
| 39 | // ConsumeEmailToken redeems a verification code for the given user. | ||
| 40 | func (s *Store) ConsumeEmailToken(userID int64, tokenHash string) (string, error) { | ||
| 41 | res, err := s.DB.Exec(` | ||
| 42 | UPDATE email_tokens SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') | ||
| 43 | WHERE token_hash = ? AND user_id = ? AND used_at IS NULL AND expires_at > ?`, | ||
| 44 | tokenHash, userID, fmtTime(time.Now())) | ||
| 45 | if err != nil { | ||
| 46 | return "", err | ||
| 47 | } | ||
| 48 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 49 | return "", ErrNotFound | ||
| 50 | } | ||
| 51 | var address string | ||
| 52 | err = s.DB.QueryRow("SELECT address FROM email_tokens WHERE token_hash = ?", tokenHash).Scan(&address) | ||
| 53 | return address, err | ||
| 54 | } | ||
| 55 | |||
| 56 | // CreateRegisteredUser makes a self-registered account, pending until its | ||
| 57 | // email is verified. | ||
| 58 | func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { | ||
| 59 | res, err := s.DB.Exec("INSERT INTO users (username, pending) VALUES (?, ?)", username, boolInt(pending)) | ||
| 60 | if err != nil { | ||
| 61 | if isUniqueErr(err) { | ||
| 62 | return 0, errors.New("that username is taken") | ||
| 63 | } | ||
| 64 | return 0, err | ||
| 65 | } | ||
| 66 | return res.LastInsertId() | ||
| 67 | } | ||
| 68 | |||
| 69 | // ClearPending activates a pending account. | ||
| 70 | func (s *Store) ClearPending(userID int64) error { | ||
| 71 | _, err := s.DB.Exec("UPDATE users SET pending = 0 WHERE id = ?", userID) | ||
| 72 | return err | ||
| 73 | } | ||
internal/store/users.go +9 −6
| @@ -11,6 +11,7 @@ type User struct { | |||
| 11 | ID int64 | 11 | ID int64 |
| 12 | Username string | 12 | Username string |
| 13 | IsAdmin bool | 13 | IsAdmin bool |
| 14 | Pending bool // self-registered, email not yet verified | ||
| 14 | } | 15 | } |
| 15 | 16 | ||
| 16 | type SSHKey struct { | 17 | type SSHKey struct { |
| @@ -41,25 +42,27 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { | |||
| 41 | 42 | ||
| 42 | func (s *Store) UserByUsername(name string) (User, error) { | 43 | func (s *Store) UserByUsername(name string) (User, error) { |
| 43 | var u User | 44 | var u User |
| 44 | var admin int | 45 | var admin, pending int |
| 45 | err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE username = ?", name). | 46 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE username = ?", name). |
| 46 | Scan(&u.ID, &u.Username, &admin) | 47 | Scan(&u.ID, &u.Username, &admin, &pending) |
| 47 | if errors.Is(err, sql.ErrNoRows) { | 48 | if errors.Is(err, sql.ErrNoRows) { |
| 48 | return u, ErrNotFound | 49 | return u, ErrNotFound |
| 49 | } | 50 | } |
| 50 | u.IsAdmin = admin != 0 | 51 | u.IsAdmin = admin != 0 |
| 52 | u.Pending = pending != 0 | ||
| 51 | return u, err | 53 | return u, err |
| 52 | } | 54 | } |
| 53 | 55 | ||
| 54 | func (s *Store) UserByID(id int64) (User, error) { | 56 | func (s *Store) UserByID(id int64) (User, error) { |
| 55 | var u User | 57 | var u User |
| 56 | var admin int | 58 | var admin, pending int |
| 57 | err := s.DB.QueryRow("SELECT id, username, is_admin FROM users WHERE id = ?", id). | 59 | err := s.DB.QueryRow("SELECT id, username, is_admin, pending FROM users WHERE id = ?", id). |
| 58 | Scan(&u.ID, &u.Username, &admin) | 60 | Scan(&u.ID, &u.Username, &admin, &pending) |
| 59 | if errors.Is(err, sql.ErrNoRows) { | 61 | if errors.Is(err, sql.ErrNoRows) { |
| 60 | return u, ErrNotFound | 62 | return u, ErrNotFound |
| 61 | } | 63 | } |
| 62 | u.IsAdmin = admin != 0 | 64 | u.IsAdmin = admin != 0 |
| 65 | u.Pending = pending != 0 | ||
| 63 | return u, err | 66 | return u, err |
| 64 | } | 67 | } |
| 65 | 68 | ||