@nakedev/go-scaffold 0.4.0 → 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 (118) 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 +42 -1
  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/platform-patcher.js +29 -7
  28. package/dist/utils/rbac-patcher.js +97 -75
  29. package/package.json +7 -2
  30. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  31. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  32. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  33. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  34. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  35. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  36. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  38. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  39. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  40. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  41. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  42. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  43. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  44. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  45. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  46. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  47. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  48. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  49. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  50. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  51. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  52. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  53. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  54. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  55. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  56. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  57. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  58. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  59. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  60. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  61. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  62. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  63. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  64. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  65. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  66. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  67. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  69. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  70. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  71. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  72. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  73. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  74. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  75. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  76. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  77. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  78. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  79. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  80. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  82. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  83. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  84. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  86. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  87. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  88. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  89. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  90. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  91. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  92. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  93. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  94. package/templates/create/base/.env.example.hbs +0 -1
  95. package/templates/create/base/AGENTS.md.hbs +255 -67
  96. package/templates/create/base/Makefile.hbs +2 -1
  97. package/templates/create/base/README.md.hbs +42 -14
  98. package/templates/create/base/cmd/api/wiring.go.hbs +11 -8
  99. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  100. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  101. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  102. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  103. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  104. package/templates/create/features/docs/architecture.md.hbs +35 -13
  105. package/templates/create/features/docs/patterns.md.hbs +40 -21
  106. package/templates/create/features/docs/techstack.md.hbs +2 -2
  107. package/templates/generate/module/commands.go.hbs +95 -0
  108. package/templates/generate/module/composition.go.hbs +23 -0
  109. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  110. package/templates/generate/module/handler.go.hbs +50 -5
  111. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  112. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  113. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  114. package/templates/generate/module/minimal/service.go.hbs +27 -1
  115. package/templates/generate/module/queries.go.hbs +62 -0
  116. package/templates/generate/module/service.go.hbs +61 -5
  117. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  118. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -0,0 +1,98 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "log/slog"
