@nakedev/go-scaffold 0.3.3 → 0.4.3

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 (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -5,33 +5,83 @@ package user
5
5
 
6
6
  import (
7
7
  "context"
8
+ "encoding/json"
9
+ "fmt"
8
10
  "time"
9
11
 
10
12
  "github.com/google/uuid"
11
13
  "github.com/redis/go-redis/v9"
14
+ "gorm.io/gorm"
12
15
  )
13
16
 
14
17
  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
18
+ // The shared {refresh} hash tag keeps all keys touched by the Lua rotation
19
+ // script on one Redis Cluster slot as well as making the operation atomic.
20
+ refreshKeyPrefix = "user:{refresh}:" // +hash -> refreshTokenRecord, TTL = ExpiresAt
21
+ refreshUserKeyPrefix = "user:{refresh}:user:" // +userID -> SET of active token hashes
22
+ refreshUsedKeyPrefix = "user:{refresh}:used:" // +hash -> refreshTokenRecord, TTL = source token expiry
23
+ oauthStateKeyPrefix = "user:{oauth}:state:"
20
24
  )
21
25
 
22
26
  type redisTokenStore struct {
23
- rdb *redis.Client
27
+ rdb *redis.Client
28
+ recovery *recoveryTokenStore
24
29
  }
25
30
 
26
- func NewRedisTokenStore(rdb *redis.Client) *redisTokenStore {
27
- return &redisTokenStore{rdb: rdb}
31
+ func NewRedisTokenStore(rdb *redis.Client, db *gorm.DB) *redisTokenStore {
32
+ return &redisTokenStore{rdb: rdb, recovery: newRecoveryTokenStore(db)}
28
33
  }
29
34
 
30
- func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
35
+ var consumeRefreshScript = redis.NewScript(`
36
+ local token = redis.call("GET", KEYS[1])
37
+ local ttl = redis.call("PTTL", KEYS[1])
38
+ if not token or ttl < 1 then
39
+ -- An empty bulk string maps to a normal "not found" result in go-redis;
40
+ -- Lua false would be surfaced as redis.Nil and look like an infrastructure
41
+ -- failure to Service.Refresh.
42
+ return ""
43
+ end
44
+
45
+ -- Decode and validate the fields used by this script before mutating anything.
46
+ -- Redis Lua does not roll back earlier writes when a later command errors; the
47
+ -- old order could therefore delete a corrupt active token and then fail while
48
+ -- decoding it, losing the only evidence needed to investigate or retry safely.
49
+ local decoded, token_data = pcall(cjson.decode, token)
50
+ if not decoded or type(token_data) ~= "table" or
51
+ type(token_data.user_id) ~= "string" or token_data.user_id == "" or
52
+ type(token_data.expires_at) ~= "string" or token_data.expires_at == "" or
53
+ type(token_data.absolute_expires_at) ~= "string" or token_data.absolute_expires_at == "" then
54
+ return redis.error_reply("invalid refresh token record")
55
+ end
56
+
57
+ redis.call("DEL", KEYS[1])
58
+ redis.call("SREM", KEYS[3] .. token_data.user_id, ARGV[1])
59
+ redis.call("SET", KEYS[2], token, "PX", ttl)
60
+ return token
61
+ `)
62
+
63
+ var revokeAllRefreshScript = redis.NewScript(`
64
+ local hashes = redis.call("SMEMBERS", KEYS[1])
65
+ for _, hash in ipairs(hashes) do
66
+ redis.call("DEL", KEYS[2] .. hash)
67
+ end
68
+ redis.call("DEL", KEYS[1])
69
+ return #hashes
70
+ `)
71
+
72
+ func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token refreshTokenRecord) error {
73
+ ttl := time.Until(token.ExpiresAt)
74
+ if token.UserID == uuid.Nil || ttl <= 0 || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
75
+ return fmt.Errorf("refresh token expiry is invalid")
76
+ }
77
+ payload, err := json.Marshal(token)
78
+ if err != nil {
79
+ return fmt.Errorf("encode refresh token record: %w", err)
80
+ }
31
81
  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)
