@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.
- package/README.md +288 -50
- package/dist/commands/auth.js +53 -22
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +32 -2
- package/dist/commands/generate.js +25 -2
- package/dist/commands/method.js +22 -7
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +3 -3
- package/dist/commands/rbac.js +3 -3
- package/dist/commands/undo.js +5 -0
- package/dist/commands/worker.js +1 -1
- package/dist/index.js +186 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +43 -2
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +31 -1
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +37 -1
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/auth-patcher.js +115 -24
- package/dist/utils/config.js +147 -3
- package/dist/utils/main-patcher.js +29 -27
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/method-patcher.js +261 -81
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/observability-patcher.js +2 -2
- package/dist/utils/platform-patcher.js +29 -7
- package/dist/utils/rbac-patcher.js +97 -75
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
- package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
- package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
- package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
- package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
- package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
- package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
- package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
- package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
- package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
- package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
- package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
- package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
- package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
- package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
- package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
- package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
- package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/AGENTS.md.hbs +255 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +45 -17
- package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +38 -16
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +3 -3
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
33
|
-
pipe.SAdd(ctx, refreshUserKeyPrefix+
|
|
34
|
-
_, err
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
// active refresh token
|
|
63
|
-
//
|
|
64
|
-
//
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
159
|
+
return token.UserID, true, nil
|
|
105
160
|
}
|
|
106
161
|
|
|
107
|
-
func (s *redisTokenStore)
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
176
|
+
return loginTransaction{}, false, nil
|
|
117
177
|
}
|
|
118
178
|
if err != nil {
|
|
119
|
-
return
|
|
179
|
+
return loginTransaction{}, false, err
|
|
120
180
|
}
|
|
121
|
-
|
|
122
|
-
if err != nil {
|
|
123
|
-
return
|
|
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
|
|
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.
|
|
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 —
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
}
|