@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.
- package/README.md +288 -50
- package/dist/commands/auth.js +53 -22
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +32 -2
- package/dist/commands/generate.js +25 -2
- package/dist/commands/method.js +22 -7
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +3 -3
- package/dist/commands/rbac.js +3 -3
- package/dist/commands/undo.js +5 -0
- package/dist/commands/worker.js +1 -1
- package/dist/index.js +186 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +43 -2
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +31 -1
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +37 -1
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/auth-patcher.js +115 -24
- package/dist/utils/config.js +147 -3
- package/dist/utils/main-patcher.js +29 -27
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/method-patcher.js +261 -81
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/observability-patcher.js +2 -2
- package/dist/utils/platform-patcher.js +29 -7
- package/dist/utils/rbac-patcher.js +97 -75
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +13 -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/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
- package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
- package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
- package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
- package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
- package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
- package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
- package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
- package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
- package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
- package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
- package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
- package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
- package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
- package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
- package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
- package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -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 +9 -4
- package/templates/add/auth/migrations/create_identities.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 +2 -2
- package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/AGENTS.md.hbs +255 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +45 -17
- package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
- 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 +38 -16
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +3 -3
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- 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(©)
|
|
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:
|
|
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
|
|
18
|
-
// subject id). Split from User so adding a login method
|
|
19
|
-
// profile row — one person can have
|
|
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
|
|
17
|
-
// tries to DROP the differently-named constraint the
|
|
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"`
|