@nakedev/go-scaffold 0.1.3 → 0.3.0

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -50
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +23 -12
  6. package/dist/commands/method.js +94 -17
  7. package/dist/commands/observability.js +114 -0
  8. package/dist/commands/rbac.js +19 -2
  9. package/dist/commands/undo.js +331 -0
  10. package/dist/commands/worker.js +92 -32
  11. package/dist/index.js +368 -64
  12. package/dist/prompts/auth-wizard.js +29 -0
  13. package/dist/prompts/create-wizard.js +5 -2
  14. package/dist/prompts/generate-wizard.js +57 -0
  15. package/dist/prompts/worker-wizard.js +25 -0
  16. package/dist/templates/auth-manifest.js +20 -3
  17. package/dist/templates/create-manifest.js +13 -20
  18. package/dist/templates/module-manifest.js +2 -0
  19. package/dist/templates/observability-manifest.js +24 -0
  20. package/dist/templates/rbac-manifest.js +1 -0
  21. package/dist/templates/worker-manifest.js +23 -6
  22. package/dist/utils/auth-patcher.js +96 -21
  23. package/dist/utils/config.js +58 -10
  24. package/dist/utils/gocheck.js +57 -5
  25. package/dist/utils/golangci-patcher.js +73 -0
  26. package/dist/utils/gomod-patcher.js +53 -0
  27. package/dist/utils/main-patcher.js +58 -4
  28. package/dist/utils/marker-patch.js +125 -3
  29. package/dist/utils/method-patcher.js +97 -18
  30. package/dist/utils/module-location.js +58 -0
  31. package/dist/utils/naming.js +125 -14
  32. package/dist/utils/observability-patcher.js +107 -0
  33. package/dist/utils/openapi-patcher.js +19 -1
  34. package/dist/utils/platform-patcher.js +98 -12
  35. package/dist/utils/rbac-patcher.js +60 -10
  36. package/dist/utils/smoke-run.js +31 -0
  37. package/package.json +11 -4
  38. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  39. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  40. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  41. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  42. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  43. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  44. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  45. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  46. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  50. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  51. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  52. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  53. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  54. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  55. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  56. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  57. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  58. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  59. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  60. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  61. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  62. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  63. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  64. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  65. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  66. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  67. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  68. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  69. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  70. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  71. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  72. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  73. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  74. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  75. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  76. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  77. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  78. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  79. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  80. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  81. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  82. package/templates/create/base/.dockerignore.hbs +13 -0
  83. package/templates/create/base/.env.example.hbs +18 -9
  84. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  85. package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
  86. package/templates/create/base/.golangci.yml.hbs +27 -0
  87. package/templates/create/base/AGENTS.md.hbs +14 -12
  88. package/templates/create/base/Dockerfile.hbs +42 -0
  89. package/templates/create/base/Makefile.hbs +52 -16
  90. package/templates/create/base/README.md.hbs +61 -12
  91. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  92. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  93. package/templates/create/base/go.mod.hbs +4 -4
  94. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  95. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  96. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  97. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  98. package/templates/create/base/redocly.yaml.hbs +21 -0
  99. package/templates/create/features/docs/architecture.md.hbs +32 -11
  100. package/templates/create/features/docs/openapi.yaml.hbs +4 -9
  101. package/templates/create/features/docs/patterns.md.hbs +91 -14
  102. package/templates/create/features/docs/techstack.md.hbs +8 -3
  103. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  104. package/templates/generate/module/dto.go.hbs +8 -1
  105. package/templates/generate/module/errors.go.hbs +5 -0
  106. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  107. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  108. package/templates/generate/module/handler.go.hbs +18 -4
  109. package/templates/generate/module/handler_test.go.hbs +88 -62
  110. package/templates/generate/module/migration.down.sql.hbs +3 -1
  111. package/templates/generate/module/migration.up.sql.hbs +7 -2
  112. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  113. package/templates/generate/module/minimal/handler.go.hbs +8 -2
  114. package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
  115. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  116. package/templates/generate/module/model/model.go.hbs +16 -0
  117. package/templates/generate/module/permission.up.sql.hbs +3 -1
  118. package/templates/generate/module/repository.go.hbs +60 -6
  119. package/templates/generate/module/repository_test.go.hbs +109 -0
  120. package/templates/generate/module/service.go.hbs +15 -4
  121. package/templates/generate/module/service_test.go.hbs +113 -17
  122. package/dist/commands/remove.js +0 -89
  123. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  124. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
