@nakedev/go-scaffold 0.1.3 → 0.3.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/LICENSE +21 -0
- package/README.md +143 -50
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +23 -12
- package/dist/commands/method.js +94 -17
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +368 -64
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/module-manifest.js +2 -0
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +97 -18
- package/dist/utils/module-location.js +58 -0
- package/dist/utils/naming.js +125 -14
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/openapi-patcher.js +19 -1
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/dist/utils/smoke-run.js +31 -0
- package/package.json +11 -4
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +14 -12
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +52 -16
- package/templates/create/base/README.md.hbs +61 -12
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +4 -9
- package/templates/create/features/docs/patterns.md.hbs +91 -14
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler.go.hbs +18 -4
- package/templates/generate/module/handler_test.go.hbs +88 -62
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -1
- package/templates/generate/module/minimal/handler.go.hbs +8 -2
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +16 -0
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +109 -0
- package/templates/generate/module/service.go.hbs +15 -4
- package/templates/generate/module/service_test.go.hbs +113 -17
- package/dist/commands/remove.js +0 -89
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nakedev/go-scaffold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -15,11 +15,16 @@
|
|
|
15
15
|
"bin"
|
|
16
16
|
],
|
|
17
17
|
"type": "commonjs",
|
|
18
|
+
"packageManager": "pnpm@11.0.7",
|
|
18
19
|
"scripts": {
|
|
19
20
|
"build": "tsc",
|
|
20
21
|
"dev": "tsc --watch",
|
|
21
|
-
"test": "
|
|
22
|
+
"test": "pnpm run test:unit && pnpm run test:integration && pnpm run test:smoke",
|
|
23
|
+
"test:unit": "node --test tests/unit/*.test.mjs",
|
|
24
|
+
"test:integration": "node --test tests/integration/*.test.mjs",
|
|
25
|
+
"test:smoke": "node scripts/smoke-test.mjs",
|
|
22
26
|
"verify": "pnpm run build && pnpm run test",
|
|
27
|
+
"prepack": "rm -rf dist && pnpm run build",
|
|
23
28
|
"prepublishOnly": "pnpm run verify"
|
|
24
29
|
},
|
|
25
30
|
"keywords": [
|
|
@@ -40,14 +45,16 @@
|
|
|
40
45
|
"commander": "^15.0.0",
|
|
41
46
|
"fs-extra": "^11.3.0",
|
|
42
47
|
"handlebars": "^4.7.8",
|
|
43
|
-
"picocolors": "^1.1.1"
|
|
48
|
+
"picocolors": "^1.1.1",
|
|
49
|
+
"pluralize": "^8.0.0"
|
|
44
50
|
},
|
|
45
51
|
"devDependencies": {
|
|
46
52
|
"@types/fs-extra": "^11.0.4",
|
|
47
53
|
"@types/node": "^24.0.0",
|
|
54
|
+
"@types/pluralize": "^0.0.33",
|
|
48
55
|
"typescript": "^5.7.0"
|
|
49
56
|
},
|
|
50
57
|
"engines": {
|
|
51
|
-
"node": ">=
|
|
58
|
+
"node": ">=22.13"
|
|
52
59
|
}
|
|
53
60
|
}
|
|
@@ -21,6 +21,13 @@ func errEmailTaken() *apperror.AppError {
|
|
|
21
21
|
// errInvalidCredentials is returned for both "no such user" and "wrong
|
|
22
22
|
// password" — a single generic message so a login attempt can't be used to
|
|
23
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
|
+
|
|
24
31
|
func errInvalidCredentials() *apperror.AppError {
|
|
25
32
|
return apperror.New(http.StatusUnauthorized, "AUTH_INVALID_CREDENTIALS", "invalid email or password")
|
|
26
33
|
}
|
|
@@ -2,6 +2,7 @@ package user
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"net/http"
|
|
5
|
+
"strings"
|
|
5
6
|
"time"
|
|
6
7
|
|
|
7
8
|
"{{goModule}}/internal/shared/httpx"
|
|
@@ -10,19 +11,26 @@ import (
|
|
|
10
11
|
|
|
11
12
|
"github.com/gin-gonic/gin"
|
|
12
13
|
"github.com/google/uuid"
|
|
13
|
-
"github.com/redis/go-redis/v9"
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
const
|
|
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
|
+
)
|
|
17
22
|
|
|
18
23
|
// go-scaffold:user-handler-consts
|
|
19
24
|
|
|
20
25
|
type Handler struct {
|
|
21
|
-
svc
|
|
22
|
-
jwtSecret
|
|
23
|
-
refreshTTL
|
|
24
|
-
cookieSecure
|
|
25
|
-
|
|
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
|
|
26
34
|
// go-scaffold:user-handler-fields
|
|
27
35
|
}
|
|
28
36
|
|
|
@@ -31,15 +39,17 @@ func NewHandler(
|
|
|
31
39
|
jwtSecret string,
|
|
32
40
|
refreshTTL time.Duration,
|
|
33
41
|
cookieSecure bool,
|
|
34
|
-
|
|
42
|
+
cookieSameSite string,
|
|
43
|
+
limiter middleware.Limiter,
|
|
35
44
|
// go-scaffold:user-handler-params
|
|
36
45
|
) *Handler {
|
|
37
46
|
return &Handler{
|
|
38
|
-
svc:
|
|
39
|
-
jwtSecret:
|
|
40
|
-
refreshTTL:
|
|
41
|
-
cookieSecure:
|
|
42
|
-
|
|
47
|
+
svc: svc,
|
|
48
|
+
jwtSecret: jwtSecret,
|
|
49
|
+
refreshTTL: refreshTTL,
|
|
50
|
+
cookieSecure: cookieSecure,
|
|
51
|
+
cookieSameSite: cookieSameSite,
|
|
52
|
+
limiter: limiter,
|
|
43
53
|
// go-scaffold:user-handler-init
|
|
44
54
|
}
|
|
45
55
|
}
|
|
@@ -52,12 +62,12 @@ func (h *Handler) Register(rg gin.IRouter) {
|
|
|
52
62
|
// doesn't spend another's budget. refresh/logout/google aren't limited:
|
|
53
63
|
// refresh/logout are gated by possessing a valid cookie already, and the
|
|
54
64
|
// Google flow's abuse surface lives on Google's side, not ours.
|
|
55
|
-
loginLimit := middleware.RateLimit(h.
|
|
56
|
-
registerLimit := middleware.RateLimit(h.
|
|
57
|
-
forgotPasswordLimit := middleware.RateLimit(h.
|
|
58
|
-
resetPasswordLimit := middleware.RateLimit(h.
|
|
59
|
-
verifyEmailLimit := middleware.RateLimit(h.
|
|
60
|
-
resendVerificationLimit := middleware.RateLimit(h.
|
|
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)
|
|
61
71
|
|
|
62
72
|
authGroup := rg.Group("/auth")
|
|
63
73
|
authGroup.POST("/register", registerLimit, h.register)
|
|
@@ -194,16 +204,26 @@ func (h *Handler) logoutAll(c *gin.Context) {
|
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
func (h *Handler) googleLogin(c *gin.Context) {
|
|
197
|
-
url, err := h.svc.GoogleLoginURL()
|
|
207
|
+
url, nonce, err := h.svc.GoogleLoginURL()
|
|
198
208
|
if err != nil {
|
|
199
209
|
c.Error(err)
|
|
200
210
|
return
|
|
201
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)
|
|
202
217
|
c.Redirect(http.StatusFound, url)
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
func (h *Handler) googleCallback(c *gin.Context) {
|
|
206
|
-
|
|
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)
|
|
207
227
|
if err != nil {
|
|
208
228
|
c.Error(err)
|
|
209
229
|
return
|
|
@@ -225,11 +245,32 @@ func (h *Handler) me(c *gin.Context) {
|
|
|
225
245
|
// go-scaffold:user-handler-funcs
|
|
226
246
|
|
|
227
247
|
func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
|
|
228
|
-
c.SetSameSite(
|
|
248
|
+
c.SetSameSite(sameSiteFrom(h.cookieSameSite))
|
|
229
249
|
c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
|
|
230
250
|
}
|
|
231
251
|
|
|
232
252
|
func (h *Handler) clearRefreshCookie(c *gin.Context) {
|
|
233
|
-
c.SetSameSite(
|
|
253
|
+
c.SetSameSite(sameSiteFrom(h.cookieSameSite))
|
|
234
254
|
c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
|
|
235
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
|
+
}
|
|
@@ -3,6 +3,7 @@ package user
|
|
|
3
3
|
import (
|
|
4
4
|
"crypto/rand"
|
|
5
5
|
"crypto/sha256"
|
|
6
|
+
"crypto/subtle"
|
|
6
7
|
"encoding/hex"
|
|
7
8
|
"time"
|
|
8
9
|
|
|
@@ -22,6 +23,8 @@ const (
|
|
|
22
23
|
// presented where the other is expected.
|
|
23
24
|
type accessClaims struct {
|
|
24
25
|
Typ string `json:"typ"`
|
|
26
|
+
// Nonce is only set on the OAuth state token — see issueOAuthState.
|
|
27
|
+
Nonce string `json:"nonce,omitempty"`
|
|
25
28
|
// go-scaffold:jwt-claims
|
|
26
29
|
jwt.RegisteredClaims
|
|
27
30
|
}
|
|
@@ -42,21 +45,37 @@ func (s *Service) issueAccessToken(
|
|
|
42
45
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
// issueOAuthState / verifyOAuthState round-trip a short-lived
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
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
|
+
}
|
|
49
63
|
claims := accessClaims{
|
|
50
|
-
Typ:
|
|
64
|
+
Typ: tokenTypeOAuthState,
|
|
65
|
+
Nonce: nonce,
|
|
51
66
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
52
67
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
53
68
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(oauthStateTTL)),
|
|
54
69
|
},
|
|
55
70
|
}
|
|
56
|
-
|
|
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
|
|
57
76
|
}
|
|
58
77
|
|
|
59
|
-
func (s *Service) verifyOAuthState(raw string) error {
|
|
78
|
+
func (s *Service) verifyOAuthState(raw, nonce string) error {
|
|
60
79
|
var claims accessClaims
|
|
61
80
|
_, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
|
|
62
81
|
return []byte(s.jwtSecret), nil
|
|
@@ -64,6 +83,11 @@ func (s *Service) verifyOAuthState(raw string) error {
|
|
|
64
83
|
if err != nil || claims.Typ != tokenTypeOAuthState {
|
|
65
84
|
return errInvalidToken()
|
|
66
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
|
+
}
|
|
67
91
|
return nil
|
|
68
92
|
}
|
|
69
93
|
|
|
@@ -0,0 +1,39 @@
|
|
|
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" }
|
|
@@ -0,0 +1,26 @@
|
|
|
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" }
|
|
@@ -12,7 +12,10 @@ import (
|
|
|
12
12
|
// resolving to one account.
|
|
13
13
|
type User struct {
|
|
14
14
|
ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
|
|
15
|
-
|
|
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"`
|
|
16
19
|
Name string `json:"name"`
|
|
17
20
|
AvatarURL string `json:"avatar_url"`
|
|
18
21
|
EmailVerified bool `json:"email_verified"`
|
|
@@ -20,3 +23,8 @@ type User struct {
|
|
|
20
23
|
CreatedAt time.Time `json:"created_at"`
|
|
21
24
|
UpdatedAt time.Time `json:"updated_at"`
|
|
22
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" }
|
|
@@ -2,8 +2,11 @@ package user
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"time"
|
|
5
7
|
|
|
6
8
|
"{{goModule}}/internal/app/user/model"
|
|
9
|
+
"{{goModule}}/internal/shared/tx"
|
|
7
10
|
|
|
8
11
|
"github.com/google/uuid"
|
|
9
12
|
"gorm.io/gorm"
|
|
@@ -19,7 +22,7 @@ func NewRepository(db *gorm.DB) *Repository {
|
|
|
19
22
|
|
|
20
23
|
func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
|
|
21
24
|
var u model.User
|
|
22
|
-
if err := r.db.WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
|
|
25
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "email = ?", email).Error; err != nil {
|
|
23
26
|
return nil, err
|
|
24
27
|
}
|
|
25
28
|
return &u, nil
|
|
@@ -27,25 +30,25 @@ func (r *Repository) FindByEmail(ctx context.Context, email string) (*model.User
|
|
|
27
30
|
|
|
28
31
|
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.User, error) {
|
|
29
32
|
var u model.User
|
|
30
|
-
if err := r.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
|
|
33
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
|
|
31
34
|
return nil, err
|
|
32
35
|
}
|
|
33
36
|
return &u, nil
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
func (r *Repository) UpdateUser(ctx context.Context, u *model.User) error {
|
|
37
|
-
return r.db.WithContext(ctx).Save(u).Error
|
|
40
|
+
return tx.From(ctx, r.db).WithContext(ctx).Save(u).Error
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.User, error) {
|
|
41
44
|
var items []model.User
|
|
42
|
-
err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
|
|
45
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error
|
|
43
46
|
return items, err
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error) {
|
|
47
50
|
var i model.Identity
|
|
48
|
-
if err := r.db.WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
|
|
51
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "user_id = ? AND provider = ?", userID, provider).Error; err != nil {
|
|
49
52
|
return nil, err
|
|
50
53
|
}
|
|
51
54
|
return &i, nil
|
|
@@ -53,7 +56,7 @@ func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provide
|
|
|
53
56
|
|
|
54
57
|
func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error) {
|
|
55
58
|
var i model.Identity
|
|
56
|
-
if err := r.db.WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
|
|
59
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).First(&i, "provider = ? AND provider_uid = ?", provider, providerUID).Error; err != nil {
|
|
57
60
|
return nil, err
|
|
58
61
|
}
|
|
59
62
|
return &i, nil
|
|
@@ -63,22 +66,72 @@ func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider mod
|
|
|
63
66
|
// Google linking onto an account that registered with a password first) —
|
|
64
67
|
// see CreateUserWithIdentity for the "brand new user" case.
|
|
65
68
|
func (r *Repository) CreateIdentity(ctx context.Context, i *model.Identity) error {
|
|
66
|
-
return r.db.WithContext(ctx).Create(i).Error
|
|
69
|
+
return tx.From(ctx, r.db).WithContext(ctx).Create(i).Error
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
func (r *Repository) UpdateIdentity(ctx context.Context, i *model.Identity) error {
|
|
70
|
-
return r.db.WithContext(ctx).Save(i).Error
|
|
73
|
+
return tx.From(ctx, r.db).WithContext(ctx).Save(i).Error
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
// CreateUserWithIdentity inserts the profile and its first login method in
|
|
74
77
|
// one transaction — a user with no identity at all can't log in any way, so
|
|
75
78
|
// the two rows always exist together or not at all.
|
|
76
79
|
func (r *Repository) CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error {
|
|
77
|
-
return r.db.WithContext(ctx).Transaction(func(
|
|
78
|
-
if err :=
|
|
80
|
+
return tx.From(ctx, r.db).WithContext(ctx).Transaction(func(t *gorm.DB) error {
|
|
81
|
+
if err := t.Create(u).Error; err != nil {
|
|
79
82
|
return err
|
|
80
83
|
}
|
|
81
84
|
i.UserID = u.ID
|
|
82
|
-
return
|
|
85
|
+
return t.Create(i).Error
|
|
83
86
|
})
|
|
84
87
|
}
|
|
88
|
+
|
|
89
|
+
// --- failed-login throttle -------------------------------------------------
|
|
90
|
+
//
|
|
91
|
+
// Keyed on a caller-supplied hash rather than a user id, because the counter
|
|
92
|
+
// has to work for addresses that have no account — see model.LoginThrottle.
|
|
93
|
+
|
|
94
|
+
// LoginLockedUntil reports when this key stops being locked out — zero time
|
|
95
|
+
// when it isn't. Read on every login attempt, so it stays a primary-key hit.
|
|
96
|
+
func (r *Repository) LoginLockedUntil(ctx context.Context, key string) (time.Time, error) {
|
|
97
|
+
var row model.LoginThrottle
|
|
98
|
+
err := tx.From(ctx, r.db).WithContext(ctx).Where("email_hash = ?", key).Take(&row).Error
|
|
99
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
100
|
+
return time.Time{}, nil
|
|
101
|
+
}
|
|
102
|
+
if err != nil || row.LockedUntil == nil {
|
|
103
|
+
return time.Time{}, err
|
|
104
|
+
}
|
|
105
|
+
return *row.LockedUntil, nil
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// RecordLoginFailure bumps the counter and pushes the lock further out,
|
|
109
|
+
// doubling each time past freeAttempts and never exceeding maxLock.
|
|
110
|
+
//
|
|
111
|
+
// One statement, so two attempts racing cannot both read the same count and
|
|
112
|
+
// write the same lock — which would let an attacker keep the backoff pinned at
|
|
113
|
+
// its first step by running attempts in parallel. Computing the interval in
|
|
114
|
+
// SQL is what buys that; a read-then-write in Go would need a transaction and
|
|
115
|
+
// a row lock to be equally safe.
|
|
116
|
+
func (r *Repository) RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error {
|
|
117
|
+
return tx.From(ctx, r.db).WithContext(ctx).Exec(`
|
|
118
|
+
INSERT INTO user_svc.login_throttle AS t (email_hash, failures, locked_until, updated_at)
|
|
119
|
+
VALUES (?, 1, NULL, now())
|
|
120
|
+
ON CONFLICT (email_hash) DO UPDATE SET
|
|
121
|
+
failures = t.failures + 1,
|
|
122
|
+
locked_until = CASE
|
|
123
|
+
WHEN t.failures + 1 <= ? THEN NULL
|
|
124
|
+
ELSE now() + make_interval(secs => least(power(2, t.failures + 1 - ?), ?))
|
|
125
|
+
END,
|
|
126
|
+
updated_at = now()`,
|
|
127
|
+
key, freeAttempts, freeAttempts, maxLock.Seconds()).Error
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ClearLoginFailures drops the row on a successful login, so someone who
|
|
131
|
+
// mistypes twice and then gets in starts clean rather than creeping toward a
|
|
132
|
+
// lockout over weeks.
|
|
133
|
+
func (r *Repository) ClearLoginFailures(ctx context.Context, key string) error {
|
|
134
|
+
return tx.From(ctx, r.db).WithContext(ctx).
|
|
135
|
+
Where("email_hash = ?", key).
|
|
136
|
+
Delete(&model.LoginThrottle{}).Error
|
|
137
|
+
}
|