@nakedev/go-scaffold 0.3.3 → 0.4.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 +288 -50
- package/dist/commands/auth.js +53 -22
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +32 -2
- package/dist/commands/generate.js +25 -2
- package/dist/commands/method.js +22 -7
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +3 -3
- package/dist/commands/rbac.js +3 -3
- package/dist/commands/undo.js +5 -0
- package/dist/commands/worker.js +1 -1
- package/dist/index.js +186 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +43 -2
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +31 -1
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +37 -1
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/auth-patcher.js +115 -24
- package/dist/utils/config.js +147 -3
- package/dist/utils/main-patcher.js +29 -27
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/method-patcher.js +261 -81
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/observability-patcher.js +2 -2
- package/dist/utils/platform-patcher.js +29 -7
- package/dist/utils/rbac-patcher.js +97 -75
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +13 -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/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
- package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
- package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
- package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
- package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
- package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
- package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
- package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
- package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
- package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
- package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
- package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
- package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
- package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
- package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
- package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
- package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -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 +9 -4
- package/templates/add/auth/migrations/create_identities.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 +2 -2
- package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/AGENTS.md.hbs +255 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +45 -17
- package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
- 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 +38 -16
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +3 -3
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
|
|
6
|
+
"github.com/gin-gonic/gin"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
|
|
10
|
+
c.SetSameSite(sameSiteFrom(h.cookieSameSite))
|
|
11
|
+
c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func (h *Handler) clearRefreshCookie(c *gin.Context) {
|
|
15
|
+
c.SetSameSite(sameSiteFrom(h.cookieSameSite))
|
|
16
|
+
c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Token responses must not be retained by browsers, shared caches, or
|
|
20
|
+
// intermediary middleware.
|
|
21
|
+
func setNoStoreHeaders(c *gin.Context) {
|
|
22
|
+
c.Header("Cache-Control", "no-store")
|
|
23
|
+
c.Header("Pragma", "no-cache")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (h *Handler) writeAuthResult(c *gin.Context, status int, result *authResult) {
|
|
27
|
+
if result.MFAChallenge != "" {
|
|
28
|
+
c.JSON(http.StatusOK, mfaChallengeResponse{MFARequired: true, Challenge: result.MFAChallenge})
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
h.setRefreshCookie(c, result.RefreshToken)
|
|
32
|
+
c.JSON(status, toCookieResponse(result.authResponse))
|
|
33
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/user/model"
|
|
9
|
+
"{{goModule}}/internal/shared/apperror"
|
|
10
|
+
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Refresh rotates a refresh token. A replayed, already-used token revokes
|
|
15
|
+
// every session for that user; an intentionally logged-out token is rejected
|
|
16
|
+
// without triggering reuse detection.
|
|
17
|
+
func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*authResponse, error) {
|
|
18
|
+
hash := hashToken(rawRefreshToken)
|
|
19
|
+
token, ok, err := s.refreshTokens.ConsumeRefreshToken(ctx, hash)
|
|
20
|
+
if err != nil {
|
|
21
|
+
return nil, apperror.NewInternal(fmt.Errorf("consume refresh token: %w", err))
|
|
22
|
+
}
|
|
23
|
+
if !ok {
|
|
24
|
+
reusedBy, used, reuseErr := s.refreshTokens.IsRefreshTokenUsed(ctx, hash)
|
|
25
|
+
if reuseErr != nil {
|
|
26
|
+
return nil, apperror.NewInternal(fmt.Errorf("check refresh token reuse: %w", reuseErr))
|
|
27
|
+
}
|
|
28
|
+
if used {
|
|
29
|
+
if revokeErr := s.refreshTokens.RevokeAllRefreshTokens(ctx, reusedBy); revokeErr != nil {
|
|
30
|
+
return nil, apperror.NewInternal(fmt.Errorf("revoke sessions after refresh token reuse: %w", revokeErr))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return nil, errInvalidToken()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
u, err := s.repo.FindByID(ctx, token.UserID)
|
|
37
|
+
if err != nil {
|
|
38
|
+
return nil, errInvalidToken()
|
|
39
|
+
}
|
|
40
|
+
return s.issueTokens(ctx, u, token.AbsoluteExpiresAt)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Logout is intentional, not reuse: a missing or already-gone token still
|
|
44
|
+
// succeeds and leaves no tombstone.
|
|
45
|
+
func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error {
|
|
46
|
+
if rawRefreshToken == "" {
|
|
47
|
+
return nil
|
|
48
|
+
}
|
|
49
|
+
hash := hashToken(rawRefreshToken)
|
|
50
|
+
userID, ok, err := s.refreshTokens.GetRefreshToken(ctx, hash)
|
|
51
|
+
if err != nil || !ok {
|
|
52
|
+
return nil
|
|
53
|
+
}
|
|
54
|
+
return s.refreshTokens.DeleteRefreshToken(ctx, hash, userID)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// LogoutAll ends every refresh session for the authenticated user.
|
|
58
|
+
func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
|
|
59
|
+
return s.refreshTokens.RevokeAllRefreshTokens(ctx, userID)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func (s *Service) issueTokens(ctx context.Context, u *model.User, absoluteExpiresAt ...time.Time) (*authResponse, error) {
|
|
63
|
+
access, err := s.issueAccessToken(
|
|
64
|
+
u.ID,
|
|
65
|
+
// go-scaffold:issue-access-token-args
|
|
66
|
+
)
|
|
67
|
+
if err != nil {
|
|
68
|
+
return nil, apperror.NewInternal(err)
|
|
69
|
+
}
|
|
70
|
+
refresh, err := randomToken()
|
|
71
|
+
if err != nil {
|
|
72
|
+
return nil, apperror.NewInternal(err)
|
|
73
|
+
}
|
|
74
|
+
clock := s.now
|
|
75
|
+
if clock == nil {
|
|
76
|
+
clock = time.Now
|
|
77
|
+
}
|
|
78
|
+
now := clock()
|
|
79
|
+
abs := now.Add(s.config.JWTRefreshMaxTTL)
|
|
80
|
+
if len(absoluteExpiresAt) > 0 && !absoluteExpiresAt[0].IsZero() {
|
|
81
|
+
abs = absoluteExpiresAt[0]
|
|
82
|
+
}
|
|
83
|
+
expiresAt := now.Add(s.config.JWTRefreshTTL)
|
|
84
|
+
if abs.Before(expiresAt) {
|
|
85
|
+
expiresAt = abs
|
|
86
|
+
}
|
|
87
|
+
if !expiresAt.After(now) || !abs.After(now) {
|
|
88
|
+
return nil, apperror.NewInternal(fmt.Errorf("refresh token lifetime is exhausted"))
|
|
89
|
+
}
|
|
90
|
+
if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), refreshTokenRecord{UserID: u.ID, ExpiresAt: expiresAt, AbsoluteExpiresAt: abs}); err != nil {
|
|
91
|
+
return nil, apperror.NewInternal(err)
|
|
92
|
+
}
|
|
93
|
+
return &authResponse{
|
|
94
|
+
AccessToken: access,
|
|
95
|
+
RefreshToken: refresh,
|
|
96
|
+
TokenType: "Bearer",
|
|
97
|
+
ExpiresIn: int(s.config.JWTAccessTTL.Seconds()),
|
|
98
|
+
}, nil
|
|
99
|
+
}
|
|
@@ -4,27 +4,55 @@ import (
|
|
|
4
4
|
"context"
|
|
5
5
|
"time"
|
|
6
6
|
|
|
7
|
+
"{{goModule}}/internal/app/user/application"
|
|
8
|
+
|
|
7
9
|
"github.com/google/uuid"
|
|
8
10
|
)
|
|
9
11
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// `add auth --store postgres` -> tokenstore_pg.go (default, no extra service)
|
|
16
|
-
// `add auth --store redis` -> tokenstore_redis.go (exact across pods)
|
|
17
|
-
//
|
|
18
|
-
// Service never learns which one it got.
|
|
19
|
-
type tokenStore interface {
|
|
20
|
-
SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
12
|
+
// refreshTokenStore is the session surface needed by the refresh/logout use
|
|
13
|
+
// cases. Keeping this port narrow means a store for OAuth state or recovery
|
|
14
|
+
// tokens cannot accidentally become a dependency of session logic.
|
|
15
|
+
type RefreshTokenStore interface {
|
|
16
|
+
SetRefreshToken(ctx context.Context, tokenHash string, token refreshTokenRecord) error
|
|
21
17
|
GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
18
|
+
ConsumeRefreshToken(ctx context.Context, tokenHash string) (refreshTokenRecord, bool, error)
|
|
22
19
|
DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error
|
|
23
20
|
RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error
|
|
24
|
-
MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
25
21
|
IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// oauthTransactionStore is the one-time authorization attempt surface. It is
|
|
25
|
+
// intentionally separate from refresh sessions: the two have different TTL,
|
|
26
|
+
// replay, and operational characteristics.
|
|
27
|
+
type OAuthTransactionStore interface {
|
|
28
|
+
SetLoginTransaction(ctx context.Context, stateHash string, transaction loginTransaction) error
|
|
29
|
+
ConsumeLoginTransaction(ctx context.Context, stateHash string) (loginTransaction, bool, error)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// recoveryTokenPort is the durable one-time-token surface. Recovery owns the
|
|
33
|
+
// transaction boundary that consumes a token and updates the user, while the
|
|
34
|
+
// outer service also needs the two write methods to issue links.
|
|
35
|
+
type RecoveryTokenStore interface {
|
|
36
|
+
application.RecoveryTokens
|
|
26
37
|
SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
27
|
-
ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
28
38
|
SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
29
|
-
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// refreshTokenRecord carries both the sliding inactivity expiry and the fixed
|
|
42
|
+
// absolute expiry. A rotation may move ExpiresAt forward, but it must never
|
|
43
|
+
// move AbsoluteExpiresAt forward.
|
|
44
|
+
type refreshTokenRecord struct {
|
|
45
|
+
UserID uuid.UUID `json:"user_id"`
|
|
46
|
+
ExpiresAt time.Time `json:"expires_at"`
|
|
47
|
+
AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// loginTransaction is the server-owned binding for a browser authorization
|
|
51
|
+
// attempt. State itself is only hashed before storage; the provider receives
|
|
52
|
+
// the original value so the frontend callback can compare it unchanged.
|
|
53
|
+
type loginTransaction struct {
|
|
54
|
+
Provider string
|
|
55
|
+
CodeChallenge string
|
|
56
|
+
Nonce string
|
|
57
|
+
ExpiresAt time.Time
|
|
30
58
|
}
|
|
@@ -12,6 +12,8 @@ import (
|
|
|
12
12
|
"context"
|
|
13
13
|
"database/sql"
|
|
14
14
|
"errors"
|
|
15
|
+
"fmt"
|
|
16
|
+
"strings"
|
|
15
17
|
"time"
|
|
16
18
|
|
|
17
19
|
"{{goModule}}/internal/app/user/model"
|
|
@@ -24,25 +26,16 @@ import (
|
|
|
24
26
|
const (
|
|
25
27
|
kindRefresh = "refresh"
|
|
26
28
|
kindRefreshUsed = "refresh_used"
|
|
27
|
-
|
|
28
|
-
kindEmailVerify = "emailverify"
|
|
29
|
+
kindOAuthState = "oauth_state"
|
|
29
30
|
)
|
|
30
31
|
|
|
31
32
|
type pgTokenStore struct {
|
|
32
|
-
db
|
|
33
|
+
db *gorm.DB
|
|
34
|
+
recovery *recoveryTokenStore
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
func NewPgTokenStore(db *gorm.DB) *pgTokenStore {
|
|
36
|
-
|
|
37
|
-
// put writes a token row, replacing any row that already carries the same
|
|
38
|
-
// hash — a collision is astronomically unlikely, but "upsert" is the right
|
|
39
|
-
// semantic for "this hash now means this" either way.
|
|
40
|
-
func (s *pgTokenStore) put(ctx context.Context, hash, kind string, userID uuid.UUID, ttl time.Duration) error {
|
|
41
|
-
row := model.AuthToken{TokenHash: hash, UserID: userID, Kind: kind, ExpiresAt: time.Now().Add(ttl)}
|
|
42
|
-
return tx.From(ctx, s.db).WithContext(ctx).
|
|
43
|
-
Where("token_hash = ?", hash).
|
|
44
|
-
Assign(map[string]any{"user_id": userID, "kind": kind, "expires_at": row.ExpiresAt}).
|
|
45
|
-
FirstOrCreate(&row).Error
|
|
37
|
+
func NewPgTokenStore(db *gorm.DB) *pgTokenStore {
|
|
38
|
+
return &pgTokenStore{db: db, recovery: newRecoveryTokenStore(db)}
|
|
46
39
|
}
|
|
47
40
|
|
|
48
41
|
// lookup finds a live token of the given kind. Expiry is enforced here rather
|
|
@@ -61,32 +54,59 @@ func (s *pgTokenStore) lookup(ctx context.Context, hash, kind string) (uuid.UUID
|
|
|
61
54
|
return row.UserID, true, nil
|
|
62
55
|
}
|
|
63
56
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
func (s *pgTokenStore) consume(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
|
|
68
|
-
var userID uuid.UUID
|
|
69
|
-
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
70
|
-
`DELETE FROM user_svc.auth_tokens
|
|
71
|
-
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
72
|
-
RETURNING user_id`, hash, kind).Row().Scan(&userID)
|
|
73
|
-
if errors.Is(err, sql.ErrNoRows) {
|
|
74
|
-
return uuid.Nil, false, nil
|
|
57
|
+
func (s *pgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token refreshTokenRecord) error {
|
|
58
|
+
if token.UserID == uuid.Nil || token.ExpiresAt.IsZero() || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
|
|
59
|
+
return fmt.Errorf("refresh token record expiry is invalid")
|
|
75
60
|
}
|
|
76
|
-
|
|
77
|
-
|
|
61
|
+
row := model.AuthToken{
|
|
62
|
+
TokenHash: tokenHash,
|
|
63
|
+
UserID: token.UserID,
|
|
64
|
+
Kind: kindRefresh,
|
|
65
|
+
ExpiresAt: token.ExpiresAt,
|
|
66
|
+
AbsoluteExpiresAt: &token.AbsoluteExpiresAt,
|
|
78
67
|
}
|
|
79
|
-
return
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
68
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
69
|
+
Where("token_hash = ?", tokenHash).
|
|
70
|
+
Assign(map[string]any{
|
|
71
|
+
"user_id": token.UserID,
|
|
72
|
+
"kind": kindRefresh,
|
|
73
|
+
"expires_at": token.ExpiresAt,
|
|
74
|
+
"absolute_expires_at": token.AbsoluteExpiresAt,
|
|
75
|
+
"provider": "",
|
|
76
|
+
"code_challenge": "",
|
|
77
|
+
"nonce": "",
|
|
78
|
+
}).FirstOrCreate(&row).Error
|
|
84
79
|
}
|
|
85
80
|
|
|
86
81
|
func (s *pgTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
87
82
|
return s.lookup(ctx, tokenHash, kindRefresh)
|
|
88
83
|
}
|
|
89
84
|
|
|
85
|
+
// ConsumeRefreshToken removes the active row and writes the reuse-detection
|
|
86
|
+
// tombstone in one statement. A concurrent caller can observe either the
|
|
87
|
+
// active row or the tombstone, never the gap between separate operations.
|
|
88
|
+
func (s *pgTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string) (refreshTokenRecord, bool, error) {
|
|
89
|
+
var token refreshTokenRecord
|
|
90
|
+
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
91
|
+
`WITH consumed AS (
|
|
92
|
+
DELETE FROM user_svc.auth_tokens
|
|
93
|
+
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
94
|
+
AND (absolute_expires_at IS NULL OR absolute_expires_at > now())
|
|
95
|
+
RETURNING user_id, expires_at, absolute_expires_at
|
|
96
|
+
)
|
|
97
|
+
INSERT INTO user_svc.auth_tokens (token_hash, user_id, kind, expires_at, absolute_expires_at)
|
|
98
|
+
SELECT ?, user_id, ?, expires_at, COALESCE(absolute_expires_at, expires_at) FROM consumed
|
|
99
|
+
RETURNING user_id, expires_at, absolute_expires_at`,
|
|
100
|
+
tokenHash, kindRefresh, tokenHash, kindRefreshUsed).Row().Scan(&token.UserID, &token.ExpiresAt, &token.AbsoluteExpiresAt)
|
|
101
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
102
|
+
return refreshTokenRecord{}, false, nil
|
|
103
|
+
}
|
|
104
|
+
if err != nil {
|
|
105
|
+
return refreshTokenRecord{}, false, err
|
|
106
|
+
}
|
|
107
|
+
return token, true, nil
|
|
108
|
+
}
|
|
109
|
+
|
|
90
110
|
// DeleteRefreshToken removes the row outright and leaves nothing behind — see
|
|
91
111
|
// the AuthToken doc comment for why logout must not leave a tombstone.
|
|
92
112
|
func (s *pgTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, _ uuid.UUID) error {
|
|
@@ -105,28 +125,73 @@ func (s *pgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.U
|
|
|
105
125
|
Delete(&model.AuthToken{}).Error
|
|
106
126
|
}
|
|
107
127
|
|
|
108
|
-
func (s *pgTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
109
|
-
return s.put(ctx, tokenHash, kindRefreshUsed, userID, ttl)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
128
|
func (s *pgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
113
129
|
return s.lookup(ctx, tokenHash, kindRefreshUsed)
|
|
114
130
|
}
|
|
115
131
|
|
|
132
|
+
func (s *pgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string, transaction loginTransaction) error {
|
|
133
|
+
if strings.TrimSpace(stateHash) == "" || strings.TrimSpace(transaction.Provider) == "" || strings.TrimSpace(transaction.CodeChallenge) == "" || strings.TrimSpace(transaction.Nonce) == "" || !transaction.ExpiresAt.After(time.Now()) {
|
|
134
|
+
return fmt.Errorf("oauth login transaction is invalid")
|
|
135
|
+
}
|
|
136
|
+
row := model.AuthToken{
|
|
137
|
+
TokenHash: stateHash,
|
|
138
|
+
Kind: kindOAuthState,
|
|
139
|
+
ExpiresAt: transaction.ExpiresAt,
|
|
140
|
+
Provider: transaction.Provider,
|
|
141
|
+
CodeChallenge: transaction.CodeChallenge,
|
|
142
|
+
Nonce: transaction.Nonce,
|
|
143
|
+
}
|
|
144
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
145
|
+
Where("token_hash = ?", stateHash).
|
|
146
|
+
Assign(map[string]any{
|
|
147
|
+
"user_id": uuid.Nil,
|
|
148
|
+
"kind": kindOAuthState,
|
|
149
|
+
"expires_at": transaction.ExpiresAt,
|
|
150
|
+
"provider": transaction.Provider,
|
|
151
|
+
"code_challenge": transaction.CodeChallenge,
|
|
152
|
+
"nonce": transaction.Nonce,
|
|
153
|
+
}).FirstOrCreate(&row).Error
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func (s *pgTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash string) (loginTransaction, bool, error) {
|
|
157
|
+
var transaction loginTransaction
|
|
158
|
+
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
159
|
+
`DELETE FROM user_svc.auth_tokens
|
|
160
|
+
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
161
|
+
RETURNING provider, code_challenge, nonce, expires_at`,
|
|
162
|
+
stateHash, kindOAuthState).Row().Scan(
|
|
163
|
+
&transaction.Provider,
|
|
164
|
+
&transaction.CodeChallenge,
|
|
165
|
+
&transaction.Nonce,
|
|
166
|
+
&transaction.ExpiresAt,
|
|
167
|
+
)
|
|
168
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
169
|
+
return loginTransaction{}, false, nil
|
|
170
|
+
}
|
|
171
|
+
if err != nil {
|
|
172
|
+
return loginTransaction{}, false, err
|
|
173
|
+
}
|
|
174
|
+
return transaction, true, nil
|
|
175
|
+
}
|
|
176
|
+
|
|
116
177
|
func (s *pgTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
117
|
-
return s.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
|
|
178
|
+
return s.recovery.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
|
|
118
179
|
}
|
|
119
180
|
|
|
120
181
|
func (s *pgTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
121
|
-
return s.consume(ctx, tokenHash, kindPasswordSet)
|
|
182
|
+
return s.recovery.consume(ctx, tokenHash, kindPasswordSet)
|
|
122
183
|
}
|
|
123
184
|
|
|
124
185
|
func (s *pgTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
125
|
-
return s.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
|
|
186
|
+
return s.recovery.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
|
|
126
187
|
}
|
|
127
188
|
|
|
128
189
|
func (s *pgTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
129
|
-
return s.consume(ctx, tokenHash, kindEmailVerify)
|
|
190
|
+
return s.recovery.consume(ctx, tokenHash, kindEmailVerify)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func (s *pgTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
|
|
194
|
+
return s.recovery.withTransaction(ctx, fn)
|
|
130
195
|
}
|
|
131
196
|
|
|
132
197
|
// DeleteExpired drops rows nothing can use any more. Correctness never depends
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/google/uuid"
|
|
9
|
+
"gorm.io/driver/postgres"
|
|
10
|
+
"gorm.io/gorm"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// tokenStoreDBForTest opens a real database instead of reusing the repository
|
|
14
|
+
// test transaction: concurrent consumers must use separate PostgreSQL
|
|
15
|
+
// connections to exercise DELETE ... RETURNING's row-level serialization.
|
|
16
|
+
func tokenStoreDBForTest(t *testing.T) *gorm.DB {
|
|
17
|
+
t.Helper()
|
|
18
|
+
dsn := os.Getenv("TEST_DB_DSN")
|
|
19
|
+
if dsn == "" {
|
|
20
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
21
|
+
t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
|
|
22
|
+
}
|
|
23
|
+
t.Skip("token-store integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
|
27
|
+
if err != nil {
|
|
28
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
29
|
+
t.Fatalf("open required token-store database: %v", err)
|
|
30
|
+
}
|
|
31
|
+
t.Skipf("token-store integration test skipped: %v", err)
|
|
32
|
+
}
|
|
33
|
+
sqlDB, err := db.DB()
|
|
34
|
+
if err != nil {
|
|
35
|
+
t.Fatalf("get token-store SQL handle: %v", err)
|
|
36
|
+
}
|
|
37
|
+
if err := sqlDB.Ping(); err != nil {
|
|
38
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
39
|
+
t.Fatalf("ping required token-store database: %v", err)
|
|
40
|
+
}
|
|
41
|
+
t.Skipf("token-store integration test skipped: %v", err)
|
|
42
|
+
}
|
|
43
|
+
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
44
|
+
return db
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func TestPgTokenStore_ConsumeRefreshToken_ConcurrentRealDatabase(t *testing.T) {
|
|
48
|
+
db := tokenStoreDBForTest(t)
|
|
49
|
+
store := NewPgTokenStore(db)
|
|
50
|
+
ctx := context.Background()
|
|
51
|
+
userID := uuid.New()
|
|
52
|
+
hash := "real-pg-refresh-" + uuid.NewString()
|
|
53
|
+
if err := store.SetRefreshToken(ctx, hash, testRefreshTokenRecord(userID)); err != nil {
|
|
54
|
+
t.Fatalf("seed refresh token: %v", err)
|
|
55
|
+
}
|
|
56
|
+
t.Cleanup(func() {
|
|
57
|
+
_ = db.Exec("DELETE FROM user_svc.auth_tokens WHERE token_hash = ?", hash).Error
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const callers = 32
|
|
61
|
+
type result struct {
|
|
62
|
+
token refreshTokenRecord
|
|
63
|
+
ok bool
|
|
64
|
+
err error
|
|
65
|
+
}
|
|
66
|
+
start := make(chan struct{})
|
|
67
|
+
results := make(chan result, callers)
|
|
68
|
+
for i := 0; i < callers; i++ {
|
|
69
|
+
go func() {
|
|
70
|
+
<-start
|
|
71
|
+
token, ok, err := store.ConsumeRefreshToken(ctx, hash)
|
|
72
|
+
results <- result{token: token, ok: ok, err: err}
|
|
73
|
+
}()
|
|
74
|
+
}
|
|
75
|
+
close(start)
|
|
76
|
+
|
|
77
|
+
winners := 0
|
|
78
|
+
for i := 0; i < callers; i++ {
|
|
79
|
+
got := <-results
|
|
80
|
+
if got.err != nil {
|
|
81
|
+
t.Fatalf("concurrent consume: %v", got.err)
|
|
82
|
+
}
|
|
83
|
+
if got.ok {
|
|
84
|
+
winners++
|
|
85
|
+
if got.token.UserID != userID {
|
|
86
|
+
t.Fatalf("winner returned user %s, want %s", got.token.UserID, userID)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if winners != 1 {
|
|
91
|
+
t.Fatalf("expected exactly one real-database refresh winner, got %d", winners)
|
|
92
|
+
}
|
|
93
|
+
if _, ok, err := store.GetRefreshToken(ctx, hash); err != nil || ok {
|
|
94
|
+
t.Fatalf("consumed refresh token remained active: ok=%t err=%v", ok, err)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"{{goModule}}/internal/app/user/model"
|
|
10
|
+
"{{goModule}}/internal/shared/tx"
|
|
11
|
+
|
|
12
|
+
"github.com/google/uuid"
|
|
13
|
+
"gorm.io/gorm"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const (
|
|
17
|
+
kindPasswordSet = "pwreset"
|
|
18
|
+
kindEmailVerify = "emailverify"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// recoveryTokenStore is deliberately Postgres-backed for both auth store
|
|
22
|
+
// choices. Refresh rotation may live in Redis, but password reset and email
|
|
23
|
+
// verification must share a durable DB transaction with the user update.
|
|
24
|
+
type recoveryTokenStore struct {
|
|
25
|
+
db *gorm.DB
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func newRecoveryTokenStore(db *gorm.DB) *recoveryTokenStore {
|
|
29
|
+
return &recoveryTokenStore{db: db}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func (s *recoveryTokenStore) withTransaction(ctx context.Context, fn func(context.Context) error) error {
|
|
33
|
+
return tx.Do(ctx, s.db, fn)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func (s *recoveryTokenStore) put(ctx context.Context, hash, kind string, userID uuid.UUID, ttl time.Duration) error {
|
|
37
|
+
expiresAt := time.Now().Add(ttl)
|
|
38
|
+
row := model.AuthToken{TokenHash: hash, UserID: userID, Kind: kind, ExpiresAt: expiresAt}
|
|
39
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
40
|
+
Where("token_hash = ?", hash).
|
|
41
|
+
Assign(map[string]any{"user_id": userID, "kind": kind, "expires_at": expiresAt}).
|
|
42
|
+
FirstOrCreate(&row).Error
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func (s *recoveryTokenStore) consume(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
|
|
46
|
+
var userID uuid.UUID
|
|
47
|
+
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
48
|
+
`DELETE FROM user_svc.auth_tokens
|
|
49
|
+
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
50
|
+
RETURNING user_id`, hash, kind).Row().Scan(&userID)
|
|
51
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
52
|
+
return uuid.Nil, false, nil
|
|
53
|
+
}
|
|
54
|
+
if err != nil {
|
|
55
|
+
return uuid.Nil, false, err
|
|
56
|
+
}
|
|
57
|
+
return userID, true, nil
|
|
58
|
+
}
|