@nakedev/go-scaffold 0.3.3 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -1,6 +1,7 @@
1
1
  package user
2
2
 
3
3
  import (
4
+ "fmt"
4
5
  "net/http"
5
6
 
6
7
  "{{goModule}}/internal/shared/apperror"
@@ -40,4 +41,28 @@ func errAlreadyVerified() *apperror.AppError {
40
41
  return apperror.New(http.StatusConflict, "AUTH_ALREADY_VERIFIED", "email is already verified")
41
42
  }
42
43
 
44
+ func errMFAUnavailable() *apperror.AppError {
45
+ return apperror.New(http.StatusServiceUnavailable, "AUTH_MFA_UNAVAILABLE", "multi-factor authentication is not enabled")
46
+ }
47
+
48
+ func errMFAInvalid() *apperror.AppError {
49
+ return apperror.New(http.StatusUnauthorized, "AUTH_MFA_INVALID", "invalid or expired multi-factor authentication code")
50
+ }
51
+
52
+ func errMFAAlreadyEnabled() *apperror.AppError {
53
+ return apperror.New(http.StatusConflict, "AUTH_MFA_ALREADY_ENABLED", "multi-factor authentication is already enabled")
54
+ }
55
+
56
+ func errMFANotEnrolled() *apperror.AppError {
57
+ return apperror.New(http.StatusConflict, "AUTH_MFA_NOT_ENROLLED", "multi-factor authentication is not enabled for this user")
58
+ }
59
+
60
+ func errMFASetupRequired() *apperror.AppError {
61
+ return apperror.New(http.StatusConflict, "AUTH_MFA_SETUP_REQUIRED", "complete multi-factor authentication setup first")
62
+ }
63
+
64
+ func errMFAConfig() *apperror.AppError {
65
+ return apperror.NewInternal(fmt.Errorf("multi-factor authentication encryption key is invalid"))
66
+ }
67
+
43
68
  // go-scaffold:user-errors
@@ -0,0 +1,208 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "crypto/subtle"
7
+ "encoding/base64"
8
+ "errors"
9
+ "fmt"
10
+ "strings"
11
+ "time"
12
+
13
+ "{{goModule}}/internal/app/user/application"
14
+ "{{goModule}}/internal/app/user/model"
15
+ "{{goModule}}/internal/shared/dberr"
16
+ "{{goModule}}/internal/shared/id"
17
+
18
+ "gorm.io/gorm"
19
+ )
20
+
21
+ // BeginLogin accepts the browser client's state and S256 PKCE challenge and
22
+ // asks the registered provider adapter to construct its authorization URL.
23
+ // The server persists a hashed state transaction binding provider, challenge,
24
+ // and OIDC nonce before the callback reaches ExchangeLogin.
25
+ func (s *Service) BeginLogin(ctx context.Context, providerName string, in application.LoginStartInput) (*application.Authorization, error) {
26
+ provider, ok := s.providers.Lookup(providerName)
27
+ if !ok {
28
+ return nil, application.NewOAuthError(application.OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
29
+ }
30
+ // OAuth values are opaque: validate their wire-safe shape without trimming
31
+ // or rewriting what the frontend and provider saw.
32
+ if !validOAuthValue(in.State) || !validPKCEChallenge(in.CodeChallenge) || in.CodeChallengeMethod != "S256" {
33
+ return nil, application.NewOAuthError(application.OAuthStateInvalid, fmt.Errorf("state and S256 code challenge are required"))
34
+ }
35
+ nonce, err := randomToken()
36
+ if err != nil {
37
+ return nil, application.NewOAuthError(application.OAuthProviderUnavailable, fmt.Errorf("create oauth transaction: %w", err))
38
+ }
39
+ in.Nonce = nonce
40
+ authorization, err := provider.Begin(ctx, in)
41
+ if err != nil {
42
+ return nil, mapProviderError(err)
43
+ }
44
+ if strings.TrimSpace(authorization.URL) == "" {
45
+ return nil, application.NewOAuthError(application.OAuthProviderUnavailable, fmt.Errorf("provider returned an empty authorization URL"))
46
+ }
47
+ clock := s.now
48
+ if clock == nil {
49
+ clock = time.Now
50
+ }
51
+ if err := s.oauthTransactions.SetLoginTransaction(ctx, hashToken(in.State), loginTransaction{
52
+ Provider: providerName,
53
+ CodeChallenge: in.CodeChallenge,
54
+ Nonce: nonce,
55
+ ExpiresAt: clock().Add(s.config.OAuthStateTTL),
56
+ }); err != nil {
57
+ return nil, application.NewOAuthError(application.OAuthProviderUnavailable, fmt.Errorf("store oauth transaction: %w", err))
58
+ }
59
+ return &authorization, nil
60
+ }
61
+
62
+ type loginExchangeInput struct {
63
+ Code string `json:"code"`
64
+ State string `json:"state"`
65
+ CodeVerifier string `json:"code_verifier"`
66
+ }
67
+
68
+ func (s *Service) ExchangeLogin(ctx context.Context, providerName string, in loginExchangeInput) (*authResult, error) {
69
+ provider, ok := s.providers.Lookup(providerName)
70
+ if !ok {
71
+ return nil, application.NewOAuthError(application.OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
72
+ }
73
+ if !validOAuthValue(in.State) || !validPKCEValue(in.CodeVerifier) {
74
+ return nil, application.NewOAuthError(application.OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
75
+ }
76
+ if !validOAuthValue(in.Code) {
77
+ return nil, application.NewOAuthError(application.OAuthFailed, fmt.Errorf("authorization code is missing"))
78
+ }
79
+
80
+ transaction, ok, err := s.oauthTransactions.ConsumeLoginTransaction(ctx, hashToken(in.State))
81
+ if err != nil {
82
+ return nil, application.NewOAuthError(application.OAuthProviderUnavailable, fmt.Errorf("consume oauth transaction: %w", err))
83
+ }
84
+ if !ok || transaction.Provider != providerName || !validPKCEVerifier(in.CodeVerifier, transaction.CodeChallenge) {
85
+ return nil, application.NewOAuthError(application.OAuthStateInvalid, fmt.Errorf("oauth state or PKCE verifier is invalid"))
86
+ }
87
+
88
+ identity, err := provider.Complete(ctx, application.LoginCompleteInput{Code: in.Code, CodeVerifier: in.CodeVerifier, Nonce: transaction.Nonce})
89
+ if err != nil {
90
+ return nil, mapProviderError(err)
91
+ }
92
+ if identity.Provider != providerName {
93
+ return nil, application.NewOAuthError(application.OAuthFailed, fmt.Errorf("provider identity name does not match the requested provider"))
94
+ }
95
+ u, err := s.findOrCreateExternalUser(ctx, identity)
96
+ if err != nil {
97
+ return nil, application.NewOAuthError(application.OAuthFailed, fmt.Errorf("resolve external identity: %w", err))
98
+ }
99
+ return s.completeLogin(ctx, u)
100
+ }
101
+
102
+ func validPKCEVerifier(verifier, challenge string) bool {
103
+ if !validPKCEValue(verifier) || !validPKCEValue(challenge) {
104
+ return false
105
+ }
106
+ computed := pkceChallenge(verifier)
107
+ return subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) == 1
108
+ }
109
+
110
+ // OAuth state and authorization codes use the RFC 6749 VSCHAR shape.
111
+ func validOAuthValue(value string) bool {
112
+ if value == "" {
113
+ return false
114
+ }
115
+ for i := 0; i < len(value); i++ {
116
+ if value[i] < 0x20 || value[i] > 0x7e {
117
+ return false
118
+ }
119
+ }
120
+ return true
121
+ }
122
+
123
+ // RFC 7636 defines code_verifier/code_challenge as 43–128 unreserved ASCII
124
+ // characters. S256 is the only method accepted by this scaffold.
125
+ func validPKCEValue(value string) bool {
126
+ if len(value) < 43 || len(value) > 128 {
127
+ return false
128
+ }
129
+ for i := 0; i < len(value); i++ {
130
+ c := value[i]
131
+ switch {
132
+ case c >= 'A' && c <= 'Z':
133
+ case c >= 'a' && c <= 'z':
134
+ case c >= '0' && c <= '9':
135
+ case c == '-' || c == '.' || c == '_' || c == '~':
136
+ default:
137
+ return false
138
+ }
139
+ }
140
+ return true
141
+ }
142
+
143
+ func validPKCEChallenge(challenge string) bool {
144
+ return validPKCEValue(challenge)
145
+ }
146
+
147
+ func pkceChallenge(verifier string) string {
148
+ sum := sha256.Sum256([]byte(verifier))
149
+ return base64.RawURLEncoding.EncodeToString(sum[:])
150
+ }
151
+
152
+ func mapProviderError(err error) error {
153
+ var oauthErr *application.OAuthError
154
+ if errors.As(err, &oauthErr) {
155
+ return oauthErr
156
+ }
157
+ if application.IsProviderUnavailable(err) {
158
+ return application.NewOAuthError(application.OAuthProviderUnavailable, err)
159
+ }
160
+ return application.NewOAuthError(application.OAuthFailed, err)
161
+ }
162
+
163
+ // findOrCreateExternalUser is provider-neutral identity resolution. A stable
164
+ // provider subject wins; only a verified external email may link to an
165
+ // existing account.
166
+ func (s *Service) findOrCreateExternalUser(ctx context.Context, info application.ExternalIdentity) (*model.User, error) {
167
+ if strings.TrimSpace(info.Provider) == "" || strings.TrimSpace(info.Subject) == "" || strings.TrimSpace(info.Email) == "" {
168
+ return nil, fmt.Errorf("external identity is missing provider, subject, or email")
169
+ }
170
+ if len(info.Provider) > 20 {
171
+ return nil, fmt.Errorf("external identity provider name is too long")
172
+ }
173
+
174
+ provider := model.Provider(info.Provider)
175
+ if ident, err := s.repo.FindIdentityByProviderUID(ctx, provider, info.Subject); err == nil {
176
+ user, findErr := s.repo.FindByID(ctx, ident.UserID)
177
+ if findErr != nil {
178
+ return nil, fmt.Errorf("find user for existing identity: %w", findErr)
179
+ }
180
+ return user, nil
181
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
182
+ return nil, fmt.Errorf("find external identity: %w", err)
183
+ }
184
+
185
+ providerUID := info.Subject
186
+ email := normalizeEmail(info.Email)
187
+ if info.EmailVerified {
188
+ if u, err := s.repo.FindByEmail(ctx, email); err == nil {
189
+ ident := &model.Identity{ID: id.New(), UserID: u.ID, Provider: provider, ProviderUID: &providerUID}
190
+ if err := s.repo.CreateIdentity(ctx, ident); err != nil {
191
+ return nil, fmt.Errorf("link external identity: %w", err)
192
+ }
193
+ return u, nil
194
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
195
+ return nil, fmt.Errorf("find user by verified external email: %w", err)
196
+ }
197
+ }
198
+
199
+ u := &model.User{ID: id.New(), Email: email, Name: info.Name, AvatarURL: info.AvatarURL, EmailVerified: info.EmailVerified}
200
+ ident := &model.Identity{ID: id.New(), Provider: provider, ProviderUID: &providerUID}
201
+ if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
202
+ if dberr.IsDuplicate(err) {
203
+ return nil, errEmailTaken()
204
+ }
205
+ return nil, fmt.Errorf("create external user: %w", err)
206
+ }
207
+ return u, nil
208
+ }
@@ -1,36 +1,37 @@
1
1
  package user
2
2
 
3
3
  import (
4
- "net/http"
5
- "strings"
6
4
  "time"
7
5
 
8
- "{{goModule}}/internal/shared/httpx"
9
6
  "{{goModule}}/internal/shared/middleware"
10
- // go-scaffold:user-handler-imports
11
7
 
12
8
  "github.com/gin-gonic/gin"
13
- "github.com/google/uuid"
9
+ // go-scaffold:user-handler-imports
14
10
  )
15
11
 
16
12
  const (
17
13
  refreshCookieName = "refresh_token"
18
- // oauthStateCookieName holds the nonce that binds a Google login to the
19
- // browser that started it — see Service.GoogleLoginURL.
20
- oauthStateCookieName = "oauth_state"
21
14
  )
22
15
 
23
16
  // go-scaffold:user-handler-consts
24
17
 
18
+ // authorizer is the optional capability RBAC supplies to protect admin
19
+ // routes. Auth itself does not import the RBAC middleware implementation.
20
+ type authorizer interface {
21
+ Require(string) gin.HandlerFunc
22
+ }
23
+
25
24
  type Handler struct {
26
25
  svc *Service
27
26
  jwtSecret string
28
27
  refreshTTL time.Duration
29
28
  cookieSecure bool
30
29
  cookieSameSite string
30
+ allowedOrigins []string
31
31
  // limiter, not a *redis.Client: which backing store counts the requests is
32
32
  // decided by `add auth --store`, and this file must not care.
33
33
  limiter middleware.Limiter
34
+ authz authorizer
34
35
  // go-scaffold:user-handler-fields
35
36
  }
36
37
 
@@ -41,33 +42,70 @@ func NewHandler(
41
42
  cookieSecure bool,
42
43
  cookieSameSite string,
43
44
  limiter middleware.Limiter,
45
+ authz ...authorizer,
44
46
  // go-scaffold:user-handler-params
45
47
  ) *Handler {
48
+ return newHandler(svc, jwtSecret, refreshTTL, cookieSecure, cookieSameSite, nil, limiter, authz...)
49
+ }
50
+
51
+ // NewHandlerWithOrigins is the current composition entry point. Keeping the
52
+ // original NewHandler signature preserves source compatibility for projects
53
+ // generated before the cross-site Origin guard was added.
54
+ func NewHandlerWithOrigins(
55
+ svc *Service,
56
+ jwtSecret string,
57
+ refreshTTL time.Duration,
58
+ cookieSecure bool,
59
+ cookieSameSite string,
60
+ allowedOrigins []string,
61
+ limiter middleware.Limiter,
62
+ authz ...authorizer,
63
+ // go-scaffold:user-handler-with-origins-params
64
+ ) *Handler {
65
+ return newHandler(svc, jwtSecret, refreshTTL, cookieSecure, cookieSameSite, allowedOrigins, limiter, authz...)
66
+ }
67
+
68
+ func newHandler(
69
+ svc *Service,
70
+ jwtSecret string,
71
+ refreshTTL time.Duration,
72
+ cookieSecure bool,
73
+ cookieSameSite string,
74
+ allowedOrigins []string,
75
+ limiter middleware.Limiter,
76
+ authz ...authorizer,
77
+ ) *Handler {
78
+ var authzMiddleware authorizer
79
+ if len(authz) > 0 {
80
+ authzMiddleware = authz[0]
81
+ }
46
82
  return &Handler{
47
83
  svc: svc,
48
84
  jwtSecret: jwtSecret,
49
85
  refreshTTL: refreshTTL,
50
86
  cookieSecure: cookieSecure,
51
87
  cookieSameSite: cookieSameSite,
88
+ allowedOrigins: append([]string(nil), allowedOrigins...),
52
89
  limiter: limiter,
90
+ authz: authzMiddleware,
53
91
  // go-scaffold:user-handler-init
54
92
  }
55
93
  }
56
94
 
57
- // Register wires both a public /auth group (register/login/refresh/logout)
58
- // and a /users group gated by RequireAuth public vs protected is decided
59
- // here, per domain, not centrally in main.go.
95
+ // Register owns the public/protected route split for the user feature. The
96
+ // endpoint implementations live in focused handler files; this file remains
97
+ // the single route/composition surface that generator patches can target.
60
98
  func (h *Handler) Register(rg gin.IRouter) {
61
- // per-IP, per-route budgets — separate names so a burst on one endpoint
62
- // doesn't spend another's budget. refresh/logout/google aren't limited:
63
- // refresh/logout are gated by possessing a valid cookie already, and the
64
- // Google flow's abuse surface lives on Google's side, not ours.
65
99
  loginLimit := middleware.RateLimit(h.limiter, "login", 10, time.Minute)
66
100
  registerLimit := middleware.RateLimit(h.limiter, "register", 5, time.Minute)
67
101
  forgotPasswordLimit := middleware.RateLimit(h.limiter, "forgot-password", 5, time.Minute)
68
102
  resetPasswordLimit := middleware.RateLimit(h.limiter, "reset-password", 10, time.Minute)
69
103
  verifyEmailLimit := middleware.RateLimit(h.limiter, "verify-email", 10, time.Minute)
70
104
  resendVerificationLimit := middleware.RateLimit(h.limiter, "resend-verification", 5, time.Minute)
105
+ mfaVerifyLimit := middleware.RateLimit(h.limiter, "mfa-verify", 5, time.Minute)
106
+ mfaSetupLimit := middleware.RateLimit(h.limiter, "mfa-setup", 5, time.Minute)
107
+ mfaConfirmLimit := middleware.RateLimit(h.limiter, "mfa-confirm", 5, time.Minute)
108
+ mfaDisableLimit := middleware.RateLimit(h.limiter, "mfa-disable", 5, time.Minute)
71
109
 
72
110
  authGroup := rg.Group("/auth")
73
111
  authGroup.POST("/register", registerLimit, h.register)
@@ -77,200 +115,19 @@ func (h *Handler) Register(rg gin.IRouter) {
77
115
  authGroup.POST("/forgot-password", forgotPasswordLimit, h.forgotPassword)
78
116
  authGroup.POST("/reset-password", resetPasswordLimit, h.resetPassword)
79
117
  authGroup.POST("/verify-email", verifyEmailLimit, h.verifyEmail)
80
- authGroup.GET("/google/login", h.googleLogin)
81
- authGroup.GET("/google/callback", h.googleCallback)
118
+ authGroup.POST("/mfa/verify", mfaVerifyLimit, h.verifyMFA)
119
+ authGroup.GET("/:provider/login", h.providerLogin)
120
+ authGroup.POST("/:provider/exchange", h.providerExchange)
82
121
 
83
122
  usersGroup := rg.Group("/users", middleware.RequireAuth(h.jwtSecret))
84
123
  usersGroup.GET("/me", h.me)
85
124
  usersGroup.POST("/me/resend-verification", resendVerificationLimit, h.resendVerification)
86
125
  usersGroup.POST("/me/logout-all", h.logoutAll)
126
+ usersGroup.GET("/me/mfa", h.mfaStatus)
127
+ usersGroup.POST("/me/mfa/setup", mfaSetupLimit, h.setupMFA)
128
+ usersGroup.POST("/me/mfa/confirm", mfaConfirmLimit, h.confirmMFA)
129
+ usersGroup.POST("/me/mfa/disable", mfaDisableLimit, h.disableMFA)
87
130
  // go-scaffold:user-routes
88
131
  }
89
132
 
90
- func (h *Handler) register(c *gin.Context) {
91
- var in registerInput
92
- if err := c.ShouldBindJSON(&in); err != nil {
93
- c.Error(httpx.BindErr(err))
94
- return
95
- }
96
- auth, err := h.svc.Register(c.Request.Context(), in)
97
- if err != nil {
98
- c.Error(err)
99
- return
100
- }
101
- h.setRefreshCookie(c, auth.RefreshToken)
102
- c.JSON(http.StatusCreated, toCookieResponse(auth))
103
- }
104
-
105
- func (h *Handler) login(c *gin.Context) {
106
- var in loginInput
107
- if err := c.ShouldBindJSON(&in); err != nil {
108
- c.Error(httpx.BindErr(err))
109
- return
110
- }
111
- auth, err := h.svc.Login(c.Request.Context(), in)
112
- if err != nil {
113
- c.Error(err)
114
- return
115
- }
116
- h.setRefreshCookie(c, auth.RefreshToken)
117
- c.JSON(http.StatusOK, toCookieResponse(auth))
118
- }
119
-
120
- func (h *Handler) refresh(c *gin.Context) {
121
- raw, err := c.Cookie(refreshCookieName)
122
- if err != nil || raw == "" {
123
- c.Error(errInvalidToken())
124
- return
125
- }
126
- auth, err := h.svc.Refresh(c.Request.Context(), raw)
127
- if err != nil {
128
- h.clearRefreshCookie(c)
129
- c.Error(err)
130
- return
131
- }
132
- h.setRefreshCookie(c, auth.RefreshToken)
133
- c.JSON(http.StatusOK, toCookieResponse(auth))
134
- }
135
-
136
- func (h *Handler) logout(c *gin.Context) {
137
- raw, _ := c.Cookie(refreshCookieName)
138
- _ = h.svc.Logout(c.Request.Context(), raw)
139
- h.clearRefreshCookie(c)
140
- c.Status(http.StatusNoContent)
141
- }
142
-
143
- func (h *Handler) forgotPassword(c *gin.Context) {
144
- var in forgotPasswordInput
145
- if err := c.ShouldBindJSON(&in); err != nil {
146
- c.Error(httpx.BindErr(err))
147
- return
148
- }
149
- if err := h.svc.ForgotPassword(c.Request.Context(), in.Email); err != nil {
150
- c.Error(err)
151
- return
152
- }
153
- // always the same response, whether or not the email exists — see
154
- // Service.ForgotPassword for why.
155
- c.JSON(http.StatusOK, gin.H{"message": "if that email exists, a reset link has been sent"})
156
- }
157
-
158
- func (h *Handler) resetPassword(c *gin.Context) {
159
- var in resetPasswordInput
160
- if err := c.ShouldBindJSON(&in); err != nil {
161
- c.Error(httpx.BindErr(err))
162
- return
163
- }
164
- if err := h.svc.ResetPassword(c.Request.Context(), in.Token, in.NewPassword); err != nil {
165
- c.Error(err)
166
- return
167
- }
168
- c.Status(http.StatusNoContent)
169
- }
170
-
171
- func (h *Handler) verifyEmail(c *gin.Context) {
172
- var in verifyEmailInput
173
- if err := c.ShouldBindJSON(&in); err != nil {
174
- c.Error(httpx.BindErr(err))
175
- return
176
- }
177
- if err := h.svc.VerifyEmail(c.Request.Context(), in.Token); err != nil {
178
- c.Error(err)
179
- return
180
- }
181
- c.Status(http.StatusNoContent)
182
- }
183
-
184
- func (h *Handler) resendVerification(c *gin.Context) {
185
- userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
186
- if err := h.svc.ResendVerificationEmail(c.Request.Context(), userID); err != nil {
187
- c.Error(err)
188
- return
189
- }
190
- c.Status(http.StatusNoContent)
191
- }
192
-
193
- // logoutAll ends every session for the caller, not just the one making the
194
- // request — also clears this request's own cookie, since that session is
195
- // dead too now.
196
- func (h *Handler) logoutAll(c *gin.Context) {
197
- userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
198
- if err := h.svc.LogoutAll(c.Request.Context(), userID); err != nil {
199
- c.Error(err)
200
- return
201
- }
202
- h.clearRefreshCookie(c)
203
- c.Status(http.StatusNoContent)
204
- }
205
-
206
- func (h *Handler) googleLogin(c *gin.Context) {
207
- url, nonce, err := h.svc.GoogleLoginURL()
208
- if err != nil {
209
- c.Error(err)
210
- return
211
- }
212
- // Lax, not Strict, and deliberately not h.cookieSameSite: the callback
213
- // arrives as a top-level navigation from Google, i.e. cross-site, and a
214
- // Strict cookie is not sent on one — the flow would fail every time.
215
- c.SetSameSite(http.SameSiteLaxMode)
216
- c.SetCookie(oauthStateCookieName, nonce, int(oauthStateTTL.Seconds()), "/", "", h.cookieSecure, true)
217
- c.Redirect(http.StatusFound, url)
218
- }
219
-
220
- func (h *Handler) googleCallback(c *gin.Context) {
221
- nonce, _ := c.Cookie(oauthStateCookieName)
222
- // One shot, whatever happens next.
223
- c.SetSameSite(http.SameSiteLaxMode)
224
- c.SetCookie(oauthStateCookieName, "", -1, "/", "", h.cookieSecure, true)
225
-
226
- auth, err := h.svc.GoogleCallback(c.Request.Context(), c.Query("code"), c.Query("state"), nonce)
227
- if err != nil {
228
- c.Error(err)
229
- return
230
- }
231
- h.setRefreshCookie(c, auth.RefreshToken)
232
- c.JSON(http.StatusOK, toCookieResponse(auth))
233
- }
234
-
235
- func (h *Handler) me(c *gin.Context) {
236
- userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
237
- u, err := h.svc.Get(c.Request.Context(), userID)
238
- if err != nil {
239
- c.Error(err)
240
- return
241
- }
242
- c.JSON(http.StatusOK, toMeResponse(u))
243
- }
244
-
245
133
  // go-scaffold:user-handler-funcs
246
-
247
- func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
248
- c.SetSameSite(sameSiteFrom(h.cookieSameSite))
249
- c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
250
- }
251
-
252
- func (h *Handler) clearRefreshCookie(c *gin.Context) {
253
- c.SetSameSite(sameSiteFrom(h.cookieSameSite))
254
- c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
255
- }
256
-
257
- // sameSiteFrom maps COOKIE_SAMESITE onto the http constant, defaulting to the
258
- // strictest option for anything it doesn't recognise.
259
- //
260
- // "strict" is right while the frontend and this API are the same site
261
- // (localhost:3000 -> localhost:8080 is, and so is app.example.com ->
262
- // api.example.com). A frontend on a genuinely different site — the usual
263
- // vercel.app-plus-own-API-domain split — needs "none", because the browser
264
- // will not attach a Strict or Lax cookie to the fetch that calls
265
- // /auth/refresh: sessions then die at every access-token expiry with no
266
- // error anywhere to explain it. "none" requires COOKIE_SECURE=true.
267
- func sameSiteFrom(mode string) http.SameSite {
268
- switch strings.ToLower(mode) {
269
- case "none":
270
- return http.SameSiteNoneMode
271
- case "lax":
272
- return http.SameSiteLaxMode
273
- default:
274
- return http.SameSiteStrictMode
275
- }
276
- }
@@ -0,0 +1,75 @@
1
+ package user
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/shared/httpx"
7
+
8
+ "github.com/gin-gonic/gin"
9
+ )
10
+
11
+ func (h *Handler) register(c *gin.Context) {
12
+ setNoStoreHeaders(c)
13
+ if !h.requireBrowserOrigin(c) {
14
+ return
15
+ }
16
+ var in registerInput
17
+ if err := c.ShouldBindJSON(&in); err != nil {
18
+ c.Error(httpx.BindErr(err))
19
+ return
20
+ }
21
+ auth, err := h.svc.Register(c.Request.Context(), in)
22
+ if err != nil {
23
+ c.Error(err)
24
+ return
25
+ }
26
+ h.writeAuthResult(c, http.StatusCreated, auth)
27
+ }
28
+
29
+ func (h *Handler) login(c *gin.Context) {
30
+ setNoStoreHeaders(c)
31
+ if !h.requireBrowserOrigin(c) {
32
+ return
33
+ }
34
+ var in loginInput
35
+ if err := c.ShouldBindJSON(&in); err != nil {
36
+ c.Error(httpx.BindErr(err))
37
+ return
38
+ }
39
+ auth, err := h.svc.Login(c.Request.Context(), in)
40
+ if err != nil {
41
+ c.Error(err)
42
+ return
43
+ }
44
+ h.writeAuthResult(c, http.StatusOK, auth)
45
+ }
46
+
47
+ func (h *Handler) refresh(c *gin.Context) {
48
+ setNoStoreHeaders(c)
49
+ if !h.requireBrowserOrigin(c) {
50
+ return
51
+ }
52
+ raw, err := c.Cookie(refreshCookieName)
53
+ if err != nil || raw == "" {
54
+ c.Error(errInvalidToken())
55
+ return
56
+ }
57
+ auth, err := h.svc.Refresh(c.Request.Context(), raw)
58
+ if err != nil {
59
+ h.clearRefreshCookie(c)
60
+ c.Error(err)
61
+ return
62
+ }
63
+ h.setRefreshCookie(c, auth.RefreshToken)
64
+ c.JSON(http.StatusOK, toCookieResponse(auth))
65
+ }
66
+
67
+ func (h *Handler) logout(c *gin.Context) {
68
+ if !h.requireBrowserOrigin(c) {
69
+ return
70
+ }
71
+ raw, _ := c.Cookie(refreshCookieName)
72
+ _ = h.svc.Logout(c.Request.Context(), raw)
73
+ h.clearRefreshCookie(c)
74
+ c.Status(http.StatusNoContent)
75
+ }