@nakedev/go-scaffold 0.3.3 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -0,0 +1,286 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/url"
7
+ "strings"
8
+ "testing"
9
+ "time"
10
+
11
+ "{{goModule}}/internal/app/user/application"
12
+ )
13
+
14
+ type fakeLoginProvider struct {
15
+ name string
16
+ beginFn func(application.LoginStartInput) (application.Authorization, error)
17
+ completeFn func(application.LoginCompleteInput) (application.ExternalIdentity, error)
18
+ beginCnt int
19
+ completeCnt int
20
+ }
21
+
22
+ func (p *fakeLoginProvider) Name() string { return p.name }
23
+
24
+ func (p *fakeLoginProvider) Begin(_ context.Context, in application.LoginStartInput) (application.Authorization, error) {
25
+ p.beginCnt++
26
+ return p.beginFn(in)
27
+ }
28
+
29
+ func (p *fakeLoginProvider) Complete(_ context.Context, in application.LoginCompleteInput) (application.ExternalIdentity, error) {
30
+ p.completeCnt++
31
+ return p.completeFn(in)
32
+ }
33
+
34
+ func newTestServiceWithProviders(repo repository, tokens testTokenStore, providers ...application.LoginProvider) *Service {
35
+ return NewService(Dependencies{
36
+ Repository: repo,
37
+ RefreshTokens: tokens,
38
+ OAuthTransactions: tokens,
39
+ RecoveryTokens: tokens,
40
+ MFA: newFakeMFAStore(),
41
+ Mailer: fakeMailer{},
42
+ Providers: application.NewProviderRegistry(providers...),
43
+ }, AuthConfig{
44
+ JWTSecret: "test-secret",
45
+ JWTAccessTTL: time.Minute,
46
+ JWTRefreshTTL: time.Hour,
47
+ })
48
+ }
49
+
50
+ func oauthErrorCode(t *testing.T, err error) application.OAuthErrorCode {
51
+ t.Helper()
52
+ var oauthErr *application.OAuthError
53
+ if !errors.As(err, &oauthErr) {
54
+ t.Fatalf("expected *application.OAuthError, got %T: %v", err, err)
55
+ }
56
+ return oauthErr.Code
57
+ }
58
+
59
+ const testOAuthVerifier = "client-code-verifier-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
60
+
61
+ func validLoginStart() application.LoginStartInput {
62
+ return application.LoginStartInput{
63
+ State: "client state",
64
+ CodeChallenge: pkceChallenge(testOAuthVerifier),
65
+ CodeChallengeMethod: "S256",
66
+ }
67
+ }
68
+
69
+ func validLoginExchange() loginExchangeInput {
70
+ return loginExchangeInput{Code: "code", State: "client state", CodeVerifier: testOAuthVerifier}
71
+ }
72
+
73
+ func validFakeProvider(t *testing.T) (*fakeLoginProvider, *Service) {
74
+ t.Helper()
75
+ provider := &fakeLoginProvider{name: "fake"}
76
+ provider.beginFn = func(in application.LoginStartInput) (application.Authorization, error) {
77
+ expected := validLoginStart()
78
+ if in.State != expected.State || in.CodeChallenge != expected.CodeChallenge || in.CodeChallengeMethod != expected.CodeChallengeMethod || strings.TrimSpace(in.Nonce) == "" {
79
+ t.Fatalf("unexpected begin input: %+v", in)
80
+ }
81
+ return application.Authorization{URL: "https://provider.example.test/authorize?" + url.Values{
82
+ "state": {in.State},
83
+ "code_challenge": {in.CodeChallenge},
84
+ "code_challenge_method": {in.CodeChallengeMethod},
85
+ "nonce": {in.Nonce},
86
+ }.Encode()}, nil
87
+ }
88
+ provider.completeFn = func(in application.LoginCompleteInput) (application.ExternalIdentity, error) {
89
+ if in.Code != "code" || in.CodeVerifier != testOAuthVerifier || strings.TrimSpace(in.Nonce) == "" {
90
+ t.Fatalf("unexpected complete input: %+v", in)
91
+ }
92
+ return application.ExternalIdentity{
93
+ Provider: "fake",
94
+ Subject: "subject-1",
95
+ Email: "user@example.com",
96
+ EmailVerified: true,
97
+ Name: "User",
98
+ }, nil
99
+ }
100
+ return provider, newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore(), provider)
101
+ }
102
+
103
+ func TestService_BeginLoginUsesRegistryAndPreservesClientStateAndS256PKCE(t *testing.T) {
104
+ provider, svc := validFakeProvider(t)
105
+ start, err := svc.BeginLogin(context.Background(), "fake", validLoginStart())
106
+ if err != nil {
107
+ t.Fatalf("begin login: %v", err)
108
+ }
109
+ if provider.beginCnt != 1 || start == nil || start.URL == "" {
110
+ t.Fatalf("unexpected begin result: provider=%+v start=%+v", provider, start)
111
+ }
112
+ parsed, err := url.Parse(start.URL)
113
+ if err != nil {
114
+ t.Fatalf("parse authorization URL: %v", err)
115
+ }
116
+ if parsed.Query().Get("state") != "client state" || parsed.Query().Get("code_challenge") != pkceChallenge(testOAuthVerifier) || parsed.Query().Get("code_challenge_method") != "S256" || parsed.Query().Get("nonce") == "" {
117
+ t.Fatalf("authorization URL did not preserve client state/PKCE: %s", start.URL)
118
+ }
119
+ }
120
+
121
+ func TestService_BeginLoginRejectsMissingStateOrNonS256PKCE(t *testing.T) {
122
+ provider, svc := validFakeProvider(t)
123
+ tests := []struct {
124
+ name string
125
+ in application.LoginStartInput
126
+ }{
127
+ {name: "missing state", in: application.LoginStartInput{CodeChallenge: "challenge", CodeChallengeMethod: "S256"}},
128
+ {name: "missing challenge", in: application.LoginStartInput{State: "state", CodeChallengeMethod: "S256"}},
129
+ {name: "plain challenge method", in: application.LoginStartInput{State: "state", CodeChallenge: "challenge", CodeChallengeMethod: "plain"}},
130
+ {name: "short challenge", in: application.LoginStartInput{State: "state", CodeChallenge: "short", CodeChallengeMethod: "S256"}},
131
+ {name: "invalid challenge character", in: application.LoginStartInput{State: "state", CodeChallenge: strings.Repeat("!", 43), CodeChallengeMethod: "S256"}},
132
+ {name: "lowercase challenge method", in: application.LoginStartInput{State: "state", CodeChallenge: strings.Repeat("A", 43), CodeChallengeMethod: "s256"}},
133
+ }
134
+ for _, tt := range tests {
135
+ t.Run(tt.name, func(t *testing.T) {
136
+ _, err := svc.BeginLogin(context.Background(), "fake", tt.in)
137
+ if got := oauthErrorCode(t, err); got != application.OAuthStateInvalid {
138
+ t.Fatalf("oauth code = %q, want %q", got, application.OAuthStateInvalid)
139
+ }
140
+ if provider.beginCnt != 0 {
141
+ t.Fatal("provider must not receive an invalid state/PKCE request")
142
+ }
143
+ })
144
+ }
145
+ }
146
+
147
+ func TestService_ExchangeLoginReturnsSessionForNormalizedProviderIdentity(t *testing.T) {
148
+ provider, svc := validFakeProvider(t)
149
+ if _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart()); err != nil {
150
+ t.Fatalf("begin login: %v", err)
151
+ }
152
+ auth, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange())
153
+ if err != nil {
154
+ t.Fatalf("exchange login: %v", err)
155
+ }
156
+ if auth.AccessToken == "" || auth.RefreshToken == "" || provider.completeCnt != 1 {
157
+ t.Fatalf("expected local session after provider completion: auth=%+v complete calls=%d", auth, provider.completeCnt)
158
+ }
159
+ }
160
+
161
+ func TestService_ExchangeLoginRejectsIdentityFromAnotherProvider(t *testing.T) {
162
+ provider, svc := validFakeProvider(t)
163
+ if _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart()); err != nil {
164
+ t.Fatalf("begin login: %v", err)
165
+ }
166
+ provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
167
+ return application.ExternalIdentity{
168
+ Provider: "another-provider",
169
+ Subject: "subject-1",
170
+ Email: "user@example.com",
171
+ EmailVerified: true,
172
+ }, nil
173
+ }
174
+ _, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange())
175
+ if got := oauthErrorCode(t, err); got != application.OAuthFailed {
176
+ t.Fatalf("oauth code = %q, want %q", got, application.OAuthFailed)
177
+ }
178
+ }
179
+
180
+ func TestService_ExchangeLoginMapsProviderErrorsToControlledCodes(t *testing.T) {
181
+ tests := []struct {
182
+ name string
183
+ providerError error
184
+ wantCode application.OAuthErrorCode
185
+ }{
186
+ {name: "provider exchange failure", providerError: application.NewProviderFailure(errors.New("exchange failed")), wantCode: application.OAuthFailed},
187
+ {name: "provider unavailable", providerError: application.NewProviderUnavailable(errors.New("upstream timeout")), wantCode: application.OAuthProviderUnavailable},
188
+ }
189
+
190
+ for _, tt := range tests {
191
+ t.Run(tt.name, func(t *testing.T) {
192
+ provider, svc := validFakeProvider(t)
193
+ if _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart()); err != nil {
194
+ t.Fatalf("begin login: %v", err)
195
+ }
196
+ provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
197
+ return application.ExternalIdentity{}, tt.providerError
198
+ }
199
+ _, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange())
200
+ if got := oauthErrorCode(t, err); got != tt.wantCode {
201
+ t.Fatalf("oauth code = %q, want %q", got, tt.wantCode)
202
+ }
203
+ })
204
+ }
205
+ }
206
+
207
+ func TestService_ExchangeLoginRejectsMissingStatePKCEAndCode(t *testing.T) {
208
+ _, svc := validFakeProvider(t)
209
+ tests := []struct {
210
+ name string
211
+ in loginExchangeInput
212
+ want application.OAuthErrorCode
213
+ }{
214
+ {name: "missing state", in: loginExchangeInput{Code: "code", CodeVerifier: testOAuthVerifier}, want: application.OAuthStateInvalid},
215
+ {name: "missing verifier", in: loginExchangeInput{Code: "code", State: "state"}, want: application.OAuthStateInvalid},
216
+ {name: "missing code", in: loginExchangeInput{State: "state", CodeVerifier: testOAuthVerifier}, want: application.OAuthFailed},
217
+ }
218
+ for _, tt := range tests {
219
+ t.Run(tt.name, func(t *testing.T) {
220
+ _, err := svc.ExchangeLogin(context.Background(), "fake", tt.in)
221
+ if got := oauthErrorCode(t, err); got != tt.want {
222
+ t.Fatalf("oauth code = %q, want %q", got, tt.want)
223
+ }
224
+ })
225
+ }
226
+ }
227
+
228
+ func TestService_ExchangeLoginConsumesStateAndBindsThePKCEVerifier(t *testing.T) {
229
+ provider, svc := validFakeProvider(t)
230
+ if _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart()); err != nil {
231
+ t.Fatalf("begin login: %v", err)
232
+ }
233
+ wrong := validLoginExchange()
234
+ wrong.CodeVerifier = testOAuthVerifier + "x"
235
+ if _, err := svc.ExchangeLogin(context.Background(), "fake", wrong); oauthErrorCode(t, err) != application.OAuthStateInvalid {
236
+ t.Fatalf("wrong verifier should be rejected as state invalid: %v", err)
237
+ }
238
+ if _, err := svc.ExchangeLogin(context.Background(), "fake", validLoginExchange()); oauthErrorCode(t, err) != application.OAuthStateInvalid {
239
+ t.Fatalf("a rejected exchange must consume the one-time transaction: %v", err)
240
+ }
241
+ if provider.completeCnt != 0 {
242
+ t.Fatal("provider must not receive a wrong PKCE verifier")
243
+ }
244
+ }
245
+
246
+ func TestService_UnconfiguredProviderDoesNotBuildAURL(t *testing.T) {
247
+ svc := newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore())
248
+ start, err := svc.BeginLogin(context.Background(), "google", validLoginStart())
249
+ if start != nil {
250
+ t.Fatalf("unconfigured provider returned a login start: %+v", start)
251
+ }
252
+ if got := oauthErrorCode(t, err); got != application.OAuthProviderUnavailable {
253
+ t.Fatalf("oauth code = %q, want %q", got, application.OAuthProviderUnavailable)
254
+ }
255
+ _, err = svc.ExchangeLogin(context.Background(), "google", validLoginExchange())
256
+ if got := oauthErrorCode(t, err); got != application.OAuthProviderUnavailable {
257
+ t.Fatalf("exchange oauth code = %q, want %q", got, application.OAuthProviderUnavailable)
258
+ }
259
+ }
260
+
261
+ func TestProviderRegistry_UsesProviderNameAsTheOnlyLookupKey(t *testing.T) {
262
+ provider := &fakeLoginProvider{name: "fake"}
263
+ registry := application.NewProviderRegistry(provider)
264
+ if got, ok := registry.Lookup("fake"); !ok || got != provider {
265
+ t.Fatalf("registry lookup = (%v, %v), want fake provider", got, ok)
266
+ }
267
+ if _, ok := registry.Lookup("google"); ok {
268
+ t.Fatal("registry must not synthesize an unconfigured provider")
269
+ }
270
+ }
271
+
272
+ func TestService_BeginLoginRejectsEmptyProviderAuthorizationURL(t *testing.T) {
273
+ provider := &fakeLoginProvider{name: "fake", beginFn: func(application.LoginStartInput) (application.Authorization, error) {
274
+ return application.Authorization{}, nil
275
+ }}
276
+ svc := newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore(), provider)
277
+ _, err := svc.BeginLogin(context.Background(), "fake", validLoginStart())
278
+ if got := oauthErrorCode(t, err); got != application.OAuthProviderUnavailable {
279
+ t.Fatalf("oauth code = %q, want %q", got, application.OAuthProviderUnavailable)
280
+ }
281
+ if strings.Contains(err.Error(), "Location") {
282
+ t.Fatalf("empty authorization must not become a redirect: %v", err)
283
+ }
284
+ }
285
+
286
+ var _ application.LoginProvider = (*fakeLoginProvider)(nil)
@@ -0,0 +1,114 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "log/slog"
8
+
9
+ "{{goModule}}/internal/app/user/application"
10
+ "{{goModule}}/internal/app/user/model"
11
+ "{{goModule}}/internal/shared/apperror"
12
+
13
+ "github.com/google/uuid"
14
+ )
15
+
16
+ // ForgotPassword always returns success from the caller's perspective. Only a
17
+ // local password identity gets a token, so this endpoint cannot enumerate
18
+ // accounts or issue a reset for an external-only account.
19
+ func (s *Service) ForgotPassword(ctx context.Context, email string) error {
20
+ key := throttleKey("pwreset", email)
21
+ if s.throttled(ctx, key) {
22
+ return nil
23
+ }
24
+ if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
25
+ slog.Error("record password reset attempt", "error", err)
26
+ }
27
+
28
+ u, err := s.repo.FindByEmail(ctx, normalizeEmail(email))
29
+ if err != nil {
30
+ return nil
31
+ }
32
+ ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
33
+ if err != nil || ident.PasswordHash == nil {
34
+ return nil
35
+ }
36
+
37
+ raw, err := randomToken()
38
+ if err != nil {
39
+ return apperror.NewInternal(err)
40
+ }
41
+ if err := s.recoveryTokens.SetPasswordResetToken(ctx, hashToken(raw), u.ID, s.config.PasswordResetTTL); err != nil {
42
+ return apperror.NewInternal(err)
43
+ }
44
+
45
+ link := s.config.PasswordResetURL + "?token=" + raw
46
+ if err := s.mailer.Send(ctx, u.Email, "Reset your password", "Reset your password: "+link); err != nil {
47
+ // The token is stored already. Log the delivery failure so it is visible
48
+ // operationally while preserving the anti-enumeration response.
49
+ slog.Error("send password reset email", "error", err)
50
+ }
51
+ return nil
52
+ }
53
+
54
+ // ResetPassword consumes a one-time token and updates the local identity in
55
+ // one transaction, then revokes all sessions. If the cross-store revoke fails,
56
+ // restoring the token lets the caller retry the complete operation.
57
+ func (s *Service) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
58
+ tokenHash := hashToken(rawToken)
59
+ userID, err := s.recovery.ResetPassword(ctx, tokenHash, newPassword)
60
+ if err != nil {
61
+ if errors.Is(err, application.ErrInvalidToken) {
62
+ return errInvalidToken()
63
+ }
64
+ return apperror.NewInternal(fmt.Errorf("reset password: %w", err))
65
+ }
66
+ if err := s.refreshTokens.RevokeAllRefreshTokens(ctx, userID); err != nil {
67
+ if restoreErr := s.recoveryTokens.SetPasswordResetToken(ctx, tokenHash, userID, s.config.PasswordResetTTL); restoreErr != nil {
68
+ return apperror.NewInternal(fmt.Errorf("revoke sessions after password reset: %w; restore reset token for retry: %v", err, restoreErr))
69
+ }
70
+ return apperror.NewInternal(fmt.Errorf("revoke sessions after password reset (reset token restored for retry): %w", err))
71
+ }
72
+ return nil
73
+ }
74
+
75
+ // sendVerificationEmail issues a one-time token and sends its link. It is
76
+ // shared by registration and the authenticated resend flow.
77
+ func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
78
+ raw, err := randomToken()
79
+ if err != nil {
80
+ slog.Error("generate email verification token", "error", err)
81
+ return
82
+ }
83
+ if err := s.recoveryTokens.SetEmailVerifyToken(ctx, hashToken(raw), u.ID, s.config.EmailVerifyTTL); err != nil {
84
+ slog.Error("store email verification token", "error", err)
85
+ return
86
+ }
87
+ link := s.config.EmailVerifyURL + "?token=" + raw
88
+ if err := s.mailer.Send(ctx, u.Email, "Verify your email", "Verify your email: "+link); err != nil {
89
+ slog.Error("send email verification email", "error", err)
90
+ }
91
+ }
92
+
93
+ func (s *Service) ResendVerificationEmail(ctx context.Context, userID uuid.UUID) error {
94
+ u, err := s.repo.FindByID(ctx, userID)
95
+ if err != nil {
96
+ return apperror.NewInternal(err)
97
+ }
98
+ if u.EmailVerified {
99
+ return errAlreadyVerified()
100
+ }
101
+ s.sendVerificationEmail(ctx, u)
102
+ return nil
103
+ }
104
+
105
+ func (s *Service) VerifyEmail(ctx context.Context, rawToken string) error {
106
+ _, err := s.recovery.VerifyEmail(ctx, hashToken(rawToken))
107
+ if err != nil {
108
+ if errors.Is(err, application.ErrInvalidToken) {
109
+ return errInvalidToken()
110
+ }
111
+ return apperror.NewInternal(fmt.Errorf("verify email: %w", err))
112
+ }
113
+ return nil
114
+ }
@@ -135,3 +135,5 @@ func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
135
135
  Where("email_hash = ?", key).
136
136
  Delete(&model.LoginThrottle{}).Error
137
137
  }
138
+
139
+ // go-scaffold:user-repository-methods