@nakedev/go-scaffold 0.4.0 → 0.5.0

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