Commit 53f8811759
Verified · cmc
cmd/gitbayd/main.go +5
| @@ -281,6 +281,11 @@ func adminInviteCmd() *cobra.Command { | ||
| 281 | 281 | } |
| 282 | 282 | defer st.Close() |
| 283 | 283 | |
| 284 | if used, err := st.EmailInUse(email); err != nil { | |
| 285 | return err | |
| 286 | } else if used { | |
| 287 | return fmt.Errorf("%s already belongs to an account; invites are for new users", email) | |
| 288 | } | |
| 284 | 289 | code, hash, err := store.NewToken() |
| 285 | 290 | if err != nil { |
| 286 | 291 | return err |
e2e/registration_test.go +31
| @@ -123,6 +123,14 @@ func TestOpenRegistration(t *testing.T) { | ||
| 123 | 123 | t.Fatalf("stranger whoami: exit %d, %s", code, errOut) |
| 124 | 124 | } |
| 125 | 125 | |
| 126 | // Registering with an address already on an account is refused | |
| 127 | // atomically (the username stays free for the real attempt). | |
| 128 | inst.admin(t, "admin", "user", "create", "existing", "--key", inst.newKey(t, "existing")+".pub", "--email", "taken@example.test") | |
| 129 | _, errOut2, code2 := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "taken@example.test") | |
| 130 | if code2 != 2 || !strings.Contains(errOut2, "already belongs") { | |
| 131 | t.Fatalf("open register with taken email: exit %d, %s", code2, errOut2) | |
| 132 | } | |
| 133 | ||
| 126 | 134 | // Register: account created pending, verification mail sent. |
| 127 | 135 | out, errOut, code := inst.ssh(t, newKey, "", "register", "--username", "dana", "--email", "dana@example.test") |
| 128 | 136 | if code != 0 { |
| @@ -218,4 +226,27 @@ func TestInviteRegistration(t *testing.T) { | ||
| 218 | 226 | if code != 4 || !strings.Contains(errOut, "already used") { |
| 219 | 227 | t.Fatalf("invite reuse: exit %d, %s", code, errOut) |
| 220 | 228 | } |
| 229 | ||
| 230 | // Atomicity: a failed redemption (taken username) leaves the invite | |
| 231 | // redeemable and no partial account. | |
| 232 | inst.admin(t, "admin", "invite", "--email", "gray@example.test") | |
| 233 | code2 := extractCode(t, smtp.waitMail(t, 1)) | |
| 234 | if _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "erin", "--invite", code2); code != 2 || !strings.Contains(errOut, "taken") { | |
| 235 | t.Fatalf("taken-name register: exit %d, %s", code, errOut) | |
| 236 | } | |
| 237 | if _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "gray", "--invite", code2); code != 0 { | |
| 238 | t.Fatalf("invite not redeemable after failed attempt: %s", errOut) | |
| 239 | } | |
| 240 | ||
| 241 | // Inviting an address that already has an account is refused. | |
| 242 | out2 := inst.forgedAdminErr(t, "admin", "invite", "--email", "erin@example.test") | |
| 243 | if !strings.Contains(out2, "already belongs") { | |
| 244 | t.Fatalf("invite to existing address: %s", out2) | |
| 245 | } | |
| 246 | ||
| 247 | // A registered key running register gets a pointer, not confusion. | |
| 248 | _, errOut, code = inst.ssh(t, otherKey, "", "register", "--username", "again", "--invite", "x") | |
| 249 | if code != 2 || !strings.Contains(errOut, "already belongs to gray") { | |
| 250 | t.Fatalf("register with known key: exit %d, %s", code, errOut) | |
| 251 | } | |
| 221 | 252 | } |
internal/control/register.go +18 −24
| @@ -17,6 +17,13 @@ import ( | ||
| 17 | 17 | ) |
| 18 | 18 | |
| 19 | 19 | func 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 | }}) | |
| 20 | 27 | register(Command{Path: []string{"email", "add"}, |
| 21 | 28 | Summary: "add an address and mail a verification code: email add <address>", Run: runEmailAdd}) |
| 22 | 29 | register(Command{Path: []string{"email", "verify"}, |
| @@ -123,25 +130,20 @@ func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []s | ||
| 123 | 130 | return fail(protocol.ExitUsage, "%v", err) |
| 124 | 131 | } |
| 125 | 132 | |
| 133 | fp := ssh.FingerprintSHA256(pub) | |
| 126 | 134 | switch cfg.Registration.Mode { |
| 127 | 135 | case "invite": |
| 128 | 136 | if invite == "" { |
| 129 | 137 | return fail(protocol.ExitDenied, "this instance is invite-only: register --username <name> --invite <code>") |
| 130 | 138 | } |
| 131 | addr, err := st.ConsumeInvite(store.HashToken(invite)) | |
| 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()) | |
| 132 | 142 | 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) | |
| 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) | |
| 145 | 147 | } |
| 146 | 148 | fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username) |
| 147 | 149 | return protocol.ExitOK |
| @@ -150,15 +152,9 @@ func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []s | ||
| 150 | 152 | if email == "" || !strings.Contains(email, "@") { |
| 151 | 153 | return fail(protocol.ExitUsage, "usage: register --username <name> --email <address>") |
| 152 | 154 | } |
| 153 | uid, err := st.CreateRegisteredUser(username, true) | |
| 155 | uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal()) | |
| 154 | 156 | 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) | |
| 157 | return fail(protocol.ExitUsage, "%v", err) | |
| 162 | 158 | } |
| 163 | 159 | if err := sendVerification(cfg, st, uid, email); err != nil { |
| 164 | 160 | return fail(protocol.ExitFailure, "sending verification mail: %v", err) |
| @@ -173,6 +169,4 @@ func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []s | ||
| 173 | 169 | } |
| 174 | 170 | } |
| 175 | 171 | |
| 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 | } | |
| 172 | ||
internal/store/registration.go +109
| @@ -76,3 +76,112 @@ func (s *Store) ClearPending(userID int64) error { | ||
| 76 | 76 | _, err := s.DB.Exec("UPDATE users SET pending = 0 WHERE id = ?", userID) |
| 77 | 77 | return err |
| 78 | 78 | } |
| 79 | ||
| 80 | // EmailInUse reports whether an address is attached to any account. | |
| 81 | func (s *Store) EmailInUse(address string) (bool, error) { | |
| 82 | var n int | |
| 83 | err := s.DB.QueryRow("SELECT COUNT(*) FROM emails WHERE address = ?", address).Scan(&n) | |
| 84 | return n > 0, err | |
| 85 | } | |
| 86 | ||
| 87 | // RedeemInvite performs the whole invite registration in one transaction: | |
| 88 | // consume the code, create the user, attach the invite's email as verified, | |
| 89 | // register the key. Any failure rolls everything back — the invite stays | |
| 90 | // redeemable and no partial account exists. | |
| 91 | func (s *Store) RedeemInvite(codeHash, username, keyFP, keyAlgo string, keyBlob []byte) (string, error) { | |
| 92 | tx, err := s.DB.Begin() | |
| 93 | if err != nil { | |
| 94 | return "", err | |
| 95 | } | |
| 96 | defer tx.Rollback() | |
| 97 | ||
| 98 | res, err := tx.Exec( | |
| 99 | "UPDATE invites SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE code_hash = ? AND used_at IS NULL", | |
| 100 | codeHash) | |
| 101 | if err != nil { | |
| 102 | return "", err | |
| 103 | } | |
| 104 | if n, _ := res.RowsAffected(); n == 0 { | |
| 105 | return "", ErrNotFound | |
| 106 | } | |
| 107 | var email string | |
| 108 | if err := tx.QueryRow("SELECT email FROM invites WHERE code_hash = ?", codeHash).Scan(&email); err != nil { | |
| 109 | return "", err | |
| 110 | } | |
| 111 | ||
| 112 | if taken, err := ownerNameTaken(tx, username); err != nil { | |
| 113 | return "", err | |
| 114 | } else if taken { | |
| 115 | return "", errors.New("that username is taken") | |
| 116 | } | |
| 117 | ures, err := tx.Exec("INSERT INTO users (username) VALUES (?)", username) | |
| 118 | if err != nil { | |
| 119 | return "", err | |
| 120 | } | |
| 121 | uid, err := ures.LastInsertId() | |
| 122 | if err != nil { | |
| 123 | return "", err | |
| 124 | } | |
| 125 | if _, err := tx.Exec( | |
| 126 | `INSERT INTO emails (user_id, address, verified_at, verified_by, is_primary) | |
| 127 | VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), 'smtp', 1)`, uid, email); err != nil { | |
| 128 | if isUniqueErr(err) { | |
| 129 | return "", errors.New("the invited address already belongs to an account") | |
| 130 | } | |
| 131 | return "", err | |
| 132 | } | |
| 133 | if _, err := tx.Exec( | |
| 134 | "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, ?, ?, ?, 'full')", | |
| 135 | uid, keyFP, keyAlgo, keyBlob); err != nil { | |
| 136 | if isUniqueErr(err) { | |
| 137 | return "", ErrDuplicateKey | |
| 138 | } | |
| 139 | return "", err | |
| 140 | } | |
| 141 | if err := bumpKeyEpoch(tx); err != nil { | |
| 142 | return "", err | |
| 143 | } | |
| 144 | return email, tx.Commit() | |
| 145 | } | |
| 146 | ||
| 147 | // RegisterOpen performs open registration in one transaction: pending user, | |
| 148 | // unverified email, key. Failure leaves nothing behind. | |
| 149 | func (s *Store) RegisterOpen(username, email, keyFP, keyAlgo string, keyBlob []byte) (int64, error) { | |
| 150 | tx, err := s.DB.Begin() | |
| 151 | if err != nil { | |
| 152 | return 0, err | |
| 153 | } | |
| 154 | defer tx.Rollback() | |
| 155 | if taken, err := ownerNameTaken(tx, username); err != nil { | |
| 156 | return 0, err | |
| 157 | } else if taken { | |
| 158 | return 0, errors.New("that username is taken") | |
| 159 | } | |
| 160 | ures, err := tx.Exec("INSERT INTO users (username, pending) VALUES (?, 1)", username) | |
| 161 | if err != nil { | |
| 162 | return 0, err | |
| 163 | } | |
| 164 | uid, err := ures.LastInsertId() | |
| 165 | if err != nil { | |
| 166 | return 0, err | |
| 167 | } | |
| 168 | if _, err := tx.Exec( | |
| 169 | "INSERT INTO emails (user_id, address, is_primary) VALUES (?, ?, 1)", uid, email); err != nil { | |
| 170 | if isUniqueErr(err) { | |
| 171 | return 0, errors.New("that address already belongs to an account") | |
| 172 | } | |
| 173 | return 0, err | |
| 174 | } | |
| 175 | if _, err := tx.Exec( | |
| 176 | "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, ?, ?, ?, 'full')", | |
| 177 | uid, keyFP, keyAlgo, keyBlob); err != nil { | |
| 178 | if isUniqueErr(err) { | |
| 179 | return 0, ErrDuplicateKey | |
| 180 | } | |
| 181 | return 0, err | |
| 182 | } | |
| 183 | if err := bumpKeyEpoch(tx); err != nil { | |
| 184 | return 0, err | |
| 185 | } | |
| 186 | return uid, tx.Commit() | |
| 187 | } | |