@nakedev/go-scaffold 0.1.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -44
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +21 -11
  6. package/dist/commands/method.js +32 -3
  7. package/dist/commands/observability.js +114 -0
  8. package/dist/commands/rbac.js +19 -2
  9. package/dist/commands/undo.js +331 -0
  10. package/dist/commands/worker.js +92 -32
  11. package/dist/index.js +366 -63
  12. package/dist/prompts/auth-wizard.js +29 -0
  13. package/dist/prompts/create-wizard.js +5 -2
  14. package/dist/prompts/generate-wizard.js +57 -0
  15. package/dist/prompts/worker-wizard.js +25 -0
  16. package/dist/templates/auth-manifest.js +20 -3
  17. package/dist/templates/create-manifest.js +13 -20
  18. package/dist/templates/observability-manifest.js +24 -0
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/templates/worker-manifest.js +23 -6
  21. package/dist/utils/auth-patcher.js +96 -21
  22. package/dist/utils/config.js +58 -10
  23. package/dist/utils/gocheck.js +57 -5
  24. package/dist/utils/golangci-patcher.js +73 -0
  25. package/dist/utils/gomod-patcher.js +53 -0
  26. package/dist/utils/main-patcher.js +58 -4
  27. package/dist/utils/marker-patch.js +125 -3
  28. package/dist/utils/method-patcher.js +17 -2
  29. package/dist/utils/module-location.js +37 -1
  30. package/dist/utils/naming.js +50 -2
  31. package/dist/utils/observability-patcher.js +107 -0
  32. package/dist/utils/platform-patcher.js +98 -12
  33. package/dist/utils/rbac-patcher.js +60 -10
  34. package/package.json +3 -5
  35. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  36. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  37. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  38. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  39. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  40. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  41. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  42. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  43. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  44. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  45. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  46. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  47. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  48. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  49. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  50. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  52. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  54. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  55. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  56. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  57. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  58. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  59. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  60. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  61. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  62. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  63. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  64. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  65. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  66. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  67. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  68. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  69. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  70. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  71. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  72. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  73. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  74. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  75. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  76. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  77. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  78. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +71 -12
  79. package/templates/create/base/.dockerignore.hbs +13 -0
  80. package/templates/create/base/.env.example.hbs +18 -9
  81. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  82. package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
  83. package/templates/create/base/.golangci.yml.hbs +27 -0
  84. package/templates/create/base/AGENTS.md.hbs +21 -7
  85. package/templates/create/base/Dockerfile.hbs +42 -0
  86. package/templates/create/base/Makefile.hbs +43 -13
  87. package/templates/create/base/README.md.hbs +45 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  89. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  90. package/templates/create/base/go.mod.hbs +4 -4
  91. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  92. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  93. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  94. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  95. package/templates/create/base/redocly.yaml.hbs +21 -0
  96. package/templates/create/features/docs/architecture.md.hbs +32 -11
  97. package/templates/create/features/docs/openapi.yaml.hbs +0 -4
  98. package/templates/create/features/docs/patterns.md.hbs +82 -8
  99. package/templates/create/features/docs/techstack.md.hbs +8 -3
  100. package/templates/generate/module/dto.go.hbs +8 -1
  101. package/templates/generate/module/errors.go.hbs +5 -0
  102. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  103. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  104. package/templates/generate/module/handler_test.go.hbs +8 -1
  105. package/templates/generate/module/migration.down.sql.hbs +3 -1
  106. package/templates/generate/module/migration.up.sql.hbs +7 -2
  107. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  108. package/templates/generate/module/model/model.go.hbs +10 -1
  109. package/templates/generate/module/permission.up.sql.hbs +3 -1
  110. package/templates/generate/module/repository.go.hbs +60 -6
  111. package/templates/generate/module/repository_test.go.hbs +30 -0
  112. package/templates/generate/module/service.go.hbs +10 -1
  113. package/templates/generate/module/service_test.go.hbs +45 -0
  114. package/dist/commands/remove.js +0 -88
  115. package/scripts/smoke-test.mjs +0 -2058
  116. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  117. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
  118. package/tests/integration/default-module.test.mjs +0 -46
  119. package/tests/integration/generator-naming.test.mjs +0 -81
  120. package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
  121. package/tests/integration/legacy-method-compat.test.mjs +0 -222
  122. package/tests/integration/remove-module.test.mjs +0 -58
  123. package/tests/unit/naming.test.mjs +0 -94
  124. package/tests/unit/smoke-isolation.test.mjs +0 -35
