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