@nakedev/go-scaffold 0.1.2 → 0.1.4
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 +93 -14
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +61 -2
- package/dist/commands/method.js +66 -15
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +36 -20
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +71 -7
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/module-manifest.js +2 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/method-patcher.js +80 -16
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/module-location.js +22 -0
- package/dist/utils/naming.js +75 -12
- package/dist/utils/openapi-patcher.js +35 -1
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/smoke-run.js +31 -0
- package/dist/utils/version.js +24 -0
- package/package.json +15 -6
- package/scripts/smoke-test.mjs +2058 -0
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/AGENTS.md.hbs +10 -12
- package/templates/create/base/Makefile.hbs +39 -8
- package/templates/create/base/README.md.hbs +52 -12
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +17 -5
- package/templates/create/features/docs/patterns.md.hbs +9 -6
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/handler.go.hbs +37 -6
- package/templates/generate/module/handler_test.go.hbs +113 -51
- package/templates/generate/module/migration.down.sql.hbs +1 -1
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/templates/generate/module/minimal/handler.go.hbs +28 -4
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +7 -0
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- package/templates/generate/module/repository_test.go.hbs +79 -0
- package/templates/generate/module/service.go.hbs +6 -4
- package/templates/generate/module/service_test.go.hbs +68 -17
- package/tests/integration/default-module.test.mjs +46 -0
- package/tests/integration/generator-naming.test.mjs +81 -0
- package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
- package/tests/integration/legacy-method-compat.test.mjs +222 -0
- package/tests/integration/remove-module.test.mjs +58 -0
- package/tests/unit/naming.test.mjs +94 -0
- package/tests/unit/smoke-isolation.test.mjs +35 -0
- package/dist/utils/module-paths.js +0 -33
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"net/http"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"{{goModule}}/internal/app/user/model"
|
|
11
|
+
"{{goModule}}/internal/shared/apperror"
|
|
12
|
+
"{{goModule}}/internal/shared/config"
|
|
13
|
+
|
|
14
|
+
"github.com/google/uuid"
|
|
15
|
+
"gorm.io/gorm"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// fakeTokenStore = in-memory mock of tokenStore, mirroring redisTokenStore's
|
|
19
|
+
// three-map shape (active / used-tombstone / per-user session set) closely
|
|
20
|
+
// enough to exercise rotation + reuse-detection without a real Redis.
|
|
21
|
+
type fakeTokenStore struct {
|
|
22
|
+
active map[string]uuid.UUID
|
|
23
|
+
used map[string]uuid.UUID
|
|
24
|
+
sessions map[uuid.UUID]map[string]struct{}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func newFakeTokenStore() *fakeTokenStore {
|
|
28
|
+
return &fakeTokenStore{
|
|
29
|
+
active: map[string]uuid.UUID{},
|
|
30
|
+
used: map[string]uuid.UUID{},
|
|
31
|
+
sessions: map[uuid.UUID]map[string]struct{}{},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
|
|
36
|
+
f.active[hash] = userID
|
|
37
|
+
if f.sessions[userID] == nil {
|
|
38
|
+
f.sessions[userID] = map[string]struct{}{}
|
|
39
|
+
}
|
|
40
|
+
f.sessions[userID][hash] = struct{}{}
|
|
41
|
+
return nil
|
|
42
|
+
}
|
|
43
|
+
func (f *fakeTokenStore) GetRefreshToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
44
|
+
id, ok := f.active[hash]
|
|
45
|
+
return id, ok, nil
|
|
46
|
+
}
|
|
47
|
+
func (f *fakeTokenStore) DeleteRefreshToken(_ context.Context, hash string, userID uuid.UUID) error {
|
|
48
|
+
delete(f.active, hash)
|
|
49
|
+
delete(f.sessions[userID], hash)
|
|
50
|
+
return nil
|
|
51
|
+
}
|
|
52
|
+
func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.UUID) error {
|
|
53
|
+
for h := range f.sessions[userID] {
|
|
54
|
+
delete(f.active, h)
|
|
55
|
+
}
|
|
56
|
+
delete(f.sessions, userID)
|
|
57
|
+
return nil
|
|
58
|
+
}
|
|
59
|
+
func (f *fakeTokenStore) MarkRefreshTokenUsed(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
|
|
60
|
+
f.used[hash] = userID
|
|
61
|
+
return nil
|
|
62
|
+
}
|
|
63
|
+
func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
64
|
+
id, ok := f.used[hash]
|
|
65
|
+
return id, ok, nil
|
|
66
|
+
}
|
|
67
|
+
func (f *fakeTokenStore) SetPasswordResetToken(context.Context, string, uuid.UUID, time.Duration) error {
|
|
68
|
+
return nil
|
|
69
|
+
}
|
|
70
|
+
func (f *fakeTokenStore) ConsumePasswordResetToken(context.Context, string) (uuid.UUID, bool, error) {
|
|
71
|
+
return uuid.Nil, false, nil
|
|
72
|
+
}
|
|
73
|
+
func (f *fakeTokenStore) SetEmailVerifyToken(context.Context, string, uuid.UUID, time.Duration) error {
|
|
74
|
+
return nil
|
|
75
|
+
}
|
|
76
|
+
func (f *fakeTokenStore) ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error) {
|
|
77
|
+
return uuid.Nil, false, nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// fakeRepo = mock of the repository interface — only FindByID is exercised
|
|
81
|
+
// by the tests below (Refresh looks the user up after validating the
|
|
82
|
+
// token), the rest just satisfy the interface.
|
|
83
|
+
type fakeRepo struct {
|
|
84
|
+
user *model.User
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func (f *fakeRepo) FindByEmail(context.Context, string) (*model.User, error) {
|
|
88
|
+
return nil, gorm.ErrRecordNotFound
|
|
89
|
+
}
|
|
90
|
+
func (f *fakeRepo) FindByID(_ context.Context, id uuid.UUID) (*model.User, error) {
|
|
91
|
+
if f.user != nil && f.user.ID == id {
|
|
92
|
+
return f.user, nil
|
|
93
|
+
}
|
|
94
|
+
return nil, gorm.ErrRecordNotFound
|
|
95
|
+
}
|
|
96
|
+
func (f *fakeRepo) UpdateUser(context.Context, *model.User) error { return nil }
|
|
97
|
+
func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.User, error) { return nil, nil }
|
|
98
|
+
func (f *fakeRepo) FindIdentity(context.Context, uuid.UUID, model.Provider) (*model.Identity, error) {
|
|
99
|
+
return nil, gorm.ErrRecordNotFound
|
|
100
|
+
}
|
|
101
|
+
func (f *fakeRepo) FindIdentityByProviderUID(context.Context, model.Provider, string) (*model.Identity, error) {
|
|
102
|
+
return nil, gorm.ErrRecordNotFound
|
|
103
|
+
}
|
|
104
|
+
func (f *fakeRepo) CreateUserWithIdentity(context.Context, *model.User, *model.Identity) error { return nil }
|
|
105
|
+
func (f *fakeRepo) CreateIdentity(context.Context, *model.Identity) error { return nil }
|
|
106
|
+
func (f *fakeRepo) UpdateIdentity(context.Context, *model.Identity) error { return nil }
|
|
107
|
+
|
|
108
|
+
type fakeMailer struct{}
|
|
109
|
+
|
|
110
|
+
func (fakeMailer) Send(string, string, string) error { return nil }
|
|
111
|
+
|
|
112
|
+
// go-scaffold:user-service-test-types
|
|
113
|
+
|
|
114
|
+
// newTestService is the one NewService call site every test below shares —
|
|
115
|
+
// keeping it singular means `add rbac`'s marker patch only has one place to
|
|
116
|
+
// add the roleChecker arg it introduces (patching every call site
|
|
117
|
+
// individually would need a marker-per-callsite, easy to miss one).
|
|
118
|
+
func newTestService(repo repository, tokens tokenStore) *Service {
|
|
119
|
+
return NewService(
|
|
120
|
+
repo,
|
|
121
|
+
tokens,
|
|
122
|
+
fakeMailer{},
|
|
123
|
+
config.Config{JWTSecret: "test-secret", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour},
|
|
124
|
+
// go-scaffold:user-service-test-args
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func status(t *testing.T, err error) int {
|
|
129
|
+
t.Helper()
|
|
130
|
+
var appErr *apperror.AppError
|
|
131
|
+
if !errors.As(err, &appErr) {
|
|
132
|
+
t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
|
|
133
|
+
}
|
|
134
|
+
return appErr.HTTPStatus
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func TestService_Refresh_RotatesTheToken(t *testing.T) {
|
|
138
|
+
ctx := context.Background()
|
|
139
|
+
userID := uuid.New()
|
|
140
|
+
tokens := newFakeTokenStore()
|
|
141
|
+
rawOld := "old-refresh-token"
|
|
142
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(rawOld), userID, time.Hour); err != nil {
|
|
143
|
+
t.Fatalf("seed: %v", err)
|
|
144
|
+
}
|
|
145
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
146
|
+
|
|
147
|
+
auth, err := svc.Refresh(ctx, rawOld)
|
|
148
|
+
if err != nil {
|
|
149
|
+
t.Fatalf("unexpected error: %v", err)
|
|
150
|
+
}
|
|
151
|
+
if auth.RefreshToken == "" || auth.RefreshToken == rawOld {
|
|
152
|
+
t.Fatalf("expected a new, different refresh token, got %q", auth.RefreshToken)
|
|
153
|
+
}
|
|
154
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOld)); ok {
|
|
155
|
+
t.Fatal("expected the old refresh token to be consumed (no longer active) after rotation")
|
|
156
|
+
}
|
|
157
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(auth.RefreshToken)); !ok {
|
|
158
|
+
t.Fatal("expected the newly rotated refresh token to be active")
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing.T) {
|
|
163
|
+
ctx := context.Background()
|
|
164
|
+
userID := uuid.New()
|
|
165
|
+
tokens := newFakeTokenStore()
|
|
166
|
+
rawOld := "old-refresh-token"
|
|
167
|
+
rawOtherSession := "another-devices-refresh-token"
|
|
168
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(rawOld), userID, time.Hour); err != nil {
|
|
169
|
+
t.Fatalf("seed: %v", err)
|
|
170
|
+
}
|
|
171
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(rawOtherSession), userID, time.Hour); err != nil {
|
|
172
|
+
t.Fatalf("seed: %v", err)
|
|
173
|
+
}
|
|
174
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
175
|
+
|
|
176
|
+
// rotate rawOld once — legitimate use, consumes + tombstones it
|
|
177
|
+
if _, err := svc.Refresh(ctx, rawOld); err != nil {
|
|
178
|
+
t.Fatalf("first refresh: unexpected error: %v", err)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// replay the now-rotated-out raw value — this is what happens if it leaked
|
|
182
|
+
_, err := svc.Refresh(ctx, rawOld)
|
|
183
|
+
if got := status(t, err); got != http.StatusUnauthorized {
|
|
184
|
+
t.Fatalf("want 401 replaying a rotated-out token, got %d", got)
|
|
185
|
+
}
|
|
186
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOtherSession)); ok {
|
|
187
|
+
t.Fatal("expected reuse of a rotated-out token to revoke every session for that user, but another session's token is still active")
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func TestService_Refresh_UnknownTokenIsRejectedWithoutRevoking(t *testing.T) {
|
|
192
|
+
ctx := context.Background()
|
|
193
|
+
userID := uuid.New()
|
|
194
|
+
tokens := newFakeTokenStore()
|
|
195
|
+
rawLegit := "a-legit-session-token"
|
|
196
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(rawLegit), userID, time.Hour); err != nil {
|
|
197
|
+
t.Fatalf("seed: %v", err)
|
|
198
|
+
}
|
|
199
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
|
|
200
|
+
|
|
201
|
+
_, err := svc.Refresh(ctx, "never-issued-token")
|
|
202
|
+
if got := status(t, err); got != http.StatusUnauthorized {
|
|
203
|
+
t.Fatalf("want 401 for an unrecognized token, got %d", got)
|
|
204
|
+
}
|
|
205
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawLegit)); !ok {
|
|
206
|
+
t.Fatal("a never-issued token shouldn't revoke unrelated sessions")
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T) {
|
|
211
|
+
ctx := context.Background()
|
|
212
|
+
userID := uuid.New()
|
|
213
|
+
otherUserID := uuid.New()
|
|
214
|
+
tokens := newFakeTokenStore()
|
|
215
|
+
rawA := "user-session-a"
|
|
216
|
+
rawB := "user-session-b"
|
|
217
|
+
rawOther := "other-users-session"
|
|
218
|
+
for raw, id := range map[string]uuid.UUID{rawA: userID, rawB: userID, rawOther: otherUserID} {
|
|
219
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(raw), id, time.Hour); err != nil {
|
|
220
|
+
t.Fatalf("seed: %v", err)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
|
|
224
|
+
|
|
225
|
+
if err := svc.LogoutAll(ctx, userID); err != nil {
|
|
226
|
+
t.Fatalf("unexpected error: %v", err)
|
|
227
|
+
}
|
|
228
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawA)); ok {
|
|
229
|
+
t.Fatal("expected session A to be revoked")
|
|
230
|
+
}
|
|
231
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawB)); ok {
|
|
232
|
+
t.Fatal("expected session B to be revoked")
|
|
233
|
+
}
|
|
234
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOther)); !ok {
|
|
235
|
+
t.Fatal("expected a different user's session to be untouched by LogoutAll")
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/google/uuid"
|
|
8
|
+
"github.com/redis/go-redis/v9"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const (
|
|
12
|
+
refreshKeyPrefix = "user:refresh:" // +hash -> userID, TTL = refreshTTL
|
|
13
|
+
refreshUserKeyPrefix = "user:refresh:user:" // +userID -> SET of active token hashes
|
|
14
|
+
refreshUsedKeyPrefix = "user:refresh:used:" // +hash -> userID, TTL = refreshTTL (reuse-detection tombstone)
|
|
15
|
+
pwresetKeyPrefix = "user:pwreset:" // +hash -> userID, TTL = resetTTL, GETDEL on consume
|
|
16
|
+
emailVerifyKeyPrefix = "user:emailverify:" // +hash -> userID, TTL = emailVerifyTTL, GETDEL on consume
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// tokenStore is what Service needs from Redis for refresh tokens — declared
|
|
20
|
+
// consumer-side so it can be faked in tests without a real Redis.
|
|
21
|
+
type tokenStore interface {
|
|
22
|
+
SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
23
|
+
GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
24
|
+
DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error
|
|
25
|
+
RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error
|
|
26
|
+
MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
27
|
+
IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
28
|
+
SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
29
|
+
ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
30
|
+
SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
31
|
+
ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type redisTokenStore struct {
|
|
35
|
+
rdb *redis.Client
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func NewRedisTokenStore(rdb *redis.Client) *redisTokenStore {
|
|
39
|
+
return &redisTokenStore{rdb: rdb}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
43
|
+
pipe := s.rdb.TxPipeline()
|
|
44
|
+
pipe.Set(ctx, refreshKeyPrefix+tokenHash, userID.String(), ttl)
|
|
45
|
+
pipe.SAdd(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
|
|
46
|
+
_, err := pipe.Exec(ctx)
|
|
47
|
+
return err
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
51
|
+
raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
|
|
52
|
+
if err == redis.Nil {
|
|
53
|
+
return uuid.Nil, false, nil
|
|
54
|
+
}
|
|
55
|
+
if err != nil {
|
|
56
|
+
return uuid.Nil, false, err
|
|
57
|
+
}
|
|
58
|
+
id, err := uuid.Parse(raw)
|
|
59
|
+
if err != nil {
|
|
60
|
+
return uuid.Nil, false, err
|
|
61
|
+
}
|
|
62
|
+
return id, true, nil
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
|
|
66
|
+
pipe := s.rdb.TxPipeline()
|
|
67
|
+
pipe.Del(ctx, refreshKeyPrefix+tokenHash)
|
|
68
|
+
pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
|
|
69
|
+
_, err := pipe.Exec(ctx)
|
|
70
|
+
return err
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// RevokeAllRefreshTokens walks the per-user session set and deletes every
|
|
74
|
+
// active refresh token for that user — used when reuse of an already-rotated
|
|
75
|
+
// token is detected (see Service.Refresh): that means the raw token leaked,
|
|
76
|
+
// so every session, not just the replayed one, is treated as compromised.
|
|
77
|
+
//
|
|
78
|
+
// ponytail: the per-user set has no per-member TTL cleanup of its own — a
|
|
79
|
+
// member outlives its key's TTL as a stale entry until the next revoke or
|
|
80
|
+
// rotation touches it. Self-heals over time; revisit if a single user's
|
|
81
|
+
// session count grows large enough to matter.
|
|
82
|
+
func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
|
|
83
|
+
setKey := refreshUserKeyPrefix + userID.String()
|
|
84
|
+
hashes, err := s.rdb.SMembers(ctx, setKey).Result()
|
|
85
|
+
if err != nil {
|
|
86
|
+
return err
|
|
87
|
+
}
|
|
88
|
+
if len(hashes) == 0 {
|
|
89
|
+
return nil
|
|
90
|
+
}
|
|
91
|
+
pipe := s.rdb.TxPipeline()
|
|
92
|
+
for _, h := range hashes {
|
|
93
|
+
pipe.Del(ctx, refreshKeyPrefix+h)
|
|
94
|
+
}
|
|
95
|
+
pipe.Del(ctx, setKey)
|
|
96
|
+
_, err = pipe.Exec(ctx)
|
|
97
|
+
return err
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func (s *redisTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
101
|
+
return s.rdb.Set(ctx, refreshUsedKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
105
|
+
raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
|
|
106
|
+
if err == redis.Nil {
|
|
107
|
+
return uuid.Nil, false, nil
|
|
108
|
+
}
|
|
109
|
+
if err != nil {
|
|
110
|
+
return uuid.Nil, false, err
|
|
111
|
+
}
|
|
112
|
+
id, err := uuid.Parse(raw)
|
|
113
|
+
if err != nil {
|
|
114
|
+
return uuid.Nil, false, err
|
|
115
|
+
}
|
|
116
|
+
return id, true, nil
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func (s *redisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
120
|
+
return s.rdb.Set(ctx, pwresetKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ConsumePasswordResetToken is one-time-use by construction: GETDEL is
|
|
124
|
+
// atomic, so a token can't be raced into being consumed twice.
|
|
125
|
+
func (s *redisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
126
|
+
raw, err := s.rdb.GetDel(ctx, pwresetKeyPrefix+tokenHash).Result()
|
|
127
|
+
if err == redis.Nil {
|
|
128
|
+
return uuid.Nil, false, nil
|
|
129
|
+
}
|
|
130
|
+
if err != nil {
|
|
131
|
+
return uuid.Nil, false, err
|
|
132
|
+
}
|
|
133
|
+
id, err := uuid.Parse(raw)
|
|
134
|
+
if err != nil {
|
|
135
|
+
return uuid.Nil, false, err
|
|
136
|
+
}
|
|
137
|
+
return id, true, nil
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func (s *redisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
141
|
+
return s.rdb.Set(ctx, emailVerifyKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ConsumeEmailVerifyToken is one-time-use by construction, same as
|
|
145
|
+
// ConsumePasswordResetToken — GETDEL is atomic.
|
|
146
|
+
func (s *redisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
147
|
+
raw, err := s.rdb.GetDel(ctx, emailVerifyKeyPrefix+tokenHash).Result()
|
|
148
|
+
if err == redis.Nil {
|
|
149
|
+
return uuid.Nil, false, nil
|
|
150
|
+
}
|
|
151
|
+
if err != nil {
|
|
152
|
+
return uuid.Nil, false, err
|
|
153
|
+
}
|
|
154
|
+
id, err := uuid.Parse(raw)
|
|
155
|
+
if err != nil {
|
|
156
|
+
return uuid.Nil, false, err
|
|
157
|
+
}
|
|
158
|
+
return id, true, nil
|
|
159
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"strings"
|
|
6
|
+
|
|
7
|
+
"{{goModule}}/internal/shared/apperror"
|
|
8
|
+
|
|
9
|
+
"github.com/gin-gonic/gin"
|
|
10
|
+
"github.com/golang-jwt/jwt/v5"
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const UserIDKey = "user_id"
|
|
15
|
+
|
|
16
|
+
// go-scaffold:middleware-auth-keys
|
|
17
|
+
|
|
18
|
+
// accessClaims mirrors internal/app/user's own claims shape — duplicated
|
|
19
|
+
// rather than imported, since shared/ can never import a domain package.
|
|
20
|
+
// The two are kept in sync by convention: sub = user id, typ = "access",
|
|
21
|
+
// nothing else is load-bearing here.
|
|
22
|
+
type accessClaims struct {
|
|
23
|
+
Typ string `json:"typ"`
|
|
24
|
+
// go-scaffold:middleware-auth-claims
|
|
25
|
+
jwt.RegisteredClaims
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// RequireAuth validates a Bearer access token and puts the caller's user id
|
|
29
|
+
// in context. Rejects a refresh token presented as an access token via the
|
|
30
|
+
// typ claim — the two are structurally identical JWTs otherwise.
|
|
31
|
+
func RequireAuth(secret string) gin.HandlerFunc {
|
|
32
|
+
return func(c *gin.Context) {
|
|
33
|
+
header := c.GetHeader("Authorization")
|
|
34
|
+
raw, ok := strings.CutPrefix(header, "Bearer ")
|
|
35
|
+
if !ok || raw == "" {
|
|
36
|
+
unauthorized(c)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var claims accessClaims
|
|
41
|
+
_, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
|
|
42
|
+
return []byte(secret), nil
|
|
43
|
+
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
|
44
|
+
if err != nil || claims.Typ != "access" {
|
|
45
|
+
unauthorized(c)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
userID, err := uuid.Parse(claims.Subject)
|
|
50
|
+
if err != nil {
|
|
51
|
+
unauthorized(c)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
c.Set(UserIDKey, userID)
|
|
56
|
+
// go-scaffold:middleware-auth-context
|
|
57
|
+
c.Next()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func unauthorized(c *gin.Context) {
|
|
62
|
+
c.Error(apperror.New(http.StatusUnauthorized, "UNAUTHORIZED", "missing or invalid access token"))
|
|
63
|
+
c.Abort()
|
|
64
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"{{goModule}}/internal/shared/apperror"
|
|
8
|
+
|
|
9
|
+
"github.com/gin-gonic/gin"
|
|
10
|
+
"github.com/redis/go-redis/v9"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// RateLimit throttles requests per client IP to `limit` within `window`,
|
|
14
|
+
// keyed by `name` (a per-route label so /auth/login and /auth/register don't
|
|
15
|
+
// share a budget) — a fixed-window counter via Redis INCR+EXPIRE.
|
|
16
|
+
//
|
|
17
|
+
// ponytail: fixed-window, not sliding — simple and correct, the tradeoff is
|
|
18
|
+
// a client can burst up to ~2x limit right at a window boundary (limit
|
|
19
|
+
// requests just before it resets, then limit more right after). Good enough
|
|
20
|
+
// for throttling credential-stuffing/spam, not a hard quota; move to a
|
|
21
|
+
// sliding-window log if that boundary burst ever actually matters.
|
|
22
|
+
func RateLimit(rdb *redis.Client, name string, limit int, window time.Duration) gin.HandlerFunc {
|
|
23
|
+
return func(c *gin.Context) {
|
|
24
|
+
key := "ratelimit:" + name + ":" + c.ClientIP()
|
|
25
|
+
ctx := c.Request.Context()
|
|
26
|
+
|
|
27
|
+
count, err := rdb.Incr(ctx, key).Result()
|
|
28
|
+
if err != nil {
|
|
29
|
+
// fail open: a Redis blip shouldn't take down login/register
|
|
30
|
+
// entirely — the endpoint's own logic is still the real guard.
|
|
31
|
+
c.Next()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if count == 1 {
|
|
35
|
+
rdb.Expire(ctx, key, window)
|
|
36
|
+
}
|
|
37
|
+
if count > int64(limit) {
|
|
38
|
+
c.Error(apperror.New(http.StatusTooManyRequests, "RATE_LIMITED", "too many requests, try again later"))
|
|
39
|
+
c.Abort()
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
c.Next()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DROP TABLE IF EXISTS identities;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CREATE TABLE identities (
|
|
2
|
+
id UUID PRIMARY KEY,
|
|
3
|
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
4
|
+
provider VARCHAR(20) NOT NULL,
|
|
5
|
+
password_hash TEXT,
|
|
6
|
+
provider_uid TEXT,
|
|
7
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
8
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
9
|
+
UNIQUE (user_id, provider),
|
|
10
|
+
UNIQUE (provider, provider_uid)
|
|
11
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DROP TABLE IF EXISTS users;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
CREATE TABLE users (
|
|
2
|
+
id UUID PRIMARY KEY,
|
|
3
|
+
email VARCHAR(255) NOT NULL UNIQUE,
|
|
4
|
+
name VARCHAR(255) NOT NULL DEFAULT '',
|
|
5
|
+
avatar_url TEXT NOT NULL DEFAULT '',
|
|
6
|
+
email_verified BOOLEAN NOT NULL DEFAULT false,
|
|
7
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
8
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
9
|
+
);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
get:
|
|
2
|
+
summary: List permissions
|
|
3
|
+
description: The catalog of every permission code roles can be granted — permissions themselves are seeded by migrations, not created via the API.
|
|
4
|
+
operationId: listPermissions
|
|
5
|
+
tags: [rbac]
|
|
6
|
+
security: [{ bearerAuth: [] }]
|
|
7
|
+
parameters:
|
|
8
|
+
- $ref: '../common/parameters.yaml#/Limit'
|
|
9
|
+
- $ref: '../common/parameters.yaml#/Offset'
|
|
10
|
+
responses:
|
|
11
|
+
"200":
|
|
12
|
+
description: paginated list
|
|
13
|
+
content:
|
|
14
|
+
application/json:
|
|
15
|
+
schema:
|
|
16
|
+
allOf:
|
|
17
|
+
- $ref: '../common/schemas.yaml#/PageEnvelope'
|
|
18
|
+
- type: object
|
|
19
|
+
properties:
|
|
20
|
+
data:
|
|
21
|
+
type: array
|
|
22
|
+
items: { $ref: './schemas.yaml#/PermissionResponse' }
|
|
23
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
24
|
+
"403": { $ref: '../common/responses.yaml#/ForbiddenError' }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
parameters:
|
|
2
|
+
- name: code
|
|
3
|
+
in: path
|
|
4
|
+
required: true
|
|
5
|
+
schema: { type: string }
|
|
6
|
+
patch:
|
|
7
|
+
summary: Replace a role's permissions
|
|
8
|
+
description: >-
|
|
9
|
+
Rejects unknown permission codes (422), and rejects removing role:manage
|
|
10
|
+
from the only role that still has it, to avoid locking every admin out
|
|
11
|
+
of role management (409).
|
|
12
|
+
operationId: setRolePermissions
|
|
13
|
+
tags: [rbac]
|
|
14
|
+
security: [{ bearerAuth: [] }]
|
|
15
|
+
requestBody:
|
|
16
|
+
required: true
|
|
17
|
+
content:
|
|
18
|
+
application/json:
|
|
19
|
+
schema: { $ref: './schemas.yaml#/SetPermissionsInput' }
|
|
20
|
+
responses:
|
|
21
|
+
"200":
|
|
22
|
+
description: updated
|
|
23
|
+
content:
|
|
24
|
+
application/json:
|
|
25
|
+
schema: { $ref: './schemas.yaml#/RoleResponse' }
|
|
26
|
+
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
27
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
28
|
+
"403": { $ref: '../common/responses.yaml#/ForbiddenError' }
|
|
29
|
+
"404": { $ref: '../common/responses.yaml#/NotFoundError' }
|
|
30
|
+
"409": { $ref: '../common/responses.yaml#/ConflictError' }
|
|
31
|
+
"422": { $ref: '../common/responses.yaml#/UnprocessableEntityError' }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
parameters:
|
|
2
|
+
- name: code
|
|
3
|
+
in: path
|
|
4
|
+
required: true
|
|
5
|
+
schema: { type: string }
|
|
6
|
+
delete:
|
|
7
|
+
summary: Delete a role
|
|
8
|
+
description: System roles (staff/admin) cannot be deleted, nor can a role still assigned to users.
|
|
9
|
+
operationId: deleteRole
|
|
10
|
+
tags: [rbac]
|
|
11
|
+
security: [{ bearerAuth: [] }]
|
|
12
|
+
responses:
|
|
13
|
+
"204": { description: deleted }
|
|
14
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
15
|
+
"403": { $ref: '../common/responses.yaml#/ForbiddenError' }
|
|
16
|
+
"404": { $ref: '../common/responses.yaml#/NotFoundError' }
|
|
17
|
+
"409": { $ref: '../common/responses.yaml#/ConflictError' }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
get:
|
|
2
|
+
summary: List roles
|
|
3
|
+
operationId: listRoles
|
|
4
|
+
tags: [rbac]
|
|
5
|
+
security: [{ bearerAuth: [] }]
|
|
6
|
+
parameters:
|
|
7
|
+
- $ref: '../common/parameters.yaml#/Limit'
|
|
8
|
+
- $ref: '../common/parameters.yaml#/Offset'
|
|
9
|
+
responses:
|
|
10
|
+
"200":
|
|
11
|
+
description: paginated list
|
|
12
|
+
content:
|
|
13
|
+
application/json:
|
|
14
|
+
schema:
|
|
15
|
+
allOf:
|
|
16
|
+
- $ref: '../common/schemas.yaml#/PageEnvelope'
|
|
17
|
+
- type: object
|
|
18
|
+
properties:
|
|
19
|
+
data:
|
|
20
|
+
type: array
|
|
21
|
+
items: { $ref: './schemas.yaml#/RoleResponse' }
|
|
22
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
23
|
+
"403": { $ref: '../common/responses.yaml#/ForbiddenError' }
|
|
24
|
+
post:
|
|
25
|
+
summary: Create a role
|
|
26
|
+
operationId: createRole
|
|
27
|
+
tags: [rbac]
|
|
28
|
+
security: [{ bearerAuth: [] }]
|
|
29
|
+
requestBody:
|
|
30
|
+
required: true
|
|
31
|
+
content:
|
|
32
|
+
application/json:
|
|
33
|
+
schema: { $ref: './schemas.yaml#/RoleCreateInput' }
|
|
34
|
+
responses:
|
|
35
|
+
"201":
|
|
36
|
+
description: created
|
|
37
|
+
content:
|
|
38
|
+
application/json:
|
|
39
|
+
schema: { $ref: './schemas.yaml#/RoleResponse' }
|
|
40
|
+
"400": { $ref: '../common/responses.yaml#/ValidationError' }
|
|
41
|
+
"401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
|
|
42
|
+
"403": { $ref: '../common/responses.yaml#/ForbiddenError' }
|
|
43
|
+
"409": { $ref: '../common/responses.yaml#/ConflictError' }
|