@@ -5,19 +5,17 @@ import (
5
5
  "time"
6
6
 
7
7
  "github.com/google/uuid"
8
- "github.com/redis/go-redis/v9"
9
8
  )
10
9
 
11
- const (
12
- refreshKeyPrefix = "user:refresh:" // +hash -> userID, TTL = refreshTTL
13
- refreshUserKeyPrefix = "user:refresh:user:" // +userID -> SET of active token hashes
14
- refreshUsedKeyPrefix = "user:refresh:used:" // +hash -> userID, TTL = refreshTTL (reuse-detection tombstone)
15
- pwresetKeyPrefix = "user:pwreset:" // +hash -> userID, TTL = resetTTL, GETDEL on consume
16
- emailVerifyKeyPrefix = "user:emailverify:" // +hash -> userID, TTL = emailVerifyTTL, GETDEL on consume
17
- )
18
-
19
- // tokenStore is what Service needs from Redis for refresh tokens — declared
20
- // consumer-side so it can be faked in tests without a real Redis.
10
+ // tokenStore is the short-lived-token surface Service needs — declared
11
+ // consumer-side so it can be faked in tests, and so the backing store is a
12
+ // choice rather than a hard dependency. Two implementations ship with the
13
+ // scaffold and exactly one is written into a project:
14
+ //
15
+ // `add auth --store postgres` -> tokenstore_pg.go (default, no extra service)
16
+ // `add auth --store redis` -> tokenstore_redis.go (exact across pods)
17
+ //
18
+ // Service never learns which one it got.
21
19
  type tokenStore interface {
22
20
  SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
23
21
  GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
@@ -30,130 +28,3 @@ type tokenStore interface {
30
28
  SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
31
29
  ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
32
30
  }
33
-
34
- type redisTokenStore struct {
35
- rdb *redis.Client
36
- }
37
-
38
- func NewRedisTokenStore(rdb *redis.Client) *redisTokenStore {
39
- return &redisTokenStore{rdb: rdb}
40
- }
41
-
42
- func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
43
- pipe := s.rdb.TxPipeline()
44
- pipe.Set(ctx, refreshKeyPrefix+tokenHash, userID.String(), ttl)
45
- pipe.SAdd(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
46
- _, err := pipe.Exec(ctx)
47
- return err
48
- }
49
-
50
- func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
51
- raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
52
- if err == redis.Nil {
53
- return uuid.Nil, false, nil
54
- }
55
- if err != nil {
56
- return uuid.Nil, false, err
57
- }
58
- id, err := uuid.Parse(raw)
59
- if err != nil {
60
- return uuid.Nil, false, err
61
- }
62
- return id, true, nil
63
- }
64
-
65
- func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
66
- pipe := s.rdb.TxPipeline()
67
- pipe.Del(ctx, refreshKeyPrefix+tokenHash)
68
- pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
69
- _, err := pipe.Exec(ctx)
70
- return err
71
- }
72
-
73
- // RevokeAllRefreshTokens walks the per-user session set and deletes every
74
- // active refresh token for that user — used when reuse of an already-rotated
75
- // token is detected (see Service.Refresh): that means the raw token leaked,
76
- // so every session, not just the replayed one, is treated as compromised.
77
- //
78
- // ponytail: the per-user set has no per-member TTL cleanup of its own — a
79
- // member outlives its key's TTL as a stale entry until the next revoke or
80
- // rotation touches it. Self-heals over time; revisit if a single user's
81
- // session count grows large enough to matter.
82
- func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
83
- setKey := refreshUserKeyPrefix + userID.String()
84
- hashes, err := s.rdb.SMembers(ctx, setKey).Result()
85
- if err != nil {
86
- return err
87
- }
88
- if len(hashes) == 0 {
89
- return nil
90
- }
91
- pipe := s.rdb.TxPipeline()
92
- for _, h := range hashes {
93
- pipe.Del(ctx, refreshKeyPrefix+h)
94
- }
95
- pipe.Del(ctx, setKey)
96
- _, err = pipe.Exec(ctx)
97
- return err
98
- }
99
-
100
- func (s *redisTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
101
- return s.rdb.Set(ctx, refreshUsedKeyPrefix+tokenHash, userID.String(), ttl).Err()
102
- }
103
-
104
- func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
105
- raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
106
- if err == redis.Nil {
107
- return uuid.Nil, false, nil
108
- }
109
- if err != nil {
110
- return uuid.Nil, false, err
111
- }
112
- id, err := uuid.Parse(raw)
113
- if err != nil {
114
- return uuid.Nil, false, err
115
- }
116
- return id, true, nil
117
- }
118
-
119
- func (s *redisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
120
- return s.rdb.Set(ctx, pwresetKeyPrefix+tokenHash, userID.String(), ttl).Err()
121
- }
122
-
123
- // ConsumePasswordResetToken is one-time-use by construction: GETDEL is
124
- // atomic, so a token can't be raced into being consumed twice.
125
- func (s *redisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
126
- raw, err := s.rdb.GetDel(ctx, pwresetKeyPrefix+tokenHash).Result()
127
- if err == redis.Nil {
128
- return uuid.Nil, false, nil
129
- }
130
- if err != nil {
131
- return uuid.Nil, false, err
132
- }
133
- id, err := uuid.Parse(raw)
134
- if err != nil {
135
- return uuid.Nil, false, err
136
- }
137
- return id, true, nil
138
- }
139
-
140
- func (s *redisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
141
- return s.rdb.Set(ctx, emailVerifyKeyPrefix+tokenHash, userID.String(), ttl).Err()
142
- }
143
-
144
- // ConsumeEmailVerifyToken is one-time-use by construction, same as
145
- // ConsumePasswordResetToken — GETDEL is atomic.
146
- func (s *redisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
147
- raw, err := s.rdb.GetDel(ctx, emailVerifyKeyPrefix+tokenHash).Result()
148
- if err == redis.Nil {
149
- return uuid.Nil, false, nil
150
- }
151
- if err != nil {
152
- return uuid.Nil, false, err
153
- }
154
- id, err := uuid.Parse(raw)
155
- if err != nil {
156
- return uuid.Nil, false, err
157
- }
158
- return id, true, nil
159
- }
@@ -0,0 +1,144 @@
1
+ // Postgres-backed tokenStore — installed by `add auth --store postgres` (the
2
+ // default). The Redis-backed alternative lives in tokenstore_redis.go; only
3
+ // one of the two is ever written into a project.
4
+ //
5
+ // Everything Redis needed two keys for collapses into one table here: the
6
+ // per-user session set is just a WHERE clause, and expiry is a column every
7
+ // read filters on rather than a TTL the store enforces. What that costs is a
8
+ // sweep to keep the table from growing — see DeleteExpired.
9
+ package user
10
+
11
+ import (
12
+ "context"
13
+ "database/sql"
14
+ "errors"
15
+ "time"
16
+
17
+ "{{goModule}}/internal/app/user/model"
18
+ "{{goModule}}/internal/shared/tx"
19
+
20
+ "github.com/google/uuid"
21
+ "gorm.io/gorm"
22
+ )
23
+
24
+ const (
25
+ kindRefresh = "refresh"
26
+ kindRefreshUsed = "refresh_used"
27
+ kindPasswordSet = "pwreset"
28
+ kindEmailVerify = "emailverify"
29
+ )
30
+
31
+ type pgTokenStore struct {
32
+ db *gorm.DB
33
+ }
34
+
35
+ func NewPgTokenStore(db *gorm.DB) *pgTokenStore { return &pgTokenStore{db: db} }
36
+
37
+ // put writes a token row, replacing any row that already carries the same
38
+ // hash — a collision is astronomically unlikely, but "upsert" is the right
39
+ // semantic for "this hash now means this" either way.
40
+ func (s *pgTokenStore) put(ctx context.Context, hash, kind string, userID uuid.UUID, ttl time.Duration) error {
41
+ row := model.AuthToken{TokenHash: hash, UserID: userID, Kind: kind, ExpiresAt: time.Now().Add(ttl)}
42
+ return tx.From(ctx, s.db).WithContext(ctx).
43
+ Where("token_hash = ?", hash).
44
+ Assign(map[string]any{"user_id": userID, "kind": kind, "expires_at": row.ExpiresAt}).
45
+ FirstOrCreate(&row).Error
46
+ }
47
+
48
+ // lookup finds a live token of the given kind. Expiry is enforced here rather
49
+ // than by a sweep, so a row that outlived its TTL is already invisible.
50
+ func (s *pgTokenStore) lookup(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
51
+ var row model.AuthToken
52
+ err := tx.From(ctx, s.db).WithContext(ctx).
53
+ Where("token_hash = ? AND kind = ? AND expires_at > now()", hash, kind).
54
+ Take(&row).Error
55
+ if errors.Is(err, gorm.ErrRecordNotFound) {
56
+ return uuid.Nil, false, nil
57
+ }
58
+ if err != nil {
59
+ return uuid.Nil, false, err
60
+ }
61
+ return row.UserID, true, nil
62
+ }
63
+
64
+ // consume deletes and returns in one statement, which is what makes a
65
+ // one-time token actually one-time: two requests racing on the same token
66
+ // cannot both come away with a user id.
67
+ func (s *pgTokenStore) consume(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
68
+ var userID uuid.UUID
69
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
70
+ `DELETE FROM user_svc.auth_tokens
71
+ WHERE token_hash = ? AND kind = ? AND expires_at > now()
72
+ RETURNING user_id`, hash, kind).Row().Scan(&userID)
73
+ if errors.Is(err, sql.ErrNoRows) {
74
+ return uuid.Nil, false, nil
75
+ }
76
+ if err != nil {
77
+ return uuid.Nil, false, err
78
+ }
79
+ return userID, true, nil
80
+ }
81
+
82
+ func (s *pgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
83
+ return s.put(ctx, tokenHash, kindRefresh, userID, ttl)
84
+ }
85
+
86
+ func (s *pgTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
87
+ return s.lookup(ctx, tokenHash, kindRefresh)
88
+ }
89
+
90
+ // DeleteRefreshToken removes the row outright and leaves nothing behind — see
91
+ // the AuthToken doc comment for why logout must not leave a tombstone.
92
+ func (s *pgTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, _ uuid.UUID) error {
93
+ return tx.From(ctx, s.db).WithContext(ctx).
94
+ Where("token_hash = ? AND kind = ?", tokenHash, kindRefresh).
95
+ Delete(&model.AuthToken{}).Error
96
+ }
97
+
98
+ // RevokeAllRefreshTokens is one statement here because Postgres can query by
99
+ // value — the Redis store has to keep a separate per-user set to answer this,
100
+ // and that set accumulates stale members its own comment calls out.
101
+ // Tombstones are left in place so reuse detection still works afterwards.
102
+ func (s *pgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
103
+ return tx.From(ctx, s.db).WithContext(ctx).
104
+ Where("user_id = ? AND kind = ?", userID, kindRefresh).
105
+ Delete(&model.AuthToken{}).Error
106
+ }
107
+
108
+ func (s *pgTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
109
+ return s.put(ctx, tokenHash, kindRefreshUsed, userID, ttl)
110
+ }
111
+
112
+ func (s *pgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
113
+ return s.lookup(ctx, tokenHash, kindRefreshUsed)
114
+ }
115
+
116
+ func (s *pgTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
117
+ return s.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
118
+ }
119
+
120
+ func (s *pgTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
121
+ return s.consume(ctx, tokenHash, kindPasswordSet)
122
+ }
123
+
124
+ func (s *pgTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
125
+ return s.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
126
+ }
127
+
128
+ func (s *pgTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
129
+ return s.consume(ctx, tokenHash, kindEmailVerify)
130
+ }
131
+
132
+ // DeleteExpired drops rows nothing can use any more. Correctness never depends
133
+ // on it — every read already filters on expires_at — so it is purely about
134
+ // table size, and calling it on a timer from cmd/worker (or a cron) is enough.
135
+ //
136
+ // ponytail: not scheduled for you. Wire it up the day the table is big enough
137
+ // to notice; a scaffold that silently runs background sweeps nobody asked for
138
+ // is harder to reason about than one line you add when it matters.
139
+ func (s *pgTokenStore) DeleteExpired(ctx context.Context) (int64, error) {
140
+ res := tx.From(ctx, s.db).WithContext(ctx).
141
+ Where("expires_at <= now()").
142
+ Delete(&model.AuthToken{})
143
+ return res.RowsAffected, res.Error
144
+ }
@@ -0,0 +1,147 @@
1
+ // Redis-backed tokenStore — installed by `add auth --store redis`.
2
+ // The Postgres-backed alternative lives in tokenstore_pg.go; only one of the
3
+ // two is ever written into a project, and Service never learns which.
4
+ package user
5
+
6
+ import (
7
+ "context"
8
+ "time"
9
+
10
+ "github.com/google/uuid"
11
+ "github.com/redis/go-redis/v9"
12
+ )
13
+
14
+ const (
15
+ refreshKeyPrefix = "user:refresh:" // +hash -> userID, TTL = refreshTTL
16
+ refreshUserKeyPrefix = "user:refresh:user:" // +userID -> SET of active token hashes
17
+ refreshUsedKeyPrefix = "user:refresh:used:" // +hash -> userID, TTL = refreshTTL, reuse-detection tombstone
18
+ pwresetKeyPrefix = "user:pwreset:" // +hash -> userID, TTL = resetTTL, GETDEL on consume
19
+ emailVerifyKeyPrefix = "user:emailverify:" // +hash -> userID, TTL = emailVerifyTTL, GETDEL on consume
20
+ )
21
+
22
+ type redisTokenStore struct {
23
+ rdb *redis.Client
24
+ }
25
+
26
+ func NewRedisTokenStore(rdb *redis.Client) *redisTokenStore {
27
+ return &redisTokenStore{rdb: rdb}
28
+ }
29
+
30
+ func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
31
+ pipe := s.rdb.TxPipeline()
32
+ pipe.Set(ctx, refreshKeyPrefix+tokenHash, userID.String(), ttl)
33
+ pipe.SAdd(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
34
+ _, err := pipe.Exec(ctx)
35
+ return err
36
+ }
37
+
38
+ func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
39
+ raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
40
+ if err == redis.Nil {
41
+ return uuid.Nil, false, nil
42
+ }
43
+ if err != nil {
44
+ return uuid.Nil, false, err
45
+ }
46
+ id, err := uuid.Parse(raw)
47
+ if err != nil {
48
+ return uuid.Nil, false, err
49
+ }
50
+ return id, true, nil
51
+ }
52
+
53
+ func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
54
+ pipe := s.rdb.TxPipeline()
55
+ pipe.Del(ctx, refreshKeyPrefix+tokenHash)
56
+ pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
57
+ _, err := pipe.Exec(ctx)
58
+ return err
59
+ }
60
+
61
+ // RevokeAllRefreshTokens walks the per-user session set and deletes every
62
+ // active refresh token for that user — used when reuse of an already-rotated
63
+ // token is detected (see Service.Refresh): that means the raw token leaked,
64
+ // so every session, not just the replayed one, is treated as compromised.
65
+ //
66
+ // ponytail: the per-user set has no per-member TTL cleanup of its own — a
67
+ // member outlives its key's TTL as a stale entry until the next revoke or
68
+ // rotation touches it. Self-heals over time; revisit if a single user's
69
+ // session count grows large enough to matter.
70
+ func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
71
+ setKey := refreshUserKeyPrefix + userID.String()
72
+ hashes, err := s.rdb.SMembers(ctx, setKey).Result()
73
+ if err != nil {
74
+ return err
75
+ }
76
+ if len(hashes) == 0 {
77
+ return nil
78
+ }
79
+ pipe := s.rdb.TxPipeline()
80
+ for _, h := range hashes {
81
+ pipe.Del(ctx, refreshKeyPrefix+h)
82
+ }
83
+ pipe.Del(ctx, setKey)
84
+ _, err = pipe.Exec(ctx)
85
+ return err
86
+ }
87
+
88
+ func (s *redisTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
89
+ return s.rdb.Set(ctx, refreshUsedKeyPrefix+tokenHash, userID.String(), ttl).Err()
90
+ }
91
+
92
+ func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
93
+ raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
94
+ if err == redis.Nil {
95
+ return uuid.Nil, false, nil
96
+ }
97
+ if err != nil {
98
+ return uuid.Nil, false, err
99
+ }
100
+ id, err := uuid.Parse(raw)
101
+ if err != nil {
102
+ return uuid.Nil, false, err
103
+ }
104
+ return id, true, nil
105
+ }
106
+
107
+ func (s *redisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
108
+ return s.rdb.Set(ctx, pwresetKeyPrefix+tokenHash, userID.String(), ttl).Err()
109
+ }
110
+
111
+ // ConsumePasswordResetToken is one-time-use by construction: GETDEL is
112
+ // atomic, so a token can't be raced into being consumed twice.
113
+ func (s *redisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
114
+ raw, err := s.rdb.GetDel(ctx, pwresetKeyPrefix+tokenHash).Result()
115
+ if err == redis.Nil {
116
+ return uuid.Nil, false, nil
117
+ }
118
+ if err != nil {
119
+ return uuid.Nil, false, err
120
+ }
121
+ id, err := uuid.Parse(raw)
122
+ if err != nil {
123
+ return uuid.Nil, false, err
124
+ }
125
+ return id, true, nil
126
+ }
127
+
128
+ func (s *redisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
129
+ return s.rdb.Set(ctx, emailVerifyKeyPrefix+tokenHash, userID.String(), ttl).Err()
130
+ }
131
+
132
+ // ConsumeEmailVerifyToken is one-time-use by construction, same as
133
+ // ConsumePasswordResetToken — GETDEL is atomic.
134
+ func (s *redisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
135
+ raw, err := s.rdb.GetDel(ctx, emailVerifyKeyPrefix+tokenHash).Result()
136
+ if err == redis.Nil {
137
+ return uuid.Nil, false, nil
138
+ }
139
+ if err != nil {
140
+ return uuid.Nil, false, err
141
+ }
142
+ id, err := uuid.Parse(raw)
143
+ if err != nil {
144
+ return uuid.Nil, false, err
145
+ }
146
+ return id, true, nil
147
+ }
@@ -1,40 +1,42 @@
1
1
  package middleware
