@nakedev/go-scaffold 0.1.2 → 0.1.3

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 (108) hide show
  1. package/README.md +75 -0
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +59 -1
  5. package/dist/commands/method.js +3 -0
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +19 -2
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +66 -3
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/rbac-manifest.js +17 -0
  16. package/dist/templates/worker-manifest.js +12 -0
  17. package/dist/utils/auth-patcher.js +96 -0
  18. package/dist/utils/gocheck.js +65 -0
  19. package/dist/utils/main-patcher.js +8 -1
  20. package/dist/utils/migrations.js +30 -8
  21. package/dist/utils/openapi-patcher.js +16 -0
  22. package/dist/utils/platform-patcher.js +59 -0
  23. package/dist/utils/rbac-patcher.js +277 -0
  24. package/dist/utils/version.js +24 -0
  25. package/package.json +2 -2
  26. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  27. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  28. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  29. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  30. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  31. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  32. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  33. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  35. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  36. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  37. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  38. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  39. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  40. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  41. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  42. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  43. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  44. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  45. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  46. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  50. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  52. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  54. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  55. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  56. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  57. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  58. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  59. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  60. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  61. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  62. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  63. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  64. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  65. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  66. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  67. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  68. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  69. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  70. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  71. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  72. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  73. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  74. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  75. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  76. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  77. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  78. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  79. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  80. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  81. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  82. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  83. package/templates/create/base/.env.example.hbs +20 -0
  84. package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
  85. package/templates/create/base/.gitignore.hbs +2 -0
  86. package/templates/create/base/Makefile.hbs +30 -5
  87. package/templates/create/base/README.md.hbs +36 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  89. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  90. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  91. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  92. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  93. package/templates/create/base/migrations/embed.go.hbs +15 -0
  94. package/templates/create/features/docs/architecture.md.hbs +22 -0
  95. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  96. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  97. package/templates/create/features/docs/openapi.yaml.hbs +13 -0
  98. package/templates/create/features/docs/techstack.md.hbs +3 -0
  99. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  100. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  101. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  102. package/templates/generate/module/handler.go.hbs +20 -3
  103. package/templates/generate/module/handler_test.go.hbs +49 -6
  104. package/templates/generate/module/minimal/handler.go.hbs +21 -3
  105. package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
  106. package/templates/generate/module/permission.down.sql.hbs +5 -0
  107. package/templates/generate/module/permission.up.sql.hbs +4 -0
  108. package/dist/utils/module-paths.js +0 -33
