@nakedev/go-scaffold 0.4.0 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +42 -1
- 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/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 +42 -14
- package/templates/create/base/cmd/api/wiring.go.hbs +11 -8
- 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 +35 -13
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +2 -2
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Package application contains auth use cases and ports that are independent
|
|
2
|
+
// of Gin, GORM models, OAuth SDKs, and HTTP error payloads.
|
|
3
|
+
package application
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"context"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// LoginProvider is the outbound port for an external authorization-code
|
|
12
|
+
// provider. Implementations own provider SDKs, token exchange, claims/userinfo
|
|
13
|
+
// validation, and provider-specific HTTP details. The user application only
|
|
14
|
+
// sees these small, provider-neutral values.
|
|
15
|
+
type LoginProvider interface {
|
|
16
|
+
Name() string
|
|
17
|
+
Begin(context.Context, LoginStartInput) (Authorization, error)
|
|
18
|
+
Complete(context.Context, LoginCompleteInput) (ExternalIdentity, error)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type LoginStartInput struct {
|
|
22
|
+
State string
|
|
23
|
+
CodeChallenge string
|
|
24
|
+
CodeChallengeMethod string
|
|
25
|
+
Nonce string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Authorization struct {
|
|
29
|
+
URL string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type LoginCompleteInput struct {
|
|
33
|
+
Code string
|
|
34
|
+
CodeVerifier string
|
|
35
|
+
Nonce string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ExternalIdentity is the normalized identity returned by every provider
|
|
39
|
+
// adapter. Provider must be the registry name and Subject must be the
|
|
40
|
+
// provider's stable subject identifier, never an email address.
|
|
41
|
+
type ExternalIdentity struct {
|
|
42
|
+
Provider string
|
|
43
|
+
Subject string
|
|
44
|
+
Email string
|
|
45
|
+
EmailVerified bool
|
|
46
|
+
Name string
|
|
47
|
+
AvatarURL string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ProviderRegistry is an immutable-by-convention lookup boundary. It is
|
|
51
|
+
// assembled in the composition root from providers whose configuration is
|
|
52
|
+
// complete; an absent provider is therefore an expected unavailable state,
|
|
53
|
+
// not an invitation to construct an empty redirect URL.
|
|
54
|
+
type ProviderRegistry struct {
|
|
55
|
+
providers map[string]LoginProvider
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func NewProviderRegistry(providers ...LoginProvider) ProviderRegistry {
|
|
59
|
+
byName := make(map[string]LoginProvider, len(providers))
|
|
60
|
+
for _, provider := range providers {
|
|
61
|
+
if provider == nil || provider.Name() == "" {
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
byName[provider.Name()] = provider
|
|
65
|
+
}
|
|
66
|
+
return ProviderRegistry{providers: byName}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func (r ProviderRegistry) Lookup(name string) (LoginProvider, bool) {
|
|
70
|
+
provider, ok := r.providers[name]
|
|
71
|
+
return provider, ok
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type OAuthErrorCode string
|
|
75
|
+
|
|
76
|
+
const (
|
|
77
|
+
OAuthDenied OAuthErrorCode = "oauth_denied"
|
|
78
|
+
OAuthStateInvalid OAuthErrorCode = "oauth_state_invalid"
|
|
79
|
+
OAuthProviderUnavailable OAuthErrorCode = "oauth_provider_unavailable"
|
|
80
|
+
OAuthFailed OAuthErrorCode = "oauth_failed"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// OAuthError is the public, controlled error contract for the browser login
|
|
84
|
+
// start/exchange boundary. Cause is retained only for server-side logs and
|
|
85
|
+
// unwrapping; handlers never serialize Error() to the browser.
|
|
86
|
+
type OAuthError struct {
|
|
87
|
+
Code OAuthErrorCode
|
|
88
|
+
Cause error
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func (e *OAuthError) Error() string {
|
|
92
|
+
if e.Cause == nil {
|
|
93
|
+
return string(e.Code)
|
|
94
|
+
}
|
|
95
|
+
return fmt.Sprintf("%s: %v", e.Code, e.Cause)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func (e *OAuthError) Unwrap() error { return e.Cause }
|
|
99
|
+
|
|
100
|
+
func NewOAuthError(code OAuthErrorCode, cause error) *OAuthError {
|
|
101
|
+
return &OAuthError{Code: code, Cause: cause}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type ProviderError struct {
|
|
105
|
+
Unavailable bool
|
|
106
|
+
Cause error
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func (e *ProviderError) Error() string {
|
|
110
|
+
if e.Cause == nil {
|
|
111
|
+
if e.Unavailable {
|
|
112
|
+
return "oauth provider unavailable"
|
|
113
|
+
}
|
|
114
|
+
return "oauth provider failed"
|
|
115
|
+
}
|
|
116
|
+
return e.Cause.Error()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func (e *ProviderError) Unwrap() error { return e.Cause }
|
|
120
|
+
|
|
121
|
+
func NewProviderUnavailable(cause error) error {
|
|
122
|
+
return &ProviderError{Unavailable: true, Cause: cause}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func NewProviderFailure(cause error) error {
|
|
126
|
+
return &ProviderError{Cause: cause}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func IsProviderUnavailable(err error) bool {
|
|
130
|
+
var providerErr *ProviderError
|
|
131
|
+
return errors.As(err, &providerErr) && providerErr.Unavailable
|
|
132
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Package application contains auth use cases and ports that are independent
|
|
2
|
+
// of Gin, GORM models, and HTTP error payloads. Adapters in the parent user
|
|
3
|
+
// package translate those contracts at the feature boundary.
|
|
4
|
+
package application
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"context"
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
var ErrInvalidToken = errors.New("invalid or expired one-time token")
|
|
15
|
+
|
|
16
|
+
type Identity struct {
|
|
17
|
+
ID uuid.UUID
|
|
18
|
+
UserID uuid.UUID
|
|
19
|
+
PasswordHash string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type User struct {
|
|
23
|
+
ID uuid.UUID
|
|
24
|
+
EmailVerified bool
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// RecoveryRepository is an outbound port owned by the recovery use case.
|
|
28
|
+
// Implementations map persistence rows to these plain application values.
|
|
29
|
+
type RecoveryRepository interface {
|
|
30
|
+
FindIdentity(context.Context, uuid.UUID) (*Identity, error)
|
|
31
|
+
UpdateIdentity(context.Context, *Identity) error
|
|
32
|
+
FindUser(context.Context, uuid.UUID) (*User, error)
|
|
33
|
+
UpdateUser(context.Context, *User) error
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type RecoveryTokens interface {
|
|
37
|
+
WithTransaction(context.Context, func(context.Context) error) error
|
|
38
|
+
ConsumePasswordResetToken(context.Context, string) (uuid.UUID, bool, error)
|
|
39
|
+
ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type PasswordHasher interface {
|
|
43
|
+
Hash(string) (string, error)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Recovery struct {
|
|
47
|
+
repo RecoveryRepository
|
|
48
|
+
tokens RecoveryTokens
|
|
49
|
+
hasher PasswordHasher
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func NewRecovery(repo RecoveryRepository, tokens RecoveryTokens, hasher PasswordHasher) *Recovery {
|
|
53
|
+
return &Recovery{repo: repo, tokens: tokens, hasher: hasher}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ResetPassword consumes the token and updates the identity in one unit of
|
|
57
|
+
// work. Postgres-backed token adapters run both writes through the same DB
|
|
58
|
+
// transaction; the contract is deliberately a port so another adapter can
|
|
59
|
+
// provide an equivalent retry-safe boundary.
|
|
60
|
+
func (r *Recovery) ResetPassword(ctx context.Context, tokenHash, newPassword string) (uuid.UUID, error) {
|
|
61
|
+
hash, err := r.hasher.Hash(newPassword)
|
|
62
|
+
if err != nil {
|
|
63
|
+
return uuid.Nil, fmt.Errorf("hash password: %w", err)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
var userID uuid.UUID
|
|
67
|
+
err = r.tokens.WithTransaction(ctx, func(txctx context.Context) error {
|
|
68
|
+
id, ok, err := r.tokens.ConsumePasswordResetToken(txctx, tokenHash)
|
|
69
|
+
if err != nil {
|
|
70
|
+
return fmt.Errorf("consume password reset token: %w", err)
|
|
71
|
+
}
|
|
72
|
+
if !ok {
|
|
73
|
+
return ErrInvalidToken
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
identity, err := r.repo.FindIdentity(txctx, id)
|
|
77
|
+
if err != nil {
|
|
78
|
+
return fmt.Errorf("find local identity: %w", err)
|
|
79
|
+
}
|
|
80
|
+
identity.PasswordHash = hash
|
|
81
|
+
if err := r.repo.UpdateIdentity(txctx, identity); err != nil {
|
|
82
|
+
return fmt.Errorf("update password: %w", err)
|
|
83
|
+
}
|
|
84
|
+
userID = id
|
|
85
|
+
return nil
|
|
86
|
+
})
|
|
87
|
+
return userID, err
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func (r *Recovery) VerifyEmail(ctx context.Context, tokenHash string) (uuid.UUID, error) {
|
|
91
|
+
var userID uuid.UUID
|
|
92
|
+
err := r.tokens.WithTransaction(ctx, func(txctx context.Context) error {
|
|
93
|
+
id, ok, err := r.tokens.ConsumeEmailVerifyToken(txctx, tokenHash)
|
|
94
|
+
if err != nil {
|
|
95
|
+
return fmt.Errorf("consume email verification token: %w", err)
|
|
96
|
+
}
|
|
97
|
+
if !ok {
|
|
98
|
+
return ErrInvalidToken
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
user, err := r.repo.FindUser(txctx, id)
|
|
102
|
+
if err != nil {
|
|
103
|
+
return fmt.Errorf("find user for email verification: %w", err)
|
|
104
|
+
}
|
|
105
|
+
user.EmailVerified = true
|
|
106
|
+
if err := r.repo.UpdateUser(txctx, user); err != nil {
|
|
107
|
+
return fmt.Errorf("mark email verified: %w", err)
|
|
108
|
+
}
|
|
109
|
+
userID = id
|
|
110
|
+
return nil
|
|
111
|
+
})
|
|
112
|
+
return userID, err
|
|
113
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/url"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
"{{goModule}}/internal/shared/apperror"
|
|
10
|
+
|
|
11
|
+
"github.com/gin-gonic/gin"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// requireBrowserOrigin is the CSRF guard for the cross-site cookie mode. CORS
|
|
15
|
+
// controls whether a caller can read the response; this exact origin check
|
|
16
|
+
// independently controls whether a state-changing request may use an ambient
|
|
17
|
+
// refresh cookie.
|
|
18
|
+
func (h *Handler) requireBrowserOrigin(c *gin.Context) bool {
|
|
19
|
+
if !strings.EqualFold(strings.TrimSpace(h.cookieSameSite), "none") {
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
|
24
|
+
if origin == "" {
|
|
25
|
+
if referer := strings.TrimSpace(c.GetHeader("Referer")); referer != "" {
|
|
26
|
+
if parsed, err := url.Parse(referer); err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" && parsed.User == nil {
|
|
27
|
+
origin = parsed.Scheme + "://" + parsed.Host
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if !browserOriginAllowed(origin, h.allowedOrigins) {
|
|
32
|
+
c.Error(apperror.New(http.StatusForbidden, "CSRF_ORIGIN_INVALID", "request origin is not allowed"))
|
|
33
|
+
c.Abort()
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func browserOriginAllowed(origin string, allowed []string) bool {
|
|
40
|
+
origin = strings.TrimSpace(origin)
|
|
41
|
+
if _, ok := canonicalBrowserOrigin(origin); !ok {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
for _, candidate := range allowed {
|
|
45
|
+
candidate = strings.TrimSpace(candidate)
|
|
46
|
+
if _, candidateOK := canonicalBrowserOrigin(candidate); candidateOK && candidate == origin {
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func canonicalBrowserOrigin(value string) (string, bool) {
|
|
54
|
+
parsed, err := url.Parse(strings.TrimSpace(value))
|
|
55
|
+
if err != nil || parsed.User != nil || parsed.Opaque != "" || parsed.Host == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
56
|
+
return "", false
|
|
57
|
+
}
|
|
58
|
+
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
59
|
+
return "", false
|
|
60
|
+
}
|
|
61
|
+
return parsed.Scheme + "://" + parsed.Host, true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// validateBrowserCookiePolicy ties deployment topology to the attributes used
|
|
65
|
+
// by /auth/refresh. Same-site is the safe local default; cross-site must opt
|
|
66
|
+
// into None + Secure, and production always requires Secure.
|
|
67
|
+
func validateBrowserCookiePolicy(topology, sameSite string, secure, production bool) error {
|
|
68
|
+
if production && !secure {
|
|
69
|
+
return fmt.Errorf("production browser cookies require COOKIE_SECURE=true")
|
|
70
|
+
}
|
|
71
|
+
switch strings.ToLower(strings.TrimSpace(topology)) {
|
|
72
|
+
case "same-origin", "same-site":
|
|
73
|
+
if strings.EqualFold(strings.TrimSpace(sameSite), "none") && !secure {
|
|
74
|
+
return fmt.Errorf("COOKIE_SAMESITE=none requires COOKIE_SECURE=true")
|
|
75
|
+
}
|
|
76
|
+
return nil
|
|
77
|
+
case "cross-site":
|
|
78
|
+
if !strings.EqualFold(strings.TrimSpace(sameSite), "none") || !secure {
|
|
79
|
+
return fmt.Errorf("cross-site browser topology requires COOKIE_SAMESITE=none and COOKIE_SECURE=true")
|
|
80
|
+
}
|
|
81
|
+
return nil
|
|
82
|
+
default:
|
|
83
|
+
return fmt.Errorf("unsupported browser topology %q", topology)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// sameSiteFrom maps COOKIE_SAMESITE to the net/http constant and defaults to
|
|
88
|
+
// the strictest option for anything unrecognised.
|
|
89
|
+
func sameSiteFrom(mode string) http.SameSite {
|
|
90
|
+
switch strings.ToLower(mode) {
|
|
91
|
+
case "none":
|
|
92
|
+
return http.SameSiteNoneMode
|
|
93
|
+
case "lax":
|
|
94
|
+
return http.SameSiteLaxMode
|
|
95
|
+
default:
|
|
96
|
+
return http.SameSiteStrictMode
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/user/application"
|
|
9
|
+
"{{goModule}}/internal/app/user/model"
|
|
10
|
+
googleprovider "{{goModule}}/internal/platform/authprovider/google"
|
|
11
|
+
"{{goModule}}/internal/platform/mail"
|
|
12
|
+
"{{goModule}}/internal/shared/config"
|
|
13
|
+
"{{goModule}}/internal/shared/middleware"
|
|
14
|
+
|
|
15
|
+
"github.com/google/uuid"
|
|
16
|
+
"golang.org/x/crypto/bcrypt"
|
|
17
|
+
{{#if redis}}
|
|
18
|
+
"github.com/redis/go-redis/v9"
|
|
19
|
+
{{/if}}
|
|
20
|
+
{{#if worker}}
|
|
21
|
+
"{{goModule}}/internal/platform/queue"
|
|
22
|
+
{{/if}}
|
|
23
|
+
"gorm.io/gorm"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// recoveryRepositoryAdapter maps persistence models to the plain values used
|
|
27
|
+
// by the application recovery use case. The adapter is the only layer that
|
|
28
|
+
// knows both the GORM model and the application port.
|
|
29
|
+
type recoveryRepositoryAdapter struct {
|
|
30
|
+
repo repository
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (a recoveryRepositoryAdapter) FindIdentity(ctx context.Context, userID uuid.UUID) (*application.Identity, error) {
|
|
34
|
+
identity, err := a.repo.FindIdentity(ctx, userID, model.ProviderLocal)
|
|
35
|
+
if err != nil {
|
|
36
|
+
return nil, err
|
|
37
|
+
}
|
|
38
|
+
if identity.PasswordHash == nil {
|
|
39
|
+
return nil, fmt.Errorf("local identity has no password")
|
|
40
|
+
}
|
|
41
|
+
return &application.Identity{ID: identity.ID, UserID: identity.UserID, PasswordHash: *identity.PasswordHash}, nil
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (a recoveryRepositoryAdapter) UpdateIdentity(ctx context.Context, identity *application.Identity) error {
|
|
45
|
+
current, err := a.repo.FindIdentity(ctx, identity.UserID, model.ProviderLocal)
|
|
46
|
+
if err != nil {
|
|
47
|
+
return err
|
|
48
|
+
}
|
|
49
|
+
current.PasswordHash = &identity.PasswordHash
|
|
50
|
+
return a.repo.UpdateIdentity(ctx, current)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func (a recoveryRepositoryAdapter) FindUser(ctx context.Context, userID uuid.UUID) (*application.User, error) {
|
|
54
|
+
user, err := a.repo.FindByID(ctx, userID)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return nil, err
|
|
57
|
+
}
|
|
58
|
+
return &application.User{ID: user.ID, EmailVerified: user.EmailVerified}, nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func (a recoveryRepositoryAdapter) UpdateUser(ctx context.Context, user *application.User) error {
|
|
62
|
+
current, err := a.repo.FindByID(ctx, user.ID)
|
|
63
|
+
if err != nil {
|
|
64
|
+
return err
|
|
65
|
+
}
|
|
66
|
+
current.EmailVerified = user.EmailVerified
|
|
67
|
+
return a.repo.UpdateUser(ctx, current)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type bcryptPasswordHasher struct{}
|
|
71
|
+
|
|
72
|
+
func (bcryptPasswordHasher) Hash(password string) (string, error) {
|
|
73
|
+
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
74
|
+
if err != nil {
|
|
75
|
+
return "", err
|
|
76
|
+
}
|
|
77
|
+
return string(hash), nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func newRecovery(repo repository, tokens application.RecoveryTokens) *application.Recovery {
|
|
81
|
+
return application.NewRecovery(recoveryRepositoryAdapter{repo: repo}, tokens, bcryptPasswordHasher{})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// NewHandlerFromDB is auth's local composition root. cmd/api owns shared
|
|
85
|
+
// infrastructure and cross-feature dependencies; repository, token store,
|
|
86
|
+
// mailer, service, limiter, and handler construction stays in this package.
|
|
87
|
+
func NewHandlerFromDB(
|
|
88
|
+
db *gorm.DB,
|
|
89
|
+
cfg config.Config,
|
|
90
|
+
{{#if redis}}
|
|
91
|
+
rdb *redis.Client,
|
|
92
|
+
{{/if}}
|
|
93
|
+
{{#if worker}}
|
|
94
|
+
q queue.Enqueuer,
|
|
95
|
+
{{/if}}
|
|
96
|
+
roleChecker RoleChecker,
|
|
97
|
+
authz authorizer,
|
|
98
|
+
) *Handler {
|
|
99
|
+
{{#if redis}}
|
|
100
|
+
tokens := NewRedisTokenStore(rdb, db)
|
|
101
|
+
{{else}}
|
|
102
|
+
tokens := NewPgTokenStore(db)
|
|
103
|
+
{{/if}}
|
|
104
|
+
|
|
105
|
+
{{#if worker}}
|
|
106
|
+
outbound := mail.NewAsyncClient(q)
|
|
107
|
+
{{else}}
|
|
108
|
+
outbound := mail.NewSyncClient(mail.Open(cfg))
|
|
109
|
+
{{/if}}
|
|
110
|
+
|
|
111
|
+
var providers []application.LoginProvider
|
|
112
|
+
if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" && cfg.GoogleOAuthRedirectURI != "" {
|
|
113
|
+
providers = append(providers, googleprovider.New(googleprovider.Config{
|
|
114
|
+
ClientID: cfg.GoogleClientID,
|
|
115
|
+
ClientSecret: cfg.GoogleClientSecret,
|
|
116
|
+
RedirectURL: cfg.GoogleOAuthRedirectURI,
|
|
117
|
+
}))
|
|
118
|
+
}
|
|
119
|
+
providerRegistry := application.NewProviderRegistry(providers...)
|
|
120
|
+
if err := validateBrowserCookiePolicy(cfg.AuthBrowserTopology, cfg.CookieSameSite, cfg.CookieSecure, cfg.IsProd()); err != nil {
|
|
121
|
+
panic(err)
|
|
122
|
+
}
|
|
123
|
+
if cfg.JWTRefreshTTL <= 0 || cfg.JWTRefreshMaxTTL <= 0 {
|
|
124
|
+
panic(fmt.Errorf("JWT refresh token lifetimes must be positive"))
|
|
125
|
+
}
|
|
126
|
+
if cfg.OAuthStateTTL <= 0 {
|
|
127
|
+
panic(fmt.Errorf("OAUTH_STATE_TTL_MIN must be positive"))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
service := NewService(Dependencies{
|
|
131
|
+
Repository: NewRepository(db),
|
|
132
|
+
RefreshTokens: tokens,
|
|
133
|
+
OAuthTransactions: tokens,
|
|
134
|
+
RecoveryTokens: tokens,
|
|
135
|
+
MFA: NewPostgresMFAStore(db),
|
|
136
|
+
Mailer: outbound,
|
|
137
|
+
Providers: providerRegistry,
|
|
138
|
+
Roles: roleChecker,
|
|
139
|
+
Clock: time.Now,
|
|
140
|
+
}, AuthConfig{
|
|
141
|
+
JWTSecret: cfg.JWTSecret,
|
|
142
|
+
JWTAccessTTL: cfg.JWTAccessTTL,
|
|
143
|
+
JWTRefreshTTL: cfg.JWTRefreshTTL,
|
|
144
|
+
JWTRefreshMaxTTL: cfg.JWTRefreshMaxTTL,
|
|
145
|
+
OAuthStateTTL: cfg.OAuthStateTTL,
|
|
146
|
+
PasswordResetTTL: cfg.PasswordResetTTL,
|
|
147
|
+
PasswordResetURL: cfg.PasswordResetURL,
|
|
148
|
+
EmailVerifyTTL: cfg.EmailVerifyTTL,
|
|
149
|
+
EmailVerifyURL: cfg.EmailVerifyURL,
|
|
150
|
+
MFA: MFASettings{
|
|
151
|
+
Enabled: cfg.AuthMFAEnabled,
|
|
152
|
+
Issuer: cfg.MFAIssuer,
|
|
153
|
+
EncryptionKey: cfg.MFAEncryptionKey,
|
|
154
|
+
ChallengeTTL: cfg.MFAChallengeTTL,
|
|
155
|
+
TOTPWindow: cfg.MFATOTPWindow,
|
|
156
|
+
RecoveryCodeCount: cfg.MFARecoveryCodeCount,
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
{{#if redis}}
|
|
160
|
+
limiter := middleware.NewRedisLimiter(rdb)
|
|
161
|
+
{{else}}
|
|
162
|
+
limiter := middleware.NewMemoryLimiter()
|
|
163
|
+
{{/if}}
|
|
164
|
+
return NewHandlerWithOrigins(service, cfg.JWTSecret, cfg.JWTRefreshTTL, cfg.CookieSecure, cfg.CookieSameSite, cfg.CORSAllowedOrigins, limiter, authz)
|
|
165
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"{{goModule}}/internal/app/user/application"
|
|
8
|
+
|
|
9
|
+
"github.com/google/uuid"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Dependencies is the explicit composition contract for the auth application.
|
|
13
|
+
// Each port represents one lifecycle or capability, so a new adapter does not
|
|
14
|
+
// have to implement unrelated token operations just to be wired in.
|
|
15
|
+
type Dependencies struct {
|
|
16
|
+
Repository UserRepository
|
|
17
|
+
RefreshTokens RefreshTokenStore
|
|
18
|
+
OAuthTransactions OAuthTransactionStore
|
|
19
|
+
RecoveryTokens RecoveryTokenStore
|
|
20
|
+
MFA MFAStore
|
|
21
|
+
Mailer AuthMailer
|
|
22
|
+
Providers ProviderRegistry
|
|
23
|
+
Roles RoleChecker
|
|
24
|
+
Clock func() time.Time
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// MFASettings is the operator-side MFA policy. Enabled is a capability
|
|
28
|
+
// switch; each user's enrollment is still disabled until that user confirms a
|
|
29
|
+
// TOTP code. Keeping both switches lets an operator turn the feature off
|
|
30
|
+
// without deleting user enrollments.
|
|
31
|
+
type MFASettings struct {
|
|
32
|
+
Enabled bool
|
|
33
|
+
Issuer string
|
|
34
|
+
EncryptionKey string
|
|
35
|
+
ChallengeTTL time.Duration
|
|
36
|
+
TOTPWindow int
|
|
37
|
+
RecoveryCodeCount int
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// AuthConfig contains only auth policy. The application layer intentionally
|
|
41
|
+
// does not depend on the generated project's shared config package.
|
|
42
|
+
type AuthConfig struct {
|
|
43
|
+
JWTSecret string
|
|
44
|
+
JWTAccessTTL time.Duration
|
|
45
|
+
JWTRefreshTTL time.Duration
|
|
46
|
+
JWTRefreshMaxTTL time.Duration
|
|
47
|
+
OAuthStateTTL time.Duration
|
|
48
|
+
PasswordResetTTL time.Duration
|
|
49
|
+
PasswordResetURL string
|
|
50
|
+
EmailVerifyTTL time.Duration
|
|
51
|
+
EmailVerifyURL string
|
|
52
|
+
MFA MFASettings
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ProviderRegistry is the application-facing lookup port for external login
|
|
56
|
+
// providers. The concrete registry lives in the application package; callers
|
|
57
|
+
// may supply a test double or another registry without coupling Service to
|
|
58
|
+
// that implementation.
|
|
59
|
+
type ProviderRegistry interface {
|
|
60
|
+
Lookup(name string) (application.LoginProvider, bool)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// MFAEnrollment is the storage-neutral representation returned by MFAStore.
|
|
64
|
+
// The secret is always encrypted at rest; it is never serialized by an API
|
|
65
|
+
// response.
|
|
66
|
+
type MFAEnrollment struct {
|
|
67
|
+
EncryptedSecret string
|
|
68
|
+
Enabled bool
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// MFAChallenge is a short-lived, one-use pre-session challenge. The raw
|
|
72
|
+
// challenge is returned only to the client; stores receive its hash.
|
|
73
|
+
type MFAChallenge struct {
|
|
74
|
+
UserID uuid.UUID
|
|
75
|
+
ExpiresAt time.Time
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// MFAStore owns durable MFA state. Implementations must make challenge and
|
|
79
|
+
// recovery-code consumption atomic so concurrent requests cannot reuse them.
|
|
80
|
+
type MFAStore interface {
|
|
81
|
+
GetEnrollment(ctx context.Context, userID uuid.UUID) (MFAEnrollment, bool, error)
|
|
82
|
+
PutPendingEnrollment(ctx context.Context, userID uuid.UUID, encryptedSecret string) error
|
|
83
|
+
ConfirmEnrollment(ctx context.Context, userID uuid.UUID, encryptedSecret string, recoveryCodeHashes []string) error
|
|
84
|
+
Disable(ctx context.Context, userID uuid.UUID) error
|
|
85
|
+
CreateChallenge(ctx context.Context, hash string, challenge MFAChallenge) error
|
|
86
|
+
ConsumeChallenge(ctx context.Context, hash string) (MFAChallenge, bool, error)
|
|
87
|
+
ConsumeRecoveryCode(ctx context.Context, userID uuid.UUID, hash string) (bool, error)
|
|
88
|
+
}
|
|
@@ -32,6 +32,15 @@ type verifyEmailInput struct {
|
|
|
32
32
|
Token string `json:"token" binding:"required"`
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
type mfaCodeInput struct {
|
|
36
|
+
Code string `json:"code" binding:"required"`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type mfaChallengeInput struct {
|
|
40
|
+
Challenge string `json:"challenge" binding:"required"`
|
|
41
|
+
Code string `json:"code" binding:"required"`
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
// authResponse is what the service returns internally (both tokens); the
|
|
36
45
|
// handler sends the refresh token as an httpOnly cookie instead of echoing
|
|
37
46
|
// it in the body, so meResponse never carries it — see toCookieResponse.
|
|
@@ -42,12 +51,43 @@ type authResponse struct {
|
|
|
42
51
|
ExpiresIn int `json:"expires_in"`
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
// authResult keeps the normal token shape source-compatible for callers while
|
|
55
|
+
// representing the pre-session state required by MFA. A challenge never
|
|
56
|
+
// carries an access or refresh token.
|
|
57
|
+
type authResult struct {
|
|
58
|
+
*authResponse
|
|
59
|
+
MFAChallenge string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func tokenResult(auth *authResponse) *authResult { return &authResult{authResponse: auth} }
|
|
63
|
+
|
|
64
|
+
func challengeResult(challenge string) *authResult { return &authResult{MFAChallenge: challenge} }
|
|
65
|
+
|
|
45
66
|
type authCookieResponse struct {
|
|
46
67
|
AccessToken string `json:"access_token"`
|
|
47
68
|
TokenType string `json:"token_type"`
|
|
48
69
|
ExpiresIn int `json:"expires_in"`
|
|
49
70
|
}
|
|
50
71
|
|
|
72
|
+
type mfaChallengeResponse struct {
|
|
73
|
+
MFARequired bool `json:"mfa_required"`
|
|
74
|
+
Challenge string `json:"challenge"`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type mfaStatusResponse struct {
|
|
78
|
+
Available bool `json:"available"`
|
|
79
|
+
Enabled bool `json:"enabled"`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type mfaSetupResponse struct {
|
|
83
|
+
Secret string `json:"secret"`
|
|
84
|
+
OTPAuthURI string `json:"otpauth_uri"`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type mfaConfirmResponse struct {
|
|
88
|
+
RecoveryCodes []string `json:"recovery_codes"`
|
|
89
|
+
}
|
|
90
|
+
|
|
51
91
|
func toCookieResponse(a *authResponse) authCookieResponse {
|
|
52
92
|
return authCookieResponse{AccessToken: a.AccessToken, TokenType: a.TokenType, ExpiresIn: a.ExpiresIn}
|
|
53
93
|
}
|
|
@@ -74,4 +114,21 @@ func toMeResponse(u *model.User) meResponse {
|
|
|
74
114
|
}
|
|
75
115
|
}
|
|
76
116
|
|
|
117
|
+
// response is the small public profile DTO used by endpoints added through
|
|
118
|
+
// `generate method user ...`. Auth's built-in /users/me response intentionally
|
|
119
|
+
// stays separate because it has a different contract, but generated admin or
|
|
120
|
+
// support lookups still need the same stable id/timestamp shape as ordinary
|
|
121
|
+
// modules.
|
|
122
|
+
//
|
|
123
|
+
//nolint:unused
|
|
124
|
+
type response struct {
|
|
125
|
+
ID uuid.UUID `json:"id"`
|
|
126
|
+
CreatedAt time.Time `json:"created_at"`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
//nolint:unused
|
|
130
|
+
func toResponse(u *model.User) response {
|
|
131
|
+
return response{ID: u.ID, CreatedAt: u.CreatedAt}
|
|
132
|
+
}
|
|
133
|
+
|
|
77
134
|
// go-scaffold:user-dto
|