@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.
- package/README.md +16 -2
- package/dist/commands/auth.js +6 -1
- package/dist/commands/check.js +5 -1
- package/dist/commands/method.js +27 -1
- package/dist/index.js +20 -6
- package/dist/prompts/auth-wizard.js +33 -1
- package/dist/templates/create-manifest.js +8 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/utils/hexagonal-method-patcher.js +79 -20
- package/package.json +1 -1
- package/templates/add/auth/docs/login.yaml.hbs +9 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +12 -2
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +97 -13
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +169 -5
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +44 -2
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +2 -1
- package/templates/add/auth/internal/app/user/application/service.go.hbs +1 -1
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +71 -12
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +5 -4
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +21 -3
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +2 -1
- package/templates/add/rbac/docs/users.yaml.hbs +2 -1
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs +52 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +18 -13
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +21 -4
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +19 -11
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +6 -6
- package/templates/add/rbac/internal/app/role/application/service_test.go.hbs +19 -1
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +13 -1
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +1 -1
- package/templates/create/base/AGENTS.md.hbs +9 -1
- package/templates/create/base/README.md.hbs +2 -1
- package/templates/create/base/internal/shared/dbq/dbq.go.hbs +47 -0
- package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +66 -0
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +19 -2
- package/templates/create/features/docs/architecture.md.hbs +9 -6
- package/templates/create/features/docs/common/parameters.yaml.hbs +5 -0
- package/templates/create/features/docs/common/schemas.yaml.hbs +1 -0
- package/templates/create/features/docs/patterns.md.hbs +60 -0
- package/templates/generate/module/docs/collection.yaml.hbs +2 -1
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +8 -3
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +27 -4
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +3 -3
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +4 -3
- 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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
982
|
+
guess := func(n int) LoginInput {
|
|
983
|
+
return LoginInput{Email: "a@example.com", Password: fmt.Sprintf("wrong-%d", n)}
|
|
984
|
+
}
|
|
957
985
|
|
|
958
|
-
// the
|
|
959
|
-
for i := 0; i <
|
|
960
|
-
if _, err := svc.Login(context.Background(),
|
|
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(),
|
|
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(),
|
|
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,
|
|
36
|
-
items, err := s.repo.FindAll(ctx,
|
|
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")
|