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 @@
1DROP INDEX login_tokens_user_created;
internal/store/migrations/0040_login_token_index.up.sql added +1
@@ -0,0 +1 @@
1CREATE 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).
41func (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.
40func (s *Store) ConsumeLoginToken(hash string) (int64, error) { 51func (s *Store) ConsumeLoginToken(hash string) (int64, error) {
internal/store/sessions_test.go added +46
@@ -0,0 +1,46 @@
1package store
2
3import (
4 "testing"
5 "time"
6)
7
8func 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}