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 | return err |
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 | // ConsumeLoginToken redeems a token exactly once; expired or used tokens |
49 | // ConsumeLoginToken redeems a token exactly once; expired or used tokens |
| 39 | // fail identically. |
50 | // fail identically. |
| 40 | func (s *Store) ConsumeLoginToken(hash string) (int64, error) { |
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 | } |