@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.
- package/README.md +75 -0
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +59 -1
- package/dist/commands/method.js +3 -0
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +19 -2
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +66 -3
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/openapi-patcher.js +16 -0
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/version.js +24 -0
- package/package.json +2 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/Makefile.hbs +30 -5
- package/templates/create/base/README.md.hbs +36 -8
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +13 -0
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/handler.go.hbs +20 -3
- package/templates/generate/module/handler_test.go.hbs +49 -6
- package/templates/generate/module/minimal/handler.go.hbs +21 -3
- package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- 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
|
+
}
|