82
+ pipe.Set(ctx, refreshKeyPrefix+tokenHash, payload, ttl)
83
+ pipe.SAdd(ctx, refreshUserKeyPrefix+token.UserID.String(), tokenHash)
84
+ _, err = pipe.Exec(ctx)
35
85
  return err
36
86
  }
37
87
 
@@ -43,11 +93,33 @@ func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string)
43
93
  if err != nil {
44
94
  return uuid.Nil, false, err
45
95
  }
46
- id, err := uuid.Parse(raw)
96
+ token, err := decodeRefreshTokenRecord(raw)
47
97
  if err != nil {
48
- return uuid.Nil, false, err
98
+ return uuid.Nil, false, fmt.Errorf("decode refresh token record: %w", err)
99
+ }
100
+ return token.UserID, true, nil
101
+ }
102
+
103
+ // ConsumeRefreshToken uses one Lua script for active-token deletion,
104
+ // per-user-session cleanup, and the reuse tombstone. A concurrent caller
105
+ // cannot observe the active value after another caller has won the rotation.
106
+ func (s *redisTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string) (refreshTokenRecord, bool, error) {
107
+ result, err := consumeRefreshScript.Run(ctx, s.rdb,
108
+ []string{refreshKeyPrefix + tokenHash, refreshUsedKeyPrefix + tokenHash, refreshUserKeyPrefix},
109
+ tokenHash,
110
+ ).Result()
111
+ if err != nil {
112
+ return refreshTokenRecord{}, false, err
49
113
  }
50
- return id, true, nil
114
+ raw, ok := result.(string)
115
+ if !ok || raw == "" {
116
+ return refreshTokenRecord{}, false, nil
117
+ }
118
+ token, err := decodeRefreshTokenRecord(raw)
119
+ if err != nil {
120
+ return refreshTokenRecord{}, false, fmt.Errorf("decode consumed refresh token record: %w", err)
121
+ }
122
+ return token, true, nil
51
123
  }
52
124
 
53
125
  func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
@@ -58,37 +130,20 @@ func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash stri
58
130
  return err
59
131
  }
60
132
 
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.
133
+ // RevokeAllRefreshTokens snapshots the per-user session set, deletes every
134
+ // active refresh token, and removes the set in one Lua invocation. This is
135
+ // important because SetRefreshToken updates two keys: a SMEMBERS followed by
136
+ // a separate pipeline could miss a token added between those operations and
137
+ // leave an active token with no session-set member.
70
138
  func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
71
139
  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)
140
+ _, err := revokeAllRefreshScript.Run(ctx, s.rdb,
141
+ []string{setKey, refreshKeyPrefix},
142
+ userID.String(),
143
+ ).Result()
85
144
  return err
86
145
  }
87
146
 
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
147
  func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
93
148
  raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
94
149
  if err == redis.Nil {
@@ -97,51 +152,70 @@ func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash stri
97
152
  if err != nil {
98
153
  return uuid.Nil, false, err
99
154
  }
100
- id, err := uuid.Parse(raw)
155
+ token, err := decodeRefreshTokenRecord(raw)
101
156
  if err != nil {
102
- return uuid.Nil, false, err
157
+ return uuid.Nil, false, fmt.Errorf("decode used refresh token record: %w", err)
103
158
  }
104
- return id, true, nil
159
+ return token.UserID, true, nil
105
160
  }
106
161
 
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()
162
+ func (s *redisTokenStore) SetLoginTransaction(ctx context.Context, stateHash string, transaction loginTransaction) error {
163
+ if !transaction.ExpiresAt.After(time.Now()) {
164
+ return fmt.Errorf("oauth login transaction expiry is invalid")
165
+ }
166
+ payload, err := json.Marshal(transaction)
167
+ if err != nil {
168
+ return fmt.Errorf("encode oauth login transaction: %w", err)
169
+ }
170
+ return s.rdb.Set(ctx, oauthStateKeyPrefix+stateHash, payload, time.Until(transaction.ExpiresAt)).Err()
109
171
  }