@@ -0,0 +1,192 @@
1
+ package user
2
+
3
+ import (
4
+ "time"
5
+ "context"
6
+ "errors"
7
+ "os"
8
+ "testing"
9
+
10
+ "{{goModule}}/internal/app/user/model"
11
+ "{{goModule}}/internal/shared/dberr"
12
+
13
+ "github.com/google/uuid"
14
+ "gorm.io/driver/postgres"
15
+ "gorm.io/gorm"
16
+ )
17
+
18
+ // repositoryDBForTest expects the test database schema to come from the same
19
+ // versioned SQL migrations used in production. Unit tests stay database-free;
20
+ // CI sets REQUIRE_TEST_DB=true so an unavailable/unmigrated database fails
21
+ // instead of becoming a false-green skip. Same pattern as every generated
22
+ // module's repository_test.go.
23
+ func repositoryDBForTest(t *testing.T) *gorm.DB {
24
+ t.Helper()
25
+ dsn := os.Getenv("TEST_DB_DSN")
26
+ if dsn == "" {
27
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
28
+ t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
29
+ }
30
+ t.Skip("repository integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
31
+ }
32
+
33
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
34
+ if err != nil {
35
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
36
+ t.Fatalf("open required test database: %v", err)
37
+ }
38
+ t.Skipf("repository integration test skipped: %v", err)
39
+ }
40
+
41
+ sqlDB, err := db.DB()
42
+ if err != nil {
43
+ t.Fatalf("get SQL database handle: %v", err)
44
+ }
45
+ if err := sqlDB.Ping(); err != nil {
46
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
47
+ t.Fatalf("ping required test database: %v", err)
48
+ }
49
+ t.Skipf("repository integration test skipped: %v", err)
50
+ }
51
+
52
+ tx := db.Begin()
53
+ if tx.Error != nil {
54
+ t.Fatalf("begin test transaction: %v", tx.Error)
55
+ }
56
+ t.Cleanup(func() {
57
+ if err := tx.Rollback().Error; err != nil {
58
+ t.Errorf("rollback test transaction: %v", err)
59
+ }
60
+ })
61
+ return tx
62
+ }
63
+
64
+ // CreateUserWithIdentity's whole reason to be a transaction: a user that
65
+ // exists with no way to log in is unreachable. If the identity insert fails,
66
+ // the user it was meant to arrive with must vanish too — a function-backed
67
+ // stub can't prove this, since it never runs a real insert against a real
68
+ // constraint. This forces exactly that: the user insert succeeds (it's a
69
+ // brand new row), but the identity insert collides with an existing Google
70
+ // identity's provider_uid.
71
+ func TestRepository_CreateUserWithIdentity_RollsBackBothOnIdentityConflict(t *testing.T) {
72
+ repo := NewRepository(repositoryDBForTest(t))
73
+ ctx := context.Background()
74
+
75
+ providerUID := "conflicting-provider-uid"
76
+ existing := &model.User{ID: uuid.New(), Email: "first@example.com"}
77
+ if err := repo.CreateUserWithIdentity(ctx, existing, &model.Identity{
78
+ ID: uuid.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID,
79
+ }); err != nil {
80
+ t.Fatalf("seed existing user+identity: %v", err)
81
+ }
82
+
83
+ blocked := &model.User{ID: uuid.New(), Email: "second@example.com"}
84
+ err := repo.CreateUserWithIdentity(ctx, blocked, &model.Identity{
85
+ ID: uuid.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID,
86
+ })
87
+ if !dberr.IsDuplicate(err) {
88
+ t.Fatalf("want a duplicate-key error from the conflicting provider_uid, got %v", err)
89
+ }
90
+
91
+ if _, findErr := repo.FindByID(ctx, blocked.ID); !errors.Is(findErr, gorm.ErrRecordNotFound) {
92
+ t.Fatalf("the new user must not survive a transaction whose identity insert failed, got %v", findErr)
93
+ }
94
+ }
95
+
96
+ // The unique index on email is what actually stops two accounts sharing a
97
+ // login — Register's "email already registered" response depends on the
98
+ // database enforcing it, not on an application-level check (which would
99
+ // race). Confirms the constraint is really there and that dberr recognizes
100
+ // the violation shape Postgres returns.
101
+ func TestRepository_CreateUserWithIdentity_DuplicateEmailIsDetectable(t *testing.T) {
102
+ repo := NewRepository(repositoryDBForTest(t))
103
+ ctx := context.Background()
104
+
105
+ email := "dup@example.com"
106
+ if err := repo.CreateUserWithIdentity(ctx, &model.User{ID: uuid.New(), Email: email}, &model.Identity{
107
+ ID: uuid.New(), Provider: model.ProviderLocal,
108
+ }); err != nil {
109
+ t.Fatalf("seed first user: %v", err)
110
+ }
111
+
112
+ err := repo.CreateUserWithIdentity(ctx, &model.User{ID: uuid.New(), Email: email}, &model.Identity{
113
+ ID: uuid.New(), Provider: model.ProviderLocal,
114
+ })
115
+ if !dberr.IsDuplicate(err) {
116
+ t.Fatalf("want a duplicate-key error for the reused email, got %v", err)
117
+ }
118
+ }
119
+
120
+ // The backoff is computed by Postgres inside one UPSERT, so this is the only
121
+ // place it can be checked — a fake can agree with itself about arithmetic the
122
+ // database would reject.
123
+ func TestRepository_LoginThrottle_BacksOffThenClears(t *testing.T) {
124
+ db := repositoryDBForTest(t)
125
+ repo := NewRepository(db)
126
+ ctx := context.Background()
127
+ key := "throttle-test-" + uuid.NewString()
128
+
129
+ // the free attempts leave no lock behind
130
+ for i := 0; i < 3; i++ {
131
+ if err := repo.RecordLoginFailure(ctx, key, 3, 15*time.Minute); err != nil {
132
+ t.Fatalf("record failure %d: %v", i+1, err)
133
+ }
134
+ until, err := repo.LoginLockedUntil(ctx, key)
135
+ if err != nil {
136
+ t.Fatalf("read lock: %v", err)
137
+ }
138
+ if !until.IsZero() {
139
+ t.Fatalf("attempt %d is within the free allowance but set a lock (%s)", i+1, until)
140
+ }
141
+ }
142
+
143
+ // the next one locks, and the one after that locks for longer
144
+ if err := repo.RecordLoginFailure(ctx, key, 3, 15*time.Minute); err != nil {
145
+ t.Fatalf("record failure 4: %v", err)
146
+ }
147
+ first, err := repo.LoginLockedUntil(ctx, key)
148
+ if err != nil || first.IsZero() {
149
+ t.Fatalf("expected a lock after exceeding the free allowance, got %s (err %v)", first, err)
150
+ }
151
+ if err := repo.RecordLoginFailure(ctx, key, 3, 15*time.Minute); err != nil {
152
+ t.Fatalf("record failure 5: %v", err)
153
+ }
154
+ second, err := repo.LoginLockedUntil(ctx, key)
155
+ if err != nil {
156
+ t.Fatalf("read lock: %v", err)
157
+ }
158
+ if !second.After(first) {
159
+ t.Fatalf("the backoff did not grow: %s then %s", first, second)
160
+ }
161
+
162
+ // a successful login wipes the slate
163
+ if err := repo.ClearLoginFailures(ctx, key); err != nil {
164
+ t.Fatalf("clear: %v", err)
165
+ }
166
+ cleared, err := repo.LoginLockedUntil(ctx, key)
167
+ if err != nil || !cleared.IsZero() {
168
+ t.Fatalf("expected no lock after clearing, got %s (err %v)", cleared, err)
169
+ }
170
+ }
171
+
172
+ // maxLock is a ceiling, not a suggestion: without it the doubling reaches
173
+ // "locked for a week" in about twenty attempts.
174
+ func TestRepository_LoginThrottle_RespectsTheCeiling(t *testing.T) {
175
+ db := repositoryDBForTest(t)
176
+ repo := NewRepository(db)
177
+ ctx := context.Background()
178
+ key := "throttle-cap-" + uuid.NewString()
179
+
180
+ for i := 0; i < 12; i++ {
181
+ if err := repo.RecordLoginFailure(ctx, key, 0, 5*time.Second); err != nil {
182
+ t.Fatalf("record failure %d: %v", i+1, err)
183
+ }
184
+ }
185
+ until, err := repo.LoginLockedUntil(ctx, key)
186
+ if err != nil {
187
+ t.Fatalf("read lock: %v", err)
188
+ }
189
+ if remaining := time.Until(until); remaining > 30*time.Second {
190
+ t.Fatalf("lock ran past its 5s ceiling: %s remaining", remaining)
191
+ }
192
+ }
@@ -34,14 +34,18 @@ type repository interface {
34
34
  CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error
35
35
  CreateIdentity(ctx context.Context, i *model.Identity) error
36
36
  UpdateIdentity(ctx context.Context, i *model.Identity) error
37
+ LoginLockedUntil(ctx context.Context, key string) (time.Time, error)
38
+ RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error
39
+ ClearLoginFailures(ctx context.Context, key string) error
37
40
  // go-scaffold:user-repository-interface
38
41
  }
