@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
@@ -2,29 +2,19 @@ package user
2
2
 
3
3
  import (
4
4
  "context"
5
- "encoding/json"
6
- "errors"
7
- "log/slog"
8
- "net/http"
9
5
  "strings"
10
6
  "time"
11
7
 
8
+ "{{goModule}}/internal/app/user/application"
12
9
  "{{goModule}}/internal/app/user/model"
13
- "{{goModule}}/internal/shared/apperror"
14
- "{{goModule}}/internal/shared/config"
15
- "{{goModule}}/internal/shared/dberr"
16
- "{{goModule}}/internal/shared/id"
17
10
 
18
11
  "github.com/google/uuid"
19
- "golang.org/x/crypto/bcrypt"
20
- "golang.org/x/oauth2"
21
- "golang.org/x/oauth2/google"
22
- "gorm.io/gorm"
23
12
  )
24
13
 
25
- // repository = what the service needs from the data layer (declared on the
26
- // consumer side, so it can be faked in tests).
27
- type repository interface {
14
+ // repository = what the user application needs from the data layer. The
15
+ // interface stays consumer-owned so every use case can be tested without a
16
+ // database, while the concrete GORM implementation remains in repository.go.
17
+ type UserRepository interface {
28
18
  FindByEmail(ctx context.Context, email string) (*model.User, error)
29
19
  FindByID(ctx context.Context, id uuid.UUID) (*model.User, error)
30
20
  UpdateUser(ctx context.Context, u *model.User) error
@@ -40,492 +30,106 @@ type repository interface {
40
30
  // go-scaffold:user-repository-interface
41
31
  }
42
32
 
43
- // mailer = what the service needs to send an email — satisfied by
44
- // platform/mail's AsyncClient (enqueues onto cmd/worker instead of blocking
45
- // the request on SMTP). Takes ctx so the enqueue can join the caller's
46
- // transaction where the queue backend supports it.
47
- type mailer interface {
33
+ // repository is kept as a package-local alias so generated method extensions
34
+ // and focused tests can use the short consumer-side name without hiding the
35
+ // public Dependencies contract from callers outside this package.
36
+ type repository = UserRepository
37
+
38
+ // mailer = what the service needs to send an email. The concrete client may
39
+ // send inline or enqueue onto cmd/worker; auth only depends on this port.
40
+ type AuthMailer interface {
48
41
  Send(ctx context.Context, to, subject, body string) error
49
42
  }
50
43
 
51
44
  // go-scaffold:user-interfaces
52
45
 
53
- type Service struct {
54
- repo repository
55
- tokens tokenStore
56
- mailer mailer
57
- jwtSecret string
58
- accessTTL time.Duration
59
- refreshTTL time.Duration
60
- resetTTL time.Duration
61
- resetURL string
62
- verifyTTL time.Duration
63
- verifyURL string
64
- googleOAuth *oauth2.Config
65
- // go-scaffold:user-service-fields
66
- }
67
-
68
- func NewService(
69
- repo repository,
70
- tokens tokenStore,
71
- mailer mailer,
72
- cfg config.Config,
73
- // go-scaffold:user-service-params
74
- ) *Service {
75
- return &Service{
76
- repo: repo,
77
- tokens: tokens,
78
- mailer: mailer,
79
- jwtSecret: cfg.JWTSecret,
80
- accessTTL: cfg.JWTAccessTTL,
81
- refreshTTL: cfg.JWTRefreshTTL,
82
- resetTTL: cfg.PasswordResetTTL,
83
- resetURL: cfg.PasswordResetURL,
84
- verifyTTL: cfg.EmailVerifyTTL,
85
- verifyURL: cfg.EmailVerifyURL,
86
- googleOAuth: &oauth2.Config{
87
- ClientID: cfg.GoogleClientID,
88
- ClientSecret: cfg.GoogleClientSecret,
89
- RedirectURL: cfg.GoogleRedirectURL,
90
- Scopes: []string{"openid", "email", "profile"},
91
- Endpoint: google.Endpoint,
92
- },
93
- // go-scaffold:user-service-init
94
- }
95
- }
96
-
97
- // normalizeEmail is applied at every boundary an address enters the service
98
- // through. The column is a plain case-sensitive UNIQUE, so without this
99
- // "Foo@x.com" and "foo@x.com" are two accounts that never collide — and a
100
- // Google login (Google always reports lowercase) fails to find the local
101
- // account it should have linked to, silently creating a second user for the
102
- // same person.
103
- func normalizeEmail(email string) string {
104
- return strings.ToLower(strings.TrimSpace(email))
105
- }
106
-
107
- // Failed-attempt policy. Constants rather than config: these are a security
108
- // posture, not something to tune per environment, and every knob added here is
109
- // a knob someone can quietly widen until the control stops working. Change the
110
- // numbers if your threat model differs.
111
- //
112
- // The first loginFreeAttempts failures cost nothing — a typo shouldn't lock
113
- // anyone out. After that each failure doubles the wait (1s, 2s, 4s, ...) up to
114
- // loginMaxLock, which is the shape OWASP's Authentication Cheat Sheet asks for.
115
- const (
116
- loginFreeAttempts = 3
117
- loginMaxLock = 15 * time.Minute
118
- )
119
-
120
- // throttleKey namespaces the counter by what is being attempted, so a locked
121
- // login never blocks the password-reset that would fix it — someone who forgot
122
- // their password is exactly the person who trips the login counter.
123
- //
124
- // Hashed, because the counter must exist for addresses that have no account
125
- // (otherwise "was I throttled" answers "does this account exist"), and a table
126
- // of plain addresses that anyone has ever typed is a user list.
127
- func throttleKey(purpose, email string) string {
128
- return hashToken(purpose + ":" + normalizeEmail(email))
129
- }
130
-
131
- // throttled reports whether this key is inside its lockout window. A failure
132
- // to read the counter is treated as not-throttled: this is a brake, and it
133
- // should not be able to lock everybody out on its own.
134
- func (s *Service) throttled(ctx context.Context, key string) bool {
135
- until, err := s.repo.LoginLockedUntil(ctx, key)
136
- if err != nil {
137
- slog.Error("read login throttle", "error", err)
138
- return false
139
- }
140
- return !until.IsZero() && time.Now().Before(until)
46
+ // RoleChecker is the narrow capability the user domain needs when RBAC is
47
+ // installed. Keeping the interface here means auth can be generated on its
48
+ // own; the role domain satisfies it structurally when the optional feature is
49
+ // added.
50
+ type RoleChecker interface {
51
+ CodeExists(ctx context.Context, code string) (bool, error)
141
52
  }
142
53
 
143
- func (s *Service) Register(ctx context.Context, in registerInput) (*authResponse, error) {
144
- hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
145
- if err != nil {
146
- return nil, apperror.NewInternal()
147
- }
148
- hashStr := string(hash)
149
-
150
- u := &model.User{ID: id.New(), Email: normalizeEmail(in.Email), Name: in.Name}
151
- i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
152
- if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
153
- if dberr.IsDuplicate(err) {
154
- return nil, errEmailTaken()
155
- }
156
- return nil, apperror.NewInternal()
157
- }
158
- // best-effort: a mail failure shouldn't block registration — the user can
159
- // always ask for another link via ResendVerificationEmail.
160
- s.sendVerificationEmail(ctx, u)
161
- return s.issueTokens(ctx, u)
162
- }
163
-
164
- func (s *Service) Login(ctx context.Context, in loginInput) (*authResponse, error) {
165
- key := throttleKey("login", in.Email)
166
- if s.throttled(ctx, key) {
167
- return nil, errTooManyAttempts()
168
- }
169
-
170
- // Every failure below is recorded against the same key whether or not the
171
- // account exists, and answers with the same error — so the counter cannot
172
- // be used to enumerate accounts either.
173
- fail := func() (*authResponse, error) {
174
- if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
175
- slog.Error("record login failure", "error", err)
176
- }
177
- return nil, errInvalidCredentials()
178
- }
179
-
180
- u, err := s.repo.FindByEmail(ctx, normalizeEmail(in.Email))
181
- if err != nil {
182
- return fail()
183
- }
184
- ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
185
- if err != nil || ident.PasswordHash == nil {
186
- return fail()
187
- }
188
- if err := bcrypt.CompareHashAndPassword([]byte(*ident.PasswordHash), []byte(in.Password)); err != nil {
189
- return fail()
190
- }
191
-
192
- if err := s.repo.ClearLoginFailures(ctx, key); err != nil {
193
- slog.Error("clear login failures", "error", err)
194
- }
195
- return s.issueTokens(ctx, u)
196
- }
197
-
198
- // Refresh rotates a refresh token: the presented one is consumed (deleted +
199
- // tombstoned as "used") and a fresh pair is issued. If the presented token
200
- // isn't active but WAS already used, that means this exact raw value got
201
- // replayed after rotation — i.e. it leaked — so every session for that user
202
- // is revoked, not just this one.
203
- func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*authResponse, error) {
204
- hash := hashToken(rawRefreshToken)
205
- userID, ok, err := s.tokens.GetRefreshToken(ctx, hash)
206
- if err != nil {
207
- return nil, apperror.NewInternal()
208
- }
209
- if !ok {
210
- if reusedBy, used, uerr := s.tokens.IsRefreshTokenUsed(ctx, hash); uerr == nil && used {
211
- _ = s.tokens.RevokeAllRefreshTokens(ctx, reusedBy)
212
- }
213
- return nil, errInvalidToken()
214
- }
215
-
216
- if err := s.tokens.DeleteRefreshToken(ctx, hash, userID); err != nil {
217
- return nil, apperror.NewInternal()
218
- }
219
- if err := s.tokens.MarkRefreshTokenUsed(ctx, hash, userID, s.refreshTTL); err != nil {
220
- return nil, apperror.NewInternal()
221
- }
222
-
223
- u, err := s.repo.FindByID(ctx, userID)
224
- if err != nil {
225
- return nil, errInvalidToken()
226
- }
227
- return s.issueTokens(ctx, u)
228
- }
229
-
230
- // Logout is intentional, not reuse — no tombstoning, and idempotent (a
231
- // missing/already-gone token still succeeds).
232
- func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error {
233
- if rawRefreshToken == "" {
234
- return nil
235
- }
236
- hash := hashToken(rawRefreshToken)
237
- userID, ok, err := s.tokens.GetRefreshToken(ctx, hash)
238
- if err != nil || !ok {
239
- return nil
240
- }
241
- return s.tokens.DeleteRefreshToken(ctx, hash, userID)
242
- }
243
-
244
- // LogoutAll is the authenticated, self-service version of what ResetPassword
245
- // already does automatically: end every session for this user, not just the
246
- // one making the request — e.g. "log out everywhere" after a lost device.
247
- func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
248
- return s.tokens.RevokeAllRefreshTokens(ctx, userID)
249
- }
250
-
251
- func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*model.User, error) {
252
- u, err := s.repo.FindByID(ctx, userID)
253
- if err != nil {
254
- if errors.Is(err, gorm.ErrRecordNotFound) {
255
- return nil, errNotFound()
256
- }
257
- return nil, apperror.NewInternal()
258
- }
259
- return u, nil
260
- }
261
-
262
- // List is unused until an admin-facing route calls it (see `add rbac`'s
263
- // GET /users) — kept here rather than gated behind a patch since it's a
264
- // generically useful piece of data access, same as UpdateUser.
265
- func (s *Service) List(ctx context.Context, limit, offset int) ([]model.User, error) {
266
- items, err := s.repo.FindAll(ctx, limit, offset)
267
- if err != nil {
268
- return nil, apperror.NewInternal()
269
- }
270
- return items, nil
271
- }
272
-
273
- // ForgotPassword always succeeds from the caller's point of view — whether
274
- // the email exists or not — so a login-attempt-shaped probe can't be used to
275
- // enumerate registered accounts. Only if the email resolves to a local
276
- // (password-based) identity does it actually issue+email a reset token.
277
- func (s *Service) ForgotPassword(ctx context.Context, email string) error {
278
- // Its own counter, so hammering this endpoint cannot lock anyone out of
279
- // logging in, and a locked-out login cannot block the reset that fixes it.
280
- // Every request counts here, not just failures: what this throttles is
281
- // using someone else's address as a mail-bomb target.
282
- key := throttleKey("pwreset", email)
283
- if s.throttled(ctx, key) {
284
- return nil // same answer as always — silence is the whole design here
285
- }
286
- if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
287
- slog.Error("record password reset attempt", "error", err)
288
- }
289
-
290
- u, err := s.repo.FindByEmail(ctx, normalizeEmail(email))
291
- if err != nil {
292
- return nil
293
- }
294
- ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
295
- if err != nil || ident.PasswordHash == nil {
296
- return nil // google-only account has no password to reset
297
- }
298
-
299
- raw, err := randomToken()
300
- if err != nil {
301
- return apperror.NewInternal()
302
- }
303
- if err := s.tokens.SetPasswordResetToken(ctx, hashToken(raw), u.ID, s.resetTTL); err != nil {
304
- return apperror.NewInternal()
305
- }
306
-
307
- link := s.resetURL + "?token=" + raw
308
- if err := s.mailer.Send(ctx, u.Email, "Reset your password", "Reset your password: "+link); err != nil {
309
- // token's already stored — a mail failure shouldn't fail the request,
310
- // just get logged so it's visible operationally.
311
- slog.Error("send password reset email", "error", err)
312
- }
313
- return nil
314
- }
315
-
316
- // ResetPassword consumes a one-time reset token (GETDEL — see
317
- // tokenStore.ConsumePasswordResetToken), sets the new password, and revokes
318
- // every existing session for that user: a password reset should end every
319
- // session an attacker (or the legitimate user on another device) still holds.
320
- func (s *Service) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
321
- userID, ok, err := s.tokens.ConsumePasswordResetToken(ctx, hashToken(rawToken))
322
- if err != nil {
323
- return apperror.NewInternal()
324
- }
325
- if !ok {
326
- return errInvalidToken()
327
- }
328
-
329
- hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
330
- if err != nil {
331
- return apperror.NewInternal()
332
- }
333
- hashStr := string(hash)
334
-
335
- ident, err := s.repo.FindIdentity(ctx, userID, model.ProviderLocal)
336
- if err != nil {
337
- return apperror.NewInternal()
338
- }
339
- ident.PasswordHash = &hashStr
340
- if err := s.repo.UpdateIdentity(ctx, ident); err != nil {
341
- return apperror.NewInternal()
342
- }
343
- return s.tokens.RevokeAllRefreshTokens(ctx, userID)
344
- }
345
-
346
- // sendVerificationEmail issues a one-time token and emails the link — called
347
- // from Register (best-effort) and ResendVerificationEmail. Not exported:
348
- // callers that want to send one go through one of those two, which decide
349
- // whether it's appropriate to (e.g. ResendVerificationEmail checks the user
350
- // isn't already verified first).
351
- func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
352
- raw, err := randomToken()
353
- if err != nil {
354
- slog.Error("generate email verification token", "error", err)
355
- return
356
- }
357
- if err := s.tokens.SetEmailVerifyToken(ctx, hashToken(raw), u.ID, s.verifyTTL); err != nil {
358
- slog.Error("store email verification token", "error", err)
359
- return
360
- }
361
- link := s.verifyURL + "?token=" + raw
362
- if err := s.mailer.Send(ctx, u.Email, "Verify your email", "Verify your email: "+link); err != nil {
363
- slog.Error("send email verification email", "error", err)
364
- }
54
+ // Service is the stable facade exposed to handlers, cmd/seed, and generated
55
+ // extensions. The behavior is implemented in focused files by use-case area:
56
+ // local_auth.go, sessions.go, recovery_service.go, external_login.go, and
57
+ // user_query.go. Keeping the facade avoids route and generator compatibility
58
+ // churn while preventing one 600-line service file from becoming the place
59
+ // where every auth concern must be edited.
60
+ type Service struct {
61
+ repo UserRepository
62
+ refreshTokens RefreshTokenStore
63
+ oauthTransactions OAuthTransactionStore
64
+ recoveryTokens RecoveryTokenStore
65
+ mfa MFAStore
66
+ mailer AuthMailer
67
+ providers ProviderRegistry
68
+ recovery *application.Recovery
69
+ config AuthConfig
70
+ roles RoleChecker
71
+ now func() time.Time
72
+ // go-scaffold:user-service-fields
365
73
  }