2
2
 
3
3
  import (
4
+ "context"
4
5
  "net/http"
5
6
  "time"
6
7
 
7
8
  "{{goModule}}/internal/shared/apperror"
8
9
 
9
10
  "github.com/gin-gonic/gin"
10
- "github.com/redis/go-redis/v9"
11
11
  )
12
12
 
13
+ // Limiter counts requests per key within a window. Two implementations ship
14
+ // with the scaffold and which one a project gets follows `add auth --store`:
15
+ // in-memory alongside the Postgres token store, Redis alongside the Redis one.
16
+ // They are paired on purpose — "exact across pods" is one decision, not two.
17
+ type Limiter interface {
18
+ // Allow records one hit for key and reports whether it stayed within
19
+ // limit. An implementation that cannot reach its backing store should
20
+ // return true: a limiter outage must not take login down with it.
21
+ Allow(ctx context.Context, key string, limit int, window time.Duration) bool
22
+ }
23
+
13
24
  // RateLimit throttles requests per client IP to `limit` within `window`,
14
25
  // keyed by `name` (a per-route label so /auth/login and /auth/register don't
15
- // share a budget) — a fixed-window counter via Redis INCR+EXPIRE.
26
+ // share a budget) — a fixed-window counter.
16
27
  //
17
28
  // ponytail: fixed-window, not sliding — simple and correct, the tradeoff is
