Commit 98cd521512
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/Admin.org +11
| @@ -101,6 +101,17 @@ default, is right when gitbayd terminates TLS itself. | ||
| 101 | 101 | self-registered account still unverified after that long is removed, |
| 102 | 102 | hourly and at start, audited as =pending.expired=. |
| 103 | 103 | |
| 104 | - =notify_admin= (false) — mail every instance admin when an account | |
| 105 | becomes active: an invite redeemed, or an open-mode signup that | |
| 106 | verified its address. The unverified row an open signup creates is | |
| 107 | not reported, because anyone can post the form and mailing on that | |
| 108 | would point a flood at the admins. Recipients are the verified | |
| 109 | primary addresses of active admins who have activity mail on, the | |
| 110 | same rule any other notice follows, so an admin with no verified | |
| 111 | address hears nothing. The notice is queued, so a dead SMTP host | |
| 112 | shows up in the admin page's Mail table instead of failing the | |
| 113 | registration. Requires [mail]. | |
| 114 | ||
| 104 | 115 | ** [mail] |
| 105 | 116 | - =smtp_host= (host:port, 587 assumed), =from=, optional =smtp_user= / |
| 106 | 117 | =smtp_pass=. STARTTLS when offered. Required for invite/open |
CHANGELOG.org +5
| @@ -8,6 +8,11 @@ anything beyond "replace the binary and restart" is needed. | ||
| 8 | 8 | |
| 9 | 9 | The web findings from the forge comparison (#232). |
| 10 | 10 | |
| 11 | - =[registration] notify_admin= mails the instance's admins when an | |
| 12 | account becomes active: an invite redeemed, or an open-mode signup | |
| 13 | that verified its address. Off by default, requires =[mail]=, and | |
| 14 | queued like any other notice (#234). | |
| 15 | ||
| 11 | 16 | - Links inside running text are underlined: the meta lines on issues, |
| 12 | 17 | merge requests and releases no longer tell a link apart by colour |
| 13 | 18 | alone. |
e2e/signupnotice_test.go added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | "time" | |
| 8 | ) | |
| 9 | ||
| 10 | // registration.notify_admin mails the instance's admins when an account | |
| 11 | // becomes active. An open-mode signup counts at verification, not when | |
| 12 | // the row is created, so an unverified attempt is silent (#234). | |
| 13 | func TestSignupNotifiesAdmins(t *testing.T) { | |
| 14 | smtp := startFakeSMTP(t) | |
| 15 | inst := startInstanceWith(t, fmt.Sprintf( | |
| 16 | "[registration]\nmode = \"open\"\nnotify_admin = true\n"+ | |
| 17 | "[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr)) | |
| 18 | rootKey := inst.newKey(t, "root") | |
| 19 | inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", | |
| 20 | "--admin", "--email", "root@example.test", "--verified") | |
| 21 | // A second admin with no verified address is skipped, not an error. | |
| 22 | inst.admin(t, "admin", "user", "create", "quiet", "--key", inst.newKey(t, "quiet")+".pub", "--admin") | |
| 23 | ||
| 24 | danaKey := inst.newKey(t, "dana") | |
| 25 | if _, errOut, code := inst.ssh(t, danaKey, "", "register", | |
| 26 | "--username", "dana", "--email", "dana@example.test"); code != 0 { | |
| 27 | t.Fatalf("register: %s", errOut) | |
| 28 | } | |
| 29 | // Pending: the admin has heard nothing yet. | |
| 30 | time.Sleep(5 * time.Second) // the mailer ticks every two seconds | |
| 31 | if got := smtp.mailTo("root@example.test"); len(got) != 0 { | |
| 32 | t.Fatalf("admin mailed before the account was verified:\n%s", got[0]) | |
| 33 | } | |
| 34 | ||
| 35 | code := extractCode(t, smtp.waitFor(t, "dana@example.test", "email verify")) | |
| 36 | if _, errOut, ec := inst.ssh(t, danaKey, "", "email", "verify", code); ec != 0 { | |
| 37 | t.Fatalf("verify: %s", errOut) | |
| 38 | } | |
| 39 | msg := smtp.waitFor(t, "root@example.test", "new account") | |
| 40 | if !strings.Contains(msg, "dana") || !strings.Contains(msg, "open registration") { | |
| 41 | t.Fatalf("notice body:\n%s", msg) | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | // With notify_admin off, the default, the same signup mails no one but | |
| 46 | // the person registering. | |
| 47 | func TestSignupNoticeOffByDefault(t *testing.T) { | |
| 48 | smtp := startFakeSMTP(t) | |
| 49 | inst := startInstanceWith(t, fmt.Sprintf( | |
| 50 | "[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr)) | |
| 51 | rootKey := inst.newKey(t, "root") | |
| 52 | inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", | |
| 53 | "--admin", "--email", "root@example.test", "--verified") | |
| 54 | ||
| 55 | eveKey := inst.newKey(t, "eve") | |
| 56 | if _, errOut, ec := inst.ssh(t, eveKey, "", "register", | |
| 57 | "--username", "eve", "--email", "eve@example.test"); ec != 0 { | |
| 58 | t.Fatalf("register: %s", errOut) | |
| 59 | } | |
| 60 | code := extractCode(t, smtp.waitFor(t, "eve@example.test", "email verify")) | |
| 61 | if _, errOut, ec := inst.ssh(t, eveKey, "", "email", "verify", code); ec != 0 { | |
| 62 | t.Fatalf("verify: %s", errOut) | |
| 63 | } | |
| 64 | time.Sleep(5 * time.Second) | |
| 65 | if got := smtp.mailTo("root@example.test"); len(got) != 0 { | |
| 66 | t.Fatalf("admin mailed with notify_admin off:\n%s", got[0]) | |
| 67 | } | |
| 68 | } | |
internal/config/config.go +10
| @@ -100,6 +100,12 @@ type Registration struct { | ||
| 100 | 100 | // unverified before it is removed, as a duration ("168h"). Empty |
| 101 | 101 | // keeps such accounts forever. |
| 102 | 102 | PendingExpiry string `toml:"pending_expiry"` |
| 103 | // NotifyAdmin mails the instance's admins when an account becomes | |
| 104 | // active: an invite redeemed, or an open-mode signup that verified | |
| 105 | // its address. The unverified row an open signup creates is not | |
| 106 | // reported — anyone can post the form, so mailing on that would | |
| 107 | // aim a flood at the admins (#234). | |
| 108 | NotifyAdmin bool `toml:"notify_admin"` | |
| 103 | 109 | } |
| 104 | 110 | |
| 105 | 111 | // PendingExpiryDuration parses PendingExpiry; zero means never. |
| @@ -327,6 +333,10 @@ func (c Config) Validate() error { | ||
| 327 | 333 | "registration.mode = %q requires [mail] smtp_host: email verification cannot run without SMTP", |
| 328 | 334 | c.Registration.Mode)) |
| 329 | 335 | } |
| 336 | if c.Registration.NotifyAdmin && c.Mail.SMTPHost == "" { | |
| 337 | errs = append(errs, errors.New( | |
| 338 | "registration.notify_admin = true requires [mail] smtp_host: there is nowhere to send the notice")) | |
| 339 | } | |
| 330 | 340 | if c.SSH.Mode == "system" && c.Registration.Mode != "closed" { |
| 331 | 341 | errs = append(errs, fmt.Errorf( |
| 332 | 342 | "ssh.mode = \"system\" requires registration.mode = \"closed\": host sshd rejects unknown keys before the dispatcher runs, so registration by unknown key is impossible")) |
internal/config/config_test.go +5
| @@ -50,6 +50,11 @@ func TestContradictions(t *testing.T) { | ||
| 50 | 50 | minimal + "\n[registration]\nmode = \"open\"\n", |
| 51 | 51 | "requires [mail] smtp_host", |
| 52 | 52 | }, |
| 53 | { | |
| 54 | "notify_admin without smtp", | |
| 55 | minimal + "\n[registration]\nnotify_admin = true\n", | |
| 56 | "notify_admin = true requires [mail] smtp_host", | |
| 57 | }, | |
| 53 | 58 | { |
| 54 | 59 | "system ssh with open registration", |
| 55 | 60 | minimal + "\n[ssh]\nmode = \"system\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\n", |
internal/control/register.go +30
| @@ -133,6 +133,29 @@ func sendVerification(cfg config.Config, st *store.Store, userID int64, address | ||
| 133 | 133 | return mail.Send(cfg, address, "verify your email on "+siteHost(cfg), body) |
| 134 | 134 | } |
| 135 | 135 | |
| 136 | // notifyAdminsOfSignup tells the instance's admins that an account just | |
| 137 | // became active, when registration.notify_admin is on. It is queued like | |
| 138 | // any other notice, so a dead SMTP host shows up in the admin page's | |
| 139 | // Mail table rather than failing the registration that caused it: the | |
| 140 | // person signing up is not responsible for the operator's mail (#234). | |
| 141 | func notifyAdminsOfSignup(cfg config.Config, st *store.Store, username, mode string) { | |
| 142 | if !cfg.Registration.NotifyAdmin { | |
| 143 | return | |
| 144 | } | |
| 145 | addrs, err := st.AdminMailAddresses() | |
| 146 | if err != nil || len(addrs) == 0 { | |
| 147 | return | |
| 148 | } | |
| 149 | host := siteHost(cfg) | |
| 150 | subject := fmt.Sprintf("new account on %s: %s", host, username) | |
| 151 | body := fmt.Sprintf("%s registered on %s and the account is active (%s registration).\n\n"+ | |
| 152 | " https://%s/%s\n\nAccounts: ssh git@%s admin user list\n", | |
| 153 | username, host, mode, host, username, host) | |
| 154 | for _, a := range addrs { | |
| 155 | st.EnqueueMail(a, subject, body) | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 136 | 159 | const maxEmailAddsPerHour = 5 |
| 137 | 160 | |
| 138 | 161 | func runEmailAdd(c *Ctx, args []string) int { |
| @@ -186,9 +209,15 @@ func runEmailVerify(c *Ctx, args []string) int { | ||
| 186 | 209 | if err := c.Store.VerifyEmail(c.User.ID, address, "smtp"); err != nil { |
| 187 | 210 | return c.fail(protocol.ExitFailure, "%v", err) |
| 188 | 211 | } |
| 212 | wasPending := c.User.Pending | |
| 189 | 213 | if err := c.Store.ClearPending(c.User.ID); err != nil { |
| 190 | 214 | return c.fail(protocol.ExitFailure, "%v", err) |
| 191 | 215 | } |
| 216 | // The open-mode account becomes real here, not when the form was | |
| 217 | // posted, so this is where the admins hear about it. | |
| 218 | if wasPending { | |
| 219 | notifyAdminsOfSignup(c.Cfg, c.Store, c.User.Username, "open") | |
| 220 | } | |
| 192 | 221 | return c.emit(map[string]string{"address": address, "status": "verified"}, func(w io.Writer) { |
| 193 | 222 | fmt.Fprintf(w, "%s verified; your account is active\n", address) |
| 194 | 223 | }) |
| @@ -249,6 +278,7 @@ func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, user | ||
| 249 | 278 | return "", err.Error(), protocol.ExitUsage |
| 250 | 279 | } |
| 251 | 280 | st.Audit(0, "auth.registered", map[string]any{"user": username, "mode": "invite", "fingerprint": fp}) |
| 281 | notifyAdminsOfSignup(cfg, st, username, "invite") | |
| 252 | 282 | return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK |
| 253 | 283 | |
| 254 | 284 | case "open": |
internal/store/adminusers.go +25
| @@ -86,6 +86,31 @@ func (s *Store) AdminUserByName(name string) (AdminUser, error) { | ||
| 86 | 86 | return u, err |
| 87 | 87 | } |
| 88 | 88 | |
| 89 | // AdminMailAddresses returns where to reach the instance's admins: the | |
| 90 | // verified primary address of every active admin who has activity mail | |
| 91 | // on. An admin with no verified primary, or with mail off, is skipped | |
| 92 | // rather than reported, the same rule ActivityMailAddress applies to | |
| 93 | // anyone else (#234). | |
| 94 | func (s *Store) AdminMailAddresses() ([]string, error) { | |
| 95 | rows, err := s.DB.Query(`SELECT e.address FROM users u | |
| 96 | JOIN emails e ON e.user_id = u.id AND e.is_primary = 1 AND e.verified_at IS NOT NULL | |
| 97 | WHERE u.is_admin = 1 AND u.pending = 0 AND u.disabled = 0 AND u.notify_mail != 0 | |
| 98 | ORDER BY e.address`) | |
| 99 | if err != nil { | |
| 100 | return nil, err | |
| 101 | } | |
| 102 | defer rows.Close() | |
| 103 | var out []string | |
| 104 | for rows.Next() { | |
| 105 | var a string | |
| 106 | if err := rows.Scan(&a); err != nil { | |
| 107 | return nil, err | |
| 108 | } | |
| 109 | out = append(out, a) | |
| 110 | } | |
| 111 | return out, rows.Err() | |
| 112 | } | |
| 113 | ||
| 89 | 114 | // OwnedRepoCount counts repositories the user owns directly, not through |
| 90 | 115 | // an org. |
| 91 | 116 | func (s *Store) OwnedRepoCount(userID int64) (int64, error) { |