@nakedev/go-scaffold 0.1.4 → 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 +133 -44
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +21 -11
  6. package/dist/commands/method.js +32 -3
  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 +366 -63
  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/observability-manifest.js +24 -0
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/templates/worker-manifest.js +23 -6
  21. package/dist/utils/auth-patcher.js +96 -21
  22. package/dist/utils/config.js +58 -10
  23. package/dist/utils/gocheck.js +57 -5
  24. package/dist/utils/golangci-patcher.js +73 -0
  25. package/dist/utils/gomod-patcher.js +53 -0
  26. package/dist/utils/main-patcher.js +58 -4
  27. package/dist/utils/marker-patch.js +125 -3
  28. package/dist/utils/method-patcher.js +17 -2
  29. package/dist/utils/module-location.js +37 -1
  30. package/dist/utils/naming.js +50 -2
  31. package/dist/utils/observability-patcher.js +107 -0
  32. package/dist/utils/platform-patcher.js +98 -12
  33. package/dist/utils/rbac-patcher.js +60 -10
  34. package/package.json +3 -5
  35. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  36. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  37. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  38. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  39. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  40. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  41. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  42. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  43. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  44. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  45. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  46. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  47. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  48. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  49. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  50. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  52. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  54. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  55. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  56. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  57. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  58. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  59. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  60. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  61. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  62. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  63. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  64. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  65. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  66. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  67. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  68. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  69. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  70. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  71. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  72. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  73. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  74. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  75. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  76. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  77. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  78. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  79. package/templates/create/base/.dockerignore.hbs +13 -0
  80. package/templates/create/base/.env.example.hbs +18 -9
  81. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  82. package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
  83. package/templates/create/base/.golangci.yml.hbs +27 -0
  84. package/templates/create/base/AGENTS.md.hbs +8 -4
  85. package/templates/create/base/Dockerfile.hbs +42 -0
  86. package/templates/create/base/Makefile.hbs +43 -13
  87. package/templates/create/base/README.md.hbs +45 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  89. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  90. package/templates/create/base/go.mod.hbs +4 -4
  91. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  92. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  93. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  94. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  95. package/templates/create/base/redocly.yaml.hbs +21 -0
  96. package/templates/create/features/docs/architecture.md.hbs +32 -11
  97. package/templates/create/features/docs/openapi.yaml.hbs +0 -4
  98. package/templates/create/features/docs/patterns.md.hbs +82 -8
  99. package/templates/create/features/docs/techstack.md.hbs +8 -3
  100. package/templates/generate/module/dto.go.hbs +8 -1
  101. package/templates/generate/module/errors.go.hbs +5 -0
  102. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  103. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  104. package/templates/generate/module/handler_test.go.hbs +8 -1
  105. package/templates/generate/module/migration.down.sql.hbs +3 -1
  106. package/templates/generate/module/migration.up.sql.hbs +7 -2
  107. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  108. package/templates/generate/module/model/model.go.hbs +10 -1
  109. package/templates/generate/module/permission.up.sql.hbs +3 -1
  110. package/templates/generate/module/repository.go.hbs +60 -6
  111. package/templates/generate/module/repository_test.go.hbs +30 -0
  112. package/templates/generate/module/service.go.hbs +10 -1
  113. package/templates/generate/module/service_test.go.hbs +45 -0
  114. package/dist/commands/remove.js +0 -88
  115. package/scripts/smoke-test.mjs +0 -2058
  116. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  117. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
  118. package/tests/integration/default-module.test.mjs +0 -46
  119. package/tests/integration/generator-naming.test.mjs +0 -81
  120. package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
  121. package/tests/integration/legacy-method-compat.test.mjs +0 -222
  122. package/tests/integration/remove-module.test.mjs +0 -58
  123. package/tests/unit/naming.test.mjs +0 -94
  124. package/tests/unit/smoke-isolation.test.mjs +0 -35
@@ -2,8 +2,11 @@ package user
2
2
 
3
3
  import (
4
4
  "context"
5
+ "errors"
6
+ "time"
5
7
 
6
8
  "{{goModule}}/internal/app/user/model"
9
+ "{{goModule}}/internal/shared/tx"
7
10
 
8
11
  "github.com/google/uuid"
9
12
  "gorm.io/gorm"
@@ -19,7 +22,7 @@ func NewRepository(db *gorm.DB) *Repository {
19
22
 
20
23
  func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
21
24
  var u model.User
22
- if err := r.db.WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
25
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
23
26
  return nil, err
24
27
  }
25
28
  return &u, nil
@@ -27,25 +30,25 @@ func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User
27
30
 
28
31
  func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
29
32
  var u model.User
30
- if err := r.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
33
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
31
34
  return nil, err
32
35
  }
33
36
  return &u, nil
34
37
  }
35
38
 
