@nakedev/go-scaffold 0.4.0 → 0.5.0
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 +598 -306
- package/dist/commands/auth.js +65 -23
- package/dist/commands/check.js +281 -0
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +33 -2
- package/dist/commands/generate.js +29 -3
- package/dist/commands/method.js +74 -63
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +21 -10
- package/dist/commands/undo.js +11 -3
- package/dist/commands/worker.js +15 -5
- package/dist/index.js +198 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +42 -1
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +50 -19
- package/dist/templates/create-manifest.js +8 -0
- package/dist/templates/module-manifest.js +84 -26
- package/dist/templates/rbac-manifest.js +16 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +8 -0
- package/dist/utils/auth-patcher.js +124 -33
- package/dist/utils/config.js +167 -4
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +32 -30
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/platform-patcher.js +56 -7
- package/dist/utils/rbac-patcher.js +89 -210
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +15 -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/adapters/inbound/http/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
- package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
- package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
- package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
- package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -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 +10 -5
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.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 +4 -3
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
- package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
- package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
- package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
- package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
- package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
- package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +279 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +115 -32
- package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- 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 +92 -32
- package/templates/create/features/docs/patterns.md.hbs +137 -91
- package/templates/create/features/docs/techstack.md.hbs +18 -3
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
- package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
- package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
- package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
- package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
- package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
- package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
- package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
- package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
- package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
- package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/dist/utils/method-patcher.js +0 -357
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
- package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
- package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
- package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
- package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
- package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
- package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
- package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
- package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
- package/templates/generate/module/dto.go.hbs +0 -36
- package/templates/generate/module/errors.go.hbs +0 -33
- package/templates/generate/module/handler.go.hbs +0 -134
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -48
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/service.go.hbs +0 -45
- package/templates/generate/module/minimal/service_test.go.hbs +0 -77
- package/templates/generate/module/model/model.go.hbs +0 -36
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -108
- package/templates/generate/module/service_test.go.hbs +0 -161
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/http"
|
|
8
|
+
"sync"
|
|
9
|
+
"testing"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"{{goModule}}/internal/app/user/domain"
|
|
13
|
+
|
|
14
|
+
"github.com/google/uuid"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// testTokenStore is the deliberately broad test-only composition used to
|
|
18
|
+
// populate the three independent token ports in Dependencies.
|
|
19
|
+
type testTokenStore interface {
|
|
20
|
+
RefreshTokenStore
|
|
21
|
+
OAuthTransactionStore
|
|
22
|
+
RecoveryTokenStore
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// fakeTokenStore = in-memory mock of the token ports, mirroring the real store's
|
|
26
|
+
// three-map shape (active / used-tombstone / per-user session set) closely
|
|
27
|
+
// enough to exercise rotation + reuse-detection without a real Redis.
|
|
28
|
+
type fakeTokenStore struct {
|
|
29
|
+
mu sync.Mutex
|
|
30
|
+
txMu sync.Mutex
|
|
31
|
+
active map[string]RefreshTokenRecord
|
|
32
|
+
used map[string]uuid.UUID
|
|
33
|
+
sessions map[uuid.UUID]map[string]struct{}
|
|
34
|
+
transactions map[string]LoginTransaction
|
|
35
|
+
reset map[string]uuid.UUID
|
|
36
|
+
verify map[string]uuid.UUID
|
|
37
|
+
revokeAllErr error
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func newFakeTokenStore() *fakeTokenStore {
|
|
41
|
+
return &fakeTokenStore{
|
|
42
|
+
active: map[string]RefreshTokenRecord{},
|
|
43
|
+
used: map[string]uuid.UUID{},
|
|
44
|
+
sessions: map[uuid.UUID]map[string]struct{}{},
|
|
45
|
+
transactions: map[string]LoginTransaction{},
|
|
46
|
+
reset: map[string]uuid.UUID{},
|
|
47
|
+
verify: map[string]uuid.UUID{},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, token RefreshTokenRecord) error {
|
|
52
|
+
f.mu.Lock()
|
|
53
|
+
defer f.mu.Unlock()
|
|
54
|
+
f.active[hash] = token
|
|
55
|
+
if f.sessions[token.UserID] == nil {
|
|
56
|
+
f.sessions[token.UserID] = map[string]struct{}{}
|
|
57
|
+
}
|
|
58
|
+
f.sessions[token.UserID][hash] = struct{}{}
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
func (f *fakeTokenStore) GetRefreshToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
62
|
+
f.mu.Lock()
|
|
63
|
+
defer f.mu.Unlock()
|
|
64
|
+
token, ok := f.active[hash]
|
|
65
|
+
if !ok || !token.ExpiresAt.After(time.Now()) {
|
|
66
|
+
return uuid.Nil, false, nil
|
|
67
|
+
}
|
|
68
|
+
return token.UserID, true, nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func (f *fakeTokenStore) ConsumeRefreshToken(_ context.Context, hash string) (RefreshTokenRecord, bool, error) {
|
|
72
|
+
f.mu.Lock()
|
|
73
|
+
defer f.mu.Unlock()
|
|
74
|
+
token, ok := f.active[hash]
|
|
75
|
+
if !ok || !token.ExpiresAt.After(time.Now()) {
|
|
76
|
+
return RefreshTokenRecord{}, false, nil
|
|
77
|
+
}
|
|
78
|
+
delete(f.active, hash)
|
|
79
|
+
delete(f.sessions[token.UserID], hash)
|
|
80
|
+
f.used[hash] = token.UserID
|
|
81
|
+
return token, true, nil
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func (f *fakeTokenStore) DeleteRefreshToken(_ context.Context, hash string, userID uuid.UUID) error {
|
|
85
|
+
f.mu.Lock()
|
|
86
|
+
defer f.mu.Unlock()
|
|
87
|
+
delete(f.active, hash)
|
|
88
|
+
delete(f.sessions[userID], hash)
|
|
89
|
+
return nil
|
|
90
|
+
}
|
|
91
|
+
func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.UUID) error {
|
|
92
|
+
f.mu.Lock()
|
|
93
|
+
defer f.mu.Unlock()
|
|
94
|
+
if f.revokeAllErr != nil {
|
|
95
|
+
err := f.revokeAllErr
|
|
96
|
+
f.revokeAllErr = nil
|
|
97
|
+
return err
|
|
98
|
+
}
|
|
99
|
+
for h := range f.sessions[userID] {
|
|
100
|
+
delete(f.active, h)
|
|
101
|
+
}
|
|
102
|
+
delete(f.sessions, userID)
|
|
103
|
+
return nil
|
|
104
|
+
}
|
|
105
|
+
func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
106
|
+
f.mu.Lock()
|
|
107
|
+
defer f.mu.Unlock()
|
|
108
|
+
id, ok := f.used[hash]
|
|
109
|
+
return id, ok, nil
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func (f *fakeTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
|
|
113
|
+
// A real DB transaction serializes the single-token DELETE against another
|
|
114
|
+
// consumer. Keep the fake's rollback snapshot isolated the same way so a
|
|
115
|
+
// losing concurrent transaction cannot restore the winner's consume.
|
|
116
|
+
f.txMu.Lock()
|
|
117
|
+
defer f.txMu.Unlock()
|
|
118
|
+
|
|
119
|
+
f.mu.Lock()
|
|
120
|
+
active := cloneRefreshTokenMap(f.active)
|
|
121
|
+
used := cloneTokenMap(f.used)
|
|
122
|
+
transactions := cloneLoginTransactions(f.transactions)
|
|
123
|
+
reset := cloneTokenMap(f.reset)
|
|
124
|
+
verify := cloneTokenMap(f.verify)
|
|
125
|
+
sessions := cloneSessions(f.sessions)
|
|
126
|
+
f.mu.Unlock()
|
|
127
|
+
|
|
128
|
+
err := fn(ctx)
|
|
129
|
+
if err == nil {
|
|
130
|
+
return nil
|
|
131
|
+
}
|
|
132
|
+
f.mu.Lock()
|
|
133
|
+
f.active, f.used, f.transactions, f.reset, f.verify, f.sessions = active, used, transactions, reset, verify, sessions
|
|
134
|
+
f.mu.Unlock()
|
|
135
|
+
return err
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func cloneRefreshTokenMap(in map[string]RefreshTokenRecord) map[string]RefreshTokenRecord {
|
|
139
|
+
out := make(map[string]RefreshTokenRecord, len(in))
|
|
140
|
+
for key, value := range in {
|
|
141
|
+
out[key] = value
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func cloneTokenMap(in map[string]uuid.UUID) map[string]uuid.UUID {
|
|
147
|
+
out := make(map[string]uuid.UUID, len(in))
|
|
148
|
+
for key, value := range in {
|
|
149
|
+
out[key] = value
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func cloneLoginTransactions(in map[string]LoginTransaction) map[string]LoginTransaction {
|
|
155
|
+
out := make(map[string]LoginTransaction, len(in))
|
|
156
|
+
for key, value := range in {
|
|
157
|
+
out[key] = value
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func (f *fakeTokenStore) SetLoginTransaction(_ context.Context, hash string, transaction LoginTransaction) error {
|
|
163
|
+
f.mu.Lock()
|
|
164
|
+
defer f.mu.Unlock()
|
|
165
|
+
f.transactions[hash] = transaction
|
|
166
|
+
return nil
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func (f *fakeTokenStore) ConsumeLoginTransaction(_ context.Context, hash string) (LoginTransaction, bool, error) {
|
|
170
|
+
f.mu.Lock()
|
|
171
|
+
defer f.mu.Unlock()
|
|
172
|
+
transaction, ok := f.transactions[hash]
|
|
173
|
+
if !ok || !transaction.ExpiresAt.After(time.Now()) {
|
|
174
|
+
return LoginTransaction{}, false, nil
|
|
175
|
+
}
|
|
176
|
+
delete(f.transactions, hash)
|
|
177
|
+
return transaction, true, nil
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func cloneSessions(in map[uuid.UUID]map[string]struct{}) map[uuid.UUID]map[string]struct{} {
|
|
181
|
+
out := make(map[uuid.UUID]map[string]struct{}, len(in))
|
|
182
|
+
for userID, hashes := range in {
|
|
183
|
+
out[userID] = map[string]struct{}{}
|
|
184
|
+
for hash := range hashes {
|
|
185
|
+
out[userID][hash] = struct{}{}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func (f *fakeTokenStore) SetPasswordResetToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
|
|
192
|
+
f.mu.Lock()
|
|
193
|
+
defer f.mu.Unlock()
|
|
194
|
+
f.reset[hash] = userID
|
|
195
|
+
return nil
|
|
196
|
+
}
|
|
197
|
+
func (f *fakeTokenStore) ConsumePasswordResetToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
198
|
+
f.mu.Lock()
|
|
199
|
+
defer f.mu.Unlock()
|
|
200
|
+
id, ok := f.reset[hash]
|
|
201
|
+
if ok {
|
|
202
|
+
delete(f.reset, hash)
|
|
203
|
+
}
|
|
204
|
+
return id, ok, nil
|
|
205
|
+
}
|
|
206
|
+
func (f *fakeTokenStore) SetEmailVerifyToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
|
|
207
|
+
f.mu.Lock()
|
|
208
|
+
defer f.mu.Unlock()
|
|
209
|
+
f.verify[hash] = userID
|
|
210
|
+
return nil
|
|
211
|
+
}
|
|
212
|
+
func (f *fakeTokenStore) ConsumeEmailVerifyToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
213
|
+
f.mu.Lock()
|
|
214
|
+
defer f.mu.Unlock()
|
|
215
|
+
id, ok := f.verify[hash]
|
|
216
|
+
if ok {
|
|
217
|
+
delete(f.verify, hash)
|
|
218
|
+
}
|
|
219
|
+
return id, ok, nil
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
type fakeMFAStore struct {
|
|
223
|
+
mu sync.Mutex
|
|
224
|
+
enrollments map[uuid.UUID]MFAEnrollment
|
|
225
|
+
challenges map[string]MFAChallenge
|
|
226
|
+
recovery map[uuid.UUID]map[string]struct{}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func newFakeMFAStore() *fakeMFAStore {
|
|
230
|
+
return &fakeMFAStore{
|
|
231
|
+
enrollments: map[uuid.UUID]MFAEnrollment{},
|
|
232
|
+
challenges: map[string]MFAChallenge{},
|
|
233
|
+
recovery: map[uuid.UUID]map[string]struct{}{},
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func (f *fakeMFAStore) GetEnrollment(_ context.Context, userID uuid.UUID) (MFAEnrollment, bool, error) {
|
|
238
|
+
f.mu.Lock()
|
|
239
|
+
defer f.mu.Unlock()
|
|
240
|
+
enrollment, ok := f.enrollments[userID]
|
|
241
|
+
return enrollment, ok, nil
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func (f *fakeMFAStore) PutPendingEnrollment(_ context.Context, userID uuid.UUID, secret string) error {
|
|
245
|
+
f.mu.Lock()
|
|
246
|
+
defer f.mu.Unlock()
|
|
247
|
+
if current, ok := f.enrollments[userID]; ok && current.Enabled {
|
|
248
|
+
return errors.New("MFA already enabled")
|
|
249
|
+
}
|
|
250
|
+
f.enrollments[userID] = MFAEnrollment{EncryptedSecret: secret}
|
|
251
|
+
return nil
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func (f *fakeMFAStore) ConfirmEnrollment(_ context.Context, userID uuid.UUID, secret string, hashes []string) error {
|
|
255
|
+
f.mu.Lock()
|
|
256
|
+
defer f.mu.Unlock()
|
|
257
|
+
if len(hashes) == 0 {
|
|
258
|
+
return errors.New("MFA recovery codes are required")
|
|
259
|
+
}
|
|
260
|
+
f.enrollments[userID] = MFAEnrollment{EncryptedSecret: secret, Enabled: true}
|
|
261
|
+
f.recovery[userID] = map[string]struct{}{}
|
|
262
|
+
for _, hash := range hashes {
|
|
263
|
+
f.recovery[userID][hash] = struct{}{}
|
|
264
|
+
}
|
|
265
|
+
return nil
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
func (f *fakeMFAStore) Disable(_ context.Context, userID uuid.UUID) error {
|
|
269
|
+
f.mu.Lock()
|
|
270
|
+
defer f.mu.Unlock()
|
|
271
|
+
delete(f.enrollments, userID)
|
|
272
|
+
delete(f.recovery, userID)
|
|
273
|
+
return nil
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func (f *fakeMFAStore) CreateChallenge(_ context.Context, hash string, challenge MFAChallenge) error {
|
|
277
|
+
f.mu.Lock()
|
|
278
|
+
defer f.mu.Unlock()
|
|
279
|
+
f.challenges[hash] = challenge
|
|
280
|
+
return nil
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
func (f *fakeMFAStore) ConsumeChallenge(_ context.Context, hash string) (MFAChallenge, bool, error) {
|
|
284
|
+
f.mu.Lock()
|
|
285
|
+
defer f.mu.Unlock()
|
|
286
|
+
challenge, ok := f.challenges[hash]
|
|
287
|
+
if !ok || !challenge.ExpiresAt.After(time.Now()) {
|
|
288
|
+
return MFAChallenge{}, false, nil
|
|
289
|
+
}
|
|
290
|
+
delete(f.challenges, hash)
|
|
291
|
+
return challenge, true, nil
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
func (f *fakeMFAStore) ConsumeRecoveryCode(_ context.Context, userID uuid.UUID, hash string) (bool, error) {
|
|
295
|
+
f.mu.Lock()
|
|
296
|
+
defer f.mu.Unlock()
|
|
297
|
+
codes := f.recovery[userID]
|
|
298
|
+
if _, ok := codes[hash]; !ok {
|
|
299
|
+
return false, nil
|
|
300
|
+
}
|
|
301
|
+
delete(codes, hash)
|
|
302
|
+
return true, nil
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// fakeRepo = mock of the repository interface — only FindByID is exercised
|
|
306
|
+
// by the tests below (Refresh looks the user up after validating the
|
|
307
|
+
// token), the rest just satisfy the interface.
|
|
308
|
+
type fakeRepo struct {
|
|
309
|
+
user *domain.User
|
|
310
|
+
identity *domain.Identity
|
|
311
|
+
updateIdentityErr error
|
|
312
|
+
updateUserErr error
|
|
313
|
+
failures map[string]int
|
|
314
|
+
lockedUntil map[string]time.Time
|
|
315
|
+
// go-scaffold:repository-stub-fields
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// throttle: the fake keeps the counter in memory so the lockout path can be
|
|
319
|
+
// exercised without a database. Same shape as the real one — a key, a count,
|
|
320
|
+
// and a time — because what the tests care about is when Service decides to
|
|
321
|
+
// stop asking bcrypt anything.
|
|
322
|
+
func (f *fakeRepo) LoginLockedUntil(_ context.Context, key string) (time.Time, error) {
|
|
323
|
+
return f.lockedUntil[key], nil
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
func (f *fakeRepo) RecordLoginFailure(_ context.Context, key string, freeAttempts int, maxLock time.Duration) error {
|
|
327
|
+
if f.lockedUntil == nil {
|
|
328
|
+
f.lockedUntil = map[string]time.Time{}
|
|
329
|
+
}
|
|
330
|
+
f.failures[key]++
|
|
331
|
+
if f.failures[key] > freeAttempts {
|
|
332
|
+
lock := time.Duration(1<<uint(f.failures[key]-freeAttempts-1)) * time.Second
|
|
333
|
+
if lock > maxLock {
|
|
334
|
+
lock = maxLock
|
|
335
|
+
}
|
|
336
|
+
f.lockedUntil[key] = time.Now().Add(lock)
|
|
337
|
+
}
|
|
338
|
+
return nil
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func (f *fakeRepo) ClearLoginFailures(_ context.Context, key string) error {
|
|
342
|
+
delete(f.failures, key)
|
|
343
|
+
delete(f.lockedUntil, key)
|
|
344
|
+
return nil
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
func (f *fakeRepo) FindByEmail(context.Context, string) (*domain.User, error) {
|
|
348
|
+
return nil, domain.ErrNotFound
|
|
349
|
+
}
|
|
350
|
+
func (f *fakeRepo) FindByID(_ context.Context, id uuid.UUID) (*domain.User, error) {
|
|
351
|
+
if f.user != nil && f.user.ID == id {
|
|
352
|
+
return f.user, nil
|
|
353
|
+
}
|
|
354
|
+
return nil, domain.ErrNotFound
|
|
355
|
+
}
|
|
356
|
+
func (f *fakeRepo) UpdateUser(_ context.Context, u *domain.User) error {
|
|
357
|
+
if f.updateUserErr != nil {
|
|
358
|
+
return f.updateUserErr
|
|
359
|
+
}
|
|
360
|
+
f.user = u
|
|
361
|
+
return nil
|
|
362
|
+
}
|
|
363
|
+
func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.User, error) { return nil, nil }
|
|
364
|
+
func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, _ domain.Provider) (*domain.Identity, error) {
|
|
365
|
+
if f.identity != nil && f.identity.UserID == userID {
|
|
366
|
+
copy := *f.identity
|
|
367
|
+
return ©, nil
|
|
368
|
+
}
|
|
369
|
+
return nil, domain.ErrNotFound
|
|
370
|
+
}
|
|
371
|
+
func (f *fakeRepo) FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error) {
|
|
372
|
+
return nil, domain.ErrNotFound
|
|
373
|
+
}
|
|
374
|
+
func (f *fakeRepo) CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error {
|
|
375
|
+
return nil
|
|
376
|
+
}
|
|
377
|
+
func (f *fakeRepo) CreateIdentity(context.Context, *domain.Identity) error { return nil }
|
|
378
|
+
func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.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
|
|
387
|
+
// go-scaffold:repository-stub-methods
|
|
388
|
+
|
|
389
|
+
type fakeMailer struct{}
|
|
390
|
+
|
|
391
|
+
func (fakeMailer) Send(context.Context, string, string, string) error { return nil }
|
|
392
|
+
|
|
393
|
+
type fakePasswordHasher struct{}
|
|
394
|
+
|
|
395
|
+
func (fakePasswordHasher) Hash(password string) (string, error) { return "hashed:" + password, nil }
|
|
396
|
+
|
|
397
|
+
func (fakePasswordHasher) Compare(hash, password string) error {
|
|
398
|
+
if hash != "hashed:"+password {
|
|
399
|
+
return errors.New("password does not match")
|
|
400
|
+
}
|
|
401
|
+
return nil
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// fakeRoles satisfies role's roleChecker interface structurally.
|
|
405
|
+
type fakeRoles struct{}
|
|
406
|
+
|
|
407
|
+
func (fakeRoles) CodeExists(context.Context, string) (bool, error) { return true, nil }
|
|
408
|
+
|
|
409
|
+
// go-scaffold:user-service-test-types
|
|
410
|
+
|
|
411
|
+
// newTestService is the one NewService call site every test below shares —
|
|
412
|
+
// keeping it singular means `add rbac`'s marker patch only has one place to
|
|
413
|
+
// add the roleChecker arg it introduces (patching every call site
|
|
414
|
+
// individually would need a marker-per-callsite, easy to miss one).
|
|
415
|
+
func newTestService(repo repository, tokens testTokenStore) *Service {
|
|
416
|
+
return NewService(Dependencies{
|
|
417
|
+
Repository: repo,
|
|
418
|
+
Passwords: fakePasswordHasher{},
|
|
419
|
+
RefreshTokens: tokens,
|
|
420
|
+
OAuthTransactions: tokens,
|
|
421
|
+
RecoveryTokens: tokens,
|
|
422
|
+
MFA: newFakeMFAStore(),
|
|
423
|
+
Mailer: fakeMailer{},
|
|
424
|
+
Providers: NewProviderRegistry(),
|
|
425
|
+
Roles: fakeRoles{},
|
|
426
|
+
// go-scaffold:user-service-test-deps
|
|
427
|
+
}, AuthConfig{
|
|
428
|
+
JWTSecret: "test-secret",
|
|
429
|
+
JWTAccessTTL: time.Minute,
|
|
430
|
+
JWTRefreshTTL: time.Hour,
|
|
431
|
+
JWTRefreshMaxTTL: 24 * time.Hour,
|
|
432
|
+
OAuthStateTTL: 10 * time.Minute,
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
func seedRefreshToken(ctx context.Context, tokens *fakeTokenStore, raw string, userID uuid.UUID, ttl time.Duration) error {
|
|
437
|
+
now := time.Now()
|
|
438
|
+
return tokens.SetRefreshToken(ctx, hashToken(raw), RefreshTokenRecord{
|
|
439
|
+
UserID: userID,
|
|
440
|
+
ExpiresAt: now.Add(ttl),
|
|
441
|
+
AbsoluteExpiresAt: now.Add(24 * time.Hour),
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
func status(t *testing.T, err error) int {
|
|
446
|
+
t.Helper()
|
|
447
|
+
var ruleErr *domain.RuleError
|
|
448
|
+
if errors.As(err, &ruleErr) {
|
|
449
|
+
switch ruleErr.Code {
|
|
450
|
+
case "AUTH_INVALID_TOKEN", "AUTH_INVALID_CREDENTIALS":
|
|
451
|
+
return http.StatusUnauthorized
|
|
452
|
+
case "AUTH_TOO_MANY_ATTEMPTS":
|
|
453
|
+
return http.StatusTooManyRequests
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return http.StatusInternalServerError
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
func TestService_Refresh_RotatesTheToken(t *testing.T) {
|
|
460
|
+
ctx := context.Background()
|
|
461
|
+
userID := uuid.New()
|
|
462
|
+
tokens := newFakeTokenStore()
|
|
463
|
+
rawOld := "old-refresh-token"
|
|
464
|
+
if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
|
|
465
|
+
t.Fatalf("seed: %v", err)
|
|
466
|
+
}
|
|
467
|
+
svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
468
|
+
|
|
469
|
+
auth, err := svc.Refresh(ctx, rawOld)
|
|
470
|
+
if err != nil {
|
|
471
|
+
t.Fatalf("unexpected error: %v", err)
|
|
472
|
+
}
|
|
473
|
+
if auth.RefreshToken == "" || auth.RefreshToken == rawOld {
|
|
474
|
+
t.Fatalf("expected a new, different refresh token, got %q", auth.RefreshToken)
|
|
475
|
+
}
|
|
476
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOld)); ok {
|
|
477
|
+
t.Fatal("expected the old refresh token to be consumed (no longer active) after rotation")
|
|
478
|
+
}
|
|
479
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(auth.RefreshToken)); !ok {
|
|
480
|
+
t.Fatal("expected the newly rotated refresh token to be active")
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
func TestService_Refresh_ConcurrentPresentationHasOneWinner(t *testing.T) {
|
|
485
|
+
ctx := context.Background()
|
|
486
|
+
userID := uuid.New()
|
|
487
|
+
tokens := newFakeTokenStore()
|
|
488
|
+
raw := "one-refresh-token"
|
|
489
|
+
if err := seedRefreshToken(ctx, tokens, raw, userID, time.Hour); err != nil {
|
|
490
|
+
t.Fatalf("seed: %v", err)
|
|
491
|
+
}
|
|
492
|
+
svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
493
|
+
|
|
494
|
+
const callers = 32
|
|
495
|
+
start := make(chan struct{})
|
|
496
|
+
results := make(chan error, callers)
|
|
497
|
+
for i := 0; i < callers; i++ {
|
|
498
|
+
go func() {
|
|
499
|
+
<-start
|
|
500
|
+
_, err := svc.Refresh(ctx, raw)
|
|
501
|
+
results <- err
|
|
502
|
+
}()
|
|
503
|
+
}
|
|
504
|
+
close(start)
|
|
505
|
+
|
|
506
|
+
winners := 0
|
|
507
|
+
for i := 0; i < callers; i++ {
|
|
508
|
+
if err := <-results; err == nil {
|
|
509
|
+
winners++
|
|
510
|
+
} else if code(err) != "AUTH_INVALID_TOKEN" {
|
|
511
|
+
t.Fatalf("unexpected loser error: %v", err)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if winners != 1 {
|
|
515
|
+
t.Fatalf("expected exactly one refresh winner, got %d", winners)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing.T) {
|
|
520
|
+
ctx := context.Background()
|
|
521
|
+
userID := uuid.New()
|
|
522
|
+
tokens := newFakeTokenStore()
|
|
523
|
+
rawOld := "old-refresh-token"
|
|
524
|
+
rawOtherSession := "another-devices-refresh-token"
|
|
525
|
+
if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
|
|
526
|
+
t.Fatalf("seed: %v", err)
|
|
527
|
+
}
|
|
528
|
+
if err := seedRefreshToken(ctx, tokens, rawOtherSession, userID, time.Hour); err != nil {
|
|
529
|
+
t.Fatalf("seed: %v", err)
|
|
530
|
+
}
|
|
531
|
+
svc := newTestService(&fakeRepo{user: &domain.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
532
|
+
|
|
533
|
+
// rotate rawOld once — legitimate use, consumes + tombstones it
|
|
534
|
+
if _, err := svc.Refresh(ctx, rawOld); err != nil {
|
|
535
|
+
t.Fatalf("first refresh: unexpected error: %v", err)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// replay the now-rotated-out raw value — this is what happens if it leaked
|
|
539
|
+
_, err := svc.Refresh(ctx, rawOld)
|
|
540
|
+
if got := status(t, err); got != http.StatusUnauthorized {
|
|
541
|
+
t.Fatalf("want 401 replaying a rotated-out token, got %d", got)
|
|
542
|
+
}
|
|
543
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOtherSession)); ok {
|
|
544
|
+
t.Fatal("expected reuse of a rotated-out token to revoke every session for that user, but another session's token is still active")
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
|
|
549
|
+
ctx := context.Background()
|
|
550
|
+
userID := uuid.New()
|
|
551
|
+
tokens := newFakeTokenStore()
|
|
552
|
+
raw := "bounded-refresh-token"
|
|
553
|
+
issuedAt := time.Now()
|
|
554
|
+
absoluteExpiry := issuedAt.Add(2 * time.Hour)
|
|
555
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(raw), RefreshTokenRecord{
|
|
556
|
+
UserID: userID,
|
|
557
|
+
ExpiresAt: issuedAt.Add(time.Hour),
|
|
558
|
+
AbsoluteExpiresAt: absoluteExpiry,
|
|
559
|
+
}); err != nil {
|
|
560
|
+
t.Fatalf("seed: %v", err)
|
|
561
|
+
}
|
|
562
|
+
svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
|
|
563
|
+
rotationTime := issuedAt.Add(90 * time.Minute)
|
|
564
|
+
svc.now = func() time.Time { return rotationTime }
|
|
565
|
+
|
|
566
|
+
auth, err := svc.Refresh(ctx, raw)
|
|
567
|
+
if err != nil {
|
|
568
|
+
t.Fatalf("refresh: %v", err)
|
|
569
|
+
}
|
|
570
|
+
tokens.mu.Lock()
|
|
571
|
+
rotated := tokens.active[hashToken(auth.RefreshToken)]
|
|
572
|
+
tokens.mu.Unlock()
|
|
573
|
+
if !rotated.AbsoluteExpiresAt.Equal(absoluteExpiry) {
|
|
574
|
+
t.Fatalf("absolute expiry moved during rotation: got %s want %s", rotated.AbsoluteExpiresAt, absoluteExpiry)
|
|
575
|
+
}
|
|
576
|
+
if !rotated.ExpiresAt.Equal(absoluteExpiry) {
|
|
577
|
+
t.Fatalf("rotated inactivity expiry was not capped at the absolute expiry: got %s want %s", rotated.ExpiresAt, absoluteExpiry)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
func TestService_Refresh_ReuseRevokeFailureFailsClosed(t *testing.T) {
|
|
582
|
+
ctx := context.Background()
|
|
583
|
+
userID := uuid.New()
|
|
584
|
+
tokens := newFakeTokenStore()
|
|
585
|
+
rawOld := "replayed-refresh-token"
|
|
586
|
+
rawOther := "still-active-refresh-token"
|
|
587
|
+
if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
|
|
588
|
+
t.Fatalf("seed old token: %v", err)
|
|
589
|
+
}
|
|
590
|
+
if err := seedRefreshToken(ctx, tokens, rawOther, userID, time.Hour); err != nil {
|
|
591
|
+
t.Fatalf("seed other token: %v", err)
|
|
592
|
+
}
|
|
593
|
+
svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
|
|
594
|
+
if _, err := svc.Refresh(ctx, rawOld); err != nil {
|
|
595
|
+
t.Fatalf("initial rotation: %v", err)
|
|
596
|
+
}
|
|
597
|
+
tokens.revokeAllErr = errors.New("session store unavailable")
|
|
598
|
+
if _, err := svc.Refresh(ctx, rawOld); status(t, err) != http.StatusInternalServerError {
|
|
599
|
+
t.Fatalf("reuse with failed revoke must fail closed with 500, got %v", err)
|
|
600
|
+
}
|
|
601
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOther)); !ok {
|
|
602
|
+
t.Fatal("failed revoke must not be reported as a completed session revocation")
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
func TestService_Refresh_UnknownTokenIsRejectedWithoutRevoking(t *testing.T) {
|
|
607
|
+
ctx := context.Background()
|
|
608
|
+
userID := uuid.New()
|
|
609
|
+
tokens := newFakeTokenStore()
|
|
610
|
+
rawLegit := "a-legit-session-token"
|
|
611
|
+
if err := seedRefreshToken(ctx, tokens, rawLegit, userID, time.Hour); err != nil {
|
|
612
|
+
t.Fatalf("seed: %v", err)
|
|
613
|
+
}
|
|
614
|
+
svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
|
|
615
|
+
|
|
616
|
+
_, err := svc.Refresh(ctx, "never-issued-token")
|
|
617
|
+
if got := status(t, err); got != http.StatusUnauthorized {
|
|
618
|
+
t.Fatalf("want 401 for an unrecognized token, got %d", got)
|
|
619
|
+
}
|
|
620
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawLegit)); !ok {
|
|
621
|
+
t.Fatal("a never-issued token shouldn't revoke unrelated sessions")
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T) {
|
|
626
|
+
ctx := context.Background()
|
|
627
|
+
userID := uuid.New()
|
|
628
|
+
otherUserID := uuid.New()
|
|
629
|
+
tokens := newFakeTokenStore()
|
|
630
|
+
rawA := "user-session-a"
|
|
631
|
+
rawB := "user-session-b"
|
|
632
|
+
rawOther := "other-users-session"
|
|
633
|
+
for raw, id := range map[string]uuid.UUID{rawA: userID, rawB: userID, rawOther: otherUserID} {
|
|
634
|
+
if err := seedRefreshToken(ctx, tokens, raw, id, time.Hour); err != nil {
|
|
635
|
+
t.Fatalf("seed: %v", err)
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
svc := newTestService(&fakeRepo{user: &domain.User{ID: userID}}, tokens)
|
|
639
|
+
|
|
640
|
+
if err := svc.LogoutAll(ctx, userID); err != nil {
|
|
641
|
+
t.Fatalf("unexpected error: %v", err)
|
|
642
|
+
}
|
|
643
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawA)); ok {
|
|
644
|
+
t.Fatal("expected session A to be revoked")
|
|
645
|
+
}
|
|
646
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawB)); ok {
|
|
647
|
+
t.Fatal("expected session B to be revoked")
|
|
648
|
+
}
|
|
649
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOther)); !ok {
|
|
650
|
+
t.Fatal("expected a different user's session to be untouched by LogoutAll")
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
func TestService_ResetPassword_DBFailureAllowsRetry(t *testing.T) {
|
|
655
|
+
ctx := context.Background()
|
|
656
|
+
userID := uuid.New()
|
|
657
|
+
passwordHash := "old-hash"
|
|
658
|
+
repo := &fakeRepo{
|
|
659
|
+
user: &domain.User{ID: userID},
|
|
660
|
+
identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
|
|
661
|
+
updateIdentityErr: errors.New("database unavailable"),
|
|
662
|
+
}
|
|
663
|
+
tokens := newFakeTokenStore()
|
|
664
|
+
raw := "reset-token"
|
|
665
|
+
if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
666
|
+
t.Fatalf("seed: %v", err)
|
|
667
|
+
}
|
|
668
|
+
svc := newTestService(repo, tokens)
|
|
669
|
+
|
|
670
|
+
if err := svc.ResetPassword(ctx, raw, "new-password"); status(t, err) != http.StatusInternalServerError {
|
|
671
|
+
t.Fatalf("expected a 500 on the simulated DB failure, got %v", err)
|
|
672
|
+
}
|
|
673
|
+
repo.updateIdentityErr = nil
|
|
674
|
+
if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
|
|
675
|
+
t.Fatalf("retry should succeed after the transaction rollback: %v", err)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
func TestService_ResetPassword_SessionRevokeFailureRestoresTokenForRetry(t *testing.T) {
|
|
680
|
+
ctx := context.Background()
|
|
681
|
+
userID := uuid.New()
|
|
682
|
+
passwordHash := "old-hash"
|
|
683
|
+
repo := &fakeRepo{
|
|
684
|
+
user: &domain.User{ID: userID},
|
|
685
|
+
identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
|
|
686
|
+
}
|
|
687
|
+
tokens := newFakeTokenStore()
|
|
688
|
+
raw := "reset-token-session-store-outage"
|
|
689
|
+
if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
690
|
+
t.Fatalf("seed: %v", err)
|
|
691
|
+
}
|
|
692
|
+
tokens.revokeAllErr = errors.New("redis unavailable")
|
|
693
|
+
svc := newTestService(repo, tokens)
|
|
694
|
+
|
|
695
|
+
if err := svc.ResetPassword(ctx, raw, "new-password"); status(t, err) != http.StatusInternalServerError {
|
|
696
|
+
t.Fatalf("expected a 500 while the session store is unavailable, got %v", err)
|
|
697
|
+
}
|
|
698
|
+
if _, ok := tokens.reset[hashToken(raw)]; !ok {
|
|
699
|
+
t.Fatal("expected the reset token to be restored so the caller can retry")
|
|
700
|
+
}
|
|
701
|
+
if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
|
|
702
|
+
t.Fatalf("retry should complete the password reset and session revoke: %v", err)
|
|
703
|
+
}
|
|
704
|
+
if _, ok := tokens.reset[hashToken(raw)]; ok {
|
|
705
|
+
t.Fatal("expected the reset token to be consumed after the retry succeeds")
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
func TestService_VerifyEmail_DBFailureAllowsRetry(t *testing.T) {
|
|
710
|
+
ctx := context.Background()
|
|
711
|
+
userID := uuid.New()
|
|
712
|
+
repo := &fakeRepo{
|
|
713
|
+
user: &domain.User{ID: userID},
|
|
714
|
+
updateUserErr: errors.New("database unavailable"),
|
|
715
|
+
}
|
|
716
|
+
tokens := newFakeTokenStore()
|
|
717
|
+
raw := "verify-token"
|
|
718
|
+
if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
719
|
+
t.Fatalf("seed: %v", err)
|
|
720
|
+
}
|
|
721
|
+
svc := newTestService(repo, tokens)
|
|
722
|
+
|
|
723
|
+
if err := svc.VerifyEmail(ctx, raw); status(t, err) != http.StatusInternalServerError {
|
|
724
|
+
t.Fatalf("expected a 500 on the simulated DB failure, got %v", err)
|
|
725
|
+
}
|
|
726
|
+
repo.updateUserErr = nil
|
|
727
|
+
if err := svc.VerifyEmail(ctx, raw); err != nil {
|
|
728
|
+
t.Fatalf("retry should succeed after the transaction rollback: %v", err)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
func TestService_ResetPassword_DuplicateTokenIsRejected(t *testing.T) {
|
|
733
|
+
ctx := context.Background()
|
|
734
|
+
userID := uuid.New()
|
|
735
|
+
passwordHash := "old-hash"
|
|
736
|
+
repo := &fakeRepo{
|
|
737
|
+
user: &domain.User{ID: userID},
|
|
738
|
+
identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
|
|
739
|
+
}
|
|
740
|
+
tokens := newFakeTokenStore()
|
|
741
|
+
raw := "one-time-reset-token"
|
|
742
|
+
if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
743
|
+
t.Fatalf("seed: %v", err)
|
|
744
|
+
}
|
|
745
|
+
svc := newTestService(repo, tokens)
|
|
746
|
+
if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
|
|
747
|
+
t.Fatalf("first use: %v", err)
|
|
748
|
+
}
|
|
749
|
+
if err := svc.ResetPassword(ctx, raw, "another-password"); code(err) != "AUTH_INVALID_TOKEN" {
|
|
750
|
+
t.Fatalf("second use should be rejected as invalid, got %v", err)
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
func TestService_ResetPassword_ConcurrentPresentationHasOneWinner(t *testing.T) {
|
|
755
|
+
ctx := context.Background()
|
|
756
|
+
userID := uuid.New()
|
|
757
|
+
passwordHash := "old-hash"
|
|
758
|
+
repo := &fakeRepo{
|
|
759
|
+
user: &domain.User{ID: userID},
|
|
760
|
+
identity: &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &passwordHash},
|
|
761
|
+
}
|
|
762
|
+
tokens := newFakeTokenStore()
|
|
763
|
+
raw := "concurrent-reset-token"
|
|
764
|
+
if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
765
|
+
t.Fatalf("seed: %v", err)
|
|
766
|
+
}
|
|
767
|
+
svc := newTestService(repo, tokens)
|
|
768
|
+
|
|
769
|
+
const callers = 8
|
|
770
|
+
start := make(chan struct{})
|
|
771
|
+
results := make(chan error, callers)
|
|
772
|
+
for i := 0; i < callers; i++ {
|
|
773
|
+
go func() {
|
|
774
|
+
<-start
|
|
775
|
+
results <- svc.ResetPassword(ctx, raw, "new-password")
|
|
776
|
+
}()
|
|
777
|
+
}
|
|
778
|
+
close(start)
|
|
779
|
+
|
|
780
|
+
winners := 0
|
|
781
|
+
for i := 0; i < callers; i++ {
|
|
782
|
+
if err := <-results; err == nil {
|
|
783
|
+
winners++
|
|
784
|
+
} else if code(err) != "AUTH_INVALID_TOKEN" {
|
|
785
|
+
t.Fatalf("unexpected loser error: %v", err)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if winners != 1 {
|
|
789
|
+
t.Fatalf("expected exactly one reset winner, got %d", winners)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
func TestService_VerifyEmail_DuplicateTokenIsRejected(t *testing.T) {
|
|
794
|
+
ctx := context.Background()
|
|
795
|
+
userID := uuid.New()
|
|
796
|
+
repo := &fakeRepo{user: &domain.User{ID: userID}}
|
|
797
|
+
tokens := newFakeTokenStore()
|
|
798
|
+
raw := "one-time-verification-token"
|
|
799
|
+
if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
800
|
+
t.Fatalf("seed: %v", err)
|
|
801
|
+
}
|
|
802
|
+
svc := newTestService(repo, tokens)
|
|
803
|
+
if err := svc.VerifyEmail(ctx, raw); err != nil {
|
|
804
|
+
t.Fatalf("first use: %v", err)
|
|
805
|
+
}
|
|
806
|
+
if err := svc.VerifyEmail(ctx, raw); code(err) != "AUTH_INVALID_TOKEN" {
|
|
807
|
+
t.Fatalf("second use should be rejected as invalid, got %v", err)
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
func TestService_VerifyEmail_ConcurrentPresentationHasOneWinner(t *testing.T) {
|
|
812
|
+
ctx := context.Background()
|
|
813
|
+
userID := uuid.New()
|
|
814
|
+
repo := &fakeRepo{user: &domain.User{ID: userID}}
|
|
815
|
+
tokens := newFakeTokenStore()
|
|
816
|
+
raw := "concurrent-verification-token"
|
|
817
|
+
if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
|
|
818
|
+
t.Fatalf("seed: %v", err)
|
|
819
|
+
}
|
|
820
|
+
svc := newTestService(repo, tokens)
|
|
821
|
+
|
|
822
|
+
const callers = 32
|
|
823
|
+
start := make(chan struct{})
|
|
824
|
+
results := make(chan error, callers)
|
|
825
|
+
for i := 0; i < callers; i++ {
|
|
826
|
+
go func() {
|
|
827
|
+
<-start
|
|
828
|
+
results <- svc.VerifyEmail(ctx, raw)
|
|
829
|
+
}()
|
|
830
|
+
}
|
|
831
|
+
close(start)
|
|
832
|
+
|
|
833
|
+
winners := 0
|
|
834
|
+
for i := 0; i < callers; i++ {
|
|
835
|
+
if err := <-results; err == nil {
|
|
836
|
+
winners++
|
|
837
|
+
} else if code(err) != "AUTH_INVALID_TOKEN" {
|
|
838
|
+
t.Fatalf("unexpected loser error: %v", err)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if winners != 1 {
|
|
842
|
+
t.Fatalf("expected exactly one verification winner, got %d", winners)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// The control that actually stops credential stuffing: the counter follows the
|
|
847
|
+
// account, so spreading attempts across a proxy pool doesn't help.
|
|
848
|
+
func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {
|
|
849
|
+
repo := &fakeRepo{
|
|
850
|
+
user: &domain.User{ID: uuid.New(), Email: "a@example.com"},
|
|
851
|
+
failures: map[string]int{},
|
|
852
|
+
}
|
|
853
|
+
svc := newTestService(repo, newFakeTokenStore())
|
|
854
|
+
in := LoginInput{Email: "a@example.com", Password: "wrong"}
|
|
855
|
+
|
|
856
|
+
// the free attempts answer "wrong password", not "locked"
|
|
857
|
+
for i := 0; i < loginFreeAttempts; i++ {
|
|
858
|
+
if _, err := svc.Login(context.Background(), in); err == nil {
|
|
859
|
+
t.Fatalf("attempt %d: expected a failure", i+1)
|
|
860
|
+
} else if code(err) != "AUTH_INVALID_CREDENTIALS" {
|
|
861
|
+
t.Fatalf("attempt %d: expected AUTH_INVALID_CREDENTIALS, got %s", i+1, code(err))
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// the next one trips the lock...
|
|
866
|
+
if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_INVALID_CREDENTIALS" {
|
|
867
|
+
t.Fatalf("the attempt that trips the lock still answers as a bad password, got %s", code(err))
|
|
868
|
+
}
|
|
869
|
+
// ...and everything after it is refused before any password is checked
|
|
870
|
+
if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_TOO_MANY_ATTEMPTS" {
|
|
871
|
+
t.Fatalf("expected AUTH_TOO_MANY_ATTEMPTS once locked, got %s", code(err))
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// A locked login must not lock the reset that fixes it — the person who forgot
|
|
876
|
+
// their password is exactly the person who trips the login counter.
|
|
877
|
+
func TestService_ForgotPassword_HasItsOwnCounter(t *testing.T) {
|
|
878
|
+
if throttleKey("login", "a@example.com") == throttleKey("pwreset", "a@example.com") {
|
|
879
|
+
t.Fatal("login and password-reset share a throttle key; a locked login would block recovery")
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// code pulls the AppError code out, so a test asserting on behaviour doesn't
|
|
884
|
+
// have to care how the error is wrapped.
|
|
885
|
+
func code(err error) string {
|
|
886
|
+
var ruleErr *domain.RuleError
|
|
887
|
+
if errors.As(err, &ruleErr) {
|
|
888
|
+
return ruleErr.Code
|
|
889
|
+
}
|
|
890
|
+
return fmt.Sprintf("%v", err)
|
|
891
|
+
}
|