@nakedev/go-scaffold 0.5.4 → 0.8.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 (50) hide show
  1. package/README.md +16 -2
  2. package/dist/commands/auth.js +6 -1
  3. package/dist/commands/check.js +5 -1
  4. package/dist/commands/method.js +27 -1
  5. package/dist/index.js +20 -6
  6. package/dist/prompts/auth-wizard.js +33 -1
  7. package/dist/templates/create-manifest.js +8 -0
  8. package/dist/templates/rbac-manifest.js +1 -0
  9. package/dist/utils/hexagonal-method-patcher.js +79 -20
  10. package/package.json +1 -1
  11. package/templates/add/auth/docs/login.yaml.hbs +9 -1
  12. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +12 -2
  13. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +1 -0
  14. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +97 -13
  15. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +169 -5
  16. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +44 -2
  17. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +2 -1
  18. package/templates/add/auth/internal/app/user/application/service.go.hbs +1 -1
  19. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +71 -12
  20. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +5 -4
  21. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -0
  22. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +21 -3
  23. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  24. package/templates/add/rbac/docs/roles.yaml.hbs +2 -1
  25. package/templates/add/rbac/docs/users.yaml.hbs +2 -1
  26. package/templates/add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs +52 -0
  27. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +18 -13
  28. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +21 -4
  29. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +19 -11
  30. package/templates/add/rbac/internal/app/role/application/service.go.hbs +6 -6
  31. package/templates/add/rbac/internal/app/role/application/service_test.go.hbs +19 -1
  32. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +13 -1
  33. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +1 -1
  34. package/templates/create/base/AGENTS.md.hbs +9 -1
  35. package/templates/create/base/README.md.hbs +2 -1
  36. package/templates/create/base/internal/shared/dbq/dbq.go.hbs +47 -0
  37. package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +66 -0
  38. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +19 -2
  39. package/templates/create/features/docs/architecture.md.hbs +9 -6
  40. package/templates/create/features/docs/common/parameters.yaml.hbs +5 -0
  41. package/templates/create/features/docs/common/schemas.yaml.hbs +1 -0
  42. package/templates/create/features/docs/patterns.md.hbs +60 -0
  43. package/templates/generate/module/docs/collection.yaml.hbs +2 -1
  44. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +8 -3
  45. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +27 -4
  46. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +3 -3
  47. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +3 -3
  48. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +3 -3
  49. package/templates/generate/module/hexagonal/application/service_test.go.hbs +4 -3
  50. package/templates/generate/module/hexagonal/ports/repository.go.hbs +18 -2
@@ -9,6 +9,7 @@ import (
9
9
 
10
10
  "{{goModule}}/internal/app/user/domain"
11
11
  "{{goModule}}/internal/app/user/ports"
12
+ "{{goModule}}/internal/shared/dbq"
12
13
  "{{goModule}}/internal/shared/dberr"
13
14
  "{{goModule}}/internal/shared/tx"
14
15
 
@@ -76,12 +77,39 @@ func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
76
77
  }))
77
78
  }
78
79
 
