@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
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
package user
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"fmt"
|
|
5
|
-
"context"
|
|
6
|
-
"errors"
|
|
7
|
-
"net/http"
|
|
8
|
-
"testing"
|
|
9
|
-
"time"
|
|
10
|
-
|
|
11
|
-
"{{goModule}}/internal/app/user/model"
|
|
12
|
-
"{{goModule}}/internal/shared/apperror"
|
|
13
|
-
"{{goModule}}/internal/shared/config"
|
|
14
|
-
|
|
15
|
-
"github.com/google/uuid"
|
|
16
|
-
"gorm.io/gorm"
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
// fakeTokenStore = in-memory mock of tokenStore, mirroring the real store's
|
|
20
|
-
// three-map shape (active / used-tombstone / per-user session set) closely
|
|
21
|
-
// enough to exercise rotation + reuse-detection without a real Redis.
|
|
22
|
-
type fakeTokenStore struct {
|
|
23
|
-
active map[string]uuid.UUID
|
|
24
|
-
used map[string]uuid.UUID
|
|
25
|
-
sessions map[uuid.UUID]map[string]struct{}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
func newFakeTokenStore() *fakeTokenStore {
|
|
29
|
-
return &fakeTokenStore{
|
|
30
|
-
active: map[string]uuid.UUID{},
|
|
31
|
-
used: map[string]uuid.UUID{},
|
|
32
|
-
sessions: map[uuid.UUID]map[string]struct{}{},
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
|
|
37
|
-
f.active[hash] = userID
|
|
38
|
-
if f.sessions[userID] == nil {
|
|
39
|
-
f.sessions[userID] = map[string]struct{}{}
|
|
40
|
-
}
|
|
41
|
-
f.sessions[userID][hash] = struct{}{}
|
|
42
|
-
return nil
|
|
43
|
-
}
|
|
44
|
-
func (f *fakeTokenStore) GetRefreshToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
45
|
-
id, ok := f.active[hash]
|
|
46
|
-
return id, ok, nil
|
|
47
|
-
}
|
|
48
|
-
func (f *fakeTokenStore) DeleteRefreshToken(_ context.Context, hash string, userID uuid.UUID) error {
|
|
49
|
-
delete(f.active, hash)
|
|
50
|
-
delete(f.sessions[userID], hash)
|
|
51
|
-
return nil
|
|
52
|
-
}
|
|
53
|
-
func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.UUID) error {
|
|
54
|
-
for h := range f.sessions[userID] {
|
|
55
|
-
delete(f.active, h)
|
|
56
|
-
}
|
|
57
|
-
delete(f.sessions, userID)
|
|
58
|
-
return nil
|
|
59
|
-
}
|
|
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
|
-
func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
65
|
-
id, ok := f.used[hash]
|
|
66
|
-
return id, ok, nil
|
|
67
|
-
}
|
|
68
|
-
func (f *fakeTokenStore) SetPasswordResetToken(context.Context, string, uuid.UUID, time.Duration) error {
|
|
69
|
-
return nil
|
|
70
|
-
}
|
|
71
|
-
func (f *fakeTokenStore) ConsumePasswordResetToken(context.Context, string) (uuid.UUID, bool, error) {
|
|
72
|
-
return uuid.Nil, false, nil
|
|
73
|
-
}
|
|
74
|
-
func (f *fakeTokenStore) SetEmailVerifyToken(context.Context, string, uuid.UUID, time.Duration) error {
|
|
75
|
-
return nil
|
|
76
|
-
}
|
|
77
|
-
func (f *fakeTokenStore) ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error) {
|
|
78
|
-
return uuid.Nil, false, nil
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// fakeRepo = mock of the repository interface — only FindByID is exercised
|
|
82
|
-
// by the tests below (Refresh looks the user up after validating the
|
|
83
|
-
// token), the rest just satisfy the interface.
|
|
84
|
-
type fakeRepo struct {
|
|
85
|
-
user *model.User
|
|
86
|
-
failures map[string]int
|
|
87
|
-
lockedUntil map[string]time.Time
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// throttle: the fake keeps the counter in memory so the lockout path can be
|
|
91
|
-
// exercised without a database. Same shape as the real one — a key, a count,
|
|
92
|
-
// and a time — because what the tests care about is when Service decides to
|
|
93
|
-
// stop asking bcrypt anything.
|
|
94
|
-
func (f *fakeRepo) LoginLockedUntil(_ context.Context, key string) (time.Time, error) {
|
|
95
|
-
return f.lockedUntil[key], nil
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
func (f *fakeRepo) RecordLoginFailure(_ context.Context, key string, freeAttempts int, maxLock time.Duration) error {
|
|
99
|
-
if f.lockedUntil == nil {
|
|
100
|
-
f.lockedUntil = map[string]time.Time{}
|
|
101
|
-
}
|
|
102
|
-
f.failures[key]++
|
|
103
|
-
if f.failures[key] > freeAttempts {
|
|
104
|
-
lock := time.Duration(1<<uint(f.failures[key]-freeAttempts-1)) * time.Second
|
|
105
|
-
if lock > maxLock {
|
|
106
|
-
lock = maxLock
|
|
107
|
-
}
|
|
108
|
-
f.lockedUntil[key] = time.Now().Add(lock)
|
|
109
|
-
}
|
|
110
|
-
return nil
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
func (f *fakeRepo) ClearLoginFailures(_ context.Context, key string) error {
|
|
114
|
-
delete(f.failures, key)
|
|
115
|
-
delete(f.lockedUntil, key)
|
|
116
|
-
return nil
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
func (f *fakeRepo) FindByEmail(context.Context, string) (*model.User, error) {
|
|
120
|
-
return nil, gorm.ErrRecordNotFound
|
|
121
|
-
}
|
|
122
|
-
func (f *fakeRepo) FindByID(_ context.Context, id uuid.UUID) (*model.User, error) {
|
|
123
|
-
if f.user != nil && f.user.ID == id {
|
|
124
|
-
return f.user, nil
|
|
125
|
-
}
|
|
126
|
-
return nil, gorm.ErrRecordNotFound
|
|
127
|
-
}
|
|
128
|
-
func (f *fakeRepo) UpdateUser(context.Context, *model.User) error { return nil }
|
|
129
|
-
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) {
|
|
131
|
-
return nil, gorm.ErrRecordNotFound
|
|
132
|
-
}
|
|
133
|
-
func (f *fakeRepo) FindIdentityByProviderUID(context.Context, model.Provider, string) (*model.Identity, error) {
|
|
134
|
-
return nil, gorm.ErrRecordNotFound
|
|
135
|
-
}
|
|
136
|
-
func (f *fakeRepo) CreateUserWithIdentity(context.Context, *model.User, *model.Identity) error { return nil }
|
|
137
|
-
func (f *fakeRepo) CreateIdentity(context.Context, *model.Identity) error { return nil }
|
|
138
|
-
func (f *fakeRepo) UpdateIdentity(context.Context, *model.Identity) error { return nil }
|
|
139
|
-
|
|
140
|
-
type fakeMailer struct{}
|
|
141
|
-
|
|
142
|
-
func (fakeMailer) Send(context.Context, string, string, string) error { return nil }
|
|
143
|
-
|
|
144
|
-
// go-scaffold:user-service-test-types
|
|
145
|
-
|
|
146
|
-
// newTestService is the one NewService call site every test below shares —
|
|
147
|
-
// keeping it singular means `add rbac`'s marker patch only has one place to
|
|
148
|
-
// add the roleChecker arg it introduces (patching every call site
|
|
149
|
-
// individually would need a marker-per-callsite, easy to miss one).
|
|
150
|
-
func newTestService(repo repository, tokens tokenStore) *Service {
|
|
151
|
-
return NewService(
|
|
152
|
-
repo,
|
|
153
|
-
tokens,
|
|
154
|
-
fakeMailer{},
|
|
155
|
-
config.Config{JWTSecret: "test-secret", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour},
|
|
156
|
-
// go-scaffold:user-service-test-args
|
|
157
|
-
)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
func status(t *testing.T, err error) int {
|
|
161
|
-
t.Helper()
|
|
162
|
-
var appErr *apperror.AppError
|
|
163
|
-
if !errors.As(err, &appErr) {
|
|
164
|
-
t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
|
|
165
|
-
}
|
|
166
|
-
return appErr.HTTPStatus
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
func TestService_Refresh_RotatesTheToken(t *testing.T) {
|
|
170
|
-
ctx := context.Background()
|
|
171
|
-
userID := uuid.New()
|
|
172
|
-
tokens := newFakeTokenStore()
|
|
173
|
-
rawOld := "old-refresh-token"
|
|
174
|
-
if err := tokens.SetRefreshToken(ctx, hashToken(rawOld), userID, time.Hour); err != nil {
|
|
175
|
-
t.Fatalf("seed: %v", err)
|
|
176
|
-
}
|
|
177
|
-
svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
178
|
-
|
|
179
|
-
auth, err := svc.Refresh(ctx, rawOld)
|
|
180
|
-
if err != nil {
|
|
181
|
-
t.Fatalf("unexpected error: %v", err)
|
|
182
|
-
}
|
|
183
|
-
if auth.RefreshToken == "" || auth.RefreshToken == rawOld {
|
|
184
|
-
t.Fatalf("expected a new, different refresh token, got %q", auth.RefreshToken)
|
|
185
|
-
}
|
|
186
|
-
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOld)); ok {
|
|
187
|
-
t.Fatal("expected the old refresh token to be consumed (no longer active) after rotation")
|
|
188
|
-
}
|
|
189
|
-
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(auth.RefreshToken)); !ok {
|
|
190
|
-
t.Fatal("expected the newly rotated refresh token to be active")
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing.T) {
|
|
195
|
-
ctx := context.Background()
|
|
196
|
-
userID := uuid.New()
|
|
197
|
-
tokens := newFakeTokenStore()
|
|
198
|
-
rawOld := "old-refresh-token"
|
|
199
|
-
rawOtherSession := "another-devices-refresh-token"
|
|
200
|
-
if err := tokens.SetRefreshToken(ctx, hashToken(rawOld), userID, time.Hour); err != nil {
|
|
201
|
-
t.Fatalf("seed: %v", err)
|
|
202
|
-
}
|
|
203
|
-
if err := tokens.SetRefreshToken(ctx, hashToken(rawOtherSession), userID, time.Hour); err != nil {
|
|
204
|
-
t.Fatalf("seed: %v", err)
|
|
205
|
-
}
|
|
206
|
-
svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
207
|
-
|
|
208
|
-
// rotate rawOld once — legitimate use, consumes + tombstones it
|
|
209
|
-
if _, err := svc.Refresh(ctx, rawOld); err != nil {
|
|
210
|
-
t.Fatalf("first refresh: unexpected error: %v", err)
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// replay the now-rotated-out raw value — this is what happens if it leaked
|
|
214
|
-
_, err := svc.Refresh(ctx, rawOld)
|
|
215
|
-
if got := status(t, err); got != http.StatusUnauthorized {
|
|
216
|
-
t.Fatalf("want 401 replaying a rotated-out token, got %d", got)
|
|
217
|
-
}
|
|
218
|
-
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOtherSession)); ok {
|
|
219
|
-
t.Fatal("expected reuse of a rotated-out token to revoke every session for that user, but another session's token is still active")
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
func TestService_Refresh_UnknownTokenIsRejectedWithoutRevoking(t *testing.T) {
|
|
224
|
-
ctx := context.Background()
|
|
225
|
-
userID := uuid.New()
|
|
226
|
-
tokens := newFakeTokenStore()
|
|
227
|
-
rawLegit := "a-legit-session-token"
|
|
228
|
-
if err := tokens.SetRefreshToken(ctx, hashToken(rawLegit), userID, time.Hour); err != nil {
|
|
229
|
-
t.Fatalf("seed: %v", err)
|
|
230
|
-
}
|
|
231
|
-
svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
|
|
232
|
-
|
|
233
|
-
_, err := svc.Refresh(ctx, "never-issued-token")
|
|
234
|
-
if got := status(t, err); got != http.StatusUnauthorized {
|
|
235
|
-
t.Fatalf("want 401 for an unrecognized token, got %d", got)
|
|
236
|
-
}
|
|
237
|
-
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawLegit)); !ok {
|
|
238
|
-
t.Fatal("a never-issued token shouldn't revoke unrelated sessions")
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T) {
|
|
243
|
-
ctx := context.Background()
|
|
244
|
-
userID := uuid.New()
|
|
245
|
-
otherUserID := uuid.New()
|
|
246
|
-
tokens := newFakeTokenStore()
|
|
247
|
-
rawA := "user-session-a"
|
|
248
|
-
rawB := "user-session-b"
|
|
249
|
-
rawOther := "other-users-session"
|
|
250
|
-
for raw, id := range map[string]uuid.UUID{rawA: userID, rawB: userID, rawOther: otherUserID} {
|
|
251
|
-
if err := tokens.SetRefreshToken(ctx, hashToken(raw), id, time.Hour); err != nil {
|
|
252
|
-
t.Fatalf("seed: %v", err)
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
|
|
256
|
-
|
|
257
|
-
if err := svc.LogoutAll(ctx, userID); err != nil {
|
|
258
|
-
t.Fatalf("unexpected error: %v", err)
|
|
259
|
-
}
|
|
260
|
-
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawA)); ok {
|
|
261
|
-
t.Fatal("expected session A to be revoked")
|
|
262
|
-
}
|
|
263
|
-
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawB)); ok {
|
|
264
|
-
t.Fatal("expected session B to be revoked")
|
|
265
|
-
}
|
|
266
|
-
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOther)); !ok {
|
|
267
|
-
t.Fatal("expected a different user's session to be untouched by LogoutAll")
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// The control that actually stops credential stuffing: the counter follows the
|
|
272
|
-
// account, so spreading attempts across a proxy pool doesn't help.
|
|
273
|
-
func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {
|
|
274
|
-
repo := &fakeRepo{
|
|
275
|
-
user: &model.User{ID: uuid.New(), Email: "a@example.com"},
|
|
276
|
-
failures: map[string]int{},
|
|
277
|
-
}
|
|
278
|
-
svc := newTestService(repo, newFakeTokenStore())
|
|
279
|
-
in := loginInput{Email: "a@example.com", Password: "wrong"}
|
|
280
|
-
|
|
281
|
-
// the free attempts answer "wrong password", not "locked"
|
|
282
|
-
for i := 0; i < loginFreeAttempts; i++ {
|
|
283
|
-
if _, err := svc.Login(context.Background(), in); err == nil {
|
|
284
|
-
t.Fatalf("attempt %d: expected a failure", i+1)
|
|
285
|
-
} else if code(err) != "AUTH_INVALID_CREDENTIALS" {
|
|
286
|
-
t.Fatalf("attempt %d: expected AUTH_INVALID_CREDENTIALS, got %s", i+1, code(err))
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// the next one trips the lock...
|
|
291
|
-
if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_INVALID_CREDENTIALS" {
|
|
292
|
-
t.Fatalf("the attempt that trips the lock still answers as a bad password, got %s", code(err))
|
|
293
|
-
}
|
|
294
|
-
// ...and everything after it is refused before any password is checked
|
|
295
|
-
if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_TOO_MANY_ATTEMPTS" {
|
|
296
|
-
t.Fatalf("expected AUTH_TOO_MANY_ATTEMPTS once locked, got %s", code(err))
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// A locked login must not lock the reset that fixes it — the person who forgot
|
|
301
|
-
// their password is exactly the person who trips the login counter.
|
|
302
|
-
func TestService_ForgotPassword_HasItsOwnCounter(t *testing.T) {
|
|
303
|
-
if throttleKey("login", "a@example.com") == throttleKey("pwreset", "a@example.com") {
|
|
304
|
-
t.Fatal("login and password-reset share a throttle key; a locked login would block recovery")
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// code pulls the AppError code out, so a test asserting on behaviour doesn't
|
|
309
|
-
// have to care how the error is wrapped.
|
|
310
|
-
func code(err error) string {
|
|
311
|
-
var appErr *apperror.AppError
|
|
312
|
-
if errors.As(err, &appErr) {
|
|
313
|
-
return appErr.Code
|
|
314
|
-
}
|
|
315
|
-
return fmt.Sprintf("%v", err)
|
|
316
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
package user
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"context"
|
|
5
|
-
"time"
|
|
6
|
-
|
|
7
|
-
"github.com/google/uuid"
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
// tokenStore is the short-lived-token surface Service needs — declared
|
|
11
|
-
// consumer-side so it can be faked in tests, and so the backing store is a
|
|
12
|
-
// choice rather than a hard dependency. Two implementations ship with the
|
|
13
|
-
// scaffold and exactly one is written into a project:
|
|
14
|
-
//
|
|
15
|
-
// `add auth --store postgres` -> tokenstore_pg.go (default, no extra service)
|
|
16
|
-
// `add auth --store redis` -> tokenstore_redis.go (exact across pods)
|
|
17
|
-
//
|
|
18
|
-
// Service never learns which one it got.
|
|
19
|
-
type tokenStore interface {
|
|
20
|
-
SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
21
|
-
GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
22
|
-
DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error
|
|
23
|
-
RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error
|
|
24
|
-
MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
25
|
-
IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
26
|
-
SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
27
|
-
ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
28
|
-
SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
29
|
-
ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
30
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
// Postgres-backed tokenStore — installed by `add auth --store postgres` (the
|
|
2
|
-
// default). The Redis-backed alternative lives in tokenstore_redis.go; only
|
|
3
|
-
// one of the two is ever written into a project.
|
|
4
|
-
//
|
|
5
|
-
// Everything Redis needed two keys for collapses into one table here: the
|
|
6
|
-
// per-user session set is just a WHERE clause, and expiry is a column every
|
|
7
|
-
// read filters on rather than a TTL the store enforces. What that costs is a
|
|
8
|
-
// sweep to keep the table from growing — see DeleteExpired.
|
|
9
|
-
package user
|
|
10
|
-
|
|
11
|
-
import (
|
|
12
|
-
"context"
|
|
13
|
-
"database/sql"
|
|
14
|
-
"errors"
|
|
15
|
-
"time"
|
|
16
|
-
|
|
17
|
-
"{{goModule}}/internal/app/user/model"
|
|
18
|
-
"{{goModule}}/internal/shared/tx"
|
|
19
|
-
|
|
20
|
-
"github.com/google/uuid"
|
|
21
|
-
"gorm.io/gorm"
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
const (
|
|
25
|
-
kindRefresh = "refresh"
|
|
26
|
-
kindRefreshUsed = "refresh_used"
|
|
27
|
-
kindPasswordSet = "pwreset"
|
|
28
|
-
kindEmailVerify = "emailverify"
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
type pgTokenStore struct {
|
|
32
|
-
db *gorm.DB
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
func NewPgTokenStore(db *gorm.DB) *pgTokenStore { return &pgTokenStore{db: db} }
|
|
36
|
-
|
|
37
|
-
// put writes a token row, replacing any row that already carries the same
|
|
38
|
-
// hash — a collision is astronomically unlikely, but "upsert" is the right
|
|
39
|
-
// semantic for "this hash now means this" either way.
|
|
40
|
-
func (s *pgTokenStore) put(ctx context.Context, hash, kind string, userID uuid.UUID, ttl time.Duration) error {
|
|
41
|
-
row := model.AuthToken{TokenHash: hash, UserID: userID, Kind: kind, ExpiresAt: time.Now().Add(ttl)}
|
|
42
|
-
return tx.From(ctx, s.db).WithContext(ctx).
|
|
43
|
-
Where("token_hash = ?", hash).
|
|
44
|
-
Assign(map[string]any{"user_id": userID, "kind": kind, "expires_at": row.ExpiresAt}).
|
|
45
|
-
FirstOrCreate(&row).Error
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// lookup finds a live token of the given kind. Expiry is enforced here rather
|
|
49
|
-
// than by a sweep, so a row that outlived its TTL is already invisible.
|
|
50
|
-
func (s *pgTokenStore) lookup(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
|
|
51
|
-
var row model.AuthToken
|
|
52
|
-
err := tx.From(ctx, s.db).WithContext(ctx).
|
|
53
|
-
Where("token_hash = ? AND kind = ? AND expires_at > now()", hash, kind).
|
|
54
|
-
Take(&row).Error
|
|
55
|
-
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
56
|
-
return uuid.Nil, false, nil
|
|
57
|
-
}
|
|
58
|
-
if err != nil {
|
|
59
|
-
return uuid.Nil, false, err
|
|
60
|
-
}
|
|
61
|
-
return row.UserID, true, nil
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// consume deletes and returns in one statement, which is what makes a
|
|
65
|
-
// one-time token actually one-time: two requests racing on the same token
|
|
66
|
-
// cannot both come away with a user id.
|
|
67
|
-
func (s *pgTokenStore) consume(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
|
|
68
|
-
var userID uuid.UUID
|
|
69
|
-
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
70
|
-
`DELETE FROM user_svc.auth_tokens
|
|
71
|
-
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
72
|
-
RETURNING user_id`, hash, kind).Row().Scan(&userID)
|
|
73
|
-
if errors.Is(err, sql.ErrNoRows) {
|
|
74
|
-
return uuid.Nil, false, nil
|
|
75
|
-
}
|
|
76
|
-
if err != nil {
|
|
77
|
-
return uuid.Nil, false, err
|
|
78
|
-
}
|
|
79
|
-
return userID, true, nil
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
func (s *pgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
83
|
-
return s.put(ctx, tokenHash, kindRefresh, userID, ttl)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
func (s *pgTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
87
|
-
return s.lookup(ctx, tokenHash, kindRefresh)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// DeleteRefreshToken removes the row outright and leaves nothing behind — see
|
|
91
|
-
// the AuthToken doc comment for why logout must not leave a tombstone.
|
|
92
|
-
func (s *pgTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, _ uuid.UUID) error {
|
|
93
|
-
return tx.From(ctx, s.db).WithContext(ctx).
|
|
94
|
-
Where("token_hash = ? AND kind = ?", tokenHash, kindRefresh).
|
|
95
|
-
Delete(&model.AuthToken{}).Error
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// RevokeAllRefreshTokens is one statement here because Postgres can query by
|
|
99
|
-
// value — the Redis store has to keep a separate per-user set to answer this,
|
|
100
|
-
// and that set accumulates stale members its own comment calls out.
|
|
101
|
-
// Tombstones are left in place so reuse detection still works afterwards.
|
|
102
|
-
func (s *pgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
|
|
103
|
-
return tx.From(ctx, s.db).WithContext(ctx).
|
|
104
|
-
Where("user_id = ? AND kind = ?", userID, kindRefresh).
|
|
105
|
-
Delete(&model.AuthToken{}).Error
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
func (s *pgTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
109
|
-
return s.put(ctx, tokenHash, kindRefreshUsed, userID, ttl)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
func (s *pgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
113
|
-
return s.lookup(ctx, tokenHash, kindRefreshUsed)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
func (s *pgTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
117
|
-
return s.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
func (s *pgTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
121
|
-
return s.consume(ctx, tokenHash, kindPasswordSet)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
func (s *pgTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
125
|
-
return s.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
func (s *pgTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
129
|
-
return s.consume(ctx, tokenHash, kindEmailVerify)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// DeleteExpired drops rows nothing can use any more. Correctness never depends
|
|
133
|
-
// on it — every read already filters on expires_at — so it is purely about
|
|
134
|
-
// table size, and calling it on a timer from cmd/worker (or a cron) is enough.
|
|
135
|
-
//
|
|
136
|
-
// ponytail: not scheduled for you. Wire it up the day the table is big enough
|
|
137
|
-
// to notice; a scaffold that silently runs background sweeps nobody asked for
|
|
138
|
-
// is harder to reason about than one line you add when it matters.
|
|
139
|
-
func (s *pgTokenStore) DeleteExpired(ctx context.Context) (int64, error) {
|
|
140
|
-
res := tx.From(ctx, s.db).WithContext(ctx).
|
|
141
|
-
Where("expires_at <= now()").
|
|
142
|
-
Delete(&model.AuthToken{})
|
|
143
|
-
return res.RowsAffected, res.Error
|
|
144
|
-
}
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
// Redis-backed tokenStore — installed by `add auth --store redis`.
|
|
2
|
-
// The Postgres-backed alternative lives in tokenstore_pg.go; only one of the
|
|
3
|
-
// two is ever written into a project, and Service never learns which.
|
|
4
|
-
package user
|
|
5
|
-
|
|
6
|
-
import (
|
|
7
|
-
"context"
|
|
8
|
-
"time"
|
|
9
|
-
|
|
10
|
-
"github.com/google/uuid"
|
|
11
|
-
"github.com/redis/go-redis/v9"
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
const (
|
|
15
|
-
refreshKeyPrefix = "user:refresh:" // +hash -> userID, TTL = refreshTTL
|
|
16
|
-
refreshUserKeyPrefix = "user:refresh:user:" // +userID -> SET of active token hashes
|
|
17
|
-
refreshUsedKeyPrefix = "user:refresh:used:" // +hash -> userID, TTL = refreshTTL, reuse-detection tombstone
|
|
18
|
-
pwresetKeyPrefix = "user:pwreset:" // +hash -> userID, TTL = resetTTL, GETDEL on consume
|
|
19
|
-
emailVerifyKeyPrefix = "user:emailverify:" // +hash -> userID, TTL = emailVerifyTTL, GETDEL on consume
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
type redisTokenStore struct {
|
|
23
|
-
rdb *redis.Client
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
func NewRedisTokenStore(rdb *redis.Client) *redisTokenStore {
|
|
27
|
-
return &redisTokenStore{rdb: rdb}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
31
|
-
pipe := s.rdb.TxPipeline()
|
|
32
|
-
pipe.Set(ctx, refreshKeyPrefix+tokenHash, userID.String(), ttl)
|
|
33
|
-
pipe.SAdd(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
|
|
34
|
-
_, err := pipe.Exec(ctx)
|
|
35
|
-
return err
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
39
|
-
raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
|
|
40
|
-
if err == redis.Nil {
|
|
41
|
-
return uuid.Nil, false, nil
|
|
42
|
-
}
|
|
43
|
-
if err != nil {
|
|
44
|
-
return uuid.Nil, false, err
|
|
45
|
-
}
|
|
46
|
-
id, err := uuid.Parse(raw)
|
|
47
|
-
if err != nil {
|
|
48
|
-
return uuid.Nil, false, err
|
|
49
|
-
}
|
|
50
|
-
return id, true, nil
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
|
|
54
|
-
pipe := s.rdb.TxPipeline()
|
|
55
|
-
pipe.Del(ctx, refreshKeyPrefix+tokenHash)
|
|
56
|
-
pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
|
|
57
|
-
_, err := pipe.Exec(ctx)
|
|
58
|
-
return err
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// RevokeAllRefreshTokens walks the per-user session set and deletes every
|
|
62
|
-
// active refresh token for that user — used when reuse of an already-rotated
|
|
63
|
-
// token is detected (see Service.Refresh): that means the raw token leaked,
|
|
64
|
-
// so every session, not just the replayed one, is treated as compromised.
|
|
65
|
-
//
|
|
66
|
-
// ponytail: the per-user set has no per-member TTL cleanup of its own — a
|
|
67
|
-
// member outlives its key's TTL as a stale entry until the next revoke or
|
|
68
|
-
// rotation touches it. Self-heals over time; revisit if a single user's
|
|
69
|
-
// session count grows large enough to matter.
|
|
70
|
-
func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
|
|
71
|
-
setKey := refreshUserKeyPrefix + userID.String()
|
|
72
|
-
hashes, err := s.rdb.SMembers(ctx, setKey).Result()
|
|
73
|
-
if err != nil {
|
|
74
|
-
return err
|
|
75
|
-
}
|
|
76
|
-
if len(hashes) == 0 {
|
|
77
|
-
return nil
|
|
78
|
-
}
|
|
79
|
-
pipe := s.rdb.TxPipeline()
|
|
80
|
-
for _, h := range hashes {
|
|
81
|
-
pipe.Del(ctx, refreshKeyPrefix+h)
|
|
82
|
-
}
|
|
83
|
-
pipe.Del(ctx, setKey)
|
|
84
|
-
_, err = pipe.Exec(ctx)
|
|
85
|
-
return err
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
func (s *redisTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
89
|
-
return s.rdb.Set(ctx, refreshUsedKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
93
|
-
raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
|
|
94
|
-
if err == redis.Nil {
|
|
95
|
-
return uuid.Nil, false, nil
|
|
96
|
-
}
|
|
97
|
-
if err != nil {
|
|
98
|
-
return uuid.Nil, false, err
|
|
99
|
-
}
|
|
100
|
-
id, err := uuid.Parse(raw)
|
|
101
|
-
if err != nil {
|
|
102
|
-
return uuid.Nil, false, err
|
|
103
|
-
}
|
|
104
|
-
return id, true, nil
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
func (s *redisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
108
|
-
return s.rdb.Set(ctx, pwresetKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ConsumePasswordResetToken is one-time-use by construction: GETDEL is
|
|
112
|
-
// atomic, so a token can't be raced into being consumed twice.
|
|
113
|
-
func (s *redisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
114
|
-
raw, err := s.rdb.GetDel(ctx, pwresetKeyPrefix+tokenHash).Result()
|
|
115
|
-
if err == redis.Nil {
|
|
116
|
-
return uuid.Nil, false, nil
|
|
117
|
-
}
|
|
118
|
-
if err != nil {
|
|
119
|
-
return uuid.Nil, false, err
|
|
120
|
-
}
|
|
121
|
-
id, err := uuid.Parse(raw)
|
|
122
|
-
if err != nil {
|
|
123
|
-
return uuid.Nil, false, err
|
|
124
|
-
}
|
|
125
|
-
return id, true, nil
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
func (s *redisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
129
|
-
return s.rdb.Set(ctx, emailVerifyKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ConsumeEmailVerifyToken is one-time-use by construction, same as
|
|
133
|
-
// ConsumePasswordResetToken — GETDEL is atomic.
|
|
134
|
-
func (s *redisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
135
|
-
raw, err := s.rdb.GetDel(ctx, emailVerifyKeyPrefix+tokenHash).Result()
|
|
136
|
-
if err == redis.Nil {
|
|
137
|
-
return uuid.Nil, false, nil
|
|
138
|
-
}
|
|
139
|
-
if err != nil {
|
|
140
|
-
return uuid.Nil, false, err
|
|
141
|
-
}
|
|
142
|
-
id, err := uuid.Parse(raw)
|
|
143
|
-
if err != nil {
|
|
144
|
-
return uuid.Nil, false, err
|
|
145
|
-
}
|
|
146
|
-
return id, true, nil
|
|
147
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
package role
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"time"
|
|
5
|
-
|
|
6
|
-
"{{goModule}}/internal/app/role/model"
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
// response = the DTO sent out (kept separate from the model so a later DB column doesn't leak automatically)
|
|
10
|
-
type response struct {
|
|
11
|
-
Code string `json:"code"`
|
|
12
|
-
Name string `json:"name"`
|
|
13
|
-
IsSystem bool `json:"is_system"`
|
|
14
|
-
Permissions []string `json:"permissions"`
|
|
15
|
-
CreatedAt time.Time `json:"created_at"`
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
func toResponse(m *model.Role, perms []string) response {
|
|
19
|
-
return response{Code: m.Code, Name: m.Name, IsSystem: m.IsSystem, Permissions: perms, CreatedAt: m.CreatedAt}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
type CreateInput struct {
|
|
23
|
-
Code string `json:"code" binding:"required,max=20"`
|
|
24
|
-
Name string `json:"name" binding:"required"`
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// SetPermissionsInput.PermissionCodes is a *pointer* so that "field absent"
|
|
28
|
-
// and "field present but empty" stay different things. An empty list is a
|
|
29
|
-
// valid request — it revokes every permission the role had — but with a plain
|
|
30
|
-
// []string a body that simply misspells the key (say `{"permissions": [...]}`)
|
|
31
|
-
// binds to nil, reads as that same "revoke everything", and answers 200 having
|
|
32
|
-
// silently stripped the role. `required` on the pointer rejects the absent
|
|
33
|
-
// case with a 400 while still allowing an explicit [].
|
|
34
|
-
type SetPermissionsInput struct {
|
|
35
|
-
PermissionCodes *[]string `json:"permission_codes" binding:"required"`
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
type permissionResponse struct {
|
|
39
|
-
Code string `json:"code"`
|
|
40
|
-
Description string `json:"description"`
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
func toPermissionResponse(m *model.Permission) permissionResponse {
|
|
44
|
-
return permissionResponse{Code: m.Code, Description: m.Description}
|
|
45
|
-
}
|