@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.
- package/README.md +598 -306
- package/dist/commands/auth.js +65 -23
- package/dist/commands/check.js +281 -0
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +33 -2
- package/dist/commands/generate.js +29 -3
- package/dist/commands/method.js +74 -63
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +21 -10
- package/dist/commands/undo.js +11 -3
- package/dist/commands/worker.js +15 -5
- package/dist/index.js +198 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +42 -1
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +50 -19
- package/dist/templates/create-manifest.js +8 -0
- package/dist/templates/module-manifest.js +84 -26
- package/dist/templates/rbac-manifest.js +16 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +8 -0
- package/dist/utils/auth-patcher.js +124 -33
- package/dist/utils/config.js +167 -4
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +32 -30
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/platform-patcher.js +56 -7
- package/dist/utils/rbac-patcher.js +89 -210
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
- package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
- package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
- package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
- package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
- package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
- package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
- package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
- package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
- package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
- package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
- package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
- package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
- package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
- package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +279 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +115 -32
- package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +92 -32
- package/templates/create/features/docs/patterns.md.hbs +137 -91
- package/templates/create/features/docs/techstack.md.hbs +18 -3
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
- package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
- package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
- package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
- package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
- package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
- package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
- package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
- package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
- package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
- package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
- package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
- package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
- package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
- package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
- package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/dist/utils/method-patcher.js +0 -357
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
- package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
- package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
- package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
- package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
- package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
- package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
- package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
- package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
- package/templates/generate/module/dto.go.hbs +0 -36
- package/templates/generate/module/errors.go.hbs +0 -33
- package/templates/generate/module/handler.go.hbs +0 -134
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -48
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/service.go.hbs +0 -45
- package/templates/generate/module/minimal/service_test.go.hbs +0 -77
- package/templates/generate/module/model/model.go.hbs +0 -36
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -108
- 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" }
|