@@ -0,0 +1,77 @@
1
+ package user
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/user/model"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ type registerInput struct {
12
+ Email string `json:"email" binding:"required,email"`
13
+ Password string `json:"password" binding:"required,min=8"`
14
+ Name string `json:"name" binding:"required"`
15
+ }
16
+
17
+ type loginInput struct {
18
+ Email string `json:"email" binding:"required,email"`
19
+ Password string `json:"password" binding:"required"`
20
+ }
21
+
22
+ type forgotPasswordInput struct {
23
+ Email string `json:"email" binding:"required,email"`
24
+ }
25
+
26
+ type resetPasswordInput struct {
27
+ Token string `json:"token" binding:"required"`
28
+ NewPassword string `json:"new_password" binding:"required,min=8"`
29
+ }
30
+
31
+ type verifyEmailInput struct {
32
+ Token string `json:"token" binding:"required"`
33
+ }
34
+
35
+ // authResponse is what the service returns internally (both tokens); the
36
+ // handler sends the refresh token as an httpOnly cookie instead of echoing
37
+ // it in the body, so meResponse never carries it — see toCookieResponse.
38
+ type authResponse struct {
39
+ AccessToken string `json:"access_token"`
40
+ RefreshToken string `json:"-"`
41
+ TokenType string `json:"token_type"`
42
+ ExpiresIn int `json:"expires_in"`
43
+ }
44
+
45
+ type authCookieResponse struct {
46
+ AccessToken string `json:"access_token"`
47
+ TokenType string `json:"token_type"`
48
+ ExpiresIn int `json:"expires_in"`
49
+ }
50
+
51
+ func toCookieResponse(a *authResponse) authCookieResponse {
52
+ return authCookieResponse{AccessToken: a.AccessToken, TokenType: a.TokenType, ExpiresIn: a.ExpiresIn}
53
+ }
54
+
55
+ type meResponse struct {
56
+ ID uuid.UUID `json:"id"`
57
+ Email string `json:"email"`
58
+ Name string `json:"name"`
59
+ AvatarURL string `json:"avatar_url"`
60
+ EmailVerified bool `json:"email_verified"`
61
+ // go-scaffold:user-me-fields
62
+ CreatedAt time.Time `json:"created_at"`
63
+ }
64
+
65
+ func toMeResponse(u *model.User) meResponse {
66
+ return meResponse{
67
+ ID: u.ID,
68
+ Email: u.Email,
69
+ Name: u.Name,
70
+ AvatarURL: u.AvatarURL,
71
+ EmailVerified: u.EmailVerified,
72
+ CreatedAt: u.CreatedAt,
73
+ // go-scaffold:user-me-values
74
+ }
75
+ }
76
+
77
+ // go-scaffold:user-dto
@@ -0,0 +1,36 @@
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
+ func errInvalidCredentials() *apperror.AppError {
25
+ return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid email or password")
26
+ }
27
+
28
+ func errInvalidToken() *apperror.AppError {
29
+ return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_TOKEN", "invalid or expired refresh token")
30
+ }
31
+
32
+ func errAlreadyVerified() *apperror.AppError {
33
+ return apperror.New(http.StatusConflict, "AUTH_ALREADY_VERIFIED", "email is already verified")
34
+ }
35
+
36
+ // go-scaffold:user-errors
@@ -0,0 +1,235 @@
1
+ package user
2
+
3
+ import (
4
+ "net/http"
5
+ "time"
6
+
7
+ "{{goModule}}/internal/shared/httpx"
8
+ "{{goModule}}/internal/shared/middleware"
9
+ // go-scaffold:user-handler-imports
10
+
11
+ "github.com/gin-gonic/gin"
12
+ "github.com/google/uuid"
13
+ "github.com/redis/go-redis/v9"
14
+ )
15
+
16
+ const refreshCookieName = "refresh_token"
17
+
18
+ // go-scaffold:user-handler-consts
19
+
20
+ type Handler struct {
21
+ svc *Service
22
+ jwtSecret string
23
+ refreshTTL time.Duration
24
+ cookieSecure bool
25
+ rdb *redis.Client
26
+ // go-scaffold:user-handler-fields
27
+ }
28
+
29
+ func NewHandler(
30
+ svc *Service,
31
+ jwtSecret string,
32
+ refreshTTL time.Duration,
33
+ cookieSecure bool,
34
+ rdb *redis.Client,
35
+ // go-scaffold:user-handler-params
36
+ ) *Handler {
37
+ return &Handler{
38
+ svc: svc,
39
+ jwtSecret: jwtSecret,
40
+ refreshTTL: refreshTTL,
41
+ cookieSecure: cookieSecure,
42
+ rdb: rdb,
43
+ // go-scaffold:user-handler-init
44
+ }
45
+ }
46
+
47
+ // Register wires both a public /auth group (register/login/refresh/logout)
48
+ // and a /users group gated by RequireAuth — public vs protected is decided
49
+ // here, per domain, not centrally in main.go.
50
+ func (h *Handler) Register(rg gin.IRouter) {
51
+ // per-IP, per-route budgets — separate names so a burst on one endpoint
52
+ // doesn't spend another's budget. refresh/logout/google aren't limited:
53
+ // refresh/logout are gated by possessing a valid cookie already, and the
54
+ // Google flow's abuse surface lives on Google's side, not ours.
55
+ loginLimit := middleware.RateLimit(h.rdb, "login", 10, time.Minute)
56
+ registerLimit := middleware.RateLimit(h.rdb, "register", 5, time.Minute)
57
+ forgotPasswordLimit := middleware.RateLimit(h.rdb, "forgot-password", 5, time.Minute)
58
+ resetPasswordLimit := middleware.RateLimit(h.rdb, "reset-password", 10, time.Minute)
59
+ verifyEmailLimit := middleware.RateLimit(h.rdb, "verify-email", 10, time.Minute)
60
+ resendVerificationLimit := middleware.RateLimit(h.rdb, "resend-verification", 5, time.Minute)
61
+
62
+ authGroup := rg.Group("/auth")
63
+ authGroup.POST("/register", registerLimit, h.register)
64
+ authGroup.POST("/login", loginLimit, h.login)
65
+ authGroup.POST("/refresh", h.refresh)
66
+ authGroup.POST("/logout", h.logout)
67
+ authGroup.POST("/forgot-password", forgotPasswordLimit, h.forgotPassword)
68
+ authGroup.POST("/reset-password", resetPasswordLimit, h.resetPassword)
69
+ authGroup.POST("/verify-email", verifyEmailLimit, h.verifyEmail)
70
+ authGroup.GET("/google/login", h.googleLogin)
71
+ authGroup.GET("/google/callback", h.googleCallback)
72
+
73
+ usersGroup := rg.Group("/users", middleware.RequireAuth(h.jwtSecret))
74
+ usersGroup.GET("/me", h.me)
75
+ usersGroup.POST("/me/resend-verification", resendVerificationLimit, h.resendVerification)
76
+ usersGroup.POST("/me/logout-all", h.logoutAll)
77
+ // go-scaffold:user-routes
78
+ }
79
+
80
+ func (h *Handler) register(c *gin.Context) {
81
+ var in registerInput
82
+ if err := c.ShouldBindJSON(&in); err != nil {
83
+ c.Error(httpx.BindErr(err))
84
+ return
85
+ }
86
+ auth, err := h.svc.Register(c.Request.Context(), in)
87
+ if err != nil {
88
+ c.Error(err)
89
+ return
90
+ }
91
+ h.setRefreshCookie(c, auth.RefreshToken)
92
+ c.JSON(http.StatusCreated, toCookieResponse(auth))
93
+ }
94
+
95
+ func (h *Handler) login(c *gin.Context) {
96
+ var in loginInput
97
+ if err := c.ShouldBindJSON(&in); err != nil {
98
+ c.Error(httpx.BindErr(err))
99
+ return
100
+ }
101
+ auth, err := h.svc.Login(c.Request.Context(), in)
102
+ if err != nil {
103
+ c.Error(err)
104
+ return
105
+ }
106
+ h.setRefreshCookie(c, auth.RefreshToken)
107
+ c.JSON(http.StatusOK, toCookieResponse(auth))
108
+ }
109
+
110
+ func (h *Handler) refresh(c *gin.Context) {
111
+ raw, err := c.Cookie(refreshCookieName)
112
+ if err != nil || raw == "" {
113
+ c.Error(errInvalidToken())
114
+ return
115
+ }
116
+ auth, err := h.svc.Refresh(c.Request.Context(), raw)
117
+ if err != nil {
118
+ h.clearRefreshCookie(c)
119
+ c.Error(err)
120
+ return
121
+ }
122
+ h.setRefreshCookie(c, auth.RefreshToken)
123
+ c.JSON(http.StatusOK, toCookieResponse(auth))
124
+ }
125
+
126
+ func (h *Handler) logout(c *gin.Context) {
127
+ raw, _ := c.Cookie(refreshCookieName)
128
+ _ = h.svc.Logout(c.Request.Context(), raw)
129
+ h.clearRefreshCookie(c)
130
+ c.Status(http.StatusNoContent)
131
+ }
132
+
133
+ func (h *Handler) forgotPassword(c *gin.Context) {
134
+ var in forgotPasswordInput
135
+ if err := c.ShouldBindJSON(&in); err != nil {
136
+ c.Error(httpx.BindErr(err))
137
+ return
138
+ }
139
+ if err := h.svc.ForgotPassword(c.Request.Context(), in.Email); err != nil {
140
+ c.Error(err)
141
+ return
142
+ }
143
+ // always the same response, whether or not the email exists — see
144
+ // Service.ForgotPassword for why.
145
+ c.JSON(http.StatusOK, gin.H{"message": "if that email exists, a reset link has been sent"})
146
+ }
147
+
148
+ func (h *Handler) resetPassword(c *gin.Context) {
149
+ var in resetPasswordInput
150
+ if err := c.ShouldBindJSON(&in); err != nil {
151
+ c.Error(httpx.BindErr(err))
152
+ return
153
+ }
154
+ if err := h.svc.ResetPassword(c.Request.Context(), in.Token, in.NewPassword); err != nil {
155
+ c.Error(err)
156
+ return
157
+ }
158
+ c.Status(http.StatusNoContent)
159
+ }
160
+
161
+ func (h *Handler) verifyEmail(c *gin.Context) {
162
+ var in verifyEmailInput
163
+ if err := c.ShouldBindJSON(&in); err != nil {
164
+ c.Error(httpx.BindErr(err))
165
+ return
166
+ }
167
+ if err := h.svc.VerifyEmail(c.Request.Context(), in.Token); err != nil {
168
+ c.Error(err)
169
+ return
170
+ }
171
+ c.Status(http.StatusNoContent)
172
+ }
173
+
174
+ func (h *Handler) resendVerification(c *gin.Context) {
175
+ userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
176
+ if err := h.svc.ResendVerificationEmail(c.Request.Context(), userID); err != nil {
177
+ c.Error(err)
178
+ return
179
+ }
180
+ c.Status(http.StatusNoContent)
181
+ }
182
+
183
+ // logoutAll ends every session for the caller, not just the one making the
184
+ // request — also clears this request's own cookie, since that session is
185
+ // dead too now.
186
+ func (h *Handler) logoutAll(c *gin.Context) {
187
+ userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
188
+ if err := h.svc.LogoutAll(c.Request.Context(), userID); err != nil {
189
+ c.Error(err)
190
+ return
191
+ }
192
+ h.clearRefreshCookie(c)
193
+ c.Status(http.StatusNoContent)
194
+ }
195
+
196
+ func (h *Handler) googleLogin(c *gin.Context) {
197
+ url, err := h.svc.GoogleLoginURL()
198
+ if err != nil {
199
+ c.Error(err)
200
+ return
201
+ }
202
+ c.Redirect(http.StatusFound, url)
203
+ }
204
+
205
+ func (h *Handler) googleCallback(c *gin.Context) {
206
+ auth, err := h.svc.GoogleCallback(c.Request.Context(), c.Query("code"), c.Query("state"))
207
+ if err != nil {
208
+ c.Error(err)
209
+ return
210
+ }
211
+ h.setRefreshCookie(c, auth.RefreshToken)
212
+ c.JSON(http.StatusOK, toCookieResponse(auth))
213
+ }
214
+
215
+ func (h *Handler) me(c *gin.Context) {
216
+ userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
217
+ u, err := h.svc.Get(c.Request.Context(), userID)
218
+ if err != nil {
219
+ c.Error(err)
220
+ return
221
+ }
222
+ c.JSON(http.StatusOK, toMeResponse(u))
223
+ }
224
+
225
+ // go-scaffold:user-handler-funcs
226
+
227
+ func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
228
+ c.SetSameSite(http.SameSiteStrictMode)
229
+ c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
230
+ }
231
+
232
+ func (h *Handler) clearRefreshCookie(c *gin.Context) {
233
+ c.SetSameSite(http.SameSiteStrictMode)
234
+ c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
235
+ }
@@ -0,0 +1,84 @@
1
+ package user
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "time"
8
+
9
+ "github.com/golang-jwt/jwt/v5"
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ const (
14
+ tokenTypeAccess = "access"
15
+ tokenTypeOAuthState = "oauth_state"
16
+ oauthStateTTL = 10 * time.Minute
17
+ )
18
+
19
+ // accessClaims — see internal/shared/middleware/auth.go for why this struct
20
+ // is duplicated there instead of imported. Also reused (with no Subject) for
21
+ // the Google OAuth CSRF state token below — Typ is what stops one kind being
22
+ // presented where the other is expected.
23
+ type accessClaims struct {
24
+ Typ string `json:"typ"`
25
+ // go-scaffold:jwt-claims
26
+ jwt.RegisteredClaims
27
+ }
28
+
29
+ func (s *Service) issueAccessToken(
30
+ userID uuid.UUID,
31
+ // go-scaffold:issue-access-token-params
32
+ ) (string, error) {
33
+ claims := accessClaims{
34
+ Typ: tokenTypeAccess,
35
+ // go-scaffold:jwt-claims-values
36
+ RegisteredClaims: jwt.RegisteredClaims{
37
+ Subject: userID.String(),
38
+ IssuedAt: jwt.NewNumericDate(time.Now()),
39
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessTTL)),
40
+ },
41
+ }
42
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
43
+ }
44
+
45
+ // issueOAuthState / verifyOAuthState round-trip a short-lived, stateless CSRF
46
+ // token through Google's own redirect — no server-side row needed, the JWT's
47
+ // signature + short TTL is the whole protection.
48
+ func (s *Service) issueOAuthState() (string, error) {
49
+ claims := accessClaims{
50
+ Typ: tokenTypeOAuthState,
51
+ RegisteredClaims: jwt.RegisteredClaims{
52
+ IssuedAt: jwt.NewNumericDate(time.Now()),
53
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(oauthStateTTL)),
54
+ },
55
+ }
56
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
57
+ }
58
+
59
+ func (s *Service) verifyOAuthState(raw string) error {
60
+ var claims accessClaims
61
+ _, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
62
+ return []byte(s.jwtSecret), nil
63
+ }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
64
+ if err != nil || claims.Typ != tokenTypeOAuthState {
65
+ return errInvalidToken()
66
+ }
67
+ return nil
68
+ }
69
+
70
+ // randomToken generates an opaque refresh token — deliberately not a JWT:
71
+ // nothing needs to read claims out of it, and only its hash (see hashToken)
72
+ // is ever stored, so a leaked server-side store never exposes usable tokens.
73
+ func randomToken() (string, error) {
74
+ b := make([]byte, 32)
75
+ if _, err := rand.Read(b); err != nil {
76
+ return "", err
77
+ }
78
+ return hex.EncodeToString(b), nil
79
+ }
80
+
81
+ func hashToken(raw string) string {
82
+ sum := sha256.Sum256([]byte(raw))
83
+ return hex.EncodeToString(sum[:])
84
+ }
@@ -0,0 +1,28 @@
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
+ }
@@ -0,0 +1,22 @@
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
+ Email string `json:"email" gorm:"uniqueIndex;not null"`
16
+ Name string `json:"name"`
17
+ AvatarURL string `json:"avatar_url"`
18
+ EmailVerified bool `json:"email_verified"`
19
+ // go-scaffold:user-fields
20
+ CreatedAt time.Time `json:"created_at"`
21
+ UpdatedAt time.Time `json:"updated_at"`
22
+ }
@@ -0,0 +1,84 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+
6
+ "{{goModule}}/internal/app/user/model"
7
+
8
+ "github.com/google/uuid"
9
+ "gorm.io/gorm"
10
+ )
11
+
12
+ type Repository struct {
13
+ db *gorm.DB
14
+ }
15
+
16
+ func NewRepository(db *gorm.DB) *Repository {
17
+ return &Repository{db: db}
18
+ }
19
+
20
+ func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
21
+ var u model.User
22
+ if err := r.db.WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
23
+ return nil, err
24
+ }
25
+ return &u, nil
26
+ }
27
+
28
+ func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
29
+ var u model.User
30
+ if err := r.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
31
+ return nil, err
32
+ }
33
+ return &u, nil
34
+ }
35
+
36
+ func (r *Repository) UpdateUser(ctx context.Context, u *model.User) error {
37
+ return r.db.WithContext(ctx).Save(u).Error
38
+ }
39
+
40
+ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.User, error) {
41
+ var items []model.User
42
+ err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
43
+ return items, err
44
+ }
45
+
46
+ func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error) {
47
+ var i model.Identity
48
+ if err := r.db.WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
49
+ return nil, err
50
+ }
51
+ return &i, nil
52
+ }
53
+
54
+ func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error) {
55
+ var i model.Identity
56
+ if err := r.db.WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
57
+ return nil, err
58
+ }
59
+ return &i, nil
60
+ }
61
+
62
+ // CreateIdentity links a new login method onto an EXISTING user (e.g.
63
+ // Google linking onto an account that registered with a password first) —
64
+ // see CreateUserWithIdentity for the "brand new user" case.
65
+ func (r *Repository) CreateIdentity(ctx context.Context, i *model.Identity) error {
66
+ return r.db.WithContext(ctx).Create(i).Error
67
+ }
68
+
69
+ func (r *Repository) UpdateIdentity(ctx context.Context, i *model.Identity) error {
70
+ return r.db.WithContext(ctx).Save(i).Error
71
+ }
72
+
73
+ // CreateUserWithIdentity inserts the profile and its first login method in
74
+ // one transaction — a user with no identity at all can't log in any way, so
75
+ // the two rows always exist together or not at all.
76
+ 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 {
79
+ return err
80
+ }
81
+ i.UserID = u.ID
82
+ return tx.Create(i).Error
83
+ })
84
+ }