36
39
  func (r *Repository) UpdateUser(ctx context.Context, u *model.User) error {
37
- return r.db.WithContext(ctx).Save(u).Error
40
+ return tx.From(ctx, r.db).WithContext(ctx).Save(u).Error
38
41
  }
39
42
 
40
43
  func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.User, error) {
41
44
  var items []model.User
42
- err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
45
+ err := tx.From(ctx, r.db).WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
43
46
  return items, err
44
47
  }
45
48
 
46
49
  func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error) {
47
50
  var i model.Identity
48
- if err := r.db.WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
51
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
49
52
  return nil, err
50
53
  }
51
54
  return &i, nil
@@ -53,7 +56,7 @@ func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provide
53
56
 
54
57
  func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error) {
55
58
  var i model.Identity
56
- if err := r.db.WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
59
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
57
60
  return nil, err
58
61
  }
59
62
  return &i, nil
@@ -63,22 +66,72 @@ func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider mod
63
66
  // Google linking onto an account that registered with a password first) —
64
67
  // see CreateUserWithIdentity for the "brand new user" case.
65
68
  func (r *Repository) CreateIdentity(ctx context.Context, i *model.Identity) error {
66
- return r.db.WithContext(ctx).Create(i).Error
69
+ return tx.From(ctx, r.db).WithContext(ctx).Create(i).Error
67
70
  }
68
71
 
69
72
  func (r *Repository) UpdateIdentity(ctx context.Context, i *model.Identity) error {
70
- return r.db.WithContext(ctx).Save(i).Error
73
+ return tx.From(ctx, r.db).WithContext(ctx).Save(i).Error
71
74
  }
72
75
 
73
76
  // CreateUserWithIdentity inserts the profile and its first login method in
74
77
  // one transaction — a user with no identity at all can't log in any way, so
75
78
  // the two rows always exist together or not at all.
76
79
  func (r *Repository) CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error {
77
- return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
78
- if err := tx.Create(u).Error; err != nil {
80
+ return tx.From(ctx, r.db).WithContext(ctx).Transaction(func(t *gorm.DB) error {
81
+ if err := t.Create(u).Error; err != nil {
79
82
  return err
80
83
  }
81
84
  i.UserID = u.ID
82
- return tx.Create(i).Error
85
+ return t.Create(i).Error
83
86
  })
84
87
  }
88
+
89
+ // --- failed-login throttle -------------------------------------------------
90
+ //
91
+ // Keyed on a caller-supplied hash rather than a user id, because the counter
92
+ // has to work for addresses that have no account — see model.LoginThrottle.
93
+
94
+ // LoginLockedUntil reports when this key stops being locked out — zero time
95
+ // when it isn't. Read on every login attempt, so it stays a primary-key hit.
96
+ func (r *Repository) LoginLockedUntil(ctx context.Context, key string) (time.Time, error) {
97
+ var row model.LoginThrottle
98
+ err := tx.From(ctx, r.db).WithContext(ctx).Where("email_hash = ?", key).Take(&row).Error
99
+ if errors.Is(err, gorm.ErrRecordNotFound) {
100
+ return time.Time{}, nil
101
+ }
102
+ if err != nil || row.LockedUntil == nil {
103
+ return time.Time{}, err
104
+ }
105
+ return *row.LockedUntil, nil
106
+ }
107
+
108
+ // RecordLoginFailure bumps the counter and pushes the lock further out,
109
+ // doubling each time past freeAttempts and never exceeding maxLock.
110
+ //
111
+ // One statement, so two attempts racing cannot both read the same count and
112
+ // write the same lock — which would let an attacker keep the backoff pinned at
113
+ // its first step by running attempts in parallel. Computing the interval in
114
+ // SQL is what buys that; a read-then-write in Go would need a transaction and
115
+ // a row lock to be equally safe.
116
+ func (r *Repository) RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error {
117
+ return tx.From(ctx, r.db).WithContext(ctx).Exec(`
118
+ INSERT INTO user_svc.login_throttle AS t (email_hash, failures, locked_until, updated_at)
119
+ VALUES (?, 1, NULL, now())
120
+ ON CONFLICT (email_hash) DO UPDATE SET
121
+ failures = t.failures + 1,
122
+ locked_until = CASE
123
+ WHEN t.failures + 1 <= ? THEN NULL
124
+ ELSE now() + make_interval(secs => least(power(2, t.failures + 1 - ?), ?))
125
+ END,
126
+ updated_at = now()`,
127
+ key, freeAttempts, freeAttempts, maxLock.Seconds()).Error
128
+ }
129
+
130
+ // ClearLoginFailures drops the row on a successful login, so someone who
131
+ // mistypes twice and then gets in starts clean rather than creeping toward a
132
+ // lockout over weeks.
133
+ func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
134
+ return tx.From(ctx, r.db).WithContext(ctx).
135
+ Where("email_hash = ?", key).
136
+ Delete(&model.LoginThrottle{}).Error
137
+ }
@@ -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
+ }