@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,43 +0,0 @@
1
- package user
2
-
3
- import (
4
- "net/http"
5
-
6
- "{{goModule}}/internal/shared/apperror"
7
- )
8
-
9
- // functions, not vars: the error middleware writes RequestID onto the
10
- // returned pointer directly — a shared instance would race across
11
- // concurrent requests.
12
-
13
- func errNotFound() *apperror.AppError {
14
- return apperror.New(http.StatusNotFound, "USER_NOT_FOUND", "user not found")
15
- }
16
-
17
- func errEmailTaken() *apperror.AppError {
18
- return apperror.New(http.StatusConflict, "USER_EMAIL_TAKEN", "email already registered")
19
- }
20
-
21
- // errInvalidCredentials is returned for both "no such user" and "wrong
22
- // password" — a single generic message so a login attempt can't be used to
23
- // enumerate which emails have accounts.
24
- // errTooManyAttempts is the account-level lockout, distinct from the per-IP
25
- // RATE_LIMITED the middleware returns: this one follows the account wherever
26
- // the attempts come from.
27
- func errTooManyAttempts() *apperror.AppError {
28
- return apperror.New(http.StatusTooManyRequests, "AUTH_TOO_MANY_ATTEMPTS", "too many failed attempts — try again later")
29
- }
30
-
31
- func errInvalidCredentials() *apperror.AppError {
32
- return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid email or password")
33
- }
34
-
35
- func errInvalidToken() *apperror.AppError {
36
- return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_TOKEN", "invalid or expired refresh token")
37
- }
38
-
39
- func errAlreadyVerified() *apperror.AppError {
40
- return apperror.New(http.StatusConflict, "AUTH_ALREADY_VERIFIED", "email is already verified")
41
- }
42
-
43
- // go-scaffold:user-errors
@@ -1,276 +0,0 @@
1
- package user
2
-
3
- import (
4
- "net/http"
5
- "strings"
6
- "time"
7
-
8
- "{{goModule}}/internal/shared/httpx"
9
- "{{goModule}}/internal/shared/middleware"
10
- // go-scaffold:user-handler-imports
11
-
12
- "github.com/gin-gonic/gin"
13
- "github.com/google/uuid"
14
- )
15
-
16
- const (
17
- refreshCookieName = "refresh_token"
18
- // oauthStateCookieName holds the nonce that binds a Google login to the
19
- // browser that started it — see Service.GoogleLoginURL.
20
- oauthStateCookieName = "oauth_state"
21
- )
22
-
23
- // go-scaffold:user-handler-consts
24
-
25
- type Handler struct {
26
- svc *Service
27
- jwtSecret string
28
- refreshTTL time.Duration
29
- cookieSecure bool
30
- cookieSameSite string
31
- // limiter, not a *redis.Client: which backing store counts the requests is
32
- // decided by `add auth --store`, and this file must not care.
33
- limiter middleware.Limiter
34
- // go-scaffold:user-handler-fields
35
- }
36
-
37
- func NewHandler(
38
- svc *Service,
39
- jwtSecret string,
40
- refreshTTL time.Duration,
41
- cookieSecure bool,
42
- cookieSameSite string,
43
- limiter middleware.Limiter,
44
- // go-scaffold:user-handler-params
45
- ) *Handler {
46
- return &Handler{
47
- svc: svc,
48
- jwtSecret: jwtSecret,
49
- refreshTTL: refreshTTL,
50
- cookieSecure: cookieSecure,
51
- cookieSameSite: cookieSameSite,
52
- limiter: limiter,
53
- // go-scaffold:user-handler-init
54
- }
55
- }
56
-
57
- // Register wires both a public /auth group (register/login/refresh/logout)
58
- // and a /users group gated by RequireAuth — public vs protected is decided
59
- // here, per domain, not centrally in main.go.
60
- func (h *Handler) Register(rg gin.IRouter) {
61
- // per-IP, per-route budgets — separate names so a burst on one endpoint
62
- // doesn't spend another's budget. refresh/logout/google aren't limited:
63
- // refresh/logout are gated by possessing a valid cookie already, and the
64
- // Google flow's abuse surface lives on Google's side, not ours.
65
- loginLimit := middleware.RateLimit(h.limiter, "login", 10, time.Minute)
66
- registerLimit := middleware.RateLimit(h.limiter, "register", 5, time.Minute)
67
- forgotPasswordLimit := middleware.RateLimit(h.limiter, "forgot-password", 5, time.Minute)
68
- resetPasswordLimit := middleware.RateLimit(h.limiter, "reset-password", 10, time.Minute)
69
- verifyEmailLimit := middleware.RateLimit(h.limiter, "verify-email", 10, time.Minute)
70
- resendVerificationLimit := middleware.RateLimit(h.limiter, "resend-verification", 5, time.Minute)
71
-
72
- authGroup := rg.Group("/auth")
73
- authGroup.POST("/register", registerLimit, h.register)
74
- authGroup.POST("/login", loginLimit, h.login)
75
- authGroup.POST("/refresh", h.refresh)
76
- authGroup.POST("/logout", h.logout)
77
- authGroup.POST("/forgot-password", forgotPasswordLimit, h.forgotPassword)
78
- authGroup.POST("/reset-password", resetPasswordLimit, h.resetPassword)
79
- authGroup.POST("/verify-email", verifyEmailLimit, h.verifyEmail)
80
- authGroup.GET("/google/login", h.googleLogin)
81
- authGroup.GET("/google/callback", h.googleCallback)
82
-
83
- usersGroup := rg.Group("/users", middleware.RequireAuth(h.jwtSecret))
84
- usersGroup.GET("/me", h.me)
85
- usersGroup.POST("/me/resend-verification", resendVerificationLimit, h.resendVerification)
86
- usersGroup.POST("/me/logout-all", h.logoutAll)
87
- // go-scaffold:user-routes
88
- }
89
-
90
- func (h *Handler) register(c *gin.Context) {
91
- var in registerInput
92
- if err := c.ShouldBindJSON(&in); err != nil {
93
- c.Error(httpx.BindErr(err))
94
- return
95
- }
96
- auth, err := h.svc.Register(c.Request.Context(), in)
97
- if err != nil {
98
- c.Error(err)
99
- return
100
- }
101
- h.setRefreshCookie(c, auth.RefreshToken)
102
- c.JSON(http.StatusCreated, toCookieResponse(auth))
103
- }
104
-
105
- func (h *Handler) login(c *gin.Context) {
106
- var in loginInput
107
- if err := c.ShouldBindJSON(&in); err != nil {
108
- c.Error(httpx.BindErr(err))
109
- return
110
- }
111
- auth, err := h.svc.Login(c.Request.Context(), in)
112
- if err != nil {
113
- c.Error(err)
114
- return
115
- }
116
- h.setRefreshCookie(c, auth.RefreshToken)
117
- c.JSON(http.StatusOK, toCookieResponse(auth))
118
- }
119
-
120
- func (h *Handler) refresh(c *gin.Context) {
121
- raw, err := c.Cookie(refreshCookieName)
122
- if err != nil || raw == "" {
123
- c.Error(errInvalidToken())
124
- return
125
- }
126
- auth, err := h.svc.Refresh(c.Request.Context(), raw)
127
- if err != nil {
128
- h.clearRefreshCookie(c)
129
- c.Error(err)
130
- return
131
- }
132
- h.setRefreshCookie(c, auth.RefreshToken)
133
- c.JSON(http.StatusOK, toCookieResponse(auth))
134
- }
135
-
136
- func (h *Handler) logout(c *gin.Context) {
137
- raw, _ := c.Cookie(refreshCookieName)
138
- _ = h.svc.Logout(c.Request.Context(), raw)
139
- h.clearRefreshCookie(c)
140
- c.Status(http.StatusNoContent)
141
- }
142
-
143
- func (h *Handler) forgotPassword(c *gin.Context) {
144
- var in forgotPasswordInput
145
- if err := c.ShouldBindJSON(&in); err != nil {
146
- c.Error(httpx.BindErr(err))
147
- return
148
- }
149
- if err := h.svc.ForgotPassword(c.Request.Context(), in.Email); err != nil {
150
- c.Error(err)
151
- return
152
- }
153
- // always the same response, whether or not the email exists — see
154
- // Service.ForgotPassword for why.
155
- c.JSON(http.StatusOK, gin.H{"message": "if that email exists, a reset link has been sent"})
156
- }
157
-
158
- func (h *Handler) resetPassword(c *gin.Context) {
159
- var in resetPasswordInput
160
- if err := c.ShouldBindJSON(&in); err != nil {
161
- c.Error(httpx.BindErr(err))
162
- return
163
- }
164
- if err := h.svc.ResetPassword(c.Request.Context(), in.Token, in.NewPassword); err != nil {
165
- c.Error(err)
166
- return
167
- }
168
- c.Status(http.StatusNoContent)
169
- }
170
-
171
- func (h *Handler) verifyEmail(c *gin.Context) {
172
- var in verifyEmailInput
173
- if err := c.ShouldBindJSON(&in); err != nil {
174
- c.Error(httpx.BindErr(err))
175
- return
176
- }
177
- if err := h.svc.VerifyEmail(c.Request.Context(), in.Token); err != nil {
178
- c.Error(err)
179
- return
180
- }
181
- c.Status(http.StatusNoContent)
182
- }
183
-
184
- func (h *Handler) resendVerification(c *gin.Context) {
185
- userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
186
- if err := h.svc.ResendVerificationEmail(c.Request.Context(), userID); err != nil {
187
- c.Error(err)
188
- return
189
- }
190
- c.Status(http.StatusNoContent)
191
- }
192
-
193
- // logoutAll ends every session for the caller, not just the one making the
194
- // request — also clears this request's own cookie, since that session is
195
- // dead too now.
196
- func (h *Handler) logoutAll(c *gin.Context) {
197
- userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
198
- if err := h.svc.LogoutAll(c.Request.Context(), userID); err != nil {
199
- c.Error(err)
200
- return
201
- }
202
- h.clearRefreshCookie(c)
203
- c.Status(http.StatusNoContent)
204
- }
205
-
206
- func (h *Handler) googleLogin(c *gin.Context) {
207
- url, nonce, err := h.svc.GoogleLoginURL()
208
- if err != nil {
209
- c.Error(err)
210
- return
211
- }
212
- // Lax, not Strict, and deliberately not h.cookieSameSite: the callback
213
- // arrives as a top-level navigation from Google, i.e. cross-site, and a
214
- // Strict cookie is not sent on one — the flow would fail every time.
215
- c.SetSameSite(http.SameSiteLaxMode)
216
- c.SetCookie(oauthStateCookieName, nonce, int(oauthStateTTL.Seconds()), "/", "", h.cookieSecure, true)
217
- c.Redirect(http.StatusFound, url)
218
- }
219
-
220
- func (h *Handler) googleCallback(c *gin.Context) {
221
- nonce, _ := c.Cookie(oauthStateCookieName)
222
- // One shot, whatever happens next.
223
- c.SetSameSite(http.SameSiteLaxMode)
224
- c.SetCookie(oauthStateCookieName, "", -1, "/", "", h.cookieSecure, true)
225
-
226
- auth, err := h.svc.GoogleCallback(c.Request.Context(), c.Query("code"), c.Query("state"), nonce)
227
- if err != nil {
228
- c.Error(err)
229
- return
230
- }
231
- h.setRefreshCookie(c, auth.RefreshToken)
232
- c.JSON(http.StatusOK, toCookieResponse(auth))
233
- }
234
-
235
- func (h *Handler) me(c *gin.Context) {
236
- userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
237
- u, err := h.svc.Get(c.Request.Context(), userID)
238
- if err != nil {
239
- c.Error(err)
240
- return
241
- }
242
- c.JSON(http.StatusOK, toMeResponse(u))
243
- }
244
-
245
- // go-scaffold:user-handler-funcs
246
-
247
- func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
248
- c.SetSameSite(sameSiteFrom(h.cookieSameSite))
249
- c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
250
- }
251
-
252
- func (h *Handler) clearRefreshCookie(c *gin.Context) {
253
- c.SetSameSite(sameSiteFrom(h.cookieSameSite))
254
- c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
255
- }
256
-
257
- // sameSiteFrom maps COOKIE_SAMESITE onto the http constant, defaulting to the
258
- // strictest option for anything it doesn't recognise.
259
- //
260
- // "strict" is right while the frontend and this API are the same site
261
- // (localhost:3000 -> localhost:8080 is, and so is app.example.com ->
262
- // api.example.com). A frontend on a genuinely different site — the usual
263
- // vercel.app-plus-own-API-domain split — needs "none", because the browser
264
- // will not attach a Strict or Lax cookie to the fetch that calls
265
- // /auth/refresh: sessions then die at every access-token expiry with no
266
- // error anywhere to explain it. "none" requires COOKIE_SECURE=true.
267
- func sameSiteFrom(mode string) http.SameSite {
268
- switch strings.ToLower(mode) {
269
- case "none":
270
- return http.SameSiteNoneMode
271
- case "lax":
272
- return http.SameSiteLaxMode
273
- default:
274
- return http.SameSiteStrictMode
275
- }
276
- }
@@ -1,108 +0,0 @@
1
- package user
2
-
3
- import (
4
- "crypto/rand"
5
- "crypto/sha256"
6
- "crypto/subtle"
7
- "encoding/hex"
8
- "time"
9
-
10
- "github.com/golang-jwt/jwt/v5"
11
- "github.com/google/uuid"
12
- )
13
-
14
- const (
15
- tokenTypeAccess = "access"
16
- tokenTypeOAuthState = "oauth_state"
17
- oauthStateTTL = 10 * time.Minute
18
- )
19
-
20
- // accessClaims — see internal/shared/middleware/auth.go for why this struct
21
- // is duplicated there instead of imported. Also reused (with no Subject) for
22
- // the Google OAuth CSRF state token below — Typ is what stops one kind being
23
- // presented where the other is expected.
24
- type accessClaims struct {
25
- Typ string `json:"typ"`
26
- // Nonce is only set on the OAuth state token — see issueOAuthState.
27
- Nonce string `json:"nonce,omitempty"`
28
- // go-scaffold:jwt-claims
29
- jwt.RegisteredClaims
30
- }
31
-
32
- func (s *Service) issueAccessToken(
33
- userID uuid.UUID,
34
- // go-scaffold:issue-access-token-params
35
- ) (string, error) {
36
- claims := accessClaims{
37
- Typ: tokenTypeAccess,
38
- // go-scaffold:jwt-claims-values
39
- RegisteredClaims: jwt.RegisteredClaims{
40
- Subject: userID.String(),
41
- IssuedAt: jwt.NewNumericDate(time.Now()),
42
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessTTL)),
43
- },
44
- }
45
- return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
46
- }
47
-
48
- // issueOAuthState / verifyOAuthState round-trip a short-lived CSRF token
49
- // through Google's own redirect. The signature and TTL are not the whole
50
- // protection: a signed-but-unbound state is one anyone can fetch from
51
- // GET /auth/google/login and then replay in a victim's browser, which is
52
- // exactly the login-CSRF the state parameter exists to stop. So the token
53
- // carries a nonce that the handler also drops in a short-lived httpOnly
54
- // cookie, and the callback only proceeds when the two agree — the state is
55
- // then usable in one browser only, the one that started the flow.
56
- //
57
- // Returns the state and the nonce to put in that cookie.
58
- func (s *Service) issueOAuthState() (string, string, error) {
59
- nonce, err := randomToken()
60
- if err != nil {
61
- return "", "", err
62
- }
63
- claims := accessClaims{
64
- Typ: tokenTypeOAuthState,
65
- Nonce: nonce,
66
- RegisteredClaims: jwt.RegisteredClaims{
67
- IssuedAt: jwt.NewNumericDate(time.Now()),
68
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(oauthStateTTL)),
69
- },
70
- }
71
- state, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
72
- if err != nil {
73
- return "", "", err
74
- }
75
- return state, nonce, nil
76
- }
77
-
78
- func (s *Service) verifyOAuthState(raw, nonce string) error {
79
- var claims accessClaims
80
- _, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
81
- return []byte(s.jwtSecret), nil
82
- }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
83
- if err != nil || claims.Typ != tokenTypeOAuthState {
84
- return errInvalidToken()
85
- }
86
- // A missing cookie is a mismatch, not a pass — otherwise stripping the
87
- // cookie is all it takes to get the old, unbound behavior back.
88
- if nonce == "" || subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonce)) != 1 {
89
- return errInvalidToken()
90
- }
91
- return nil
92
- }
93
-
94
- // randomToken generates an opaque refresh token — deliberately not a JWT:
95
- // nothing needs to read claims out of it, and only its hash (see hashToken)
96
- // is ever stored, so a leaked server-side store never exposes usable tokens.
97
- func randomToken() (string, error) {
98
- b := make([]byte, 32)
99
- if _, err := rand.Read(b); err != nil {
100
- return "", err
101
- }
102
- return hex.EncodeToString(b), nil
103
- }
104
-
105
- func hashToken(raw string) string {
106
- sum := sha256.Sum256([]byte(raw))
107
- return hex.EncodeToString(sum[:])
108
- }
@@ -1,39 +0,0 @@
1
- package model
2
-
3
- import (
4
- "time"
5
-
6
- "github.com/google/uuid"
7
- )
8
-
9
- // AuthToken is one short-lived token — the Postgres equivalent of the keys the
10
- // Redis store would hold. Only the SHA-256 hash of the raw token is stored, so
11
- // a database dump never yields a usable token.
12
- //
13
- // `Kind` mirrors the Redis key prefixes one-for-one, deliberately:
14
- //
15
- // refresh an active refresh token
16
- // refresh_used a tombstone left by rotation, so replaying a rotated-out
17
- // token is detectable as reuse rather than merely unknown
18
- // pwreset password reset, consumed once
19
- // emailverify email verification, consumed once
20
- //
21
- // Keeping the tombstone a separate row (rather than a used_at column on the
22
- // refresh row) is what preserves logout's semantics: logout deletes the row
23
- // outright and leaves no tombstone, so someone replaying a logged-out token
24
- // gets a plain rejection instead of tripping reuse detection and nuking every
25
- // session the user has.
26
- //
27
- // No foreign key to users on purpose: GORM's AutoMigrate (dev) wouldn't create
28
- // one from these tags, and a constraint that exists in production but not in
29
- // development is the exact mismatch that stops the app booting. A token whose
30
- // user is gone simply fails the lookup that follows.
31
- type AuthToken struct {
32
- TokenHash string `gorm:"primaryKey;type:text"`
33
- UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
34
- Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
35
- ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
36
- CreatedAt time.Time
37
- }
38
-
39
- func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
@@ -1,31 +0,0 @@
1
- package model
2
-
3
- import (
4
- "time"
5
-
6
- "github.com/google/uuid"
7
- )
8
-
9
- type Provider string
10
-
11
- const (
12
- ProviderLocal Provider = "local"
13
- ProviderGoogle Provider = "google"
14
- )
15
-
16
- // Identity = how you log in (one row per login method). PasswordHash is set
17
- // for a "local" identity; ProviderUID is set for "google" (the provider's own
18
- // subject id). Split from User so adding a login method never touches the
19
- // profile row — one person can have both and resolve to the same account.
20
- type Identity struct {
21
- ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
22
- UserID uuid.UUID `json:"user_id" gorm:"type:uuid;not null;uniqueIndex:idx_identities_user_provider,priority:1"`
23
- Provider Provider `json:"provider" gorm:"type:varchar(20);not null;uniqueIndex:idx_identities_user_provider,priority:2;uniqueIndex:idx_identities_provider_uid,priority:1"`
24
- PasswordHash *string `json:"-"`
25
- ProviderUID *string `json:"-" gorm:"column:provider_uid;uniqueIndex:idx_identities_provider_uid,priority:2"`
26
- CreatedAt time.Time `json:"created_at"`
27
- UpdatedAt time.Time `json:"updated_at"`
28
- }
29
-
30
- // TableName — see User.TableName; same schema, same reasoning.
31
- func (Identity) TableName() string { return "user_svc.identities" }
@@ -1,26 +0,0 @@
1
- package model
2
-
3
- import "time"
4
-
5
- // LoginThrottle is the failed-attempt counter that makes lockout survive a
6
- // deploy and mean the same thing on every replica. It is not the rate limiter
7
- // — that one counts requests per IP and is allowed to be approximate. This
8
- // counts failures per account, and per OWASP that is the control that actually
9
- // stops credential stuffing: an attacker with a proxy pool keeps per-IP volume
10
- // under any threshold you set, but they cannot spread attempts against one
11
- // account across accounts.
12
- //
13
- // EmailHash, not the address: the counter has to be keyed on what the caller
14
- // typed whether or not an account exists — otherwise "did this get throttled"
15
- // answers "does this account exist" — and hashing means this table never
16
- // becomes a directory of who has signed up.
17
- type LoginThrottle struct {
18
- EmailHash string `gorm:"primaryKey;type:text"`
19
- Failures int `gorm:"not null;default:0"`
20
- // nil until the free attempts are spent; afterwards it moves further out
21
- // with each failure.
22
- LockedUntil *time.Time
23
- UpdatedAt time.Time
24
- }
25
-
26
- func (LoginThrottle) TableName() string { return "user_svc.login_throttle" }
@@ -1,30 +0,0 @@
1
- // Package model holds this domain's GORM tables.
2
- package model
3
-
4
- import (
5
- "time"
6
-
7
- "github.com/google/uuid"
8
- )
9
-
10
- // User = who you are (profile). How you log in is a separate concept — see
11
- // Identity — so the same person can have both a password and a Google login
12
- // resolving to one account.
13
- type User struct {
14
- ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
15
- // index named explicitly, and named the same in create_users.up.sql: an
16
- // anonymous `uniqueIndex` makes GORM invent one, and AutoMigrate then
17
- // tries to DROP the differently-named constraint the migration created.
18
- Email string `json:"email" gorm:"uniqueIndex:idx_users_email;not null"`
19
- Name string `json:"name"`
20
- AvatarURL string `json:"avatar_url"`
21
- EmailVerified bool `json:"email_verified"`
22
- // go-scaffold:user-fields
23
- CreatedAt time.Time `json:"created_at"`
24
- UpdatedAt time.Time `json:"updated_at"`
25
- }
26
-
27
- // TableName pins this to user_svc rather than GORM's default inflection
28
- // ("users", schema-less) — every domain gets its own schema, see
29
- // docs/architect/patterns.md.
30
- func (User) TableName() string { return "user_svc.users" }