366
74
 
367
- // ResendVerificationEmail is the authenticated counterpart to the automatic
368
- // send in Register for when the first email never arrived or its token
369
- // expired. Rejects an already-verified user instead of silently no-op'ing:
370
- // unlike ForgotPassword this is authenticated, so there's no enumeration
371
- // concern in telling the caller their own account's real state.
372
- func (s *Service) ResendVerificationEmail(ctx context.Context, userID uuid.UUID) error {
373
- u, err := s.repo.FindByID(ctx, userID)
374
- if err != nil {
375
- return apperror.NewInternal()
75
+ // NewService is deliberately explicit: infrastructure dependencies and auth
76
+ // policy are separate values, so the application layer never receives the
77
+ // generated project's catch-all shared configuration object.
78
+ func NewService(deps Dependencies, cfg AuthConfig) *Service {
79
+ if cfg.JWTAccessTTL <= 0 {
80
+ cfg.JWTAccessTTL = 15 * time.Minute
376
81
  }
377
- if u.EmailVerified {
378
- return errAlreadyVerified()
82
+ if cfg.JWTRefreshTTL <= 0 {
83
+ cfg.JWTRefreshTTL = 12 * time.Hour
379
84
  }
380
- s.sendVerificationEmail(ctx, u)
381
- return nil
382
- }
383
-
384
- // VerifyEmail consumes a one-time verification token (GETDEL — see
385
- // tokenStore.ConsumeEmailVerifyToken) and marks the user verified.
386
- func (s *Service) VerifyEmail(ctx context.Context, rawToken string) error {
387
- userID, ok, err := s.tokens.ConsumeEmailVerifyToken(ctx, hashToken(rawToken))
388
- if err != nil {
389
- return apperror.NewInternal()
85
+ cfg.JWTRefreshMaxTTL = defaultDuration(cfg.JWTRefreshMaxTTL, cfg.JWTRefreshTTL)
86
+ cfg.OAuthStateTTL = defaultDuration(cfg.OAuthStateTTL, 10*time.Minute)
87
+ if cfg.MFA.RecoveryCodeCount == 0 {
88
+ cfg.MFA.RecoveryCodeCount = defaultMFACodes
390
89
  }
391
- if !ok {
392
- return errInvalidToken()
90
+ clock := deps.Clock
91
+ if clock == nil {
92
+ clock = time.Now
393
93
  }
394
- u, err := s.repo.FindByID(ctx, userID)
395
- if err != nil {
396
- return apperror.NewInternal()
94
+ providers := deps.Providers
95
+ if providers == nil {
96
+ providers = application.NewProviderRegistry()
397
97
  }
398
- u.EmailVerified = true
399
- return s.repo.UpdateUser(ctx, u)
400
- }
401
-
402
- // GoogleLoginURL signs a short-lived CSRF state token and builds the redirect
403
- // URL. The second return value is the nonce the caller must set as a cookie:
404
- // the callback checks it against the one inside the state (see jwt.go), which
405
- // is what binds the flow to this one browser.
406
- func (s *Service) GoogleLoginURL() (string, string, error) {
407
- state, nonce, err := s.issueOAuthState()
408
- if err != nil {
409
- return "", "", apperror.NewInternal()
98
+ return &Service{
99
+ repo: deps.Repository,
100
+ refreshTokens: deps.RefreshTokens,
101
+ oauthTransactions: deps.OAuthTransactions,
102
+ recoveryTokens: deps.RecoveryTokens,
103
+ mfa: deps.MFA,
104
+ mailer: deps.Mailer,
105
+ providers: providers,
106
+ recovery: newRecovery(deps.Repository, deps.RecoveryTokens),
107
+ config: cfg,
108
+ roles: deps.Roles,
109
+ now: clock,
110
+ // go-scaffold:user-service-init
410
111
  }
411
- return s.googleOAuth.AuthCodeURL(state), nonce, nil
412
- }
413
-
414
- type googleUserInfo struct {
415
- Sub string `json:"sub"`
416
- Email string `json:"email"`
417
- EmailVerified bool `json:"email_verified"`
418
- Name string `json:"name"`
419
- Picture string `json:"picture"`
420
112
  }
421
113
 
422
- func (s *Service) GoogleCallback(ctx context.Context, code, state, nonce string) (*authResponse, error) {
423
- if err := s.verifyOAuthState(state, nonce); err != nil {
424
- return nil, err
425
- }
426
- tok, err := s.googleOAuth.Exchange(ctx, code)
427
- if err != nil {
428
- return nil, errInvalidToken()
429
- }
430
-
431
- resp, err := s.googleOAuth.Client(ctx, tok).Get("https://openidconnect.googleapis.com/v1/userinfo")
432
- if err != nil {
433
- return nil, apperror.NewInternal()
434
- }
435
- defer func() { _ = resp.Body.Close() }()
436
- if resp.StatusCode != http.StatusOK {
437
- return nil, apperror.NewInternal()
114
+ func defaultDuration(value, fallback time.Duration) time.Duration {
115
+ if value > 0 {
116
+ return value
438
117
  }
439
- var info googleUserInfo
440
- if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
441
- return nil, apperror.NewInternal()
442
- }
443
-
444
- u, err := s.findOrCreateGoogleUser(ctx, info)
445
- if err != nil {
446
- return nil, err
447
- }
448
- return s.issueTokens(ctx, u)
118
+ return fallback
449
119
  }
450
120
 
451
- // findOrCreateGoogleUser: (1) an existing google identity for this sub wins
452
- // outright; (2) failing that, ONLY if Google says the email is verified, link
453
- // a new google identity onto an existing local account with that email — an
454
- // unverified email can't be used to take over someone else's account; (3)
455
- // otherwise, brand new user + identity.
456
- func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInfo) (*model.User, error) {
457
- if ident, err := s.repo.FindIdentityByProviderUID(ctx, model.ProviderGoogle, info.Sub); err == nil {
458
- return s.repo.FindByID(ctx, ident.UserID)
459
- }
460
-
461
- providerUID := info.Sub
462
- email := normalizeEmail(info.Email)
463
- if info.EmailVerified {
464
- if u, err := s.repo.FindByEmail(ctx, email); err == nil {
465
- ident := &model.Identity{ID: id.New(), UserID: u.ID, Provider: model.ProviderGoogle, ProviderUID: &providerUID}
466
- if err := s.repo.CreateIdentity(ctx, ident); err != nil {
467
- return nil, apperror.NewInternal()
468
- }
469
- return u, nil
470
- }
471
- }
472
-
473
- u := &model.User{ID: id.New(), Email: email, Name: info.Name, AvatarURL: info.Picture, EmailVerified: info.EmailVerified}
474
- ident := &model.Identity{ID: id.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID}
475
- if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
476
- if dberr.IsDuplicate(err) {
477
- return nil, errEmailTaken()
478
- }
479
- return nil, apperror.NewInternal()
121
+ func (s *Service) clock() time.Time {
122
+ if s.now == nil {
123
+ return time.Now()
480
124
  }
481
- return u, nil
125
+ return s.now()
482
126
  }
483
127
 
484
- // EnsureUser is idempotent: an existing user with this email is returned
485
- // as-is (password untouched cmd/seed shouldn't silently reset a real
486
- // password on every deploy), otherwise a new local user+identity is
487
- // created. Used by cmd/seed only — nothing in the HTTP API calls this.
488
- func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*model.User, error) {
489
- email = normalizeEmail(email)
490
- if u, err := s.repo.FindByEmail(ctx, email); err == nil {
491
- return u, nil
492
- }
493
-
494
- hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
495
- if err != nil {
496
- return nil, apperror.NewInternal()
497
- }
498
- hashStr := string(hash)
499
-
500
- u := &model.User{ID: id.New(), Email: email, Name: name}
501
- i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
502
- if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
503
- return nil, apperror.NewInternal()
504
- }
505
- return u, nil
128
+ // normalizeEmail is applied at every boundary an address enters the service
129
+ // through. The column is a plain case-sensitive UNIQUE, so without this
130
+ // "Foo@x.com" and "foo@x.com" could become two accounts.
131
+ func normalizeEmail(email string) string {
132
+ return strings.ToLower(strings.TrimSpace(email))
506
133
  }
507
134
 
508
135
  // go-scaffold:user-service-methods
509
-
510
- func (s *Service) issueTokens(ctx context.Context, u *model.User) (*authResponse, error) {
511
- access, err := s.issueAccessToken(
512
- u.ID,
513
- // go-scaffold:issue-access-token-args
514
- )
515
- if err != nil {
516
- return nil, apperror.NewInternal()
517
- }
518
- refresh, err := randomToken()
519
- if err != nil {
520
- return nil, apperror.NewInternal()
521
- }
522
- if err := s.tokens.SetRefreshToken(ctx, hashToken(refresh), u.ID, s.refreshTTL); err != nil {
523
- return nil, apperror.NewInternal()
524
- }
525
- return &authResponse{
526
- AccessToken: access,
527
- RefreshToken: refresh,
528
- TokenType: "Bearer",
529
- ExpiresIn: int(s.accessTTL.Seconds()),
530
- }, nil
531
- }