@nakedev/go-scaffold 0.4.3 → 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 +567 -513
- package/dist/commands/auth.js +12 -1
- package/dist/commands/check.js +281 -0
- package/dist/commands/create.js +2 -1
- package/dist/commands/generate.js +5 -2
- package/dist/commands/method.js +66 -70
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +19 -8
- package/dist/commands/undo.js +6 -3
- package/dist/commands/worker.js +14 -4
- package/dist/index.js +13 -1
- package/dist/templates/auth-manifest.js +46 -45
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +82 -60
- package/dist/templates/rbac-manifest.js +15 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +2 -0
- package/dist/utils/auth-patcher.js +22 -22
- package/dist/utils/config.js +24 -5
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +3 -3
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/platform-patcher.js +27 -0
- package/dist/utils/rbac-patcher.js +73 -216
- package/package.json +1 -1
- package/templates/add/auth/cmd/seed/main.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/{browser_policy.go.hbs → adapters/inbound/http/browser_policy.go.hbs} +3 -3
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/{handler.go.hbs → adapters/inbound/http/handler.go.hbs} +103 -8
- package/templates/add/auth/internal/app/user/{handler_local.go.hbs → adapters/inbound/http/handler_local.go.hbs} +8 -7
- package/templates/add/auth/internal/app/user/{handler_mfa.go.hbs → adapters/inbound/http/handler_mfa.go.hbs} +6 -6
- package/templates/add/auth/internal/app/user/{handler_oauth.go.hbs → adapters/inbound/http/handler_oauth.go.hbs} +17 -17
- package/templates/add/auth/internal/app/user/{handler_recovery.go.hbs → adapters/inbound/http/handler_recovery.go.hbs} +4 -4
- package/templates/add/auth/internal/app/user/{handler_test.go.hbs → adapters/inbound/http/handler_test.go.hbs} +52 -31
- package/templates/add/auth/internal/app/user/{handler_user.go.hbs → adapters/inbound/http/handler_user.go.hbs} +4 -4
- package/templates/add/auth/internal/app/user/{session_cookie.go.hbs → adapters/inbound/http/session_cookie.go.hbs} +5 -3
- 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/{mfa_store.go.hbs → adapters/outbound/postgres/mfa_store.go.hbs} +22 -20
- package/templates/add/auth/internal/app/user/{mfa_store_test.go.hbs → adapters/outbound/postgres/mfa_store_test.go.hbs} +7 -7
- 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/{tokenstore_pg.go.hbs → adapters/outbound/postgres/tokenstore_pg.go.hbs} +36 -32
- package/templates/add/auth/internal/app/user/{tokenstore_pg_test.go.hbs → adapters/outbound/postgres/tokenstore_pg_test.go.hbs} +10 -3
- package/templates/add/auth/internal/app/user/{tokenstore_recovery.go.hbs → adapters/outbound/postgres/tokenstore_recovery.go.hbs} +29 -3
- package/templates/add/auth/internal/app/user/{tokenstore_redis.go.hbs → adapters/outbound/redis/tokenstore.go.hbs} +43 -36
- package/templates/add/auth/internal/app/user/{tokenstore_redis_test.go.hbs → adapters/outbound/redis/tokenstore_test.go.hbs} +14 -3
- 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/{external_login.go.hbs → application/external_login.go.hbs} +30 -40
- package/templates/add/auth/internal/app/user/{jwt.go.hbs → application/jwt.go.hbs} +6 -3
- package/templates/add/auth/internal/app/user/{local_auth.go.hbs → application/local_auth.go.hbs} +17 -19
- package/templates/add/auth/internal/app/user/{mfa_service.go.hbs → application/mfa_service.go.hbs} +27 -28
- package/templates/add/auth/internal/app/user/{mfa_service_test.go.hbs → application/mfa_service_test.go.hbs} +12 -11
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +3 -3
- package/templates/add/auth/internal/app/user/{provider_test.go.hbs → application/provider_test.go.hbs} +55 -56
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +16 -47
- package/templates/add/auth/internal/app/user/{recovery_service.go.hbs → application/recovery_service.go.hbs} +13 -15
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/{service_test.go.hbs → application/service_test.go.hbs} +94 -75
- package/templates/add/auth/internal/app/user/{sessions.go.hbs → application/sessions.go.hbs} +14 -14
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/{user_query.go.hbs → application/user_query.go.hbs} +16 -16
- package/templates/add/auth/internal/app/user/composition.go.hbs +89 -86
- 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/migrations/create_auth_tokens.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -1
- 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 +23 -10
- 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 +54 -36
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +55 -31
- package/templates/create/base/README.md.hbs +77 -22
- package/templates/create/base/cmd/api/wiring.go.hbs +3 -2
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- package/templates/create/features/docs/architecture.md.hbs +64 -26
- package/templates/create/features/docs/patterns.md.hbs +117 -90
- package/templates/create/features/docs/techstack.md.hbs +17 -2
- 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 -537
- package/templates/add/auth/internal/app/user/contracts.go.hbs +0 -88
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -134
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -68
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -45
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -32
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +0 -17
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +0 -20
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +0 -17
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -139
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -135
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -58
- 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/commands.go.hbs +0 -95
- package/templates/generate/module/composition.go.hbs +0 -23
- package/templates/generate/module/cqrs_test.go.hbs +0 -7
- 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 -179
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/commands.go.hbs +0 -34
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -82
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/queries.go.hbs +0 -45
- package/templates/generate/module/minimal/service.go.hbs +0 -71
- 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/queries.go.hbs +0 -62
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -164
- package/templates/generate/module/service_test.go.hbs +0 -161
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package postgres
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
@@ -7,7 +7,7 @@ import (
|
|
|
7
7
|
"fmt"
|
|
8
8
|
"time"
|
|
9
9
|
|
|
10
|
-
"{{goModule}}/internal/app/user/
|
|
10
|
+
"{{goModule}}/internal/app/user/ports"
|
|
11
11
|
"{{goModule}}/internal/shared/tx"
|
|
12
12
|
|
|
13
13
|
"github.com/google/uuid"
|
|
@@ -22,25 +22,27 @@ type PostgresMFAStore struct {
|
|
|
22
22
|
db *gorm.DB
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
var _ ports.MFAStore = (*PostgresMFAStore)(nil)
|
|
26
|
+
|
|
25
27
|
func NewPostgresMFAStore(db *gorm.DB) *PostgresMFAStore { return &PostgresMFAStore{db: db} }
|
|
26
28
|
|
|
27
|
-
func (s *PostgresMFAStore) GetEnrollment(ctx context.Context, userID uuid.UUID) (MFAEnrollment, bool, error) {
|
|
28
|
-
var row
|
|
29
|
+
func (s *PostgresMFAStore) GetEnrollment(ctx context.Context, userID uuid.UUID) (ports.MFAEnrollment, bool, error) {
|
|
30
|
+
var row MFAEnrollment
|
|
29
31
|
err := tx.From(ctx, s.db).WithContext(ctx).First(&row, "user_id = ?", userID).Error
|
|
30
32
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
31
|
-
return MFAEnrollment{}, false, nil
|
|
33
|
+
return ports.MFAEnrollment{}, false, nil
|
|
32
34
|
}
|
|
33
35
|
if err != nil {
|
|
34
|
-
return MFAEnrollment{}, false, err
|
|
36
|
+
return ports.MFAEnrollment{}, false, err
|
|
35
37
|
}
|
|
36
|
-
return MFAEnrollment{EncryptedSecret: row.EncryptedSecret, Enabled: row.Enabled}, true, nil
|
|
38
|
+
return ports.MFAEnrollment{EncryptedSecret: row.EncryptedSecret, Enabled: row.Enabled}, true, nil
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
func (s *PostgresMFAStore) PutPendingEnrollment(ctx context.Context, userID uuid.UUID, encryptedSecret string) error {
|
|
40
42
|
if userID == uuid.Nil || encryptedSecret == "" {
|
|
41
43
|
return errors.New("MFA enrollment is incomplete")
|
|
42
44
|
}
|
|
43
|
-
row :=
|
|
45
|
+
row := MFAEnrollment{UserID: userID, EncryptedSecret: encryptedSecret, Enabled: false}
|
|
44
46
|
return tx.From(ctx, s.db).WithContext(ctx).
|
|
45
47
|
Where("user_id = ? AND enabled = ?", userID, false).
|
|
46
48
|
Assign(map[string]any{"encrypted_secret": encryptedSecret, "enabled": false, "updated_at": time.Now()}).
|
|
@@ -52,7 +54,7 @@ func (s *PostgresMFAStore) ConfirmEnrollment(ctx context.Context, userID uuid.UU
|
|
|
52
54
|
return errors.New("MFA enrollment confirmation is incomplete")
|
|
53
55
|
}
|
|
54
56
|
return tx.From(ctx, s.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
55
|
-
var enrollment
|
|
57
|
+
var enrollment MFAEnrollment
|
|
56
58
|
if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(&enrollment).Error; err != nil {
|
|
57
59
|
return err
|
|
58
60
|
}
|
|
@@ -66,12 +68,12 @@ func (s *PostgresMFAStore) ConfirmEnrollment(ctx context.Context, userID uuid.UU
|
|
|
66
68
|
}).Error; err != nil {
|
|
67
69
|
return err
|
|
68
70
|
}
|
|
69
|
-
if err := db.Where("user_id = ?", userID).Delete(&
|
|
71
|
+
if err := db.Where("user_id = ?", userID).Delete(&MFARecoveryCode{}).Error; err != nil {
|
|
70
72
|
return err
|
|
71
73
|
}
|
|
72
|
-
rows := make([]
|
|
74
|
+
rows := make([]MFARecoveryCode, len(recoveryCodeHashes))
|
|
73
75
|
for i, hash := range recoveryCodeHashes {
|
|
74
|
-
rows[i] =
|
|
76
|
+
rows[i] = MFARecoveryCode{UserID: userID, CodeHash: hash}
|
|
75
77
|
}
|
|
76
78
|
return db.Create(&rows).Error
|
|
77
79
|
})
|
|
@@ -79,33 +81,33 @@ func (s *PostgresMFAStore) ConfirmEnrollment(ctx context.Context, userID uuid.UU
|
|
|
79
81
|
|
|
80
82
|
func (s *PostgresMFAStore) Disable(ctx context.Context, userID uuid.UUID) error {
|
|
81
83
|
return tx.From(ctx, s.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
82
|
-
if err := db.Where("user_id = ?", userID).Delete(&
|
|
84
|
+
if err := db.Where("user_id = ?", userID).Delete(&MFARecoveryCode{}).Error; err != nil {
|
|
83
85
|
return err
|
|
84
86
|
}
|
|
85
|
-
return db.Where("user_id = ?", userID).Delete(&
|
|
87
|
+
return db.Where("user_id = ?", userID).Delete(&MFAEnrollment{}).Error
|
|
86
88
|
})
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
func (s *PostgresMFAStore) CreateChallenge(ctx context.Context, hash string, challenge MFAChallenge) error {
|
|
91
|
+
func (s *PostgresMFAStore) CreateChallenge(ctx context.Context, hash string, challenge ports.MFAChallenge) error {
|
|
90
92
|
if hash == "" || challenge.UserID == uuid.Nil || !challenge.ExpiresAt.After(time.Now()) {
|
|
91
93
|
return fmt.Errorf("MFA challenge is invalid")
|
|
92
94
|
}
|
|
93
|
-
row :=
|
|
95
|
+
row := MFAChallenge{ChallengeHash: hash, UserID: challenge.UserID, ExpiresAt: challenge.ExpiresAt}
|
|
94
96
|
return tx.From(ctx, s.db).WithContext(ctx).Create(&row).Error
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
func (s *PostgresMFAStore) ConsumeChallenge(ctx context.Context, hash string) (MFAChallenge, bool, error) {
|
|
98
|
-
var challenge MFAChallenge
|
|
99
|
+
func (s *PostgresMFAStore) ConsumeChallenge(ctx context.Context, hash string) (ports.MFAChallenge, bool, error) {
|
|
100
|
+
var challenge ports.MFAChallenge
|
|
99
101
|
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
100
102
|
`DELETE FROM user_svc.mfa_challenges
|
|
101
103
|
WHERE challenge_hash = ? AND expires_at > now()
|
|
102
104
|
RETURNING user_id, expires_at`, hash,
|
|
103
105
|
).Row().Scan(&challenge.UserID, &challenge.ExpiresAt)
|
|
104
106
|
if errors.Is(err, sql.ErrNoRows) {
|
|
105
|
-
return MFAChallenge{}, false, nil
|
|
107
|
+
return ports.MFAChallenge{}, false, nil
|
|
106
108
|
}
|
|
107
109
|
if err != nil {
|
|
108
|
-
return MFAChallenge{}, false, err
|
|
110
|
+
return ports.MFAChallenge{}, false, err
|
|
109
111
|
}
|
|
110
112
|
return challenge, true, nil
|
|
111
113
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package postgres
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
@@ -7,7 +7,7 @@ import (
|
|
|
7
7
|
"testing"
|
|
8
8
|
"time"
|
|
9
9
|
|
|
10
|
-
"{{goModule}}/internal/app/user/
|
|
10
|
+
"{{goModule}}/internal/app/user/ports"
|
|
11
11
|
|
|
12
12
|
"github.com/google/uuid"
|
|
13
13
|
"gorm.io/driver/postgres"
|
|
@@ -53,10 +53,10 @@ func TestPostgresMFAStore_ConsumesChallengesAndRecoveryCodesOnce(t *testing.T) {
|
|
|
53
53
|
store := NewPostgresMFAStore(db)
|
|
54
54
|
ctx := context.Background()
|
|
55
55
|
userID := uuid.New()
|
|
56
|
-
if err := db.Create(&
|
|
56
|
+
if err := db.Create(&User{ID: userID, Email: "mfa-store-" + uuid.NewString() + "@example.com"}).Error; err != nil {
|
|
57
57
|
t.Fatalf("create test user: %v", err)
|
|
58
58
|
}
|
|
59
|
-
t.Cleanup(func() { _ = db.Delete(&
|
|
59
|
+
t.Cleanup(func() { _ = db.Delete(&User{}, "id = ?", userID).Error })
|
|
60
60
|
|
|
61
61
|
if err := store.PutPendingEnrollment(ctx, userID, "encrypted-secret"); err != nil {
|
|
62
62
|
t.Fatalf("put pending enrollment: %v", err)
|
|
@@ -76,7 +76,7 @@ func TestPostgresMFAStore_ConsumesChallengesAndRecoveryCodesOnce(t *testing.T) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
challengeHash := "challenge-hash-real-pg-" + uuid.NewString()
|
|
79
|
-
if err := store.CreateChallenge(ctx, challengeHash, MFAChallenge{UserID: userID, ExpiresAt: time.Now().Add(time.Minute)}); err != nil {
|
|
79
|
+
if err := store.CreateChallenge(ctx, challengeHash, ports.MFAChallenge{UserID: userID, ExpiresAt: time.Now().Add(time.Minute)}); err != nil {
|
|
80
80
|
t.Fatalf("create challenge: %v", err)
|
|
81
81
|
}
|
|
82
82
|
consumeChallengeConcurrently(t, store, challengeHash, userID)
|
|
@@ -95,7 +95,7 @@ func consumeChallengeConcurrently(t *testing.T, store *PostgresMFAStore, hash st
|
|
|
95
95
|
const callers = 16
|
|
96
96
|
start := make(chan struct{})
|
|
97
97
|
results := make(chan struct {
|
|
98
|
-
challenge MFAChallenge
|
|
98
|
+
challenge ports.MFAChallenge
|
|
99
99
|
found bool
|
|
100
100
|
err error
|
|
101
101
|
}, callers)
|
|
@@ -107,7 +107,7 @@ func consumeChallengeConcurrently(t *testing.T, store *PostgresMFAStore, hash st
|
|
|
107
107
|
<-start
|
|
108
108
|
challenge, found, err := store.ConsumeChallenge(context.Background(), hash)
|
|
109
109
|
results <- struct {
|
|
110
|
-
challenge MFAChallenge
|
|
110
|
+
challenge ports.MFAChallenge
|
|
111
111
|
found bool
|
|
112
112
|
err error
|
|
113
113
|
}{challenge: challenge, found: found, err: err}
|
|
@@ -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
|
|