@nakedev/go-scaffold 0.3.3 → 0.4.3

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.
Files changed (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -0,0 +1,199 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "{{goModule}}/internal/app/user/model"
10
+ "{{goModule}}/internal/shared/apperror"
11
+
12
+ "github.com/google/uuid"
13
+ )
14
+
15
+ const testMFAEncryptionKey = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
16
+
17
+ func configuredMFATestService(t *testing.T) (*Service, *fakeMFAStore, uuid.UUID, time.Time) {
18
+ t.Helper()
19
+ userID := uuid.New()
20
+ mfa := newFakeMFAStore()
21
+ svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "mfa@example.com"}}, newFakeTokenStore())
22
+ now := time.Now()
23
+ svc.mfa = mfa
24
+ svc.now = func() time.Time { return now }
25
+ svc.config.MFA = MFASettings{
26
+ Enabled: true,
27
+ Issuer: "Example",
28
+ EncryptionKey: testMFAEncryptionKey,
29
+ ChallengeTTL: 5 * time.Minute,
30
+ TOTPWindow: 1,
31
+ RecoveryCodeCount: 10,
32
+ }
33
+ return svc, mfa, userID, now
34
+ }
35
+
36
+ func TestTOTPCodeMatchesRFC6238SHA1Vector(t *testing.T) {
37
+ secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
38
+ code, err := totpCode(secret, time.Unix(59, 0))
39
+ if err != nil {
40
+ t.Fatalf("generate TOTP: %v", err)
41
+ }
42
+ if code != "287082" {
43
+ t.Fatalf("expected RFC 6238 six-digit code 287082, got %s", code)
44
+ }
45
+ }
46
+
47
+ func TestMFADefaultIsDisabled(t *testing.T) {
48
+ userID := uuid.New()
49
+ svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "default@example.com"}}, newFakeTokenStore())
50
+
51
+ status, err := svc.MFAStatus(context.Background(), userID)
52
+ if err != nil {
53
+ t.Fatalf("read default MFA status: %v", err)
54
+ }
55
+ if status.Available || status.Enabled {
56
+ t.Fatalf("MFA must be disabled and unavailable by default: %+v", status)
57
+ }
58
+ }
59
+
60
+ func TestValidateMFASettingsRejectsUnsafeEnabledConfiguration(t *testing.T) {
61
+ valid := MFASettings{
62
+ Enabled: true,
63
+ EncryptionKey: testMFAEncryptionKey,
64
+ ChallengeTTL: 5 * time.Minute,
65
+ TOTPWindow: 1,
66
+ RecoveryCodeCount: 10,
67
+ }
68
+ if err := ValidateMFASettings(valid); err != nil {
69
+ t.Fatalf("valid MFA settings rejected: %v", err)
70
+ }
71
+
72
+ for name, settings := range map[string]MFASettings{
73
+ "missing encryption key": validWithMFAOverride(valid, func(s *MFASettings) { s.EncryptionKey = "" }),
74
+ "non-positive challenge": validWithMFAOverride(valid, func(s *MFASettings) { s.ChallengeTTL = 0 }),
75
+ "wide TOTP window": validWithMFAOverride(valid, func(s *MFASettings) { s.TOTPWindow = 4 }),
76
+ "too few recovery codes": validWithMFAOverride(valid, func(s *MFASettings) { s.RecoveryCodeCount = 4 }),
77
+ "too many recovery codes": validWithMFAOverride(valid, func(s *MFASettings) { s.RecoveryCodeCount = 21 }),
78
+ } {
79
+ if err := ValidateMFASettings(settings); err == nil {
80
+ t.Errorf("%s: expected invalid enabled configuration to fail", name)
81
+ }
82
+ }
83
+ if err := ValidateMFASettings(MFASettings{Enabled: false}); err != nil {
84
+ t.Fatalf("disabled MFA should not require an encryption key: %v", err)
85
+ }
86
+ }
87
+
88
+ func validWithMFAOverride(base MFASettings, override func(*MFASettings)) MFASettings {
89
+ copy := base
90
+ override(&copy)
91
+ return copy
92
+ }
93
+
94
+ func TestMFAEnrollmentIsPendingUntilTOTPConfirmation(t *testing.T) {
95
+ svc, store, userID, now := configuredMFATestService(t)
96
+ ctx := context.Background()
97
+
98
+ setup, err := svc.SetupMFA(ctx, userID)
99
+ if err != nil {
100
+ t.Fatalf("setup MFA: %v", err)
101
+ }
102
+ if setup.Secret == "" || !strings.HasPrefix(setup.OTPAuthURI, "otpauth://totp/") {
103
+ t.Fatalf("unexpected setup response: %+v", setup)
104
+ }
105
+ if strings.Contains(store.enrollments[userID].EncryptedSecret, setup.Secret) {
106
+ t.Fatal("MFA secret must not be stored in plaintext")
107
+ }
108
+
109
+ status, err := svc.MFAStatus(ctx, userID)
110
+ if err != nil || !status.Available || status.Enabled {
111
+ t.Fatalf("expected available but disabled pending enrollment: %+v, %v", status, err)
112
+ }
113
+ code, _ := totpCode(setup.Secret, now)
114
+ codes, err := svc.ConfirmMFA(ctx, userID, code)
115
+ if err != nil {
116
+ t.Fatalf("confirm MFA: %v", err)
117
+ }
118
+ if len(codes) != 10 {
119
+ t.Fatalf("expected 10 recovery codes, got %d", len(codes))
120
+ }
121
+ status, err = svc.MFAStatus(ctx, userID)
122
+ if err != nil || !status.Enabled {
123
+ t.Fatalf("expected enabled MFA status: %+v, %v", status, err)
124
+ }
125
+ }
126
+
127
+ func TestMFAChallengeIsRequiredAndOneUse(t *testing.T) {
128
+ svc, _, userID, now := configuredMFATestService(t)
129
+ ctx := context.Background()
130
+ secret, err := newTOTPSecret()
131
+ if err != nil {
132
+ t.Fatalf("secret: %v", err)
133
+ }
134
+ encrypted, err := encryptMFASecret(testMFAEncryptionKey, secret)
135
+ if err != nil {
136
+ t.Fatalf("encrypt: %v", err)
137
+ }
138
+ if err := svc.mfa.ConfirmEnrollment(ctx, userID, encrypted, []string{}); err == nil {
139
+ t.Fatal("expected store to reject an enrollment without recovery codes")
140
+ }
141
+ // Use the service path so the exact recovery-code policy is exercised.
142
+ if err := svc.mfa.PutPendingEnrollment(ctx, userID, encrypted); err != nil {
143
+ t.Fatalf("pending: %v", err)
144
+ }
145
+ code, _ := totpCode(secret, now)
146
+ if _, err := svc.ConfirmMFA(ctx, userID, code); err != nil {
147
+ t.Fatalf("confirm: %v", err)
148
+ }
149
+
150
+ result, err := svc.completeLogin(ctx, &model.User{ID: userID, Email: "mfa@example.com"})
151
+ if err != nil || result.MFAChallenge == "" || result.authResponse != nil {
152
+ t.Fatalf("expected pre-session MFA challenge: %+v, %v", result, err)
153
+ }
154
+ auth, err := svc.VerifyMFA(ctx, result.MFAChallenge, code)
155
+ if err != nil || auth == nil || auth.AccessToken == "" || auth.RefreshToken == "" {
156
+ t.Fatalf("verify MFA: %+v, %v", auth, err)
157
+ }
158
+ if _, err := svc.VerifyMFA(ctx, result.MFAChallenge, code); codeOfMFAError(err) != "AUTH_MFA_INVALID" {
159
+ t.Fatalf("expected one-use challenge rejection, got %v", err)
160
+ }
161
+ }
162
+
163
+ func TestMFARecoveryCodeIsOneUse(t *testing.T) {
164
+ svc, _, userID, now := configuredMFATestService(t)
165
+ ctx := context.Background()
166
+ setup, err := svc.SetupMFA(ctx, userID)
167
+ if err != nil {
168
+ t.Fatalf("setup: %v", err)
169
+ }
170
+ code, _ := totpCode(setup.Secret, now)
171
+ recoveryCodes, err := svc.ConfirmMFA(ctx, userID, code)
172
+ if err != nil {
173
+ t.Fatalf("confirm: %v", err)
174
+ }
175
+ result, err := svc.completeLogin(ctx, &model.User{ID: userID, Email: "mfa@example.com"})
176
+ if err != nil {
177
+ t.Fatalf("login challenge: %v", err)
178
+ }
179
+ if _, err := svc.VerifyMFA(ctx, result.MFAChallenge, recoveryCodes[0]); err != nil {
180
+ t.Fatalf("verify with recovery code: %v", err)
181
+ }
182
+ result, err = svc.completeLogin(ctx, &model.User{ID: userID, Email: "mfa@example.com"})
183
+ if err != nil {
184
+ t.Fatalf("second login challenge: %v", err)
185
+ }
186
+ if _, err := svc.VerifyMFA(ctx, result.MFAChallenge, recoveryCodes[0]); codeOfMFAError(err) != "AUTH_MFA_INVALID" {
187
+ t.Fatalf("expected consumed recovery code rejection, got %v", err)
188
+ }
189
+ }
190
+
191
+ func codeOfMFAError(err error) string {
192
+ if err == nil {
193
+ return ""
194
+ }
195
+ if appErr, ok := err.(*apperror.AppError); ok {
196
+ return appErr.Code
197
+ }
198
+ return ""
199
+ }
@@ -0,0 +1,127 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "time"
9
+
10
+ "{{goModule}}/internal/app/user/model"
11
+ "{{goModule}}/internal/shared/tx"
12
+
13
+ "github.com/google/uuid"
14
+ "gorm.io/gorm"
15
+ "gorm.io/gorm/clause"
16
+ )
17
+
18
+ // PostgresMFAStore is intentionally independent of the selected refresh-token
19
+ // adapter. MFA challenges and recovery codes need durable, atomic semantics in
20
+ // every deployment, including Redis-backed auth behind multiple replicas.
21
+ type PostgresMFAStore struct {
22
+ db *gorm.DB
23
+ }
24
+
25
+ func NewPostgresMFAStore(db *gorm.DB) *PostgresMFAStore { return &PostgresMFAStore{db: db} }
26
+
27
+ func (s *PostgresMFAStore) GetEnrollment(ctx context.Context, userID uuid.UUID) (MFAEnrollment, bool, error) {
28
+ var row model.MFAEnrollment
29
+ err := tx.From(ctx, s.db).WithContext(ctx).First(&row, "user_id = ?", userID).Error
30
+ if errors.Is(err, gorm.ErrRecordNotFound) {
31
+ return MFAEnrollment{}, false, nil
32
+ }
33
+ if err != nil {
34
+ return MFAEnrollment{}, false, err
35
+ }
36
+ return MFAEnrollment{EncryptedSecret: row.EncryptedSecret, Enabled: row.Enabled}, true, nil
37
+ }
38
+
39
+ func (s *PostgresMFAStore) PutPendingEnrollment(ctx context.Context, userID uuid.UUID, encryptedSecret string) error {
40
+ if userID == uuid.Nil || encryptedSecret == "" {
41
+ return errors.New("MFA enrollment is incomplete")
42
+ }
43
+ row := model.MFAEnrollment{UserID: userID, EncryptedSecret: encryptedSecret, Enabled: false}
44
+ return tx.From(ctx, s.db).WithContext(ctx).
45
+ Where("user_id = ? AND enabled = ?", userID, false).
46
+ Assign(map[string]any{"encrypted_secret": encryptedSecret, "enabled": false, "updated_at": time.Now()}).
47
+ FirstOrCreate(&row).Error
48
+ }
49
+
50
+ func (s *PostgresMFAStore) ConfirmEnrollment(ctx context.Context, userID uuid.UUID, encryptedSecret string, recoveryCodeHashes []string) error {
51
+ if userID == uuid.Nil || encryptedSecret == "" || len(recoveryCodeHashes) == 0 {
52
+ return errors.New("MFA enrollment confirmation is incomplete")
53
+ }
54
+ return tx.From(ctx, s.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
55
+ var enrollment model.MFAEnrollment
56
+ if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(&enrollment).Error; err != nil {
57
+ return err
58
+ }
59
+ if enrollment.Enabled {
60
+ return errors.New("MFA enrollment is already enabled")
61
+ }
62
+ if err := db.Model(&enrollment).Updates(map[string]any{
63
+ "encrypted_secret": encryptedSecret,
64
+ "enabled": true,
65
+ "updated_at": time.Now(),
66
+ }).Error; err != nil {
67
+ return err
68
+ }
69
+ if err := db.Where("user_id = ?", userID).Delete(&model.MFARecoveryCode{}).Error; err != nil {
70
+ return err
71
+ }
72
+ rows := make([]model.MFARecoveryCode, len(recoveryCodeHashes))
73
+ for i, hash := range recoveryCodeHashes {
74
+ rows[i] = model.MFARecoveryCode{UserID: userID, CodeHash: hash}
75
+ }
76
+ return db.Create(&rows).Error
77
+ })
78
+ }
79
+
80
+ func (s *PostgresMFAStore) Disable(ctx context.Context, userID uuid.UUID) error {
81
+ return tx.From(ctx, s.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
82
+ if err := db.Where("user_id = ?", userID).Delete(&model.MFARecoveryCode{}).Error; err != nil {
83
+ return err
84
+ }
85
+ return db.Where("user_id = ?", userID).Delete(&model.MFAEnrollment{}).Error
86
+ })
87
+ }
88
+
89
+ func (s *PostgresMFAStore) CreateChallenge(ctx context.Context, hash string, challenge MFAChallenge) error {
90
+ if hash == "" || challenge.UserID == uuid.Nil || !challenge.ExpiresAt.After(time.Now()) {
91
+ return fmt.Errorf("MFA challenge is invalid")
92
+ }
93
+ row := model.MFAChallenge{ChallengeHash: hash, UserID: challenge.UserID, ExpiresAt: challenge.ExpiresAt}
94
+ return tx.From(ctx, s.db).WithContext(ctx).Create(&row).Error
95
+ }
96
+
97
+ func (s *PostgresMFAStore) ConsumeChallenge(ctx context.Context, hash string) (MFAChallenge, bool, error) {
98
+ var challenge MFAChallenge
99
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
100
+ `DELETE FROM user_svc.mfa_challenges
101
+ WHERE challenge_hash = ? AND expires_at > now()
102
+ RETURNING user_id, expires_at`, hash,
103
+ ).Row().Scan(&challenge.UserID, &challenge.ExpiresAt)
104
+ if errors.Is(err, sql.ErrNoRows) {
105
+ return MFAChallenge{}, false, nil
106
+ }
107
+ if err != nil {
108
+ return MFAChallenge{}, false, err
109
+ }
110
+ return challenge, true, nil
111
+ }
112
+
113
+ func (s *PostgresMFAStore) ConsumeRecoveryCode(ctx context.Context, userID uuid.UUID, hash string) (bool, error) {
114
+ var consumed string
115
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
116
+ `DELETE FROM user_svc.mfa_recovery_codes
117
+ WHERE user_id = ? AND code_hash = ?
118
+ RETURNING code_hash`, userID, hash,
119
+ ).Row().Scan(&consumed)
120
+ if errors.Is(err, sql.ErrNoRows) {
121
+ return false, nil
122
+ }
123
+ if err != nil {
124
+ return false, err
125
+ }
126
+ return consumed != "", nil
127
+ }
@@ -0,0 +1,174 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "sync"
7
+ "testing"
8
+ "time"
9
+
10
+ "{{goModule}}/internal/app/user/model"
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(&model.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(&model.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, 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 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 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
+ }
@@ -24,8 +24,8 @@ import (
24
24
  // gets a plain rejection instead of tripping reuse detection and nuking every
25
25
  // session the user has.
26
26
  //
27
- // No foreign key to users on purpose: GORM's AutoMigrate (dev) wouldn't create
28
- // one from these tags, and a constraint that exists in production but not in
27
+ // No foreign key to users on purpose: the development bootstrap wouldn't
28
+ // create one from these tags, and a constraint that exists in production but not in
29
29
  // development is the exact mismatch that stops the app booting. A token whose
30
30
  // user is gone simply fails the lookup that follows.
31
31
  type AuthToken struct {
@@ -33,6 +33,12 @@ type AuthToken struct {
33
33
  UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
34
34
  Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
35
35
  ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
36
+ // AbsoluteExpiresAt is set only for refresh tokens. Recovery tokens and
37
+ // OAuth transactions have no sliding lifetime, so it remains nil there.
38
+ AbsoluteExpiresAt *time.Time `gorm:"index:idx_auth_tokens_absolute_expires_at"`
39
+ Provider string `gorm:"type:varchar(20);not null;default:''"`
40
+ CodeChallenge string `gorm:"type:text;not null;default:''"`
41
+ Nonce string `gorm:"type:text;not null;default:''"`
36
42
  CreatedAt time.Time
37
43
  }
38
44
 
@@ -14,9 +14,10 @@ const (
14
14
  )
15
15
 
16
16
  // Identity = how you log in (one row per login method). PasswordHash is set
17
- // for a "local" identity; ProviderUID is set for "google" (the provider's own
18
- // subject id). Split from User so adding a login method never touches the
19
- // profile row — one person can have both and resolve to the same account.
17
+ // for a "local" identity; ProviderUID is set for an external provider (the
18
+ // provider's own stable subject id). Split from User so adding a login method
19
+ // never touches the profile row — one person can have several methods and
20
+ // resolve to the same account.
20
21
  type Identity struct {
21
22
  ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
22
23
  UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;uniqueIndex:idx_identities_user_provider,priority:1"`
@@ -0,0 +1,17 @@
1
+ package model
2
+
3
+ import (
4
+ "time"
5
+
6
+ "github.com/google/uuid"
7
+ )
8
+
9
+ // MFAChallenge is a one-use pre-session challenge. Only its hash is stored.
10
+ type MFAChallenge struct {
11
+ ChallengeHash string `gorm:"type:text;primaryKey"`
12
+ UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_mfa_challenges_user"`
13
+ ExpiresAt time.Time `gorm:"not null;index:idx_mfa_challenges_expires_at"`
14
+ CreatedAt time.Time
15
+ }
16
+
17
+ func (MFAChallenge) TableName() string { return "user_svc.mfa_challenges" }
@@ -0,0 +1,20 @@
1
+ package model
2
+
3
+ import (
4
+ "time"
5
+
6
+ "github.com/google/uuid"
7
+ )
8
+
9
+ // MFAEnrollment stores one user's TOTP enrollment. The secret is encrypted by
10
+ // the application before it reaches this model; plaintext secrets never live
11
+ // in the database.
12
+ type MFAEnrollment struct {
13
+ UserID uuid.UUID `gorm:"type:uuid;primaryKey"`
14
+ EncryptedSecret string `gorm:"type:text;not null"`
15
+ Enabled bool `gorm:"not null;default:false"`
16
+ CreatedAt time.Time
17
+ UpdatedAt time.Time
18
+ }
19
+
20
+ func (MFAEnrollment) TableName() string { return "user_svc.mfa_enrollments" }
@@ -0,0 +1,17 @@
1
+ package model
2
+
3
+ import (
4
+ "time"
5
+
6
+ "github.com/google/uuid"
7
+ )
8
+
9
+ // MFARecoveryCode stores a hash only. A consumed code is deleted atomically,
10
+ // so the table never becomes a second mutable session state machine.
11
+ type MFARecoveryCode struct {
12
+ UserID uuid.UUID `gorm:"type:uuid;primaryKey;index:idx_mfa_recovery_codes_user"`
13
+ CodeHash string `gorm:"type:text;primaryKey"`
14
+ CreatedAt time.Time
15
+ }
16
+
17
+ func (MFARecoveryCode) TableName() string { return "user_svc.mfa_recovery_codes" }
@@ -13,8 +13,9 @@ import (
13
13
  type User struct {
14
14
  ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
15
15
  // index named explicitly, and named the same in create_users.up.sql: an
16
- // anonymous `uniqueIndex` makes GORM invent one, and AutoMigrate then
17
- // tries to DROP the differently-named constraint the migration created.
16
+ // anonymous `uniqueIndex` makes GORM invent one, and the development
17
+ // bootstrap then tries to DROP the differently-named constraint the
18
+ // migration created.
18
19
  Email string `json:"email" gorm:"uniqueIndex:idx_users_email;not null"`
19
20
  Name string `json:"name"`
20
21
  AvatarURL string `json:"avatar_url"`