6
+ "time"
7
+
8
+ "{{goModule}}/internal/app/user/model"
9
+ "{{goModule}}/internal/shared/apperror"
10
+ "{{goModule}}/internal/shared/dberr"
11
+ "{{goModule}}/internal/shared/id"
12
+
13
+ "golang.org/x/crypto/bcrypt"
14
+ )
15
+
16
+ // Failed-attempt policy. These values are security posture rather than
17
+ // per-environment tuning knobs. The first few failures cost nothing, then the
18
+ // wait doubles up to a bounded maximum.
19
+ const (
20
+ loginFreeAttempts = 3
21
+ loginMaxLock = 15 * time.Minute
22
+ )
23
+
24
+ // throttleKey namespaces the counter by purpose, so a locked login never
25
+ // blocks the password reset that would fix it. The address is hashed because
26
+ // the counter must also work for addresses that have no account.
27
+ func throttleKey(purpose, email string) string {
28
+ return hashToken(purpose + ":" + normalizeEmail(email))
29
+ }
30
+
31
+ // throttled treats a counter read failure as not-throttled: this is a brake,
32
+ // and it should not be able to lock everybody out on its own.
33
+ func (s *Service) throttled(ctx context.Context, key string) bool {
34
+ until, err := s.repo.LoginLockedUntil(ctx, key)
35
+ if err != nil {
36
+ slog.Error("read login throttle", "error", err)
37
+ return false
38
+ }
39
+ return !until.IsZero() && time.Now().Before(until)
40
+ }
41
+
42
+ func (s *Service) Register(ctx context.Context, in registerInput) (*authResult, error) {
43
+ hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
44
+ if err != nil {
45
+ return nil, apperror.NewInternal(err)
46
+ }
47
+ hashStr := string(hash)
48
+
49
+ u := &model.User{ID: id.New(), Email: normalizeEmail(in.Email), Name: in.Name}
50
+ i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
51
+ if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
52
+ if dberr.IsDuplicate(err) {
53
+ return nil, errEmailTaken()
54
+ }
55
+ return nil, apperror.NewInternal(err)
56
+ }
57
+ // Best-effort: a mail failure should not block registration. The user can
58
+ // request another link through ResendVerificationEmail.
59
+ s.sendVerificationEmail(ctx, u)
60
+ auth, err := s.issueTokens(ctx, u)
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ return tokenResult(auth), nil
65
+ }
66
+
67
+ func (s *Service) Login(ctx context.Context, in loginInput) (*authResult, error) {
68
+ key := throttleKey("login", in.Email)
69
+ if s.throttled(ctx, key) {
70
+ return nil, errTooManyAttempts()
71
+ }
72
+
73
+ // Every failure records the same key and returns the same error whether or
74
+ // not the account exists, preventing account enumeration through the counter.
75
+ fail := func() (*authResult, error) {
76
+ if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
77
+ slog.Error("record login failure", "error", err)
78
+ }
79
+ return nil, errInvalidCredentials()
80
+ }
81
+
82
+ u, err := s.repo.FindByEmail(ctx, normalizeEmail(in.Email))
83
+ if err != nil {
84
+ return fail()
85
+ }
86
+ ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
87
+ if err != nil || ident.PasswordHash == nil {
88
+ return fail()
89
+ }
90
+ if err := bcrypt.CompareHashAndPassword([]byte(*ident.PasswordHash), []byte(in.Password)); err != nil {
91
+ return fail()
92
+ }
93
+
94
+ if err := s.repo.ClearLoginFailures(ctx, key); err != nil {
95
+ slog.Error("clear login failures", "error", err)
96
+ }
97
+ return s.completeLogin(ctx, u)
98
+ }
@@ -0,0 +1,450 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "crypto/aes"
6
+ "crypto/cipher"
7
+ "crypto/hmac"
8
+ "crypto/rand"
9
+ "crypto/sha1"
10
+ "crypto/subtle"
11
+ "encoding/base32"
12
+ "encoding/base64"
13
+ "encoding/binary"
14
+ "encoding/hex"
15
+ "errors"
16
+ "fmt"
17
+ "net/url"
18
+ "strings"
19
+ "time"
20
+
21
+ "{{goModule}}/internal/app/user/model"
22
+ "{{goModule}}/internal/shared/apperror"
23
+
24
+ "github.com/google/uuid"
25
+ )
26
+
27
+ const (
28
+ totpDigits = 6
29
+ totpPeriod = 30 * time.Second
30
+ defaultMFAWindow = 1
31
+ maxMFAWindow = 3
32
+ defaultMFATTL = 5 * time.Minute
33
+ defaultMFACodes = 10
34
+ )
35
+
36
+ // MFASetup contains the secret material an authenticator app needs. It is
37
+ // returned only while setup is pending; status and login never expose it.
38
+ type MFASetup struct {
39
+ Secret string
40
+ OTPAuthURI string
41
+ }
42
+
43
+ type MFAStatus struct {
44
+ Available bool
45
+ Enabled bool
46
+ }
47
+
48
+ func (s *Service) mfaSettings() MFASettings {
49
+ settings := s.config.MFA
50
+ if strings.TrimSpace(settings.Issuer) == "" {
51
+ settings.Issuer = "go-scaffold"
52
+ }
53
+ if settings.ChallengeTTL <= 0 {
54
+ settings.ChallengeTTL = defaultMFATTL
55
+ }
56
+ if settings.TOTPWindow < 0 || settings.TOTPWindow > maxMFAWindow {
57
+ settings.TOTPWindow = defaultMFAWindow
58
+ }
59
+ if settings.RecoveryCodeCount <= 0 {
60
+ settings.RecoveryCodeCount = defaultMFACodes
61
+ }
62
+ if settings.RecoveryCodeCount > 20 {
63
+ settings.RecoveryCodeCount = 20
64
+ }
65
+ return settings
66
+ }
67
+
68
+ // ValidateMFASettings is called by the composition root when the operator
69
+ // enables MFA, so a bad encryption key fails at boot rather than after the
70
+ // first user clicks "enable MFA".
71
+ func ValidateMFASettings(settings MFASettings) error {
72
+ if !settings.Enabled {
73
+ return nil
74
+ }
75
+ if _, err := decodeMFAKey(settings.EncryptionKey); err != nil {
76
+ return fmt.Errorf("MFA_ENCRYPTION_KEY must be base64-encoded 32 bytes: %w", err)
77
+ }
78
+ if settings.ChallengeTTL <= 0 {
79
+ return errors.New("MFA_CHALLENGE_TTL must be positive")
80
+ }
81
+ if settings.TOTPWindow < 0 || settings.TOTPWindow > maxMFAWindow {
82
+ return fmt.Errorf("MFA_TOTP_WINDOW must be between 0 and %d", maxMFAWindow)
83
+ }
84
+ if settings.RecoveryCodeCount < 5 || settings.RecoveryCodeCount > 20 {
85
+ return errors.New("MFA_RECOVERY_CODE_COUNT must be between 5 and 20")
86
+ }
87
+ return nil
88
+ }
89
+
90
+ func (s *Service) completeLogin(ctx context.Context, u *model.User) (*authResult, error) {
91
+ settings := s.mfaSettings()
92
+ if !settings.Enabled {
93
+ auth, err := s.issueTokens(ctx, u)
94
+ if err != nil {
95
+ return nil, err
96
+ }
97
+ return tokenResult(auth), nil
98
+ }
99
+ if s.mfa == nil {
100
+ return nil, apperror.NewInternal(errors.New("MFA is enabled but no MFA store was configured"))
101
+ }
102
+ enrollment, found, err := s.mfa.GetEnrollment(ctx, u.ID)
103
+ if err != nil {
104
+ return nil, apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
105
+ }
106
+ if !found || !enrollment.Enabled {
107
+ auth, err := s.issueTokens(ctx, u)
108
+ if err != nil {
109
+ return nil, err
110
+ }
111
+ return tokenResult(auth), nil
112
+ }
113
+
114
+ rawChallenge, err := randomToken()
115
+ if err != nil {
116
+ return nil, apperror.NewInternal(fmt.Errorf("create MFA challenge: %w", err))
117
+ }
118
+ if err := s.mfa.CreateChallenge(ctx, hashToken(rawChallenge), MFAChallenge{
119
+ UserID: u.ID,
120
+ ExpiresAt: s.clock().Add(settings.ChallengeTTL),
121
+ }); err != nil {
122
+ return nil, apperror.NewInternal(fmt.Errorf("store MFA challenge: %w", err))
123
+ }
124
+ return challengeResult(rawChallenge), nil
125
+ }
126
+
127
+ func (s *Service) MFAStatus(ctx context.Context, userID uuid.UUID) (MFAStatus, error) {
128
+ settings := s.mfaSettings()
129
+ if !settings.Enabled || s.mfa == nil {
130
+ return MFAStatus{Available: false}, nil
131
+ }
132
+ enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
133
+ if err != nil {
134
+ return MFAStatus{}, apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
135
+ }
136
+ return MFAStatus{Available: true, Enabled: found && enrollment.Enabled}, nil
137
+ }
138
+
139
+ func (s *Service) SetupMFA(ctx context.Context, userID uuid.UUID) (*MFASetup, error) {
140
+ settings, err := s.requireMFA()
141
+ if err != nil {
142
+ return nil, err
143
+ }
144
+ if s.mfa == nil {
145
+ return nil, apperror.NewInternal(errors.New("MFA store is not configured"))
146
+ }
147
+ enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
148
+ if err != nil {
149
+ return nil, apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
150
+ }
151
+ if found && enrollment.Enabled {
152
+ return nil, errMFAAlreadyEnabled()
153
+ }
154
+ u, err := s.repo.FindByID(ctx, userID)
155
+ if err != nil {
156
+ return nil, wrapFindErr(err)
157
+ }
158
+ secret, err := newTOTPSecret()
159
+ if err != nil {
160
+ return nil, apperror.NewInternal(fmt.Errorf("generate MFA secret: %w", err))
161
+ }
162
+ encrypted, err := encryptMFASecret(settings.EncryptionKey, secret)
163
+ if err != nil {
164
+ return nil, errMFAConfig()
165
+ }
166
+ if err := s.mfa.PutPendingEnrollment(ctx, userID, encrypted); err != nil {
167
+ return nil, apperror.NewInternal(fmt.Errorf("store MFA enrollment: %w", err))
168
+ }
169
+ return &MFASetup{
170
+ Secret: secret,
171
+ OTPAuthURI: buildOTPAuthURI(settings.Issuer, u.Email, secret),
172
+ }, nil
173
+ }
174
+
175
+ func (s *Service) ConfirmMFA(ctx context.Context, userID uuid.UUID, code string) ([]string, error) {
176
+ settings, err := s.requireMFA()
177
+ if err != nil {
178
+ return nil, err
179
+ }
180
+ if s.mfa == nil {
181
+ return nil, apperror.NewInternal(errors.New("MFA store is not configured"))
182
+ }
183
+ enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
184
+ if err != nil {
185
+ return nil, apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
186
+ }
187
+ if !found {
188
+ return nil, errMFASetupRequired()
189
+ }
190
+ if enrollment.Enabled {
191
+ return nil, errMFAAlreadyEnabled()
192
+ }
193
+ secret, err := decryptMFASecret(settings.EncryptionKey, enrollment.EncryptedSecret)
194
+ if err != nil {
195
+ return nil, errMFAConfig()
196
+ }
197
+ if !validTOTPCode(secret, code, s.clock(), settings.TOTPWindow) {
198
+ return nil, errMFAInvalid()
199
+ }
200
+
201
+ codes, hashes, err := newRecoveryCodes(settings.RecoveryCodeCount)
202
+ if err != nil {
203
+ return nil, apperror.NewInternal(fmt.Errorf("generate MFA recovery codes: %w", err))
204
+ }
205
+ if err := s.mfa.ConfirmEnrollment(ctx, userID, enrollment.EncryptedSecret, hashes); err != nil {
206
+ return nil, apperror.NewInternal(fmt.Errorf("confirm MFA enrollment: %w", err))
207
+ }
208
+ return codes, nil
209
+ }
210
+
211
+ // DisableMFA requires a current TOTP code. Recovery codes are intentionally
212
+ // accepted for login only: consuming one and then failing the disable write
213
+ // would otherwise destroy a recovery factor without completing the change.
214
+ func (s *Service) DisableMFA(ctx context.Context, userID uuid.UUID, code string) error {
215
+ settings, err := s.requireMFA()
216
+ if err != nil {
217
+ return err
218
+ }
219
+ if s.mfa == nil {
220
+ return apperror.NewInternal(errors.New("MFA store is not configured"))
221
+ }
222
+ enrollment, found, err := s.mfa.GetEnrollment(ctx, userID)
223
+ if err != nil {
224
+ return apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
225
+ }
226
+ if !found || !enrollment.Enabled {
227
+ return errMFANotEnrolled()
228
+ }
229
+ secret, err := decryptMFASecret(settings.EncryptionKey, enrollment.EncryptedSecret)
230
+ if err != nil {
231
+ return errMFAConfig()
232
+ }
233
+ if !validTOTPCode(secret, code, s.clock(), settings.TOTPWindow) {
234
+ return errMFAInvalid()
235
+ }
236
+ if err := s.mfa.Disable(ctx, userID); err != nil {
237
+ return apperror.NewInternal(fmt.Errorf("disable MFA: %w", err))
238
+ }
239
+ return nil
240
+ }
241
+
242
+ // VerifyMFA consumes the challenge before checking the code. This makes every
243
+ // challenge single-use, including a failed attempt, and prevents an attacker
244
+ // from turning a leaked challenge into an unlimited six-digit guessing oracle.
245
+ func (s *Service) VerifyMFA(ctx context.Context, rawChallenge, code string) (*authResponse, error) {
246
+ settings, err := s.requireMFA()
247
+ if err != nil {
248
+ return nil, err
249
+ }
250
+ if s.mfa == nil {
251
+ return nil, apperror.NewInternal(errors.New("MFA store is not configured"))
252
+ }
253
+ challenge, found, err := s.mfa.ConsumeChallenge(ctx, hashToken(rawChallenge))
254
+ if err != nil {
255
+ return nil, apperror.NewInternal(fmt.Errorf("consume MFA challenge: %w", err))
256
+ }
257
+ if !found {
258
+ return nil, errMFAInvalid()
259
+ }
260
+ enrollment, found, err := s.mfa.GetEnrollment(ctx, challenge.UserID)
261
+ if err != nil {
262
+ return nil, apperror.NewInternal(fmt.Errorf("read MFA enrollment: %w", err))
263
+ }
264
+ if !found || !enrollment.Enabled {
265
+ return nil, errMFAInvalid()
266
+ }
267
+ secret, err := decryptMFASecret(settings.EncryptionKey, enrollment.EncryptedSecret)
268
+ if err != nil {
269
+ return nil, errMFAConfig()
270
+ }
271
+ if !validTOTPCode(secret, code, s.clock(), settings.TOTPWindow) {
272
+ recoveryHash, ok := normalizedRecoveryCodeHash(code)
273
+ if !ok {
274
+ return nil, errMFAInvalid()
275
+ }
276
+ used, err := s.mfa.ConsumeRecoveryCode(ctx, challenge.UserID, recoveryHash)
277
+ if err != nil {
278
+ return nil, apperror.NewInternal(fmt.Errorf("consume MFA recovery code: %w", err))
279
+ }
280
+ if !used {
281
+ return nil, errMFAInvalid()
282
+ }
283
+ }
284
+
285
+ u, err := s.repo.FindByID(ctx, challenge.UserID)
286
+ if err != nil {
287
+ return nil, errInvalidCredentials()
288
+ }
289
+ return s.issueTokens(ctx, u)
290
+ }
291
+
292
+ func (s *Service) requireMFA() (MFASettings, error) {
293
+ settings := s.mfaSettings()
294
+ if !settings.Enabled {
295
+ return MFASettings{}, errMFAUnavailable()
296
+ }
297
+ return settings, nil
298
+ }
299
+
300
+ func newTOTPSecret() (string, error) {
301
+ bytes := make([]byte, 20)
302
+ if _, err := rand.Read(bytes); err != nil {
303
+ return "", err
304
+ }
305
+ return strings.TrimRight(base32.StdEncoding.EncodeToString(bytes), "="), nil
306
+ }
307
+
308
+ func buildOTPAuthURI(issuer, email, secret string) string {
309
+ label := url.PathEscape(issuer + ":" + email)
310
+ query := url.Values{
311
+ "secret": {secret},
312
+ "issuer": {issuer},
313
+ "algorithm": {"SHA1"},
314
+ "digits": {fmt.Sprintf("%d", totpDigits)},
315
+ "period": {fmt.Sprintf("%d", int(totpPeriod/time.Second))},
316
+ }
317
+ return "otpauth://totp/" + label + "?" + query.Encode()
318
+ }
319
+
320
+ func validTOTPCode(secret, rawCode string, now time.Time, window int) bool {
321
+ code := strings.TrimSpace(rawCode)
322
+ if len(code) != totpDigits {
323
+ return false
324
+ }
325
+ for i := 0; i < len(code); i++ {
326
+ if code[i] < '0' || code[i] > '9' {
327
+ return false
328
+ }
329
+ }
330
+ for offset := -window; offset <= window; offset++ {
331
+ candidate, err := totpCode(secret, now.Add(time.Duration(offset)*totpPeriod))
332
+ if err == nil && subtle.ConstantTimeCompare([]byte(candidate), []byte(code)) == 1 {
333
+ return true
334
+ }
335
+ }
336
+ return false
337
+ }
338
+
339
+ // totpCode follows RFC 6238's interoperable default: HMAC-SHA-1, six digits,
340
+ // and a 30-second time step, which is what common authenticator apps expect.
341
+ func totpCode(secret string, now time.Time) (string, error) {
342
+ decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(strings.TrimSpace(secret)))
343
+ if err != nil || len(decoded) == 0 {
344
+ return "", errors.New("invalid base32 TOTP secret")
345
+ }
346
+ counter := uint64(now.Unix() / int64(totpPeriod/time.Second))
347
+ var message [8]byte
348
+ binary.BigEndian.PutUint64(message[:], counter)
349
+ hash := hmac.New(sha1.New, decoded)
350
+ _, _ = hash.Write(message[:])
351
+ sum := hash.Sum(nil)
352
+ offset := sum[len(sum)-1] & 0x0f
353
+ value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
354
+ return fmt.Sprintf("%06d", value%1000000), nil
355
+ }
356
+
357
+ func newRecoveryCodes(count int) ([]string, []string, error) {
358
+ codes := make([]string, 0, count)
359
+ hashes := make([]string, 0, count)
360
+ for i := 0; i < count; i++ {
361
+ bytes := make([]byte, 8)
362
+ if _, err := rand.Read(bytes); err != nil {
363
+ return nil, nil, err
364
+ }
365
+ normalized := strings.ToUpper(hex.EncodeToString(bytes))
366
+ code := normalized[:4] + "-" + normalized[4:8] + "-" + normalized[8:12] + "-" + normalized[12:]
367
+ hash, ok := normalizedRecoveryCodeHash(code)
368
+ if !ok {
369
+ return nil, nil, errors.New("generated invalid MFA recovery code")
370
+ }
371
+ codes = append(codes, code)
372
+ hashes = append(hashes, hash)
373
+ }
374
+ return codes, hashes, nil
375
+ }
376
+
377
+ func normalizedRecoveryCodeHash(raw string) (string, bool) {
378
+ value := strings.ToUpper(strings.TrimSpace(raw))
379
+ value = strings.NewReplacer("-", "", " ", "").Replace(value)
380
+ if len(value) != 16 {
381
+ return "", false
382
+ }
383
+ for i := 0; i < len(value); i++ {
384
+ if (value[i] < 'A' || value[i] > 'F') && (value[i] < '0' || value[i] > '9') {
385
+ return "", false
386
+ }
387
+ }
388
+ return hashToken("mfa-recovery:" + value), true
389
+ }
390
+
391
+ func decodeMFAKey(raw string) ([]byte, error) {
392
+ value := strings.TrimSpace(raw)
393
+ if value == "" {
394
+ return nil, errors.New("empty key")
395
+ }
396
+ decoded, err := base64.StdEncoding.DecodeString(value)
397
+ if err != nil {
398
+ decoded, err = base64.RawStdEncoding.DecodeString(value)
399
+ }
400
+ if err != nil || len(decoded) != 32 {
401
+ return nil, errors.New("key is not 32 bytes")
402
+ }
403
+ return decoded, nil
404
+ }
405
+
406
+ func encryptMFASecret(rawKey, secret string) (string, error) {
407
+ key, err := decodeMFAKey(rawKey)
408
+ if err != nil {
409
+ return "", err
410
+ }
411
+ block, err := aes.NewCipher(key)
412
+ if err != nil {
413
+ return "", err
414
+ }
415
+ gcm, err := cipher.NewGCM(block)
416
+ if err != nil {
417
+ return "", err
418
+ }
419
+ nonce := make([]byte, gcm.NonceSize())
420
+ if _, err := rand.Read(nonce); err != nil {
421
+ return "", err
422
+ }
423
+ ciphertext := gcm.Seal(nil, nonce, []byte(secret), nil)
424
+ payload := append(nonce, ciphertext...)
425
+ return base64.RawStdEncoding.EncodeToString(payload), nil
426
+ }
427
+
428
+ func decryptMFASecret(rawKey, encoded string) (string, error) {
429
+ key, err := decodeMFAKey(rawKey)
430
+ if err != nil {
431
+ return "", err
432
+ }
433
+ payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(encoded))
434
+ if err != nil {
435
+ return "", err
436
+ }
437
+ block, err := aes.NewCipher(key)
438
+ if err != nil {
439
+ return "", err
440
+ }
441
+ gcm, err := cipher.NewGCM(block)
442
+ if err != nil || len(payload) < gcm.NonceSize() {
443
+ return "", errors.New("invalid encrypted MFA secret")
444
+ }
445
+ secret, err := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil)
446
+ if err != nil {
447
+ return "", err
448
+ }
449
+ return string(secret), nil
450
+ }