@nakedev/go-scaffold 0.3.3 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +288 -50
- package/dist/commands/auth.js +53 -22
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +32 -2
- package/dist/commands/generate.js +25 -2
- package/dist/commands/method.js +22 -7
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +3 -3
- package/dist/commands/rbac.js +3 -3
- package/dist/commands/undo.js +5 -0
- package/dist/commands/worker.js +1 -1
- package/dist/index.js +186 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +43 -2
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +31 -1
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +37 -1
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/auth-patcher.js +115 -24
- package/dist/utils/config.js +147 -3
- package/dist/utils/main-patcher.js +29 -27
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/method-patcher.js +261 -81
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/observability-patcher.js +2 -2
- package/dist/utils/platform-patcher.js +29 -7
- package/dist/utils/rbac-patcher.js +97 -75
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
- package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
- package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
- package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
- package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
- package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
- package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
- package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
- package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
- package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
- package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
- package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
- package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
- package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
- package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
- package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
- package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
- package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/AGENTS.md.hbs +255 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +45 -17
- package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +38 -16
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +3 -3
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
|
@@ -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
|
-
|
|
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
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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.
|
|
81
|
-
authGroup.GET("/
|
|
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
|
+
}
|