@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
|
@@ -1,90 +1,320 @@
|
|
|
1
1
|
package user
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
-
"fmt"
|
|
5
4
|
"context"
|
|
6
5
|
"errors"
|
|
6
|
+
"fmt"
|
|
7
7
|
"net/http"
|
|
8
|
+
"sync"
|
|
8
9
|
"testing"
|
|
9
10
|
"time"
|
|
10
11
|
|
|
11
12
|
"{{goModule}}/internal/app/user/model"
|
|
13
|
+
"{{goModule}}/internal/app/user/application"
|
|
12
14
|
"{{goModule}}/internal/shared/apperror"
|
|
13
|
-
"{{goModule}}/internal/shared/config"
|
|
14
15
|
|
|
15
16
|
"github.com/google/uuid"
|
|
16
17
|
"gorm.io/gorm"
|
|
17
18
|
)
|
|
18
19
|
|
|
19
|
-
//
|
|
20
|
+
// testTokenStore is the deliberately broad test-only composition used to
|
|
21
|
+
// populate the three independent token ports in Dependencies.
|
|
22
|
+
type testTokenStore interface {
|
|
23
|
+
RefreshTokenStore
|
|
24
|
+
OAuthTransactionStore
|
|
25
|
+
RecoveryTokenStore
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// fakeTokenStore = in-memory mock of the token ports, mirroring the real store's
|
|
20
29
|
// three-map shape (active / used-tombstone / per-user session set) closely
|
|
21
30
|
// enough to exercise rotation + reuse-detection without a real Redis.
|
|
22
31
|
type fakeTokenStore struct {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
mu sync.Mutex
|
|
33
|
+
txMu sync.Mutex
|
|
34
|
+
active map[string]refreshTokenRecord
|
|
35
|
+
used map[string]uuid.UUID
|
|
36
|
+
sessions map[uuid.UUID]map[string]struct{}
|
|
37
|
+
transactions map[string]loginTransaction
|
|
38
|
+
reset map[string]uuid.UUID
|
|
39
|
+
verify map[string]uuid.UUID
|
|
40
|
+
revokeAllErr error
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
func newFakeTokenStore() *fakeTokenStore {
|
|
29
44
|
return &fakeTokenStore{
|
|
30
|
-
active:
|
|
31
|
-
used:
|
|
32
|
-
sessions:
|
|
45
|
+
active: map[string]refreshTokenRecord{},
|
|
46
|
+
used: map[string]uuid.UUID{},
|
|
47
|
+
sessions: map[uuid.UUID]map[string]struct{}{},
|
|
48
|
+
transactions: map[string]loginTransaction{},
|
|
49
|
+
reset: map[string]uuid.UUID{},
|
|
50
|
+
verify: map[string]uuid.UUID{},
|
|
33
51
|
}
|
|
34
52
|
}
|
|
35
53
|
|
|
36
|
-
func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string,
|
|
37
|
-
f.
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, token refreshTokenRecord) error {
|
|
55
|
+
f.mu.Lock()
|
|
56
|
+
defer f.mu.Unlock()
|
|
57
|
+
f.active[hash] = token
|
|
58
|
+
if f.sessions[token.UserID] == nil {
|
|
59
|
+
f.sessions[token.UserID] = map[string]struct{}{}
|
|
40
60
|
}
|
|
41
|
-
f.sessions[
|
|
61
|
+
f.sessions[token.UserID][hash] = struct{}{}
|
|
42
62
|
return nil
|
|
43
63
|
}
|
|
44
64
|
func (f *fakeTokenStore) GetRefreshToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
45
|
-
|
|
46
|
-
|
|
65
|
+
f.mu.Lock()
|
|
66
|
+
defer f.mu.Unlock()
|
|
67
|
+
token, ok := f.active[hash]
|
|
68
|
+
if !ok || !token.ExpiresAt.After(time.Now()) {
|
|
69
|
+
return uuid.Nil, false, nil
|
|
70
|
+
}
|
|
71
|
+
return token.UserID, true, nil
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func (f *fakeTokenStore) ConsumeRefreshToken(_ context.Context, hash string) (refreshTokenRecord, bool, error) {
|
|
75
|
+
f.mu.Lock()
|
|
76
|
+
defer f.mu.Unlock()
|
|
77
|
+
token, ok := f.active[hash]
|
|
78
|
+
if !ok || !token.ExpiresAt.After(time.Now()) {
|
|
79
|
+
return refreshTokenRecord{}, false, nil
|
|
80
|
+
}
|
|
81
|
+
delete(f.active, hash)
|
|
82
|
+
delete(f.sessions[token.UserID], hash)
|
|
83
|
+
f.used[hash] = token.UserID
|
|
84
|
+
return token, true, nil
|
|
47
85
|
}
|
|
86
|
+
|
|
48
87
|
func (f *fakeTokenStore) DeleteRefreshToken(_ context.Context, hash string, userID uuid.UUID) error {
|
|
88
|
+
f.mu.Lock()
|
|
89
|
+
defer f.mu.Unlock()
|
|
49
90
|
delete(f.active, hash)
|
|
50
91
|
delete(f.sessions[userID], hash)
|
|
51
92
|
return nil
|
|
52
93
|
}
|
|
53
94
|
func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.UUID) error {
|
|
95
|
+
f.mu.Lock()
|
|
96
|
+
defer f.mu.Unlock()
|
|
97
|
+
if f.revokeAllErr != nil {
|
|
98
|
+
err := f.revokeAllErr
|
|
99
|
+
f.revokeAllErr = nil
|
|
100
|
+
return err
|
|
101
|
+
}
|
|
54
102
|
for h := range f.sessions[userID] {
|
|
55
103
|
delete(f.active, h)
|
|
56
104
|
}
|
|
57
105
|
delete(f.sessions, userID)
|
|
58
106
|
return nil
|
|
59
107
|
}
|
|
60
|
-
func (f *fakeTokenStore) MarkRefreshTokenUsed(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
|
|
61
|
-
f.used[hash] = userID
|
|
62
|
-
return nil
|
|
63
|
-
}
|
|
64
108
|
func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
109
|
+
f.mu.Lock()
|
|
110
|
+
defer f.mu.Unlock()
|
|
65
111
|
id, ok := f.used[hash]
|
|
66
112
|
return id, ok, nil
|
|
67
113
|
}
|
|
68
|
-
|
|
114
|
+
|
|
115
|
+
func (f *fakeTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
|
|
116
|
+
// A real DB transaction serializes the single-token DELETE against another
|
|
117
|
+
// consumer. Keep the fake's rollback snapshot isolated the same way so a
|
|
118
|
+
// losing concurrent transaction cannot restore the winner's consume.
|
|
119
|
+
f.txMu.Lock()
|
|
120
|
+
defer f.txMu.Unlock()
|
|
121
|
+
|
|
122
|
+
f.mu.Lock()
|
|
123
|
+
active := cloneRefreshTokenMap(f.active)
|
|
124
|
+
used := cloneTokenMap(f.used)
|
|
125
|
+
transactions := cloneLoginTransactions(f.transactions)
|
|
126
|
+
reset := cloneTokenMap(f.reset)
|
|
127
|
+
verify := cloneTokenMap(f.verify)
|
|
128
|
+
sessions := cloneSessions(f.sessions)
|
|
129
|
+
f.mu.Unlock()
|
|
130
|
+
|
|
131
|
+
err := fn(ctx)
|
|
132
|
+
if err == nil {
|
|
133
|
+
return nil
|
|
134
|
+
}
|
|
135
|
+
f.mu.Lock()
|
|
136
|
+
f.active, f.used, f.transactions, f.reset, f.verify, f.sessions = active, used, transactions, reset, verify, sessions
|
|
137
|
+
f.mu.Unlock()
|
|
138
|
+
return err
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func cloneRefreshTokenMap(in map[string]refreshTokenRecord) map[string]refreshTokenRecord {
|
|
142
|
+
out := make(map[string]refreshTokenRecord, len(in))
|
|
143
|
+
for key, value := range in {
|
|
144
|
+
out[key] = value
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func cloneTokenMap(in map[string]uuid.UUID) map[string]uuid.UUID {
|
|
150
|
+
out := make(map[string]uuid.UUID, len(in))
|
|
151
|
+
for key, value := range in {
|
|
152
|
+
out[key] = value
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func cloneLoginTransactions(in map[string]loginTransaction) map[string]loginTransaction {
|
|
158
|
+
out := make(map[string]loginTransaction, len(in))
|
|
159
|
+
for key, value := range in {
|
|
160
|
+
out[key] = value
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func (f *fakeTokenStore) SetLoginTransaction(_ context.Context, hash string, transaction loginTransaction) error {
|
|
166
|
+
f.mu.Lock()
|
|
167
|
+
defer f.mu.Unlock()
|
|
168
|
+
f.transactions[hash] = transaction
|
|
69
169
|
return nil
|
|
70
170
|
}
|
|
71
|
-
|
|
72
|
-
|
|
171
|
+
|
|
172
|
+
func (f *fakeTokenStore) ConsumeLoginTransaction(_ context.Context, hash string) (loginTransaction, bool, error) {
|
|
173
|
+
f.mu.Lock()
|
|
174
|
+
defer f.mu.Unlock()
|
|
175
|
+
transaction, ok := f.transactions[hash]
|
|
176
|
+
if !ok || !transaction.ExpiresAt.After(time.Now()) {
|
|
177
|
+
return loginTransaction{}, false, nil
|
|
178
|
+
}
|
|
179
|
+
delete(f.transactions, hash)
|
|
180
|
+
return transaction, true, nil
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func cloneSessions(in map[uuid.UUID]map[string]struct{}) map[uuid.UUID]map[string]struct{} {
|
|
184
|
+
out := make(map[uuid.UUID]map[string]struct{}, len(in))
|
|
185
|
+
for userID, hashes := range in {
|
|
186
|
+
out[userID] = map[string]struct{}{}
|
|
187
|
+
for hash := range hashes {
|
|
188
|
+
out[userID][hash] = struct{}{}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func (f *fakeTokenStore) SetPasswordResetToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
|
|
195
|
+
f.mu.Lock()
|
|
196
|
+
defer f.mu.Unlock()
|
|
197
|
+
f.reset[hash] = userID
|
|
198
|
+
return nil
|
|
199
|
+
}
|
|
200
|
+
func (f *fakeTokenStore) ConsumePasswordResetToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
201
|
+
f.mu.Lock()
|
|
202
|
+
defer f.mu.Unlock()
|
|
203
|
+
id, ok := f.reset[hash]
|
|
204
|
+
if ok {
|
|
205
|
+
delete(f.reset, hash)
|
|
206
|
+
}
|
|
207
|
+
return id, ok, nil
|
|
73
208
|
}
|
|
74
|
-
func (f *fakeTokenStore) SetEmailVerifyToken(context.Context, string, uuid.UUID, time.Duration) error {
|
|
209
|
+
func (f *fakeTokenStore) SetEmailVerifyToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
|
|
210
|
+
f.mu.Lock()
|
|
211
|
+
defer f.mu.Unlock()
|
|
212
|
+
f.verify[hash] = userID
|
|
75
213
|
return nil
|
|
76
214
|
}
|
|
77
|
-
func (f *fakeTokenStore) ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error) {
|
|
78
|
-
|
|
215
|
+
func (f *fakeTokenStore) ConsumeEmailVerifyToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
216
|
+
f.mu.Lock()
|
|
217
|
+
defer f.mu.Unlock()
|
|
218
|
+
id, ok := f.verify[hash]
|
|
219
|
+
if ok {
|
|
220
|
+
delete(f.verify, hash)
|
|
221
|
+
}
|
|
222
|
+
return id, ok, nil
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type fakeMFAStore struct {
|
|
226
|
+
mu sync.Mutex
|
|
227
|
+
enrollments map[uuid.UUID]MFAEnrollment
|
|
228
|
+
challenges map[string]MFAChallenge
|
|
229
|
+
recovery map[uuid.UUID]map[string]struct{}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func newFakeMFAStore() *fakeMFAStore {
|
|
233
|
+
return &fakeMFAStore{
|
|
234
|
+
enrollments: map[uuid.UUID]MFAEnrollment{},
|
|
235
|
+
challenges: map[string]MFAChallenge{},
|
|
236
|
+
recovery: map[uuid.UUID]map[string]struct{}{},
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func (f *fakeMFAStore) GetEnrollment(_ context.Context, userID uuid.UUID) (MFAEnrollment, bool, error) {
|
|
241
|
+
f.mu.Lock()
|
|
242
|
+
defer f.mu.Unlock()
|
|
243
|
+
enrollment, ok := f.enrollments[userID]
|
|
244
|
+
return enrollment, ok, nil
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
func (f *fakeMFAStore) PutPendingEnrollment(_ context.Context, userID uuid.UUID, secret string) error {
|
|
248
|
+
f.mu.Lock()
|
|
249
|
+
defer f.mu.Unlock()
|
|
250
|
+
if current, ok := f.enrollments[userID]; ok && current.Enabled {
|
|
251
|
+
return errors.New("MFA already enabled")
|
|
252
|
+
}
|
|
253
|
+
f.enrollments[userID] = MFAEnrollment{EncryptedSecret: secret}
|
|
254
|
+
return nil
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func (f *fakeMFAStore) ConfirmEnrollment(_ context.Context, userID uuid.UUID, secret string, hashes []string) error {
|
|
258
|
+
f.mu.Lock()
|
|
259
|
+
defer f.mu.Unlock()
|
|
260
|
+
if len(hashes) == 0 {
|
|
261
|
+
return errors.New("MFA recovery codes are required")
|
|
262
|
+
}
|
|
263
|
+
f.enrollments[userID] = MFAEnrollment{EncryptedSecret: secret, Enabled: true}
|
|
264
|
+
f.recovery[userID] = map[string]struct{}{}
|
|
265
|
+
for _, hash := range hashes {
|
|
266
|
+
f.recovery[userID][hash] = struct{}{}
|
|
267
|
+
}
|
|
268
|
+
return nil
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func (f *fakeMFAStore) Disable(_ context.Context, userID uuid.UUID) error {
|
|
272
|
+
f.mu.Lock()
|
|
273
|
+
defer f.mu.Unlock()
|
|
274
|
+
delete(f.enrollments, userID)
|
|
275
|
+
delete(f.recovery, userID)
|
|
276
|
+
return nil
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func (f *fakeMFAStore) CreateChallenge(_ context.Context, hash string, challenge MFAChallenge) error {
|
|
280
|
+
f.mu.Lock()
|
|
281
|
+
defer f.mu.Unlock()
|
|
282
|
+
f.challenges[hash] = challenge
|
|
283
|
+
return nil
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
func (f *fakeMFAStore) ConsumeChallenge(_ context.Context, hash string) (MFAChallenge, bool, error) {
|
|
287
|
+
f.mu.Lock()
|
|
288
|
+
defer f.mu.Unlock()
|
|
289
|
+
challenge, ok := f.challenges[hash]
|
|
290
|
+
if !ok || !challenge.ExpiresAt.After(time.Now()) {
|
|
291
|
+
return MFAChallenge{}, false, nil
|
|
292
|
+
}
|
|
293
|
+
delete(f.challenges, hash)
|
|
294
|
+
return challenge, true, nil
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func (f *fakeMFAStore) ConsumeRecoveryCode(_ context.Context, userID uuid.UUID, hash string) (bool, error) {
|
|
298
|
+
f.mu.Lock()
|
|
299
|
+
defer f.mu.Unlock()
|
|
300
|
+
codes := f.recovery[userID]
|
|
301
|
+
if _, ok := codes[hash]; !ok {
|
|
302
|
+
return false, nil
|
|
303
|
+
}
|
|
304
|
+
delete(codes, hash)
|
|
305
|
+
return true, nil
|
|
79
306
|
}
|
|
80
307
|
|
|
81
308
|
// fakeRepo = mock of the repository interface — only FindByID is exercised
|
|
82
309
|
// by the tests below (Refresh looks the user up after validating the
|
|
83
310
|
// token), the rest just satisfy the interface.
|
|
84
311
|
type fakeRepo struct {
|
|
85
|
-
user
|
|
86
|
-
|
|
87
|
-
|
|
312
|
+
user *model.User
|
|
313
|
+
identity *model.Identity
|
|
314
|
+
updateIdentityErr error
|
|
315
|
+
updateUserErr error
|
|
316
|
+
failures map[string]int
|
|
317
|
+
lockedUntil map[string]time.Time
|
|
88
318
|
}
|
|
89
319
|
|
|
90
320
|
// throttle: the fake keeps the counter in memory so the lockout path can be
|
|
@@ -125,9 +355,19 @@ func (f *fakeRepo) FindByID(_ context.Context, id uuid.UUID) (*model.User, error
|
|
|
125
355
|
}
|
|
126
356
|
return nil, gorm.ErrRecordNotFound
|
|
127
357
|
}
|
|
128
|
-
func (f *fakeRepo) UpdateUser(context.Context, *model.User) error {
|
|
358
|
+
func (f *fakeRepo) UpdateUser(_ context.Context, u *model.User) error {
|
|
359
|
+
if f.updateUserErr != nil {
|
|
360
|
+
return f.updateUserErr
|
|
361
|
+
}
|
|
362
|
+
f.user = u
|
|
363
|
+
return nil
|
|
364
|
+
}
|
|
129
365
|
func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.User, error) { return nil, nil }
|
|
130
|
-
func (f *fakeRepo) FindIdentity(context.Context, uuid.UUID, model.Provider) (*model.Identity, error) {
|
|
366
|
+
func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, _ model.Provider) (*model.Identity, error) {
|
|
367
|
+
if f.identity != nil && f.identity.UserID == userID {
|
|
368
|
+
copy := *f.identity
|
|
369
|
+
return ©, nil
|
|
370
|
+
}
|
|
131
371
|
return nil, gorm.ErrRecordNotFound
|
|
132
372
|
}
|
|
133
373
|
func (f *fakeRepo) FindIdentityByProviderUID(context.Context, model.Provider, string) (*model.Identity, error) {
|
|
@@ -135,7 +375,15 @@ func (f *fakeRepo) FindIdentityByProviderUID(context.Context, model.Provider, st
|
|
|
135
375
|
}
|
|
136
376
|
func (f *fakeRepo) CreateUserWithIdentity(context.Context, *model.User, *model.Identity) error { return nil }
|
|
137
377
|
func (f *fakeRepo) CreateIdentity(context.Context, *model.Identity) error { return nil }
|
|
138
|
-
func (f *fakeRepo) UpdateIdentity(context.Context, *model.Identity) error
|
|
378
|
+
func (f *fakeRepo) UpdateIdentity(_ context.Context, i *model.Identity) error {
|
|
379
|
+
if f.updateIdentityErr != nil {
|
|
380
|
+
return f.updateIdentityErr
|
|
381
|
+
}
|
|
382
|
+
f.identity = i
|
|
383
|
+
return nil
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// go-scaffold:user-fake-repo-methods
|
|
139
387
|
|
|
140
388
|
type fakeMailer struct{}
|
|
141
389
|
|
|
@@ -147,14 +395,37 @@ func (fakeMailer) Send(context.Context, string, string, string) error { return n
|
|
|
147
395
|
// keeping it singular means `add rbac`'s marker patch only has one place to
|
|
148
396
|
// add the roleChecker arg it introduces (patching every call site
|
|
149
397
|
// individually would need a marker-per-callsite, easy to miss one).
|
|
150
|
-
func newTestService(repo repository, tokens
|
|
151
|
-
return NewService(
|
|
152
|
-
repo,
|
|
153
|
-
tokens,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
398
|
+
func newTestService(repo repository, tokens testTokenStore) *Service {
|
|
399
|
+
return NewService(Dependencies{
|
|
400
|
+
Repository: repo,
|
|
401
|
+
RefreshTokens: tokens,
|
|
402
|
+
OAuthTransactions: tokens,
|
|
403
|
+
RecoveryTokens: tokens,
|
|
404
|
+
MFA: newFakeMFAStore(),
|
|
405
|
+
Mailer: fakeMailer{},
|
|
406
|
+
Providers: application.NewProviderRegistry(),
|
|
407
|
+
// go-scaffold:user-service-test-deps
|
|
408
|
+
}, AuthConfig{
|
|
409
|
+
JWTSecret: "test-secret",
|
|
410
|
+
JWTAccessTTL: time.Minute,
|
|
411
|
+
JWTRefreshTTL: time.Hour,
|
|
412
|
+
JWTRefreshMaxTTL: 24 * time.Hour,
|
|
413
|
+
OAuthStateTTL: 10 * time.Minute,
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func seedRefreshToken(ctx context.Context, tokens *fakeTokenStore, raw string, userID uuid.UUID, ttl time.Duration) error {
|
|
418
|
+
now := time.Now()
|
|
419
|
+
return tokens.SetRefreshToken(ctx, hashToken(raw), refreshTokenRecord{
|
|
420
|
+
UserID: userID,
|
|
421
|
+
ExpiresAt: now.Add(ttl),
|
|
422
|
+
AbsoluteExpiresAt: now.Add(24 * time.Hour),
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
func testRefreshTokenRecord(userID uuid.UUID) refreshTokenRecord {
|
|
427
|
+
now := time.Now()
|
|
428
|
+
return refreshTokenRecord{UserID: userID, ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(24 * time.Hour)}
|
|
158
429
|
}
|
|
159
430
|
|
|
160
431
|
func status(t *testing.T, err error) int {
|
|
@@ -171,7 +442,7 @@ func TestService_Refresh_RotatesTheToken(t *testing.T) {
|
|
|
171
442
|
userID := uuid.New()
|
|
172
443
|
tokens := newFakeTokenStore()
|
|
173
444
|
rawOld := "old-refresh-token"
|
|
174
|
-
if err :=
|
|
445
|
+
if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
|
|
175
446
|
t.Fatalf("seed: %v", err)
|
|
176
447
|
}
|
|
177
448
|
svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
@@ -191,16 +462,51 @@ func TestService_Refresh_RotatesTheToken(t *testing.T) {
|
|
|
191
462
|
}
|
|
192
463
|
}
|
|
193
464
|
|
|
465
|
+
func TestService_Refresh_ConcurrentPresentationHasOneWinner(t *testing.T) {
|
|
466
|
+
ctx := context.Background()
|
|
467
|
+
userID := uuid.New()
|
|
468
|
+
tokens := newFakeTokenStore()
|
|
469
|
+
raw := "one-refresh-token"
|
|
470
|
+
if err := seedRefreshToken(ctx, tokens, raw, userID, time.Hour); err != nil {
|
|
471
|
+
t.Fatalf("seed: %v", err)
|
|
472
|
+
}
|
|
473
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
474
|
+
|
|
475
|
+
const callers = 32
|
|
476
|
+
start := make(chan struct{})
|
|
477
|
+
results := make(chan error, callers)
|
|
478
|
+
for i := 0; i < callers; i++ {
|
|
479
|
+
go func() {
|
|
480
|
+
<-start
|
|
481
|
+
_, err := svc.Refresh(ctx, raw)
|
|
482
|
+
results <- err
|
|
483
|
+
}()
|
|
484
|
+
}
|
|
485
|
+
close(start)
|
|
486
|
+
|
|
487
|
+
winners := 0
|
|
488
|
+
for i := 0; i < callers; i++ {
|
|
489
|
+
if err := <-results; err == nil {
|
|
490
|
+
winners++
|
|
491
|
+
} else if code(err) != "AUTH_INVALID_TOKEN" {
|
|
492
|
+
t.Fatalf("unexpected loser error: %v", err)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if winners != 1 {
|
|
496
|
+
t.Fatalf("expected exactly one refresh winner, got %d", winners)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
194
500
|
func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing.T) {
|
|
195
501
|
ctx := context.Background()
|
|
196
502
|
userID := uuid.New()
|
|
197
503
|
tokens := newFakeTokenStore()
|
|
198
504
|
rawOld := "old-refresh-token"
|
|
199
505
|
rawOtherSession := "another-devices-refresh-token"
|
|
200
|
-
if err :=
|
|
506
|
+
if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
|
|
201
507
|
t.Fatalf("seed: %v", err)
|
|
202
508
|
}
|
|
203
|
-
if err :=
|
|
509
|
+
if err := seedRefreshToken(ctx, tokens, rawOtherSession, userID, time.Hour); err != nil {
|
|
204
510
|
t.Fatalf("seed: %v", err)
|
|
205
511
|
}
|
|
206
512
|
svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
@@ -220,12 +526,70 @@ func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing
|
|
|
220
526
|
}
|
|
221
527
|
}
|
|
222
528
|
|
|
529
|
+
func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
|
|
530
|
+
ctx := context.Background()
|
|
531
|
+
userID := uuid.New()
|
|
532
|
+
tokens := newFakeTokenStore()
|
|
533
|
+
raw := "bounded-refresh-token"
|
|
534
|
+
issuedAt := time.Now()
|
|
535
|
+
absoluteExpiry := issuedAt.Add(2 * time.Hour)
|
|
536
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(raw), refreshTokenRecord{
|
|
537
|
+
UserID: userID,
|
|
538
|
+
ExpiresAt: issuedAt.Add(time.Hour),
|
|
539
|
+
AbsoluteExpiresAt: absoluteExpiry,
|
|
540
|
+
}); err != nil {
|
|
541
|
+
t.Fatalf("seed: %v", err)
|
|
542
|
+
}
|
|
543
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
|
|
544
|
+
rotationTime := issuedAt.Add(90 * time.Minute)
|
|
545
|
+
svc.now = func() time.Time { return rotationTime }
|
|
546
|
+
|
|
547
|
+
auth, err := svc.Refresh(ctx, raw)
|
|
548
|
+
if err != nil {
|
|
549
|
+
t.Fatalf("refresh: %v", err)
|
|
550
|
+
}
|
|
551
|
+
tokens.mu.Lock()
|
|
552
|
+
rotated := tokens.active[hashToken(auth.RefreshToken)]
|
|
553
|
+
tokens.mu.Unlock()
|
|
554
|
+
if !rotated.AbsoluteExpiresAt.Equal(absoluteExpiry) {
|
|
555
|
+
t.Fatalf("absolute expiry moved during rotation: got %s want %s", rotated.AbsoluteExpiresAt, absoluteExpiry)
|
|
556
|
+
}
|
|
557
|
+
if !rotated.ExpiresAt.Equal(absoluteExpiry) {
|
|
558
|
+
t.Fatalf("rotated inactivity expiry was not capped at the absolute expiry: got %s want %s", rotated.ExpiresAt, absoluteExpiry)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
func TestService_Refresh_ReuseRevokeFailureFailsClosed(t *testing.T) {
|
|
563
|
+
ctx := context.Background()
|
|
564
|
+
userID := uuid.New()
|
|
565
|
+
tokens := newFakeTokenStore()
|
|
566
|
+
rawOld := "replayed-refresh-token"
|
|
567
|
+
rawOther := "still-active-refresh-token"
|
|
568
|
+
if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
|
|
569
|
+
t.Fatalf("seed old token: %v", err)
|
|
570
|
+
}
|
|
571
|
+
if err := seedRefreshToken(ctx, tokens, rawOther, userID, time.Hour); err != nil {
|
|
572
|
+
t.Fatalf("seed other token: %v", err)
|
|
573
|
+
}
|
|
574
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
|
|
575
|
+
if _, err := svc.Refresh(ctx, rawOld); err != nil {
|
|
576
|
+
t.Fatalf("initial rotation: %v", err)
|
|
577
|
+
}
|
|
578
|
+
tokens.revokeAllErr = errors.New("session store unavailable")
|
|
579
|
+
if _, err := svc.Refresh(ctx, rawOld); status(t, err) != http.StatusInternalServerError {
|
|
580
|
+
t.Fatalf("reuse with failed revoke must fail closed with 500, got %v", err)
|
|
581
|
+
}
|
|
582
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOther)); !ok {
|
|
583
|
+
t.Fatal("failed revoke must not be reported as a completed session revocation")
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
223
587
|
func TestService_Refresh_UnknownTokenIsRejectedWithoutRevoking(t *testing.T) {
|
|
224
588
|
ctx := context.Background()
|
|
225
589
|
userID := uuid.New()
|
|
226
590
|
tokens := newFakeTokenStore()
|
|
227
591
|
rawLegit := "a-legit-session-token"
|
|
228
|
-
if err :=
|
|
592
|
+
if err := seedRefreshToken(ctx, tokens, rawLegit, userID, time.Hour); err != nil {
|
|
229
593
|
t.Fatalf("seed: %v", err)
|
|
230
594
|
}
|
|
231
595
|
svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
|
|
@@ -248,7 +612,7 @@ func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T)
|
|
|
248
612
|
rawB := "user-session-b"
|
|
249
613
|
rawOther := "other-users-session"
|
|
250
614
|
for raw, id := range map[string]uuid.UUID{rawA: userID, rawB: userID, rawOther: otherUserID} {
|
|
251
|
-
if err :=
|
|
615
|
+
if err := seedRefreshToken(ctx, tokens, raw, id, time.Hour); err != nil {
|
|
252
616
|
t.Fatalf("seed: %v", err)
|
|
253
617
|
}
|
|
254
618
|
}
|
|
@@ -268,6 +632,198 @@ func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T)
|
|
|
268
632
|
}
|
|
269
633
|
}
|
|
270
634
|
|
|
635
|
+
func TestService_ResetPassword_DBFailureAllowsRetry(t *testing.T) {
|
|
636
|
+
ctx := context.Background()
|
|
637
|
+
userID := uuid.New()
|
|
638
|
+
passwordHash := "old-hash"
|
|
639
|
+
repo := &fakeRepo{
|
|
640
|
+
user: &model.User{ID: userID},
|
|
641
|
+
identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
|
|
642
|
+
updateIdentityErr: errors.New("database unavailable"),
|
|
643
|
+
}
|
|
644
|
+
tokens := newFakeTokenStore()
|
|
645
|
+
raw := "reset-token"
|
|
646
|
+
if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
647
|
+
t.Fatalf("seed: %v", err)
|
|
648
|
+
}
|
|
649
|
+
svc := newTestService(repo, tokens)
|
|
650
|
+
|
|
651
|
+
if err := svc.ResetPassword(ctx, raw, "new-password"); status(t, err) != http.StatusInternalServerError {
|
|
652
|
+
t.Fatalf("expected a 500 on the simulated DB failure, got %v", err)
|
|
653
|
+
}
|
|
654
|
+
repo.updateIdentityErr = nil
|
|
655
|
+
if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
|
|
656
|
+
t.Fatalf("retry should succeed after the transaction rollback: %v", err)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
func TestService_ResetPassword_SessionRevokeFailureRestoresTokenForRetry(t *testing.T) {
|
|
661
|
+
ctx := context.Background()
|
|
662
|
+
userID := uuid.New()
|
|
663
|
+
passwordHash := "old-hash"
|
|
664
|
+
repo := &fakeRepo{
|
|
665
|
+
user: &model.User{ID: userID},
|
|
666
|
+
identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
|
|
667
|
+
}
|
|
668
|
+
tokens := newFakeTokenStore()
|
|
669
|
+
raw := "reset-token-session-store-outage"
|
|
670
|
+
if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
671
|
+
t.Fatalf("seed: %v", err)
|
|
672
|
+
}
|
|
673
|
+
tokens.revokeAllErr = errors.New("redis unavailable")
|
|
674
|
+
svc := newTestService(repo, tokens)
|
|
675
|
+
|
|
676
|
+
if err := svc.ResetPassword(ctx, raw, "new-password"); status(t, err) != http.StatusInternalServerError {
|
|
677
|
+
t.Fatalf("expected a 500 while the session store is unavailable, got %v", err)
|
|
678
|
+
}
|
|
679
|
+
if _, ok := tokens.reset[hashToken(raw)]; !ok {
|
|
680
|
+
t.Fatal("expected the reset token to be restored so the caller can retry")
|
|
681
|
+
}
|
|
682
|
+
if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
|
|
683
|
+
t.Fatalf("retry should complete the password reset and session revoke: %v", err)
|
|
684
|
+
}
|
|
685
|
+
if _, ok := tokens.reset[hashToken(raw)]; ok {
|
|
686
|
+
t.Fatal("expected the reset token to be consumed after the retry succeeds")
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
func TestService_VerifyEmail_DBFailureAllowsRetry(t *testing.T) {
|
|
691
|
+
ctx := context.Background()
|
|
692
|
+
userID := uuid.New()
|
|
693
|
+
repo := &fakeRepo{
|
|
694
|
+
user: &model.User{ID: userID},
|
|
695
|
+
updateUserErr: errors.New("database unavailable"),
|
|
696
|
+
}
|
|
697
|
+
tokens := newFakeTokenStore()
|
|
698
|
+
raw := "verify-token"
|
|
699
|
+
if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
700
|
+
t.Fatalf("seed: %v", err)
|
|
701
|
+
}
|
|
702
|
+
svc := newTestService(repo, tokens)
|
|
703
|
+
|
|
704
|
+
if err := svc.VerifyEmail(ctx, raw); status(t, err) != http.StatusInternalServerError {
|
|
705
|
+
t.Fatalf("expected a 500 on the simulated DB failure, got %v", err)
|
|
706
|
+
}
|
|
707
|
+
repo.updateUserErr = nil
|
|
708
|
+
if err := svc.VerifyEmail(ctx, raw); err != nil {
|
|
709
|
+
t.Fatalf("retry should succeed after the transaction rollback: %v", err)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
func TestService_ResetPassword_DuplicateTokenIsRejected(t *testing.T) {
|
|
714
|
+
ctx := context.Background()
|
|
715
|
+
userID := uuid.New()
|
|
716
|
+
passwordHash := "old-hash"
|
|
717
|
+
repo := &fakeRepo{
|
|
718
|
+
user: &model.User{ID: userID},
|
|
719
|
+
identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
|
|
720
|
+
}
|
|
721
|
+
tokens := newFakeTokenStore()
|
|
722
|
+
raw := "one-time-reset-token"
|
|
723
|
+
if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
724
|
+
t.Fatalf("seed: %v", err)
|
|
725
|
+
}
|
|
726
|
+
svc := newTestService(repo, tokens)
|
|
727
|
+
if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
|
|
728
|
+
t.Fatalf("first use: %v", err)
|
|
729
|
+
}
|
|
730
|
+
if err := svc.ResetPassword(ctx, raw, "another-password"); code(err) != "AUTH_INVALID_TOKEN" {
|
|
731
|
+
t.Fatalf("second use should be rejected as invalid, got %v", err)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
func TestService_ResetPassword_ConcurrentPresentationHasOneWinner(t *testing.T) {
|
|
736
|
+
ctx := context.Background()
|
|
737
|
+
userID := uuid.New()
|
|
738
|
+
passwordHash := "old-hash"
|
|
739
|
+
repo := &fakeRepo{
|
|
740
|
+
user: &model.User{ID: userID},
|
|
741
|
+
identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
|
|
742
|
+
}
|
|
743
|
+
tokens := newFakeTokenStore()
|
|
744
|
+
raw := "concurrent-reset-token"
|
|
745
|
+
if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
746
|
+
t.Fatalf("seed: %v", err)
|
|
747
|
+
}
|
|
748
|
+
svc := newTestService(repo, tokens)
|
|
749
|
+
|
|
750
|
+
const callers = 8
|
|
751
|
+
start := make(chan struct{})
|
|
752
|
+
results := make(chan error, callers)
|
|
753
|
+
for i := 0; i < callers; i++ {
|
|
754
|
+
go func() {
|
|
755
|
+
<-start
|
|
756
|
+
results <- svc.ResetPassword(ctx, raw, "new-password")
|
|
757
|
+
}()
|
|
758
|
+
}
|
|
759
|
+
close(start)
|
|
760
|
+
|
|
761
|
+
winners := 0
|
|
762
|
+
for i := 0; i < callers; i++ {
|
|
763
|
+
if err := <-results; err == nil {
|
|
764
|
+
winners++
|
|
765
|
+
} else if code(err) != "AUTH_INVALID_TOKEN" {
|
|
766
|
+
t.Fatalf("unexpected loser error: %v", err)
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if winners != 1 {
|
|
770
|
+
t.Fatalf("expected exactly one reset winner, got %d", winners)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
func TestService_VerifyEmail_DuplicateTokenIsRejected(t *testing.T) {
|
|
775
|
+
ctx := context.Background()
|
|
776
|
+
userID := uuid.New()
|
|
777
|
+
repo := &fakeRepo{user: &model.User{ID: userID}}
|
|
778
|
+
tokens := newFakeTokenStore()
|
|
779
|
+
raw := "one-time-verification-token"
|
|
780
|
+
if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
781
|
+
t.Fatalf("seed: %v", err)
|
|
782
|
+
}
|
|
783
|
+
svc := newTestService(repo, tokens)
|
|
784
|
+
if err := svc.VerifyEmail(ctx, raw); err != nil {
|
|
785
|
+
t.Fatalf("first use: %v", err)
|
|
786
|
+
}
|
|
787
|
+
if err := svc.VerifyEmail(ctx, raw); code(err) != "AUTH_INVALID_TOKEN" {
|
|
788
|
+
t.Fatalf("second use should be rejected as invalid, got %v", err)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
func TestService_VerifyEmail_ConcurrentPresentationHasOneWinner(t *testing.T) {
|
|
793
|
+
ctx := context.Background()
|
|
794
|
+
userID := uuid.New()
|
|
795
|
+
repo := &fakeRepo{user: &model.User{ID: userID}}
|
|
796
|
+
tokens := newFakeTokenStore()
|
|
797
|
+
raw := "concurrent-verification-token"
|
|
798
|
+
if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
799
|
+
t.Fatalf("seed: %v", err)
|
|
800
|
+
}
|
|
801
|
+
svc := newTestService(repo, tokens)
|
|
802
|
+
|
|
803
|
+
const callers = 32
|
|
804
|
+
start := make(chan struct{})
|
|
805
|
+
results := make(chan error, callers)
|
|
806
|
+
for i := 0; i < callers; i++ {
|
|
807
|
+
go func() {
|
|
808
|
+
<-start
|
|
809
|
+
results <- svc.VerifyEmail(ctx, raw)
|
|
810
|
+
}()
|
|
811
|
+
}
|
|
812
|
+
close(start)
|
|
813
|
+
|
|
814
|
+
winners := 0
|
|
815
|
+
for i := 0; i < callers; i++ {
|
|
816
|
+
if err := <-results; err == nil {
|
|
817
|
+
winners++
|
|
818
|
+
} else if code(err) != "AUTH_INVALID_TOKEN" {
|
|
819
|
+
t.Fatalf("unexpected loser error: %v", err)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if winners != 1 {
|
|
823
|
+
t.Fatalf("expected exactly one verification winner, got %d", winners)
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
271
827
|
// The control that actually stops credential stuffing: the counter follows the
|
|
272
828
|
// account, so spreading attempts across a proxy pool doesn't help.
|
|
273
829
|
func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {
|