@nakedev/go-scaffold 0.4.0 → 0.4.3

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