Commit 9a58f1933f
9a58f1933f634d7738b51ec0ef7a9f09321b0698
parent: cc3550cfbb
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-05 03:26 UTC
store: count login tokens per account, indexed
Ref #155
Layout: unified · split
internal/store/migrations/0040_login_token_index.down.sql
added
+1
| @@ -0,0 +1 @@ |
| 1 | DROP INDEX login_tokens_user_created; |
internal/store/migrations/0040_login_token_index.up.sql
added
+1
| @@ -0,0 +1 @@ |
| 1 | CREATE INDEX login_tokens_user_created ON login_tokens(user_id, created_at); |
internal/store/sessions.go
+11
| @@ -35,6 +35,17 @@ func (s *Store) CreateLoginToken(userID int64, hash string, ttl time.Duration) e |
| 35 | 35 | return err |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | // CountLoginTokensSince counts the login tokens minted for a user within a |
| 39 | // window. An unauthenticated request can ask for a login link, so the mint |
| 40 | // needs a durable per-account bound the way email verification does (#136). |
| 41 | func (s *Store) CountLoginTokensSince(userID int64, since time.Time) (int, error) { |
| 42 | var n int |
| 43 | err := s.DB.QueryRow( |
| 44 | "SELECT count(*) FROM login_tokens WHERE user_id = ? AND created_at > ?", |
| 45 | userID, fmtTime(since)).Scan(&n) |
| 46 | return n, err |
| 47 | } |
| 48 | |
| 38 | 49 | // ConsumeLoginToken redeems a token exactly once; expired or used tokens |
| 39 | 50 | // fail identically. |
| 40 | 51 | func (s *Store) ConsumeLoginToken(hash string) (int64, error) { |
internal/store/sessions_test.go
added
+46
| @@ -0,0 +1,46 @@ |
| 1 | package store |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | func TestCountLoginTokensSince(t *testing.T) { |
| 9 | s := open(t) |
| 10 | if err := s.MigrateUp(); err != nil { |
| 11 | t.Fatal(err) |
| 12 | } |
| 13 | uid, err := s.CreateUser("cmc", true) |
| 14 | if err != nil { |
| 15 | t.Fatal(err) |
| 16 | } |
| 17 | for i := 0; i < 3; i++ { |
| 18 | _, hash, err := NewToken() |
| 19 | if err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | if err := s.CreateLoginToken(uid, hash, time.Minute); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | n, err := s.CountLoginTokensSince(uid, time.Now().Add(-time.Hour)) |
| 28 | if err != nil || n != 3 { |
| 29 | t.Fatalf("count in the last hour = %d, %v; want 3", n, err) |
| 30 | } |
| 31 | |
| 32 | // A window that opens in the future sees none of them, which is what |
| 33 | // makes the hourly bound a window rather than a lifetime total. |
| 34 | if n, err := s.CountLoginTokensSince(uid, time.Now().Add(time.Hour)); err != nil || n != 0 { |
| 35 | t.Fatalf("count in a future window = %d, %v; want 0", n, err) |
| 36 | } |
| 37 | |
| 38 | // One account's requests must not spend another account's budget. |
| 39 | other, err := s.CreateUser("kim", false) |
| 40 | if err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if n, err := s.CountLoginTokensSince(other, time.Now().Add(-time.Hour)); err != nil || n != 0 { |
| 44 | t.Fatalf("other account count = %d, %v; want 0", n, err) |
| 45 | } |
| 46 | } |