39
42
 
40
43
  // mailer = what the service needs to send an email — satisfied by
41
44
  // platform/mail's AsyncClient (enqueues onto cmd/worker instead of blocking
42
- // the request on SMTP).
45
+ // the request on SMTP). Takes ctx so the enqueue can join the caller's
46
+ // transaction where the queue backend supports it.
43
47
  type mailer interface {
44
- Send(to, subject, body string) error
48
+ Send(ctx context.Context, to, subject, body string) error
45
49
  }
46
50
 
47
51
  // go-scaffold:user-interfaces
@@ -90,6 +94,52 @@ func NewService(
90
94
  }
91
95
  }
92
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)
141
+ }
142
+
93
143
  func (s *Service) Register(ctx context.Context, in registerInput) (*authResponse, error) {
94
144
  hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
95
145
  if err != nil {
@@ -97,7 +147,7 @@ func (s *Service) Register(ctx context.Context, in registerInput) (*authResponse
97
147
  }
98
148
  hashStr := string(hash)
99
149
 
100
- u := &model.User{ID: id.New(), Email: in.Email, Name: in.Name}
150
+ u := &model.User{ID: id.New(), Email: normalizeEmail(in.Email), Name: in.Name}
101
151
  i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
102
152
  if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
103
153
  if dberr.IsDuplicate(err) {
@@ -112,16 +162,35 @@ func (s *Service) Register(ctx context.Context, in registerInput) (*authResponse
112
162
  }
113
163
 
114
164
  func (s *Service) Login(ctx context.Context, in loginInput) (*authResponse, error) {
115
- u, err := s.repo.FindByEmail(ctx, in.Email)
116
- if err != nil {
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
+ }
117
177
  return nil, errInvalidCredentials()
118
178
  }
179
+
180
+ u, err := s.repo.FindByEmail(ctx, normalizeEmail(in.Email))
181
+ if err != nil {
182
+ return fail()
183
+ }
119
184
  ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
120
185
  if err != nil || ident.PasswordHash == nil {
121
- return nil, errInvalidCredentials()
186
+ return fail()
122
187
  }
123
188
  if err := bcrypt.CompareHashAndPassword([]byte(*ident.PasswordHash), []byte(in.Password)); err != nil {
124
- return nil, errInvalidCredentials()
189
+ return fail()
190
+ }
191
+
192
+ if err := s.repo.ClearLoginFailures(ctx, key); err != nil {
193
+ slog.Error("clear login failures", "error", err)
125
194
  }
126
195
  return s.issueTokens(ctx, u)
127
196
  }
@@ -206,7 +275,19 @@ func (s *Service) List(ctx context.Context, limit, offset int) ([]model.User, er
206
275
  // enumerate registered accounts. Only if the email resolves to a local
207
276
  // (password-based) identity does it actually issue+email a reset token.
208
277
  func (s *Service) ForgotPassword(ctx context.Context, email string) error {
209
- u, err := s.repo.FindByEmail(ctx, email)
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))
210
291
  if err != nil {
211
292
  return nil
212
293
  }
@@ -224,7 +305,7 @@ func (s *Service) ForgotPassword(ctx context.Context, email string) error {
224
305
  }
225
306
 
226
307
  link := s.resetURL + "?token=" + raw
227
- if err := s.mailer.Send(u.Email, "Reset your password", "Reset your password: "+link); err != nil {
308
+ if err := s.mailer.Send(ctx, u.Email, "Reset your password", "Reset your password: "+link); err != nil {
228
309
  // token's already stored — a mail failure shouldn't fail the request,
229
310
  // just get logged so it's visible operationally.
230
311
  slog.Error("send password reset email", "error", err)
@@ -278,7 +359,7 @@ func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
278
359
  return
279
360
  }
280
361
  link := s.verifyURL + "?token=" + raw
281
- if err := s.mailer.Send(u.Email, "Verify your email", "Verify your email: "+link); err != nil {
362
+ if err := s.mailer.Send(ctx, u.Email, "Verify your email", "Verify your email: "+link); err != nil {
282
363
  slog.Error("send email verification email", "error", err)
283
364
  }
284
365
  }
@@ -318,14 +399,16 @@ func (s *Service) VerifyEmail(ctx context.Context, rawToken string) error {
318
399
  return s.repo.UpdateUser(ctx, u)
319
400
  }
320
401
 
321
- // GoogleLoginURL signs a short-lived CSRF state token and builds the
322
- // redirect URL stateless, no server-side row for the state (see jwt.go).
323
- func (s *Service) GoogleLoginURL() (string, error) {
324
- state, err := s.issueOAuthState()
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()
325
408
  if err != nil {
326
- return "", apperror.NewInternal()
409
+ return "", "", apperror.NewInternal()
327
410
  }
328
- return s.googleOAuth.AuthCodeURL(state), nil
411
+ return s.googleOAuth.AuthCodeURL(state), nonce, nil
329
412
  }
330
413
 
331
414
  type googleUserInfo struct {
@@ -336,8 +419,8 @@ type googleUserInfo struct {
336
419
  Picture string `json:"picture"`
337
420
  }
338
421
 
339
- func (s *Service) GoogleCallback(ctx context.Context, code, state string) (*authResponse, error) {
340
- if err := s.verifyOAuthState(state); err != nil {
422
+ func (s *Service) GoogleCallback(ctx context.Context, code, state, nonce string) (*authResponse, error) {
423
+ if err := s.verifyOAuthState(state, nonce); err != nil {
341
424
  return nil, err
342
425
  }
343
426
  tok, err := s.googleOAuth.Exchange(ctx, code)
@@ -376,8 +459,9 @@ func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInf
376
459
  }
377
460
 
378
461
  providerUID := info.Sub
462
+ email := normalizeEmail(info.Email)
379
463
  if info.EmailVerified {
380
- if u, err := s.repo.FindByEmail(ctx, info.Email); err == nil {
464
+ if u, err := s.repo.FindByEmail(ctx, email); err == nil {
381
465
  ident := &model.Identity{ID: id.New(), UserID: u.ID, Provider: model.ProviderGoogle, ProviderUID: &providerUID}
382
466
  if err := s.repo.CreateIdentity(ctx, ident); err != nil {
383
467
  return nil, apperror.NewInternal()
@@ -386,7 +470,7 @@ func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInf
386
470
  }
387
471
  }
388
472
 
389
- u := &model.User{ID: id.New(), Email: info.Email, Name: info.Name, AvatarURL: info.Picture, EmailVerified: info.EmailVerified}
473
+ u := &model.User{ID: id.New(), Email: email, Name: info.Name, AvatarURL: info.Picture, EmailVerified: info.EmailVerified}
390
474
  ident := &model.Identity{ID: id.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID}
391
475
  if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
392
476
  if dberr.IsDuplicate(err) {
@@ -402,7 +486,7 @@ func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInf
402
486
  // password on every deploy), otherwise a new local user+identity is
403
487
  // created. Used by cmd/seed only — nothing in the HTTP API calls this.
404
488
  func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*model.User, error) {
405
- email = strings.ToLower(strings.TrimSpace(email))
489
+ email = normalizeEmail(email)
406
490
  if u, err := s.repo.FindByEmail(ctx, email); err == nil {
407
491
  return u, nil
408
492
  }
@@ -1,6 +1,7 @@
1
1
  package user
2
2
 
3
3
  import (
4
+ "fmt"
4
5
  "context"
5
6
  "errors"
6
7
  "net/http"
@@ -15,7 +16,7 @@ import (
15
16
  "gorm.io/gorm"
16
17
  )
17
18
 
18
- // fakeTokenStore = in-memory mock of tokenStore, mirroring redisTokenStore's
19
+ // fakeTokenStore = in-memory mock of tokenStore, mirroring the real store's
19
20
  // three-map shape (active / used-tombstone / per-user session set) closely
20
21
  // enough to exercise rotation + reuse-detection without a real Redis.
21
22
  type fakeTokenStore struct {
@@ -82,6 +83,37 @@ func (f *fakeTokenStore) ConsumeEmailVerifyToken(context.Context, string) (uuid.
82
83
  // token), the rest just satisfy the interface.
83
84
  type fakeRepo struct {
84
85
  user *model.User
86
+ failures map[string]int
87
+ lockedUntil map[string]time.Time
88
+ }
89
+
90
+ // throttle: the fake keeps the counter in memory so the lockout path can be
91
+ // exercised without a database. Same shape as the real one — a key, a count,
92
+ // and a time — because what the tests care about is when Service decides to
93
+ // stop asking bcrypt anything.
94
+ func (f *fakeRepo) LoginLockedUntil(_ context.Context, key string) (time.Time, error) {
95
+ return f.lockedUntil[key], nil
96
+ }
97
+
98
+ func (f *fakeRepo) RecordLoginFailure(_ context.Context, key string, freeAttempts int, maxLock time.Duration) error {
99
+ if f.lockedUntil == nil {
100
+ f.lockedUntil = map[string]time.Time{}
101
+ }
102
+ f.failures[key]++
103
+ if f.failures[key] > freeAttempts {
104
+ lock := time.Duration(1<<uint(f.failures[key]-freeAttempts-1)) * time.Second
105
+ if lock > maxLock {
106
+ lock = maxLock
107
+ }
108
+ f.lockedUntil[key] = time.Now().Add(lock)
109
+ }
110
+ return nil
111
+ }
112
+
113
+ func (f *fakeRepo) ClearLoginFailures(_ context.Context, key string) error {
114
+ delete(f.failures, key)
115
+ delete(f.lockedUntil, key)
116
+ return nil
85
117
  }
86
118
 
87
119
  func (f *fakeRepo) FindByEmail(context.Context, string) (*model.User, error) {
@@ -107,7 +139,7 @@ func (f *fakeRepo) UpdateIdentity(context.Context, *model.Identity) error
107
139
 
108
140
  type fakeMailer struct{}
109
141
 
110
- func (fakeMailer) Send(string, string, string) error { return nil }
142
+ func (fakeMailer) Send(context.Context, string, string, string) error { return nil }
111
143
 
112
144
  // go-scaffold:user-service-test-types
113
145
 
@@ -235,3 +267,50 @@ func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T)
235
267
  t.Fatal("expected a different user's session to be untouched by LogoutAll")
236
268
  }
237
269
  }
270
+
271
+ // The control that actually stops credential stuffing: the counter follows the
272
+ // account, so spreading attempts across a proxy pool doesn't help.
273
+ func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {
274
+ repo := &fakeRepo{
275
+ user: &model.User{ID: uuid.New(), Email: "a@example.com"},
276
+ failures: map[string]int{},
277
+ }
278
+ svc := newTestService(repo, newFakeTokenStore())
279
+ in := loginInput{Email: "a@example.com", Password: "wrong"}
280
+
281
+ // the free attempts answer "wrong password", not "locked"
282
+ for i := 0; i < loginFreeAttempts; i++ {
283
+ if _, err := svc.Login(context.Background(), in); err == nil {
284
+ t.Fatalf("attempt %d: expected a failure", i+1)
285
+ } else if code(err) != "AUTH_INVALID_CREDENTIALS" {
286
+ t.Fatalf("attempt %d: expected AUTH_INVALID_CREDENTIALS, got %s", i+1, code(err))
287
+ }
288
+ }
289
+
290
+ // the next one trips the lock...
291
+ if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_INVALID_CREDENTIALS" {
292
+ t.Fatalf("the attempt that trips the lock still answers as a bad password, got %s", code(err))
293
+ }
294
+ // ...and everything after it is refused before any password is checked
295
+ if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_TOO_MANY_ATTEMPTS" {
296
+ t.Fatalf("expected AUTH_TOO_MANY_ATTEMPTS once locked, got %s", code(err))
297
+ }
298
+ }
299
+
300
+ // A locked login must not lock the reset that fixes it — the person who forgot
301
+ // their password is exactly the person who trips the login counter.
302
+ func TestService_ForgotPassword_HasItsOwnCounter(t *testing.T) {
303
+ if throttleKey("login", "a@example.com") == throttleKey("pwreset", "a@example.com") {
304
+ t.Fatal("login and password-reset share a throttle key; a locked login would block recovery")
305
+ }
306
+ }
307
+
308
+ // code pulls the AppError code out, so a test asserting on behaviour doesn't
309
+ // have to care how the error is wrapped.
310
+ func code(err error) string {
311
+ var appErr *apperror.AppError
312
+ if errors.As(err, &appErr) {
313
+ return appErr.Code
314
+ }
315
+ return fmt.Sprintf("%v", err)
316
+ }
@@ -5,19 +5,17 @@ import (
5
5
  "time"
6
6
 
7
7
  "github.com/google/uuid"
8
- "github.com/redis/go-redis/v9"
9
8
  )
10
9
 
11
- const (
12
- refreshKeyPrefix = "user:refresh:" // +hash -> userID, TTL = refreshTTL
13
- refreshUserKeyPrefix = "user:refresh:user:" // +userID -> SET of active token hashes
14
- refreshUsedKeyPrefix = "user:refresh:used:" // +hash -> userID, TTL = refreshTTL (reuse-detection tombstone)
15
- pwresetKeyPrefix = "user:pwreset:" // +hash -> userID, TTL = resetTTL, GETDEL on consume
16
- emailVerifyKeyPrefix = "user:emailverify:" // +hash -> userID, TTL = emailVerifyTTL, GETDEL on consume
17
- )
18
-
19
- // tokenStore is what Service needs from Redis for refresh tokens — declared
20
- // consumer-side so it can be faked in tests without a real Redis.
10
+ // tokenStore is the short-lived-token surface Service needs — declared
11
+ // consumer-side so it can be faked in tests, and so the backing store is a
12
+ // choice rather than a hard dependency. Two implementations ship with the
13
+ // scaffold and exactly one is written into a project:
14
+ //
15
+ // `add auth --store postgres` -> tokenstore_pg.go (default, no extra service)
16
+ // `add auth --store redis` -> tokenstore_redis.go (exact across pods)
17
+ //
18
+ // Service never learns which one it got.
21
19
  type tokenStore interface {
22
20
  SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
23
21
  GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
@@ -30,130 +28,3 @@ type tokenStore interface {
30
28
  SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
31
29
  ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
32
30
  }
33
-
34
- type redisTokenStore struct {
35
- rdb *redis.Client
36
- }
37
-
38
- func NewRedisTokenStore(rdb *redis.Client) *redisTokenStore {
39
- return &redisTokenStore{rdb: rdb}
40
- }
41
-
42
- func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
43
- pipe := s.rdb.TxPipeline()
44
- pipe.Set(ctx, refreshKeyPrefix+tokenHash, userID.String(), ttl)
45
- pipe.SAdd(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
46
- _, err := pipe.Exec(ctx)
47
- return err
48
- }
49
-
50
- func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
51
- raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
52
- if err == redis.Nil {
53
- return uuid.Nil, false, nil
54
- }
55
- if err != nil {
56
- return uuid.Nil, false, err
57
- }
58
- id, err := uuid.Parse(raw)
59
- if err != nil {
60
- return uuid.Nil, false, err
61
- }
62
- return id, true, nil
63
- }
64
-
65
- func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
66
- pipe := s.rdb.TxPipeline()
67
- pipe.Del(ctx, refreshKeyPrefix+tokenHash)
68
- pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
69
- _, err := pipe.Exec(ctx)
70
- return err
71
- }
72
-
73
- // RevokeAllRefreshTokens walks the per-user session set and deletes every
74
- // active refresh token for that user — used when reuse of an already-rotated
75
- // token is detected (see Service.Refresh): that means the raw token leaked,
76
- // so every session, not just the replayed one, is treated as compromised.
77
- //
78
- // ponytail: the per-user set has no per-member TTL cleanup of its own — a
79
- // member outlives its key's TTL as a stale entry until the next revoke or
80
- // rotation touches it. Self-heals over time; revisit if a single user's
81
- // session count grows large enough to matter.
82
- func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
83
- setKey := refreshUserKeyPrefix + userID.String()
84
- hashes, err := s.rdb.SMembers(ctx, setKey).Result()
85
- if err != nil {
86
- return err
87
- }
88
- if len(hashes) == 0 {
89
- return nil
90
- }
91
- pipe := s.rdb.TxPipeline()
92
- for _, h := range hashes {
93
- pipe.Del(ctx, refreshKeyPrefix+h)
94
- }
95
- pipe.Del(ctx, setKey)
96
- _, err = pipe.Exec(ctx)
97
- return err
98
- }
99
-
100
- func (s *redisTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
101
- return s.rdb.Set(ctx, refreshUsedKeyPrefix+tokenHash, userID.String(), ttl).Err()
102
- }
103
-
104
- func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
105
- raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
106
- if err == redis.Nil {
107
- return uuid.Nil, false, nil
108
- }
109
- if err != nil {
110
- return uuid.Nil, false, err
111
- }
112
- id, err := uuid.Parse(raw)
113
- if err != nil {
114
- return uuid.Nil, false, err
115
- }
116
- return id, true, nil
117
- }
118
-
119
- func (s *redisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
120
- return s.rdb.Set(ctx, pwresetKeyPrefix+tokenHash, userID.String(), ttl).Err()
121
- }
122
-
123
- // ConsumePasswordResetToken is one-time-use by construction: GETDEL is
124
- // atomic, so a token can't be raced into being consumed twice.
125
- func (s *redisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
126
- raw, err := s.rdb.GetDel(ctx, pwresetKeyPrefix+tokenHash).Result()
127
- if err == redis.Nil {
128
- return uuid.Nil, false, nil
129
- }
130
- if err != nil {
131
- return uuid.Nil, false, err
132
- }
133
- id, err := uuid.Parse(raw)
134
- if err != nil {
135
- return uuid.Nil, false, err
136
- }
137
- return id, true, nil
138
- }
139
-
140
- func (s *redisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
141
- return s.rdb.Set(ctx, emailVerifyKeyPrefix+tokenHash, userID.String(), ttl).Err()
142
- }
143
-
144
- // ConsumeEmailVerifyToken is one-time-use by construction, same as
145
- // ConsumePasswordResetToken — GETDEL is atomic.
146
- func (s *redisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
147
- raw, err := s.rdb.GetDel(ctx, emailVerifyKeyPrefix+tokenHash).Result()
148
- if err == redis.Nil {
149
- return uuid.Nil, false, nil
150
- }
151
- if err != nil {
152
- return uuid.Nil, false, err
153
- }
154
- id, err := uuid.Parse(raw)
155
- if err != nil {
156
- return uuid.Nil, false, err
157
- }
158
- return id, true, nil
159
- }