110
172
 
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()
173
+ func (s *redisTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash string) (loginTransaction, bool, error) {
174
+ raw, err := s.rdb.GetDel(ctx, oauthStateKeyPrefix+stateHash).Result()
115
175
  if err == redis.Nil {
116
- return uuid.Nil, false, nil
176
+ return loginTransaction{}, false, nil
117
177
  }
118
178
  if err != nil {
119
- return uuid.Nil, false, err
179
+ return loginTransaction{}, false, err
120
180
  }
121
- id, err := uuid.Parse(raw)
122
- if err != nil {
123
- return uuid.Nil, false, err
181
+ var transaction loginTransaction
182
+ if err := json.Unmarshal([]byte(raw), &transaction); err != nil {
183
+ return loginTransaction{}, false, fmt.Errorf("decode oauth login transaction: %w", err)
124
184
  }
125
- return id, true, nil
185
+ return transaction, true, nil
186
+ }
187
+
188
+ func decodeRefreshTokenRecord(raw string) (refreshTokenRecord, error) {
189
+ var token refreshTokenRecord
190
+ if err := json.Unmarshal([]byte(raw), &token); err != nil {
191
+ return refreshTokenRecord{}, err
192
+ }
193
+ if token.UserID == uuid.Nil || token.ExpiresAt.IsZero() || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
194
+ return refreshTokenRecord{}, fmt.Errorf("refresh token record is incomplete")
195
+ }
196
+ return token, nil
197
+ }
198
+
199
+ func (s *redisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
200
+ return s.recovery.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
201
+ }
202
+
203
+ // ConsumePasswordResetToken is one-time-use by construction: the SQL DELETE
204
+ // runs inside the same transaction as the user update.
205
+ func (s *redisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
206
+ return s.recovery.consume(ctx, tokenHash, kindPasswordSet)
126
207
  }
127
208
 
128
209
  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()
210
+ return s.recovery.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
130
211
  }
131
212
 
132
213
  // ConsumeEmailVerifyToken is one-time-use by construction, same as
133
- // ConsumePasswordResetToken — GETDEL is atomic.
214
+ // ConsumePasswordResetToken — the SQL delete participates in the transaction.
134
215
  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
216
+ return s.recovery.consume(ctx, tokenHash, kindEmailVerify)
217
+ }
218
+
219
+ func (s *redisTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
220
+ return s.recovery.withTransaction(ctx, fn)
147
221
  }
