@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,174 @@
|
|
|
1
|
+
package postgres
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"sync"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"{{goModule}}/internal/app/user/ports"
|
|
11
|
+
|
|
12
|
+
"github.com/google/uuid"
|
|
13
|
+
"gorm.io/driver/postgres"
|
|
14
|
+
"gorm.io/gorm"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// mfaStoreDBForTest opens the same versioned-migration database used by
|
|
18
|
+
// production. This is intentionally separate from the unit fake: the
|
|
19
|
+
// one-use guarantees depend on PostgreSQL's row-level atomicity.
|
|
20
|
+
func mfaStoreDBForTest(t *testing.T) *gorm.DB {
|
|
21
|
+
t.Helper()
|
|
22
|
+
dsn := os.Getenv("TEST_DB_DSN")
|
|
23
|
+
if dsn == "" {
|
|
24
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
25
|
+
t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
|
|
26
|
+
}
|
|
27
|
+
t.Skip("MFA store integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
|
31
|
+
if err != nil {
|
|
32
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
33
|
+
t.Fatalf("open required MFA store database: %v", err)
|
|
34
|
+
}
|
|
35
|
+
t.Skipf("MFA store integration test skipped: %v", err)
|
|
36
|
+
}
|
|
37
|
+
sqlDB, err := db.DB()
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatalf("get MFA store SQL handle: %v", err)
|
|
40
|
+
}
|
|
41
|
+
if err := sqlDB.Ping(); err != nil {
|
|
42
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
43
|
+
t.Fatalf("ping required MFA store database: %v", err)
|
|
44
|
+
}
|
|
45
|
+
t.Skipf("MFA store integration test skipped: %v", err)
|
|
46
|
+
}
|
|
47
|
+
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
48
|
+
return db
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func TestPostgresMFAStore_ConsumesChallengesAndRecoveryCodesOnce(t *testing.T) {
|
|
52
|
+
db := mfaStoreDBForTest(t)
|
|
53
|
+
store := NewPostgresMFAStore(db)
|
|
54
|
+
ctx := context.Background()
|
|
55
|
+
userID := uuid.New()
|
|
56
|
+
if err := db.Create(&User{ID: userID, Email: "mfa-store-" + uuid.NewString() + "@example.com"}).Error; err != nil {
|
|
57
|
+
t.Fatalf("create test user: %v", err)
|
|
58
|
+
}
|
|
59
|
+
t.Cleanup(func() { _ = db.Delete(&User{}, "id = ?", userID).Error })
|
|
60
|
+
|
|
61
|
+
if err := store.PutPendingEnrollment(ctx, userID, "encrypted-secret"); err != nil {
|
|
62
|
+
t.Fatalf("put pending enrollment: %v", err)
|
|
63
|
+
}
|
|
64
|
+
enrollment, found, err := store.GetEnrollment(ctx, userID)
|
|
65
|
+
if err != nil || !found || enrollment.Enabled {
|
|
66
|
+
t.Fatalf("expected pending enrollment, got found=%t enrollment=%+v err=%v", found, enrollment, err)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const recoveryHash = "recovery-hash-real-pg"
|
|
70
|
+
if err := store.ConfirmEnrollment(ctx, userID, enrollment.EncryptedSecret, []string{recoveryHash}); err != nil {
|
|
71
|
+
t.Fatalf("confirm enrollment: %v", err)
|
|
72
|
+
}
|
|
73
|
+
enrollment, found, err = store.GetEnrollment(ctx, userID)
|
|
74
|
+
if err != nil || !found || !enrollment.Enabled {
|
|
75
|
+
t.Fatalf("expected enabled enrollment, got found=%t enrollment=%+v err=%v", found, enrollment, err)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
challengeHash := "challenge-hash-real-pg-" + uuid.NewString()
|
|
79
|
+
if err := store.CreateChallenge(ctx, challengeHash, ports.MFAChallenge{UserID: userID, ExpiresAt: time.Now().Add(time.Minute)}); err != nil {
|
|
80
|
+
t.Fatalf("create challenge: %v", err)
|
|
81
|
+
}
|
|
82
|
+
consumeChallengeConcurrently(t, store, challengeHash, userID)
|
|
83
|
+
|
|
84
|
+
consumeRecoveryCodeConcurrently(t, store, userID, recoveryHash)
|
|
85
|
+
if err := store.Disable(ctx, userID); err != nil {
|
|
86
|
+
t.Fatalf("disable MFA: %v", err)
|
|
87
|
+
}
|
|
88
|
+
if _, found, err := store.GetEnrollment(ctx, userID); err != nil || found {
|
|
89
|
+
t.Fatalf("expected disable to remove enrollment, found=%t err=%v", found, err)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func consumeChallengeConcurrently(t *testing.T, store *PostgresMFAStore, hash string, wantUser uuid.UUID) {
|
|
94
|
+
t.Helper()
|
|
95
|
+
const callers = 16
|
|
96
|
+
start := make(chan struct{})
|
|
97
|
+
results := make(chan struct {
|
|
98
|
+
challenge ports.MFAChallenge
|
|
99
|
+
found bool
|
|
100
|
+
err error
|
|
101
|
+
}, callers)
|
|
102
|
+
var wg sync.WaitGroup
|
|
103
|
+
for i := 0; i < callers; i++ {
|
|
104
|
+
wg.Add(1)
|
|
105
|
+
go func() {
|
|
106
|
+
defer wg.Done()
|
|
107
|
+
<-start
|
|
108
|
+
challenge, found, err := store.ConsumeChallenge(context.Background(), hash)
|
|
109
|
+
results <- struct {
|
|
110
|
+
challenge ports.MFAChallenge
|
|
111
|
+
found bool
|
|
112
|
+
err error
|
|
113
|
+
}{challenge: challenge, found: found, err: err}
|
|
114
|
+
}()
|
|
115
|
+
}
|
|
116
|
+
close(start)
|
|
117
|
+
wg.Wait()
|
|
118
|
+
close(results)
|
|
119
|
+
|
|
120
|
+
winners := 0
|
|
121
|
+
for result := range results {
|
|
122
|
+
if result.err != nil {
|
|
123
|
+
t.Fatalf("concurrent challenge consume: %v", result.err)
|
|
124
|
+
}
|
|
125
|
+
if result.found {
|
|
126
|
+
winners++
|
|
127
|
+
if result.challenge.UserID != wantUser {
|
|
128
|
+
t.Fatalf("challenge winner returned user %s, want %s", result.challenge.UserID, wantUser)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if winners != 1 {
|
|
133
|
+
t.Fatalf("expected exactly one real-database challenge winner, got %d", winners)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func consumeRecoveryCodeConcurrently(t *testing.T, store *PostgresMFAStore, userID uuid.UUID, hash string) {
|
|
138
|
+
t.Helper()
|
|
139
|
+
const callers = 16
|
|
140
|
+
start := make(chan struct{})
|
|
141
|
+
results := make(chan struct {
|
|
142
|
+
used bool
|
|
143
|
+
err error
|
|
144
|
+
}, callers)
|
|
145
|
+
var wg sync.WaitGroup
|
|
146
|
+
for i := 0; i < callers; i++ {
|
|
147
|
+
wg.Add(1)
|
|
148
|
+
go func() {
|
|
149
|
+
defer wg.Done()
|
|
150
|
+
<-start
|
|
151
|
+
used, err := store.ConsumeRecoveryCode(context.Background(), userID, hash)
|
|
152
|
+
results <- struct {
|
|
153
|
+
used bool
|
|
154
|
+
err error
|
|
155
|
+
}{used: used, err: err}
|
|
156
|
+
}()
|
|
157
|
+
}
|
|
158
|
+
close(start)
|
|
159
|
+
wg.Wait()
|
|
160
|
+
close(results)
|
|
161
|
+
|
|
162
|
+
winners := 0
|
|
163
|
+
for result := range results {
|
|
164
|
+
if result.err != nil {
|
|
165
|
+
t.Fatalf("concurrent recovery-code consume: %v", result.err)
|
|
166
|
+
}
|
|
167
|
+
if result.used {
|
|
168
|
+
winners++
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if winners != 1 {
|
|
172
|
+
t.Fatalf("expected exactly one real-database recovery-code winner, got %d", winners)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Package postgres contains the user module's PostgreSQL adapters and their
|
|
2
|
+
// persistence-only records. These types never cross into application code.
|
|
3
|
+
package postgres
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"github.com/google/uuid"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type User struct {
|
|
12
|
+
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
|
13
|
+
Email string `gorm:"uniqueIndex:idx_users_email;not null"`
|
|
14
|
+
Name string
|
|
15
|
+
AvatarURL string
|
|
16
|
+
EmailVerified bool
|
|
17
|
+
Role string `gorm:"type:varchar(20);not null;default:'staff'"`
|
|
18
|
+
CreatedAt time.Time
|
|
19
|
+
UpdatedAt time.Time
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func (User) TableName() string { return "user_svc.users" }
|
|
23
|
+
|
|
24
|
+
type Identity struct {
|
|
25
|
+
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
|
26
|
+
UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_identities_user_provider,priority:1"`
|
|
27
|
+
Provider string `gorm:"type:varchar(20);not null;uniqueIndex:idx_identities_user_provider,priority:2;uniqueIndex:idx_identities_provider_uid,priority:1"`
|
|
28
|
+
PasswordHash *string
|
|
29
|
+
ProviderUID *string `gorm:"column:provider_uid;uniqueIndex:idx_identities_provider_uid,priority:2"`
|
|
30
|
+
CreatedAt time.Time
|
|
31
|
+
UpdatedAt time.Time
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func (Identity) TableName() string { return "user_svc.identities" }
|
|
35
|
+
|
|
36
|
+
type AuthToken struct {
|
|
37
|
+
TokenHash string `gorm:"primaryKey;type:text"`
|
|
38
|
+
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
|
|
39
|
+
Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
|
|
40
|
+
ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
|
|
41
|
+
AbsoluteExpiresAt *time.Time `gorm:"index:idx_auth_tokens_absolute_expires_at"`
|
|
42
|
+
Provider string `gorm:"type:varchar(20);not null;default:''"`
|
|
43
|
+
CodeChallenge string `gorm:"type:text;not null;default:''"`
|
|
44
|
+
Nonce string `gorm:"type:text;not null;default:''"`
|
|
45
|
+
CreatedAt time.Time
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
|
|
49
|
+
|
|
50
|
+
type LoginThrottle struct {
|
|
51
|
+
EmailHash string `gorm:"primaryKey;type:text"`
|
|
52
|
+
Failures int `gorm:"not null;default:0"`
|
|
53
|
+
LockedUntil *time.Time
|
|
54
|
+
UpdatedAt time.Time
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func (LoginThrottle) TableName() string { return "user_svc.login_throttle" }
|
|
58
|
+
|
|
59
|
+
type MFAEnrollment struct {
|
|
60
|
+
UserID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
|
61
|
+
EncryptedSecret string `gorm:"type:text;not null"`
|
|
62
|
+
Enabled bool `gorm:"not null;default:false"`
|
|
63
|
+
CreatedAt time.Time
|
|
64
|
+
UpdatedAt time.Time
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func (MFAEnrollment) TableName() string { return "user_svc.mfa_enrollments" }
|
|
68
|
+
|
|
69
|
+
type MFAChallenge struct {
|
|
70
|
+
ChallengeHash string `gorm:"type:text;primaryKey"`
|
|
71
|
+
UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_mfa_challenges_user"`
|
|
72
|
+
ExpiresAt time.Time `gorm:"not null;index:idx_mfa_challenges_expires_at"`
|
|
73
|
+
CreatedAt time.Time
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func (MFAChallenge) TableName() string { return "user_svc.mfa_challenges" }
|
|
77
|
+
|
|
78
|
+
type MFARecoveryCode struct {
|
|
79
|
+
UserID uuid.UUID `gorm:"type:uuid;primaryKey;index:idx_mfa_recovery_codes_user"`
|
|
80
|
+
CodeHash string `gorm:"type:text;primaryKey"`
|
|
81
|
+
CreatedAt time.Time
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func (MFARecoveryCode) TableName() string { return "user_svc.mfa_recovery_codes" }
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
package postgres
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/user/domain"
|
|
9
|
+
"{{goModule}}/internal/app/user/ports"
|
|
10
|
+
"{{goModule}}/internal/shared/dberr"
|
|
11
|
+
"{{goModule}}/internal/shared/tx"
|
|
12
|
+
|
|
13
|
+
"github.com/google/uuid"
|
|
14
|
+
"gorm.io/gorm"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type Repository struct {
|
|
18
|
+
db *gorm.DB
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
var _ ports.UserRepository = (*Repository)(nil)
|
|
22
|
+
|
|
23
|
+
func NewRepository(db *gorm.DB) *Repository {
|
|
24
|
+
return &Repository{db: db}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (r *Repository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
|
28
|
+
var row User
|
|
29
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "email = ?", email).Error; err != nil {
|
|
30
|
+
return nil, persistenceError(err)
|
|
31
|
+
}
|
|
32
|
+
return toDomainUser(&row), nil
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
|
36
|
+
var row User
|
|
37
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "id = ?", id).Error; err != nil {
|
|
38
|
+
return nil, persistenceError(err)
|
|
39
|
+
}
|
|
40
|
+
return toDomainUser(&row), nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
|
|
44
|
+
row := fromDomainUser(user)
|
|
45
|
+
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Save(&row).Error)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.User, error) {
|
|
49
|
+
var rows []User
|
|
50
|
+
err := tx.From(ctx, r.db).WithContext(ctx).
|
|
51
|
+
Order("created_at desc").Limit(limit).Offset(offset).Find(&rows).Error
|
|
52
|
+
if err != nil {
|
|
53
|
+
return nil, persistenceError(err)
|
|
54
|
+
}
|
|
55
|
+
items := make([]domain.User, len(rows))
|
|
56
|
+
for i := range rows {
|
|
57
|
+
items[i] = *toDomainUser(&rows[i])
|
|
58
|
+
}
|
|
59
|
+
return items, nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
|
|
63
|
+
var row Identity
|
|
64
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).
|
|
65
|
+
First(&row, "user_id = ? AND provider = ?", userID, string(provider)).Error; err != nil {
|
|
66
|
+
return nil, persistenceError(err)
|
|
67
|
+
}
|
|
68
|
+
return toDomainIdentity(&row), nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider domain.Provider, providerUID string) (*domain.Identity, error) {
|
|
72
|
+
var row Identity
|
|
73
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).
|
|
74
|
+
First(&row, "provider = ? AND provider_uid = ?", string(provider), providerUID).Error; err != nil {
|
|
75
|
+
return nil, persistenceError(err)
|
|
76
|
+
}
|
|
77
|
+
return toDomainIdentity(&row), nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// CreateIdentity links a new login method onto an existing user.
|
|
81
|
+
func (r *Repository) CreateIdentity(ctx context.Context, identity *domain.Identity) error {
|
|
82
|
+
row := fromDomainIdentity(identity)
|
|
83
|
+
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Create(&row).Error)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func (r *Repository) UpdateIdentity(ctx context.Context, identity *domain.Identity) error {
|
|
87
|
+
row := fromDomainIdentity(identity)
|
|
88
|
+
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Save(&row).Error)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// CreateUserWithIdentity inserts the profile and its first login method in
|
|
92
|
+
// one transaction. A user without an identity cannot authenticate.
|
|
93
|
+
func (r *Repository) CreateUserWithIdentity(ctx context.Context, user *domain.User, identity *domain.Identity) error {
|
|
94
|
+
userRow := fromDomainUser(user)
|
|
95
|
+
identityRow := fromDomainIdentity(identity)
|
|
96
|
+
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
97
|
+
if err := db.Create(&userRow).Error; err != nil {
|
|
98
|
+
return persistenceError(err)
|
|
99
|
+
}
|
|
100
|
+
identityRow.UserID = userRow.ID
|
|
101
|
+
return persistenceError(db.Create(&identityRow).Error)
|
|
102
|
+
}))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func (r *Repository) LoginLockedUntil(ctx context.Context, key string) (time.Time, error) {
|
|
106
|
+
var row LoginThrottle
|
|
107
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Where("email_hash = ?", key).Take(&row).Error
|
|
108
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
109
|
+
return time.Time{}, nil
|
|
110
|
+
}
|
|
111
|
+
if err != nil || row.LockedUntil == nil {
|
|
112
|
+
return time.Time{}, persistenceError(err)
|
|
113
|
+
}
|
|
114
|
+
return *row.LockedUntil, nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// RecordLoginFailure uses one SQL statement so concurrent attempts cannot
|
|
118
|
+
// both read and write the same backoff value.
|
|
119
|
+
func (r *Repository) RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error {
|
|
120
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Exec(`
|
|
121
|
+
INSERT INTO user_svc.login_throttle AS t (email_hash, failures, locked_until, updated_at)
|
|
122
|
+
VALUES (?, 1, NULL, now())
|
|
123
|
+
ON CONFLICT (email_hash) DO UPDATE SET
|
|
124
|
+
failures = t.failures + 1,
|
|
125
|
+
locked_until = CASE
|
|
126
|
+
WHEN t.failures + 1 <= ? THEN NULL
|
|
127
|
+
ELSE now() + make_interval(secs => least(power(2, t.failures + 1 - ?), ?))
|
|
128
|
+
END,
|
|
129
|
+
updated_at = now()`,
|
|
130
|
+
key, freeAttempts, freeAttempts, maxLock.Seconds()).Error
|
|
131
|
+
return persistenceError(err)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
|
|
135
|
+
err := tx.From(ctx, r.db).WithContext(ctx).
|
|
136
|
+
Where("email_hash = ?", key).
|
|
137
|
+
Delete(&LoginThrottle{}).Error
|
|
138
|
+
return persistenceError(err)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func persistenceError(err error) error {
|
|
142
|
+
if err == nil {
|
|
143
|
+
return nil
|
|
144
|
+
}
|
|
145
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
146
|
+
return domain.ErrNotFound
|
|
147
|
+
}
|
|
148
|
+
if dberr.IsDuplicate(err) {
|
|
149
|
+
return domain.ErrConflict
|
|
150
|
+
}
|
|
151
|
+
return err
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func toDomainUser(row *User) *domain.User {
|
|
155
|
+
if row == nil {
|
|
156
|
+
return nil
|
|
157
|
+
}
|
|
158
|
+
return &domain.User{
|
|
159
|
+
ID: row.ID,
|
|
160
|
+
Email: row.Email,
|
|
161
|
+
Name: row.Name,
|
|
162
|
+
AvatarURL: row.AvatarURL,
|
|
163
|
+
EmailVerified: row.EmailVerified,
|
|
164
|
+
Role: row.Role,
|
|
165
|
+
CreatedAt: row.CreatedAt,
|
|
166
|
+
UpdatedAt: row.UpdatedAt,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func fromDomainUser(user *domain.User) User {
|
|
171
|
+
return User{
|
|
172
|
+
ID: user.ID,
|
|
173
|
+
Email: user.Email,
|
|
174
|
+
Name: user.Name,
|
|
175
|
+
AvatarURL: user.AvatarURL,
|
|
176
|
+
EmailVerified: user.EmailVerified,
|
|
177
|
+
Role: user.Role,
|
|
178
|
+
CreatedAt: user.CreatedAt,
|
|
179
|
+
UpdatedAt: user.UpdatedAt,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func toDomainIdentity(row *Identity) *domain.Identity {
|
|
184
|
+
if row == nil {
|
|
185
|
+
return nil
|
|
186
|
+
}
|
|
187
|
+
return &domain.Identity{
|
|
188
|
+
ID: row.ID,
|
|
189
|
+
UserID: row.UserID,
|
|
190
|
+
Provider: domain.Provider(row.Provider),
|
|
191
|
+
PasswordHash: row.PasswordHash,
|
|
192
|
+
ProviderUID: row.ProviderUID,
|
|
193
|
+
CreatedAt: row.CreatedAt,
|
|
194
|
+
UpdatedAt: row.UpdatedAt,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func fromDomainIdentity(identity *domain.Identity) Identity {
|
|
199
|
+
return Identity{
|
|
200
|
+
ID: identity.ID,
|
|
201
|
+
UserID: identity.UserID,
|
|
202
|
+
Provider: string(identity.Provider),
|
|
203
|
+
PasswordHash: identity.PasswordHash,
|
|
204
|
+
ProviderUID: identity.ProviderUID,
|
|
205
|
+
CreatedAt: identity.CreatedAt,
|
|
206
|
+
UpdatedAt: identity.UpdatedAt,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// go-scaffold:repository-methods
|
|
211
|
+
// go-scaffold:user-repository-methods
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
package
|
|
1
|
+
package postgres
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
-
"time"
|
|
5
4
|
"context"
|
|
6
5
|
"errors"
|
|
7
6
|
"os"
|
|
8
7
|
"testing"
|
|
8
|
+
"time"
|
|
9
9
|
|
|
10
|
-
"{{goModule}}/internal/app/user/
|
|
11
|
-
"{{goModule}}/internal/shared/dberr"
|
|
10
|
+
"{{goModule}}/internal/app/user/domain"
|
|
12
11
|
|
|
13
12
|
"github.com/google/uuid"
|
|
14
13
|
"gorm.io/driver/postgres"
|
|
@@ -73,22 +72,22 @@ func TestRepository_CreateUserWithIdentity_RollsBackBothOnIdentityConflict(t *te
|
|
|
73
72
|
ctx := context.Background()
|
|
74
73
|
|
|
75
74
|
providerUID := "conflicting-provider-uid"
|
|
76
|
-
existing := &
|
|
77
|
-
if err := repo.CreateUserWithIdentity(ctx, existing, &
|
|
78
|
-
ID: uuid.New(), Provider:
|
|
75
|
+
existing := &domain.User{ID: uuid.New(), Email: "first@example.com"}
|
|
76
|
+
if err := repo.CreateUserWithIdentity(ctx, existing, &domain.Identity{
|
|
77
|
+
ID: uuid.New(), Provider: domain.ProviderGoogle, ProviderUID: &providerUID,
|
|
79
78
|
}); err != nil {
|
|
80
79
|
t.Fatalf("seed existing user+identity: %v", err)
|
|
81
80
|
}
|
|
82
81
|
|
|
83
|
-
blocked := &
|
|
84
|
-
err := repo.CreateUserWithIdentity(ctx, blocked, &
|
|
85
|
-
ID: uuid.New(), Provider:
|
|
82
|
+
blocked := &domain.User{ID: uuid.New(), Email: "second@example.com"}
|
|
83
|
+
err := repo.CreateUserWithIdentity(ctx, blocked, &domain.Identity{
|
|
84
|
+
ID: uuid.New(), Provider: domain.ProviderGoogle, ProviderUID: &providerUID,
|
|
86
85
|
})
|
|
87
|
-
if !
|
|
88
|
-
t.Fatalf("want a
|
|
86
|
+
if !errors.Is(err, domain.ErrConflict) {
|
|
87
|
+
t.Fatalf("want a mapped user conflict from the conflicting provider_uid, got %v", err)
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
if _, findErr := repo.FindByID(ctx, blocked.ID); !errors.Is(findErr,
|
|
90
|
+
if _, findErr := repo.FindByID(ctx, blocked.ID); !errors.Is(findErr, domain.ErrNotFound) {
|
|
92
91
|
t.Fatalf("the new user must not survive a transaction whose identity insert failed, got %v", findErr)
|
|
93
92
|
}
|
|
94
93
|
}
|
|
@@ -103,17 +102,17 @@ func TestRepository_CreateUserWithIdentity_DuplicateEmailIsDetectable(t *testing
|
|
|
103
102
|
ctx := context.Background()
|
|
104
103
|
|
|
105
104
|
email := "dup@example.com"
|
|
106
|
-
if err := repo.CreateUserWithIdentity(ctx, &
|
|
107
|
-
ID: uuid.New(), Provider:
|
|
105
|
+
if err := repo.CreateUserWithIdentity(ctx, &domain.User{ID: uuid.New(), Email: email}, &domain.Identity{
|
|
106
|
+
ID: uuid.New(), Provider: domain.ProviderLocal,
|
|
108
107
|
}); err != nil {
|
|
109
108
|
t.Fatalf("seed first user: %v", err)
|
|
110
109
|
}
|
|
111
110
|
|
|
112
|
-
err := repo.CreateUserWithIdentity(ctx, &
|
|
113
|
-
ID: uuid.New(), Provider:
|
|
111
|
+
err := repo.CreateUserWithIdentity(ctx, &domain.User{ID: uuid.New(), Email: email}, &domain.Identity{
|
|
112
|
+
ID: uuid.New(), Provider: domain.ProviderLocal,
|
|
114
113
|
})
|
|
115
|
-
if !
|
|
116
|
-
t.Fatalf("want a
|
|
114
|
+
if !errors.Is(err, domain.ErrConflict) {
|
|
115
|
+
t.Fatalf("want a mapped user conflict for the reused email, got %v", err)
|
|
117
116
|
}
|
|
118
117
|
}
|
|
119
118
|
|