18
29
  // a client can burst up to ~2x limit right at a window boundary (limit
19
30
  // requests just before it resets, then limit more right after). Good enough
20
- // for throttling credential-stuffing/spam, not a hard quota; move to a
21
- // sliding-window log if that boundary burst ever actually matters.
22
- func RateLimit(rdb *redis.Client, name string, limit int, window time.Duration) gin.HandlerFunc {
31
+ // for throttling spam, not a hard quota; move to a sliding-window log if that
32
+ // boundary burst ever actually matters.
33
+ //
34
+ // This is the coarse layer. It keys on c.ClientIP(), which a distributed
35
+ // attacker simply spreads across — the control that actually stops credential
36
+ // stuffing is the per-account lockout in the user service, not this.
37
+ func RateLimit(l Limiter, name string, limit int, window time.Duration) gin.HandlerFunc {
23
38
  return func(c *gin.Context) {
24
- key := "ratelimit:" + name + ":" + c.ClientIP()
25
- ctx := c.Request.Context()
26
-
27
- count, err := rdb.Incr(ctx, key).Result()
28
- if err != nil {
29
- // fail open: a Redis blip shouldn't take down login/register
30
- // entirely — the endpoint's own logic is still the real guard.
31
- c.Next()
32
- return
33
- }
34
- if count == 1 {
35
- rdb.Expire(ctx, key, window)
36
- }
37
- if count > int64(limit) {
39
+ if !l.Allow(c.Request.Context(), name+":"+c.ClientIP(), limit, window) {
38
40
  c.Error(apperror.New(http.StatusTooManyRequests, "RATE_LIMITED", "too many requests, try again later"))
39
41
  c.Abort()
40
42
  return
@@ -0,0 +1,63 @@
1
+ package middleware
2
+
3
+ import (
4
+ "context"
5
+ "sync"
6
+ "time"
7
+ )
8
+
9
+ // MemoryLimiter keeps the counters in this process. It is the limiter that
10
+ // ships alongside the Postgres token store, so a project can run auth with no
11
+ // service beyond its own database.
12
+ //
13
+ // ponytail: per-process budget. Behind N replicas a client effectively gets
14
+ // N x limit, because nothing is shared. That is a deliberate trade: this layer
15
+ // only damps casual abuse, and the control that has to be exact — failed-login
16
+ // lockout — lives in Postgres where every replica sees the same counter.
17
+ // Switch to `add auth --store redis` if you need this layer exact too.
18
+ //
19
+ // Deliberately a fixed-window counter written by hand rather than
20
+ // golang.org/x/time/rate: the Redis limiter is a fixed window, and keeping the
21
+ // two identical means changing --store never changes what a client observes.
22
+ // A token bucket would have been fewer lines and different behaviour.
23
+ type MemoryLimiter struct {
24
+ mu sync.Mutex
25
+ windows map[string]*rateWindow
26
+ lastSweep time.Time
27
+ }
28
+
29
+ type rateWindow struct {
30
+ count int
31
+ resetAt time.Time
32
+ }
33
+
34
+ func NewMemoryLimiter() *MemoryLimiter {
35
+ return &MemoryLimiter{windows: map[string]*rateWindow{}, lastSweep: time.Now()}
36
+ }
37
+
38
+ func (m *MemoryLimiter) Allow(_ context.Context, key string, limit int, window time.Duration) bool {
39
+ now := time.Now()
40
+
41
+ m.mu.Lock()
42
+ defer m.mu.Unlock()
43
+
44
+ // Sweep at most once per window: every entry is dead within one window of
45
+ // its last use, so this is enough to stop the map growing with every
46
+ // distinct client IP, without paying an O(n) walk on each request.
47
+ if now.Sub(m.lastSweep) > window {
48
+ for k, w := range m.windows {
49
+ if now.After(w.resetAt) {
50
+ delete(m.windows, k)
51
+ }
52
+ }
53
+ m.lastSweep = now
54
+ }
55
+
56
+ w, ok := m.windows[key]
57
+ if !ok || now.After(w.resetAt) {
58
+ m.windows[key] = &rateWindow{count: 1, resetAt: now.Add(window)}
59
+ return true
60
+ }
61
+ w.count++
62
+ return w.count <= limit
63
+ }
@@ -0,0 +1,32 @@
1
+ package middleware
2
+
3
+ import (
4
+ "context"
5
+ "time"
6
+
7
+ "github.com/redis/go-redis/v9"
8
+ )
9
+
10
+ // RedisLimiter shares one counter across every replica, so `limit` means the
11
+ // same thing whether you run one pod or ten. It ships alongside the Redis
12
+ // token store (`add auth --store redis`).
13
+ type RedisLimiter struct {
14
+ rdb *redis.Client
15
+ }
16
+
17
+ func NewRedisLimiter(rdb *redis.Client) *RedisLimiter { return &RedisLimiter{rdb: rdb} }
18
+
19
+ func (r *RedisLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) bool {
20
+ redisKey := "ratelimit:" + key
21
+
22
+ count, err := r.rdb.Incr(ctx, redisKey).Result()
23
+ if err != nil {
24
+ // fail open: a Redis blip shouldn't take down login/register entirely
25
+ // — the endpoint's own logic is still the real guard.
26
+ return true
27
+ }
28
+ if count == 1 {
29
+ r.rdb.Expire(ctx, redisKey, window)
30
+ }
31
+ return count <= int64(limit)
32
+ }
@@ -0,0 +1 @@
1
+ DROP TABLE IF EXISTS user_svc.auth_tokens;
@@ -0,0 +1,16 @@
1
+ -- Short-lived auth tokens, for projects scaffolded with `--store postgres`.
2
+ -- Only the SHA-256 hash is stored; see model/authtoken.go for what `kind`
3
+ -- means and why the rotation tombstone is a separate row.
4
+ CREATE TABLE user_svc.auth_tokens (
5
+ token_hash TEXT PRIMARY KEY,
6
+ user_id UUID NOT NULL,
7
+ kind VARCHAR(20) NOT NULL,
8
+ expires_at TIMESTAMPTZ NOT NULL,
9
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
10
+ );
11
+
12
+ -- Named to match model.AuthToken's own `index:` tags exactly — GORM's
13
+ -- AutoMigrate (dev) reconciles against these names, and an index the
14
+ -- migration named differently is what stops the app booting.
15
+ CREATE INDEX idx_auth_tokens_user_kind ON user_svc.auth_tokens (user_id, kind);
16
+ CREATE INDEX idx_auth_tokens_expires_at ON user_svc.auth_tokens (expires_at);
@@ -1 +1 @@
1
- DROP TABLE IF EXISTS identities;
1
+ DROP TABLE IF EXISTS user_svc.identities;
@@ -1,11 +1,15 @@
1
- CREATE TABLE identities (
1
+ CREATE TABLE user_svc.identities (
2
2
  id UUID PRIMARY KEY,
3
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
3
+ user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
4
4
  provider VARCHAR(20) NOT NULL,
5
5
  password_hash TEXT,
6
6
  provider_uid TEXT,
7
7
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9
- UNIQUE (user_id, provider),
10
- UNIQUE (provider, provider_uid)
8
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
11
9
  );
10
+
11
+ -- Named unique indexes matching model.Identity's own
12
+ -- `uniqueIndex:idx_identities_*` tags — see create_users.up.sql for why an
13
+ -- anonymous UNIQUE constraint breaks AutoMigrate.
14
+ CREATE UNIQUE INDEX idx_identities_user_provider ON user_svc.identities (user_id, provider);
15
+ CREATE UNIQUE INDEX idx_identities_provider_uid ON user_svc.identities (provider, provider_uid);
@@ -0,0 +1 @@
1
+ DROP TABLE IF EXISTS user_svc.login_throttle;
@@ -0,0 +1,10 @@
1
+ -- Failed-attempt counter per account. Keyed on a hash of the submitted email
2
+ -- so it works for addresses that have no account (otherwise throttling itself
3
+ -- leaks which addresses are registered) and so this table is not a user list.
4
+ -- See model/loginthrottle.go for why this exists separately from the rate limiter.
5
+ CREATE TABLE user_svc.login_throttle (
6
+ email_hash TEXT PRIMARY KEY,
7
+ failures INTEGER NOT NULL DEFAULT 0,
8
+ locked_until TIMESTAMPTZ,
9
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
10
+ );
@@ -1 +1 @@
1
- DROP TABLE IF EXISTS users;
1
+ DROP TABLE IF EXISTS user_svc.users;