79
- func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.User, error) {
80
+ // FindAll answers one page of the filter and how many accounts it matched
81
+ // altogether. Two queries for that, because a count over a LIMITed query would
82
+ // only ever count the page.
83
+ func (r *Repository) FindAll(ctx context.Context, filter ports.ListFilter) ([]domain.User, int64, error) {
84
+ // A closure rather than one *gorm.DB reused twice: conditions accumulate
85
+ // on the value, so the count would silently inherit the page's LIMIT.
86
+ matching := func() *gorm.DB {
87
+ q := tx.From(ctx, r.db).WithContext(ctx).Model(&User{})
88
+ if filter.Search != "" {
89
+ // The address lives one table over, so its leg of the OR is a
90
+ // subquery on user_emails rather than a join — a join would
91
+ // multiply a user by their addresses and break both the page size
92
+ // and the count.
93
+ pattern := dbq.LikePattern(filter.Search)
94
+ primaryEmails := tx.From(ctx, r.db).WithContext(ctx).Model(&UserEmail{}).
95
+ Select("user_id").Where("is_primary = true AND email ILIKE ?", pattern)
96
+ q = q.Where("(name ILIKE ? OR id IN (?))", pattern, primaryEmails)
97
+ }
98
+ return q
99
+ }
100
+
101
+ var total int64
102
+ if err := matching().Count(&total).Error; err != nil {
103
+ return nil, 0, persistenceError(err)
104
+ }
105
+
80
106
  var rows []User
81
- err := tx.From(ctx, r.db).WithContext(ctx).
82
- Order("created_at desc").Limit(limit).Offset(offset).Find(&rows).Error
107
+ // id breaks ties in created_at: without it two accounts created in the
108
+ // same instant can swap between page 1 and page 2, showing one twice and
109
+ // hiding the other.
110
+ err := matching().Order("created_at desc, id").Limit(filter.Limit).Offset(filter.Offset).Find(&rows).Error
83
111
  if err != nil {
84
- return nil, persistenceError(err)
112
+ return nil, 0, persistenceError(err)
85
113
  }
86
114
  ids := make([]uuid.UUID, len(rows))
87
115
  for i := range rows {
@@ -91,7 +119,7 @@ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.U
91
119
  if len(ids) > 0 {
92
120
  if err := tx.From(ctx, r.db).WithContext(ctx).
93
121
  Where("user_id IN ? AND is_primary = true", ids).Find(&emailRows).Error; err != nil {
94
- return nil, persistenceError(err)
122
+ return nil, 0, persistenceError(err)
95
123
  }
96
124
  }
97
125
  emails := make(map[uuid.UUID]*UserEmail, len(emailRows))
@@ -102,7 +130,7 @@ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.U
102
130
  for i := range rows {
103
131
  items[i] = *toDomainUserWithEmail(&rows[i], emails[rows[i].ID])
104
132
  }
105
- return items, nil
133
+ return items, total, nil
106
134
  }
107
135
 
108
136
  func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
@@ -254,23 +282,79 @@ func (r *Repository) LoginLockedUntil(ctx context.Context, key string) (time.Tim
254
282
  }
255
283
 
256
284
  // RecordLoginFailure uses one SQL statement so concurrent attempts cannot
257
- // both read and write the same backoff value.
258
- func (r *Repository) RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error {
285
+ // both read and write the same counter.
286
+ //
287
+ // attempt is an opaque fingerprint of the password that was submitted, or ""
288
+ // for callers that have no password to fingerprint. Repeating the fingerprint
289
+ // that is already on the row leaves the counter and the lock exactly where
290
+ // they are: a browser replaying one saved, outdated password costs a single
291
+ // attempt instead of all of them, while a guessing attack — which submits
292
+ // something different every time — is untouched. "" never matches, so those
293
+ // callers keep counting normally.
294
+ //
295
+ // The leading DELETE is the table's whole retention policy. A row for a handle
296
+ // that never logs in successfully — a typo'd address, a sprayed list — is
297
+ // never cleared by ClearLoginFailures, and it holds a fingerprint derived from
298
+ // a real password, so it cannot simply sit there. A failed login is the only
299
+ // moment this table is written, which makes it the only moment it can clean
300
+ // itself, and no scheduler has to exist for that to work.
301
+ //
302
+ // The current key is excluded from the sweep on purpose: the DELETE and the
303
+ // UPSERT run on the same snapshot and cannot see each other, so letting them
304
+ // meet on one row makes the outcome depend on which one wins.
305
+ {{#if fixedLockout}}//
306
+ // A failure inside window continues the count, one after it starts over. With
307
+ // window longer than the lock, sitting out a lock resets nothing: the next
308
+ // wrong password trips it again.
309
+ func (r *Repository) RecordLoginFailure(ctx context.Context, key, attempt string, maxAttempts int, lockFor, window time.Duration) error {
310
+ stale := window.Seconds()
311
+ err := tx.From(ctx, r.db).WithContext(ctx).Exec(`
312
+ WITH gc AS (
313
+ DELETE FROM user_svc.login_throttle
314
+ WHERE email_hash <> ? AND updated_at < now() - interval '1 day'
315
+ )
316
+ INSERT INTO user_svc.login_throttle AS t (email_hash, failures, last_attempt, locked_until, updated_at)
317
+ VALUES (?, 1, NULLIF(?, ''), NULL, now())
318
+ ON CONFLICT (email_hash) DO UPDATE SET
319
+ failures = CASE
320
+ WHEN t.last_attempt = ? THEN t.failures
321
+ WHEN t.updated_at < now() - make_interval(secs => ?) THEN 1
322
+ ELSE t.failures + 1
323
+ END,
324
+ locked_until = CASE
325
+ WHEN t.last_attempt = ? THEN t.locked_until
326
+ WHEN t.updated_at < now() - make_interval(secs => ?) THEN NULL
327
+ WHEN t.failures + 1 >= ? THEN now() + make_interval(secs => ?)
328
+ ELSE NULL
329
+ END,
330
+ last_attempt = NULLIF(?, ''),
331
+ updated_at = now()`,
332
+ key, key, attempt, attempt, stale, attempt, stale, maxAttempts, lockFor.Seconds(), attempt).Error
333
+ return persistenceError(err)
334
+ }
335
+
336
+ {{else}}func (r *Repository) RecordLoginFailure(ctx context.Context, key, attempt string, freeAttempts int, maxLock time.Duration) error {
259
337
  err := tx.From(ctx, r.db).WithContext(ctx).Exec(`
260
- INSERT INTO user_svc.login_throttle AS t (email_hash, failures, locked_until, updated_at)
261
- VALUES (?, 1, NULL, now())
338
+ WITH gc AS (
339
+ DELETE FROM user_svc.login_throttle
340
+ WHERE email_hash <> ? AND updated_at < now() - interval '1 day'
341
+ )
342
+ INSERT INTO user_svc.login_throttle AS t (email_hash, failures, last_attempt, locked_until, updated_at)
343
+ VALUES (?, 1, NULLIF(?, ''), NULL, now())
262
344
  ON CONFLICT (email_hash) DO UPDATE SET
263
- failures = t.failures + 1,
345
+ failures = CASE WHEN t.last_attempt = ? THEN t.failures ELSE t.failures + 1 END,
264
346
  locked_until = CASE
347
+ WHEN t.last_attempt = ? THEN t.locked_until
265
348
  WHEN t.failures + 1 <= ? THEN NULL
266
349
  ELSE now() + make_interval(secs => least(power(2, t.failures + 1 - ?), ?))
267
350
  END,
351
+ last_attempt = NULLIF(?, ''),
268
352
  updated_at = now()`,
269
- key, freeAttempts, freeAttempts, maxLock.Seconds()).Error
353
+ key, key, attempt, attempt, attempt, freeAttempts, freeAttempts, maxLock.Seconds(), attempt).Error
270
354
  return persistenceError(err)
271
355
  }
272
356
 
273
- func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
357
+ {{/if}}func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
274
358
  err := tx.From(ctx, r.db).WithContext(ctx).
275
359
  Where("email_hash = ?", key).
276
360
  Delete(&LoginThrottle{}).Error
@@ -2,6 +2,7 @@ package postgres
2
2
 
3
3
  import (
4
4
  "context"
5
+ "fmt"
5
6
  "errors"
6
7
  "os"
7
8
  "testing"
@@ -119,7 +120,91 @@ func TestRepository_CreateUserWithIdentity_DuplicateEmailIsDetectable(t *testing
119
120
  }
120
121
  }
121
122
 
122
- // The backoff is computed by Postgres inside one UPSERT, so this is the only
123
+ // throttle records one failed attempt, hiding this project's lockout
124
+ // parameters from the tests that do not care about them. lockAt is the
125
+ // attempt number that should set a lock.
126
+ {{#if fixedLockout}}func throttle(ctx context.Context, repo *Repository, key, attempt string, lockAt int) error {
127
+ return repo.RecordLoginFailure(ctx, key, attempt, lockAt, 5*time.Minute, 15*time.Minute)
128
+ }
129
+ {{else}}func throttle(ctx context.Context, repo *Repository, key, attempt string, lockAt int) error {
130
+ return repo.RecordLoginFailure(ctx, key, attempt, lockAt-1, 15*time.Minute)
131
+ }
132
+ {{/if}}
133
+ {{#if fixedLockout}}// The counter is computed by Postgres inside one UPSERT, so this is the only
134
+ // place it can be checked — a fake can agree with itself about arithmetic the
135
+ // database would reject.
136
+ func TestRepository_LoginThrottle_LocksAtTheThresholdThenClears(t *testing.T) {
137
+ db := repositoryDBForTest(t)
138
+ repo := NewRepository(db)
139
+ ctx := context.Background()
140
+ key := "throttle-test-" + uuid.NewString()
141
+
142
+ // everything below the threshold leaves no lock behind
143
+ for i := 0; i < 2; i++ {
144
+ if err := throttle(ctx, repo, key, fmt.Sprintf("attempt-%d", i), 3); err != nil {
145
+ t.Fatalf("record failure %d: %v", i+1, err)
146
+ }
147
+ until, err := repo.LoginLockedUntil(ctx, key)
148
+ if err != nil {
149
+ t.Fatalf("read lock: %v", err)
150
+ }
151
+ if !until.IsZero() {
152
+ t.Fatalf("attempt %d is below the threshold but set a lock (%s)", i+1, until)
153
+ }
154
+ }
155
+
156
+ // the attempt that reaches it locks, for the configured span
157
+ if err := throttle(ctx, repo, key, "attempt-3", 3); err != nil {
158
+ t.Fatalf("record failure 3: %v", err)
159
+ }
160
+ until, err := repo.LoginLockedUntil(ctx, key)
161
+ if err != nil {
162
+ t.Fatalf("read lock: %v", err)
163
+ }
164
+ if remaining := time.Until(until); remaining < 4*time.Minute || remaining > 5*time.Minute {
165
+ t.Fatalf("expected a ~5 minute lock, got %s remaining", remaining)
166
+ }
167
+
168
+ // a successful login wipes the slate
169
+ if err := repo.ClearLoginFailures(ctx, key); err != nil {
170
+ t.Fatalf("clear: %v", err)
171
+ }
172
+ cleared, err := repo.LoginLockedUntil(ctx, key)
173
+ if err != nil || !cleared.IsZero() {
174
+ t.Fatalf("expected no lock after clearing, got %s (err %v)", cleared, err)
175
+ }
176
+ }
177
+
178
+ // The third knob: without it the count would only ever be cleared by a
179
+ // successful login, so a typo from last week would still count towards today's
180
+ // lock. now() is frozen inside this test's transaction, so the wait is
181
+ // simulated by backdating the row rather than by sleeping.
182
+ func TestRepository_LoginThrottle_CountExpiresAfterAQuietWindow(t *testing.T) {
183
+ db := repositoryDBForTest(t)
184
+ repo := NewRepository(db)
185
+ ctx := context.Background()
186
+ key := "throttle-window-" + uuid.NewString()
187
+
188
+ if err := throttle(ctx, repo, key, "first-attempt", 2); err != nil {
189
+ t.Fatalf("record first failure: %v", err)
190
+ }
191
+ if err := db.Exec(`UPDATE user_svc.login_throttle SET updated_at = now() - interval '16 minutes' WHERE email_hash = ?`, key).Error; err != nil {
192
+ t.Fatalf("backdate the row: %v", err)
193
+ }
194
+ if err := throttle(ctx, repo, key, "second-attempt", 2); err != nil {
195
+ t.Fatalf("record second failure: %v", err)
196
+ }
197
+
198
+ until, err := repo.LoginLockedUntil(ctx, key)
199
+ if err != nil {
200
+ t.Fatalf("read lock: %v", err)
201
+ }
202
+ if !until.IsZero() {
203
+ t.Fatalf("the failure after a quiet window counted as the second, not the first: locked until %s", until)
204
+ }
205
+ }
206
+
207
+ {{else}}// The backoff is computed by Postgres inside one UPSERT, so this is the only
123
208
  // place it can be checked — a fake can agree with itself about arithmetic the
124
209
  // database would reject.
125
210
  func TestRepository_LoginThrottle_BacksOffThenClears(t *testing.T) {
@@ -130,7 +215,7 @@ func TestRepository_LoginThrottle_BacksOffThenClears(t *testing.T) {
130
215
 
131
216
  // the free attempts leave no lock behind
132
217
  for i := 0; i < 3; i++ {
133
- if err := repo.RecordLoginFailure(ctx, key, 3, 15*time.Minute); err != nil {
218
+ if err := repo.RecordLoginFailure(ctx, key, fmt.Sprintf("attempt-%d", i), 3, 15*time.Minute); err != nil {
134
219
  t.Fatalf("record failure %d: %v", i+1, err)
135
220
  }
136
221
  until, err := repo.LoginLockedUntil(ctx, key)
@@ -143,14 +228,14 @@ func TestRepository_LoginThrottle_BacksOffThenClears(t *testing.T) {
143
228
  }
144
229
 
145
230
  // the next one locks, and the one after that locks for longer
146
- if err := repo.RecordLoginFailure(ctx, key, 3, 15*time.Minute); err != nil {
231
+ if err := repo.RecordLoginFailure(ctx, key, "attempt-4", 3, 15*time.Minute); err != nil {
147
232
  t.Fatalf("record failure 4: %v", err)
148
233
  }
149
234
  first, err := repo.LoginLockedUntil(ctx, key)
150
235
  if err != nil || first.IsZero() {
151
236
  t.Fatalf("expected a lock after exceeding the free allowance, got %s (err %v)", first, err)
152
237
  }
153
- if err := repo.RecordLoginFailure(ctx, key, 3, 15*time.Minute); err != nil {
238
+ if err := repo.RecordLoginFailure(ctx, key, "attempt-5", 3, 15*time.Minute); err != nil {
154
239
  t.Fatalf("record failure 5: %v", err)
155
240
  }
156
241
  second, err := repo.LoginLockedUntil(ctx, key)
@@ -180,7 +265,7 @@ func TestRepository_LoginThrottle_RespectsTheCeiling(t *testing.T) {
180
265
  key := "throttle-cap-" + uuid.NewString()
181
266
 
182
267
  for i := 0; i < 12; i++ {
183
- if err := repo.RecordLoginFailure(ctx, key, 0, 5*time.Second); err != nil {
268
+ if err := repo.RecordLoginFailure(ctx, key, fmt.Sprintf("attempt-%d", i), 0, 5*time.Second); err != nil {
184
269
  t.Fatalf("record failure %d: %v", i+1, err)
185
270
  }
186
271
  }
@@ -192,3 +277,82 @@ func TestRepository_LoginThrottle_RespectsTheCeiling(t *testing.T) {
192
277
  t.Fatalf("lock ran past its 5s ceiling: %s remaining", remaining)
193
278
  }
194
279
  }
280
+
281
+ {{/if}}// The repeat rule lives in SQL, so only the database can prove it. This is the
282
+ // browser-with-a-saved-password case: one wrong password sent over and over
283
+ // costs one attempt, no matter how many times it arrives.
284
+ func TestRepository_LoginThrottle_CountsARepeatedAttemptOnce(t *testing.T) {
285
+ db := repositoryDBForTest(t)
286
+ repo := NewRepository(db)
287
+ ctx := context.Background()
288
+ key := "throttle-repeat-" + uuid.NewString()
289
+
290
+ for i := 0; i < 8; i++ {
291
+ if err := throttle(ctx, repo, key, "one-stale-password", 3); err != nil {
292
+ t.Fatalf("record failure %d: %v", i+1, err)
293
+ }
294
+ }
295
+ until, err := repo.LoginLockedUntil(ctx, key)
296
+ if err != nil {
297
+ t.Fatalf("read lock: %v", err)
298
+ }
299
+ if !until.IsZero() {
300
+ t.Fatalf("the same wrong password locked the account (%s); it should count once", until)
301
+ }
302
+
303
+ // an empty fingerprint is "no password to compare" — those callers, like
304
+ // the password reset counter, must keep counting every attempt.
305
+ blank := "throttle-blank-" + uuid.NewString()
306
+ for i := 0; i < 8; i++ {
307
+ if err := throttle(ctx, repo, blank, "", 3); err != nil {
308
+ t.Fatalf("record blank failure %d: %v", i+1, err)
309
+ }
310
+ }
311
+ locked, err := repo.LoginLockedUntil(ctx, blank)
312
+ if err != nil {
313
+ t.Fatalf("read lock: %v", err)
314
+ }
315
+ if locked.IsZero() {
316
+ t.Fatal("attempts without a fingerprint never locked; they must not be treated as repeats of each other")
317
+ }
318
+ }
319
+
320
+ // Retention with no scheduler: the sweep rides along with the next failure,
321
+ // whoever it belongs to. Without it a row for a handle that never logs in
322
+ // successfully — a mistyped address is enough — keeps a fingerprint of a real
323
+ // password for good.
324
+ func TestRepository_LoginThrottle_SweepsRowsNothingTouchesAnyMore(t *testing.T) {
325
+ db := repositoryDBForTest(t)
326
+ repo := NewRepository(db)
327
+ ctx := context.Background()
328
+ stale := "throttle-stale-" + uuid.NewString()
329
+ fresh := "throttle-fresh-" + uuid.NewString()
330
+
331
+ for _, key := range []string{stale, fresh} {
332
+ if err := throttle(ctx, repo, key, "some-password", 3); err != nil {
333
+ t.Fatalf("seed %s: %v", key, err)
334
+ }
335
+ }
336
+ if err := db.Exec(`UPDATE user_svc.login_throttle SET updated_at = now() - interval '2 days' WHERE email_hash = ?`, stale).Error; err != nil {
337
+ t.Fatalf("age the row: %v", err)
338
+ }
339
+
340
+ // somebody else's failed login, later that week
341
+ if err := throttle(ctx, repo, "throttle-other-"+uuid.NewString(), "another-password", 3); err != nil {
342
+ t.Fatalf("record unrelated failure: %v", err)
343
+ }
344
+
345
+ count := func(key string) int64 {
346
+ var n int64
347
+ if err := db.Raw(`SELECT count(*) FROM user_svc.login_throttle WHERE email_hash = ?`, key).Scan(&n).Error; err != nil {
348
+ t.Fatalf("count %s: %v", key, err)
349
+ }
350
+ return n
351
+ }
352
+ if count(stale) != 0 {
353
+ t.Fatal("a row nothing has touched for two days survived the sweep")
354
+ }
355
+ if count(fresh) != 1 {
356
+ t.Fatal("the sweep took a row that is still counting")
357
+ }
358
+ }
@@ -2,6 +2,9 @@ package application
2
2
 
3
3
  import (
4
4
  "context"
5
+ "crypto/hmac"
6
+ "crypto/sha256"
7
+ "encoding/hex"
5
8
  "errors"
6
9
  "fmt"
7
10
  "log/slog"
@@ -11,13 +14,35 @@ import (
11
14
  "{{goModule}}/internal/shared/id"
12
15
  )
13
16
 
14
- // Failed-attempt policy. These values are security posture rather than
17
+ {{#if fixedLockout}}// Failed-attempt policy, the shape enterprise directories use: a threshold, a
18
+ // lock duration, and a window after which the count expires. These values are
19
+ // security posture rather than per-environment tuning knobs.
20
+ //
21
+ // The window is longer than the lock on purpose. Sitting out a lock is not the
22
+ // same as walking away: the count survives it, so the eleventh wrong password
23
+ // locks again instead of buying another ten tries. Only fifteen quiet minutes
24
+ // clear the slate, which also means yesterday's typos never shorten today's
25
+ // allowance.
26
+ const (
27
+ loginMaxAttempts = 10
28
+ loginLockFor = 5 * time.Minute
29
+ loginCountWindow = 15 * time.Minute
30
+ )
31
+ {{else}}// Failed-attempt policy. These values are security posture rather than
15
32
  // per-environment tuning knobs. The first few failures cost nothing, then the
16
33
  // wait doubles up to a bounded maximum.
17
34
  const (
18
35
  loginFreeAttempts = 3
19
36
  loginMaxLock = 15 * time.Minute
20
37
  )
38
+ {{/if}}
39
+ // recordFailure is where the policy above meets the counter. Callers hand it
40
+ // one failed attempt and never repeat the parameter list.
41
+ func (s *Service) recordFailure(ctx context.Context, key, attempt string) error {
42
+ {{#if fixedLockout}} return s.repo.RecordLoginFailure(ctx, key, attempt, loginMaxAttempts, loginLockFor, loginCountWindow)
43
+ {{else}} return s.repo.RecordLoginFailure(ctx, key, attempt, loginFreeAttempts, loginMaxLock)
44
+ {{/if}}
45
+ }
21
46
 
22
47
  // throttleKey namespaces the counter by purpose, so a locked login never
23
48
  // blocks the password reset that would fix it. The address is hashed because
@@ -26,6 +51,22 @@ func throttleKey(purpose, email string) string {
26
51
  return hashToken(purpose + ":" + normalizeEmail(email))
27
52
  }
28
53
 
54
+ // attemptFingerprint identifies the password that was submitted so the counter
55
+ // can ignore a repeat of one it has already punished. A saved password that
56
+ // went stale gets replayed by the browser on every visit and would otherwise
57
+ // lock the account out on its own, while an attacker gains nothing: guessing
58
+ // means sending something new each time.
59
+ //
60
+ // It is an HMAC under the signing secret rather than a bare hash. The value
61
+ // is derived from a real password — often the user's password somewhere else,
62
+ // or one character away from the right one — so a leak of this table alone
63
+ // must not be crackable offline.
64
+ func (s *Service) attemptFingerprint(password string) string {
65
+ mac := hmac.New(sha256.New, []byte(s.config.JWTSecret))
66
+ mac.Write([]byte(password))
67
+ return hex.EncodeToString(mac.Sum(nil))
68
+ }
69
+
29
70
  // throttled treats a counter read failure as not-throttled: this is a brake,
30
71
  // and it should not be able to lock everybody out on its own.
31
72
  func (s *Service) throttled(ctx context.Context, key string) bool {
@@ -73,8 +114,9 @@ func (s *Service) Login(ctx context.Context, in LoginInput) (*AuthResult, error)
73
114
 
74
115
  // Every failure records the same key and returns the same error whether or
75
116
  // not the account exists, preventing account enumeration through the counter.
117
+ attempt := s.attemptFingerprint(in.Password)
76
118
  fail := func() (*AuthResult, error) {
77
- if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
119
+ if err := s.recordFailure(ctx, key, attempt); err != nil {
78
120
  slog.Error("record login failure", "error", err)
79
121
  }
80
122
  return nil, errInvalidCredentials()
@@ -19,7 +19,8 @@ func (s *Service) ForgotPassword(ctx context.Context, email string) error {
19
19
  if s.throttled(ctx, key) {
20
20
  return nil
21
21
  }
22
- if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
22
+ // no password to fingerprint here, so every request counts
23
+ if err := s.recordFailure(ctx, key, ""); err != nil {
23
24
  slog.Error("record password reset attempt", "error", err)
24
25
  }
25
26
 
@@ -154,7 +154,7 @@ type ServicePort interface {
154
154
  ConfirmMFA(context.Context, uuid.UUID, string) ([]string, error)
155
155
  DisableMFA(context.Context, uuid.UUID, string) error
156
156
  Get(context.Context, uuid.UUID) (*domain.User, error)
157
- List(context.Context, int, int) ([]domain.User, error)
157
+ List(context.Context, ports.ListFilter) ([]domain.User, int64, error)
158
158
  SetRole(context.Context, uuid.UUID, string) (*domain.User, error)
159
159
  }
160
160
 
@@ -10,6 +10,7 @@ import (
10
10
  "time"
11
11
 
12
12
  "{{goModule}}/internal/app/user/domain"
13
+ "{{goModule}}/internal/app/user/ports"
13
14
 
14
15
  "github.com/google/uuid"
15
16
  )
@@ -338,6 +339,7 @@ type fakeRepo struct {
338
339
  updateIdentityErr error
339
340
  updateUserErr error
340
341
  failures map[string]int
342
+ lastAttempt map[string]string
341
343
  lockedUntil map[string]time.Time
342
344
  // go-scaffold:repository-stub-fields
343
345
  }
@@ -350,23 +352,44 @@ func (f *fakeRepo) LoginLockedUntil(_ context.Context, key string) (time.Time, e
350
352
  return f.lockedUntil[key], nil
351
353
  }
352
354
 
353
- func (f *fakeRepo) RecordLoginFailure(_ context.Context, key string, freeAttempts int, maxLock time.Duration) error {
354
- if f.lockedUntil == nil {
355
+ // lockoutAfter is how many distinct wrong passwords it takes to be locked out
356
+ // under this project's policy, so the tests below read the same either way.
357
+ {{#if fixedLockout}}const lockoutAfter = loginMaxAttempts
358
+ {{else}}const lockoutAfter = loginFreeAttempts + 1
359
+ {{/if}}
360
+ {{#if fixedLockout}}func (f *fakeRepo) RecordLoginFailure(_ context.Context, key, attempt string, maxAttempts int, lockFor, _ time.Duration) error {
361
+ {{else}}func (f *fakeRepo) RecordLoginFailure(_ context.Context, key, attempt string, freeAttempts int, maxLock time.Duration) error {
362
+ {{/if}} if f.lockedUntil == nil {
355
363
  f.lockedUntil = map[string]time.Time{}
356
364
  }
365
+ if f.lastAttempt == nil {
366
+ f.lastAttempt = map[string]string{}
367
+ }
368
+ // same rule as the SQL: a repeat of the fingerprint already on the row
369
+ // changes nothing, and "" is never a repeat
370
+ if attempt != "" && f.lastAttempt[key] == attempt {
371
+ return nil
372
+ }
373
+ f.lastAttempt[key] = attempt
357
374
  f.failures[key]++
358
- if f.failures[key] > freeAttempts {
375
+ {{#if fixedLockout}} // the expiry window is left to the repository test: nothing here waits
376
+ // fifteen minutes, and Postgres is what computes it
377
+ if f.failures[key] >= maxAttempts {
378
+ f.lockedUntil[key] = time.Now().Add(lockFor)
379
+ }
380
+ {{else}} if f.failures[key] > freeAttempts {
359
381
  lock := time.Duration(1<<uint(f.failures[key]-freeAttempts-1)) * time.Second
360
382
  if lock > maxLock {
361
383
  lock = maxLock
362
384
  }
363
385
  f.lockedUntil[key] = time.Now().Add(lock)
364
386
  }
365
- return nil
387
+ {{/if}} return nil
366
388
  }
367
389
 
368
390
  func (f *fakeRepo) ClearLoginFailures(_ context.Context, key string) error {
369
391
  delete(f.failures, key)
392
+ delete(f.lastAttempt, key)
370
393
  delete(f.lockedUntil, key)
371
394
  return nil
372
395
  }
@@ -387,7 +410,9 @@ func (f *fakeRepo) UpdateUser(_ context.Context, u *domain.User) error {
387
410
  f.user = u
388
411
  return nil
389
412
  }
390
- func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.User, error) { return nil, nil }
413
+ func (f *fakeRepo) FindAll(context.Context, ports.ListFilter) ([]domain.User, int64, error) {
414
+ return nil, 0, nil
415
+ }
391
416
 
392
417
  func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
393
418
  for i := range f.identities {
@@ -946,18 +971,21 @@ func TestService_VerifyEmail_ConcurrentPresentationHasOneWinner(t *testing.T) {
946
971
  }
947
972
 
948
973
  // The control that actually stops credential stuffing: the counter follows the
949
- // account, so spreading attempts across a proxy pool doesn't help.
974
+ // account, so spreading attempts across a proxy pool doesn't help. Every guess
975
+ // here is a different password, which is what guessing looks like.
950
976
  func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {
951
977
  repo := &fakeRepo{
952
978
  user: &domain.User{ID: uuid.New(), Email: "a@example.com"},
953
979
  failures: map[string]int{},
954
980
  }
955
981
  svc := newTestService(repo, newFakeTokenStore())
956
- in := LoginInput{Email: "a@example.com", Password: "wrong"}
982
+ guess := func(n int) LoginInput {
983
+ return LoginInput{Email: "a@example.com", Password: fmt.Sprintf("wrong-%d", n)}
984
+ }
957
985
 
958
- // the free attempts answer "wrong password", not "locked"
959
- for i := 0; i < loginFreeAttempts; i++ {
960
- if _, err := svc.Login(context.Background(), in); err == nil {
986
+ // everything below the limit answers "wrong password", not "locked"
987
+ for i := 0; i < lockoutAfter-1; i++ {
988
+ if _, err := svc.Login(context.Background(), guess(i)); err == nil {
961
989
  t.Fatalf("attempt %d: expected a failure", i+1)
962
990
  } else if code(err) != "AUTH_INVALID_CREDENTIALS" {
963
991
  t.Fatalf("attempt %d: expected AUTH_INVALID_CREDENTIALS, got %s", i+1, code(err))
@@ -965,15 +993,46 @@ func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {
965
993
  }
966
994
 
967
995
  // the next one trips the lock...
968
- if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_INVALID_CREDENTIALS" {
996
+ if _, err := svc.Login(context.Background(), guess(lockoutAfter)); code(err) != "AUTH_INVALID_CREDENTIALS" {
969
997
  t.Fatalf("the attempt that trips the lock still answers as a bad password, got %s", code(err))
970
998
  }
971
999
  // ...and everything after it is refused before any password is checked
972
- if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_TOO_MANY_ATTEMPTS" {
1000
+ if _, err := svc.Login(context.Background(), guess(lockoutAfter+1)); code(err) != "AUTH_TOO_MANY_ATTEMPTS" {
973
1001
  t.Fatalf("expected AUTH_TOO_MANY_ATTEMPTS once locked, got %s", code(err))
974
1002
  }
975
1003
  }
976
1004
 
1005
+ // The other half of that rule: one wrong password replayed forever is a stale
1006
+ // saved credential, not an attack, and must never lock the account. Without
1007
+ // this the browser locks the user out of their own account on a schedule.
1008
+ func TestService_Login_CountsOneWrongPasswordOnce(t *testing.T) {
1009
+ repo := &fakeRepo{
1010
+ user: &domain.User{ID: uuid.New(), Email: "a@example.com"},
1011
+ failures: map[string]int{},
1012
+ }
1013
+ svc := newTestService(repo, newFakeTokenStore())
1014
+ saved := LoginInput{Email: "a@example.com", Password: "last-years-password"}
1015
+
1016
+ for i := 0; i < lockoutAfter*4; i++ {
1017
+ if _, err := svc.Login(context.Background(), saved); code(err) != "AUTH_INVALID_CREDENTIALS" {
1018
+ t.Fatalf("replay %d: one stale password should never lock, got %s", i+1, code(err))
1019
+ }
1020
+ }
1021
+
1022
+ // passwords it has not seen are new material: enough of those still lock,
1023
+ // and the lock then covers the replayed one too
1024
+ for i := 0; i < lockoutAfter; i++ {
1025
+ if _, err := svc.Login(context.Background(), LoginInput{
1026
+ Email: "a@example.com", Password: fmt.Sprintf("guess-%d", i),
1027
+ }); err == nil {
1028
+ t.Fatalf("guess %d: expected a failure", i+1)
1029
+ }
1030
+ }
1031
+ if _, err := svc.Login(context.Background(), saved); code(err) != "AUTH_TOO_MANY_ATTEMPTS" {
1032
+ t.Fatalf("expected the lock once real guesses arrived, got %s", code(err))
1033
+ }
1034
+ }
1035
+
977
1036
  // A locked login must not lock the reset that fixes it — the person who forgot
978
1037
  // their password is exactly the person who trips the login counter.
979
1038
  func TestService_ForgotPassword_HasItsOwnCounter(t *testing.T) {
@@ -6,6 +6,7 @@ import (
6
6
  "fmt"
7
7
 
8
8
  "{{goModule}}/internal/app/user/domain"
9
+ "{{goModule}}/internal/app/user/ports"
9
10
  "{{goModule}}/internal/shared/id"
10
11
 
11
12
  "github.com/google/uuid"
@@ -32,12 +33,12 @@ func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*domain.User, erro
32
33
 
33
34
  // List is kept for the optional RBAC admin routes. It is not part of the
34
35
  // public self-service auth surface until that feature is installed.
35
- func (s *Service) List(ctx context.Context, limit, offset int) ([]domain.User, error) {
36
- items, err := s.repo.FindAll(ctx, limit, offset)
36
+ func (s *Service) List(ctx context.Context, filter ports.ListFilter) ([]domain.User, int64, error) {
37
+ items, total, err := s.repo.FindAll(ctx, filter)
37
38
  if err != nil {
38
- return nil, fmt.Errorf("list users: %w", err)
39
+ return nil, 0, fmt.Errorf("list users: %w", err)
39
40
  }
40
- return items, nil
41
+ return items, total, nil
41
42
  }
42
43
 
43
44
  // EnsureUser is idempotent and used only by cmd/seed. It never resets an
@@ -3,6 +3,10 @@ package domain
3
3
  import "errors"
4
4
 
5
5
  var (
6
+ // What every endpoint `generate method` adds returns until an engineer
7
+ // writes its body. The inbound adapter maps it to 501, so a scaffolded
8
+ // route answers "not built yet" rather than reporting a server fault.
9
+ ErrNotImplemented = errors.New("user operation is not implemented")
6
10
  ErrNotFound = errors.New("user not found")
7
11
  ErrConflict = errors.New("user conflict")
8
12
  ErrInvalidCredential = errors.New("invalid credentials")