@@ -0,0 +1,185 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/google/uuid"
12
+ "github.com/redis/go-redis/v9"
13
+ )
14
+
15
+ func redisClientForTest(t *testing.T) *redis.Client {
16
+ t.Helper()
17
+ url := os.Getenv("TEST_REDIS_URL")
18
+ if url == "" {
19
+ if os.Getenv("REQUIRE_TEST_REDIS") == "true" {
20
+ t.Fatal("TEST_REDIS_URL is required when REQUIRE_TEST_REDIS=true")
21
+ }
22
+ t.Skip("token-store integration test skipped: set TEST_REDIS_URL to a Redis instance")
23
+ }
24
+
25
+ options, err := redis.ParseURL(url)
26
+ if err != nil {
27
+ if os.Getenv("REQUIRE_TEST_REDIS") == "true" {
28
+ t.Fatalf("parse required Redis URL: %v", err)
29
+ }
30
+ t.Skipf("token-store integration test skipped: %v", err)
31
+ }
32
+ client := redis.NewClient(options)
33
+ if err := client.Ping(context.Background()).Err(); err != nil {
34
+ _ = client.Close()
35
+ if os.Getenv("REQUIRE_TEST_REDIS") == "true" {
36
+ t.Fatalf("ping required Redis: %v", err)
37
+ }
38
+ t.Skipf("token-store integration test skipped: %v", err)
39
+ }
40
+ t.Cleanup(func() { _ = client.Close() })
41
+ return client
42
+ }
43
+
44
+ func TestRefreshTokenRecord_JSONUsesLuaFieldNames(t *testing.T) {
45
+ raw, err := json.Marshal(testRefreshTokenRecord(uuid.New()))
46
+ if err != nil {
47
+ t.Fatalf("marshal refresh token record: %v", err)
48
+ }
49
+
50
+ var payload map[string]json.RawMessage
51
+ if err := json.Unmarshal(raw, &payload); err != nil {
52
+ t.Fatalf("decode refresh token record: %v", err)
53
+ }
54
+ if _, ok := payload["user_id"]; !ok {
55
+ t.Fatalf("Redis refresh payload must contain user_id, got %s", raw)
56
+ }
57
+ if _, ok := payload["UserID"]; ok {
58
+ t.Fatalf("Redis refresh payload must not contain Go field name UserID: %s", raw)
59
+ }
60
+ }
61
+
62
+ func TestRedisTokenStore_ConsumeRefreshToken_ConcurrentRealRedis(t *testing.T) {
63
+ client := redisClientForTest(t)
64
+ store := NewRedisTokenStore(client, nil)
65
+ ctx := context.Background()
66
+ userID := uuid.New()
67
+ hash := "real-redis-refresh-" + uuid.NewString()
68
+ if err := store.SetRefreshToken(ctx, hash, testRefreshTokenRecord(userID)); err != nil {
69
+ t.Fatalf("seed refresh token: %v", err)
70
+ }
71
+ t.Cleanup(func() { _ = store.RevokeAllRefreshTokens(ctx, userID) })
72
+
73
+ const callers = 32
74
+ start := make(chan struct{})
75
+ results := make(chan struct {
76
+ token refreshTokenRecord
77
+ ok bool
78
+ err error
79
+ }, callers)
80
+ for i := 0; i < callers; i++ {
81
+ go func() {
82
+ <-start
83
+ token, ok, err := store.ConsumeRefreshToken(ctx, hash)
84
+ results <- struct {
85
+ token refreshTokenRecord
86
+ ok bool
87
+ err error
88
+ }{token: token, ok: ok, err: err}
89
+ }()
90
+ }
91
+ close(start)
92
+
93
+ winners := 0
94
+ for i := 0; i < callers; i++ {
95
+ got := <-results
96
+ if got.err != nil {
97
+ t.Fatalf("concurrent consume: %v", got.err)
98
+ }
99
+ if got.ok {
100
+ winners++
101
+ if got.token.UserID != userID {
102
+ t.Fatalf("winner returned user %s, want %s", got.token.UserID, userID)
103
+ }
104
+ }
105
+ }
106
+ if winners != 1 {
107
+ t.Fatalf("expected exactly one real-Redis refresh winner, got %d", winners)
108
+ }
109
+ }
110
+
111
+ func TestRedisTokenStore_ConsumeRefreshToken_MalformedRecordDoesNotDelete(t *testing.T) {
112
+ client := redisClientForTest(t)
113
+ store := NewRedisTokenStore(client, nil)
114
+ ctx := context.Background()
115
+ hash := "real-redis-malformed-" + uuid.NewString()
116
+ key := refreshKeyPrefix + hash
117
+ if err := client.Set(ctx, key, "not-json", time.Minute).Err(); err != nil {
118
+ t.Fatalf("seed malformed refresh token: %v", err)
119
+ }
120
+ t.Cleanup(func() { _ = client.Del(ctx, key).Err() })
121
+
122
+ if _, ok, err := store.ConsumeRefreshToken(ctx, hash); err == nil || ok {
123
+ t.Fatalf("malformed refresh token must fail without being consumed, ok=%t err=%v", ok, err)
124
+ }
125
+ exists, err := client.Exists(ctx, key).Result()
126
+ if err != nil {
127
+ t.Fatalf("check malformed refresh token: %v", err)
128
+ }
129
+ if exists != 1 {
130
+ t.Fatal("malformed refresh token was deleted before validation")
131
+ }
132
+ }
133
+
134
+ func TestRedisTokenStore_RevokeAllRefreshTokens_IsAtomicWithSet(t *testing.T) {
135
+ client := redisClientForTest(t)
136
+ store := NewRedisTokenStore(client, nil)
137
+ ctx := context.Background()
138
+ userID := uuid.New()
139
+ setKey := refreshUserKeyPrefix + userID.String()
140
+ t.Cleanup(func() { _ = store.RevokeAllRefreshTokens(ctx, userID) })
141
+
142
+ // An invariant is stronger than checking the final session count: an
143
+ // active token must always have a member in the user's set. The old
144
+ // SMEMBERS -> pipeline implementation could leave exactly that split when
145
+ // SetRefreshToken landed between the two phases.
146
+ for i := 0; i < 128; i++ {
147
+ existingHash := fmt.Sprintf("real-redis-existing-%s-%d", userID, i)
148
+ newHash := fmt.Sprintf("real-redis-racing-%s-%d", userID, i)
149
+ if err := store.SetRefreshToken(ctx, existingHash, testRefreshTokenRecord(userID)); err != nil {
150
+ t.Fatalf("seed existing token: %v", err)
151
+ }
152
+
153
+ start := make(chan struct{})
154
+ errs := make(chan error, 2)
155
+ go func() {
156
+ <-start
157
+ errs <- store.RevokeAllRefreshTokens(ctx, userID)
158
+ }()
159
+ go func() {
160
+ <-start
161
+ errs <- store.SetRefreshToken(ctx, newHash, testRefreshTokenRecord(userID))
162
+ }()
163
+ close(start)
164
+ for j := 0; j < 2; j++ {
165
+ if err := <-errs; err != nil {
166
+ t.Fatalf("concurrent revoke/set: %v", err)
167
+ }
168
+ }
169
+
170
+ _, active, err := store.GetRefreshToken(ctx, newHash)
171
+ if err != nil {
172
+ t.Fatalf("read racing token: %v", err)
173
+ }
174
+ member, err := client.SIsMember(ctx, setKey, newHash).Result()
175
+ if err != nil {
176
+ t.Fatalf("read session set: %v", err)
177
+ }
178
+ if active != member {
179
+ t.Fatalf("inconsistent Redis session state at round %d: active=%t set_member=%t", i, active, member)
180
+ }
181
+ if err := store.RevokeAllRefreshTokens(ctx, userID); err != nil {
182
+ t.Fatalf("cleanup round %d: %v", i, err)
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,65 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+
7
+ "{{goModule}}/internal/app/user/model"
8
+ "{{goModule}}/internal/shared/apperror"
9
+ "{{goModule}}/internal/shared/id"
10
+
11
+ "github.com/google/uuid"
12
+ "golang.org/x/crypto/bcrypt"
13
+ "gorm.io/gorm"
14
+ )
15
+
16
+ // wrapFindErr is shared by methods added through `generate method`. Keeping
17
+ // not-found translation in this query file means the small service facade
18
+ // does not need to import database-specific error packages just because a
19
+ // later method was generated.
20
+ func wrapFindErr(err error) error {
21
+ if errors.Is(err, gorm.ErrRecordNotFound) {
22
+ return errNotFound()
23
+ }
24
+ return apperror.NewInternal(err)
25
+ }
26
+
27
+ func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*model.User, error) {
28
+ u, err := s.repo.FindByID(ctx, userID)
29
+ if err != nil {
30
+ return nil, wrapFindErr(err)
31
+ }
32
+ return u, nil
33
+ }
34
+
35
+ // List is kept for the optional RBAC admin routes. It is not part of the
36
+ // public self-service auth surface until that feature is installed.
37
+ func (s *Service) List(ctx context.Context, limit, offset int) ([]model.User, error) {
38
+ items, err := s.repo.FindAll(ctx, limit, offset)
39
+ if err != nil {
40
+ return nil, apperror.NewInternal(err)
41
+ }
42
+ return items, nil
43
+ }
44
+
45
+ // EnsureUser is idempotent and used only by cmd/seed. It never resets an
46
+ // existing user's password as a side effect of a later deployment.
47
+ func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*model.User, error) {
48
+ email = normalizeEmail(email)
49
+ if u, err := s.repo.FindByEmail(ctx, email); err == nil {
50
+ return u, nil
51
+ }
52
+
53
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
54
+ if err != nil {
55
+ return nil, apperror.NewInternal(err)
56
+ }
57
+ hashStr := string(hash)
58
+
59
+ u := &model.User{ID: id.New(), Email: email, Name: name}
60
+ i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
61
+ if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
62
+ return nil, apperror.NewInternal(err)
63
+ }
64
+ return u, nil
65
+ }