@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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Postgres-backed tokenStore — installed by `add auth --store postgres` (the
|
|
2
|
+
// default). The Redis-backed alternative lives in tokenstore_redis.go; only
|
|
3
|
+
// one of the two is ever written into a project.
|
|
4
|
+
//
|
|
5
|
+
// Everything Redis needed two keys for collapses into one table here: the
|
|
6
|
+
// per-user session set is just a WHERE clause, and expiry is a column every
|
|
7
|
+
// read filters on rather than a TTL the store enforces. What that costs is a
|
|
8
|
+
// sweep to keep the table from growing — see DeleteExpired.
|
|
9
|
+
package postgres
|
|
10
|
+
|
|
11
|
+
import (
|
|
12
|
+
"context"
|
|
13
|
+
"database/sql"
|
|
14
|
+
"errors"
|
|
15
|
+
"fmt"
|
|
16
|
+
"strings"
|
|
17
|
+
"time"
|
|
18
|
+
|
|
19
|
+
"{{goModule}}/internal/app/user/ports"
|
|
20
|
+
"{{goModule}}/internal/shared/tx"
|
|
21
|
+
|
|
22
|
+
"github.com/google/uuid"
|
|
23
|
+
"gorm.io/gorm"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const (
|
|
27
|
+
kindRefresh = "refresh"
|
|
28
|
+
kindRefreshUsed = "refresh_used"
|
|
29
|
+
kindOAuthState = "oauth_state"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
type PgTokenStore struct {
|
|
33
|
+
db *gorm.DB
|
|
34
|
+
recovery *recoveryTokenStore
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func NewPgTokenStore(db *gorm.DB) *PgTokenStore {
|
|
38
|
+
return &PgTokenStore{db: db, recovery: newRecoveryTokenStore(db)}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
var _ ports.RefreshTokenStore = (*PgTokenStore)(nil)
|
|
42
|
+
var _ ports.OAuthTransactionStore = (*PgTokenStore)(nil)
|
|
43
|
+
var _ ports.RecoveryTokenStore = (*PgTokenStore)(nil)
|
|
44
|
+
|
|
45
|
+
// lookup finds a live token of the given kind. Expiry is enforced here rather
|
|
46
|
+
// than by a sweep, so a row that outlived its TTL is already invisible.
|
|
47
|
+
func (s *PgTokenStore) lookup(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
|
|
48
|
+
var row AuthToken
|
|
49
|
+
err := tx.From(ctx, s.db).WithContext(ctx).
|
|
50
|
+
Where("token_hash = ? AND kind = ? AND expires_at > now()", hash, kind).
|
|
51
|
+
Take(&row).Error
|
|
52
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
53
|
+
return uuid.Nil, false, nil
|
|
54
|
+
}
|
|
55
|
+
if err != nil {
|
|
56
|
+
return uuid.Nil, false, err
|
|
57
|
+
}
|
|
58
|
+
return row.UserID, true, nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func (s *PgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token ports.RefreshTokenRecord) error {
|
|
62
|
+
if token.UserID == uuid.Nil || token.ExpiresAt.IsZero() || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
|
|
63
|
+
return fmt.Errorf("refresh token record expiry is invalid")
|
|
64
|
+
}
|
|
65
|
+
row := AuthToken{
|
|
66
|
+
TokenHash: tokenHash,
|
|
67
|
+
UserID: token.UserID,
|
|
68
|
+
Kind: kindRefresh,
|
|
69
|
+
ExpiresAt: token.ExpiresAt,
|
|
70
|
+
AbsoluteExpiresAt: &token.AbsoluteExpiresAt,
|
|
71
|
+
}
|
|
72
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
73
|
+
Where("token_hash = ?", tokenHash).
|
|
74
|
+
Assign(map[string]any{
|
|
75
|
+
"user_id": token.UserID,
|
|
76
|
+
"kind": kindRefresh,
|
|
77
|
+
"expires_at": token.ExpiresAt,
|
|
78
|
+
"absolute_expires_at": token.AbsoluteExpiresAt,
|
|
79
|
+
"provider": "",
|
|
80
|
+
"code_challenge": "",
|
|
81
|
+
"nonce": "",
|
|
82
|
+
}).FirstOrCreate(&row).Error
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func (s *PgTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
86
|
+
return s.lookup(ctx, tokenHash, kindRefresh)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ConsumeRefreshToken removes the active row and writes the reuse-detection
|
|
90
|
+
// tombstone in one statement. A concurrent caller can observe either the
|
|
91
|
+
// active row or the tombstone, never the gap between separate operations.
|
|
92
|
+
func (s *PgTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string) (ports.RefreshTokenRecord, bool, error) {
|
|
93
|
+
var token ports.RefreshTokenRecord
|
|
94
|
+
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
95
|
+
`WITH consumed AS (
|
|
96
|
+
DELETE FROM user_svc.auth_tokens
|
|
97
|
+
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
98
|
+
AND (absolute_expires_at IS NULL OR absolute_expires_at > now())
|
|
99
|
+
RETURNING user_id, expires_at, absolute_expires_at
|
|
100
|
+
)
|
|
101
|
+
INSERT INTO user_svc.auth_tokens (token_hash, user_id, kind, expires_at, absolute_expires_at)
|
|
102
|
+
SELECT ?, user_id, ?, expires_at, COALESCE(absolute_expires_at, expires_at) FROM consumed
|
|
103
|
+
RETURNING user_id, expires_at, absolute_expires_at`,
|
|
104
|
+
tokenHash, kindRefresh, tokenHash, kindRefreshUsed).Row().Scan(&token.UserID, &token.ExpiresAt, &token.AbsoluteExpiresAt)
|
|
105
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
106
|
+
return ports.RefreshTokenRecord{}, false, nil
|
|
107
|
+
}
|
|
108
|
+
if err != nil {
|
|
109
|
+
return ports.RefreshTokenRecord{}, false, err
|
|
110
|
+
}
|
|
111
|
+
return token, true, nil
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// DeleteRefreshToken removes the row outright and leaves nothing behind — see
|
|
115
|
+
// the AuthToken doc comment for why logout must not leave a tombstone.
|
|
116
|
+
func (s *PgTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, _ uuid.UUID) error {
|
|
117
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
118
|
+
Where("token_hash = ? AND kind = ?", tokenHash, kindRefresh).
|
|
119
|
+
Delete(&AuthToken{}).Error
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// RevokeAllRefreshTokens is one statement here because Postgres can query by
|
|
123
|
+
// value — the Redis store has to keep a separate per-user set to answer this,
|
|
124
|
+
// and that set accumulates stale members its own comment calls out.
|
|
125
|
+
// Tombstones are left in place so reuse detection still works afterwards.
|
|
126
|
+
func (s *PgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
|
|
127
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
128
|
+
Where("user_id = ? AND kind = ?", userID, kindRefresh).
|
|
129
|
+
Delete(&AuthToken{}).Error
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func (s *PgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
133
|
+
return s.lookup(ctx, tokenHash, kindRefreshUsed)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string, transaction ports.LoginTransaction) error {
|
|
137
|
+
if strings.TrimSpace(stateHash) == "" || strings.TrimSpace(transaction.Provider) == "" || strings.TrimSpace(transaction.CodeChallenge) == "" || strings.TrimSpace(transaction.Nonce) == "" || !transaction.ExpiresAt.After(time.Now()) {
|
|
138
|
+
return fmt.Errorf("oauth login transaction is invalid")
|
|
139
|
+
}
|
|
140
|
+
row := AuthToken{
|
|
141
|
+
TokenHash: stateHash,
|
|
142
|
+
Kind: kindOAuthState,
|
|
143
|
+
ExpiresAt: transaction.ExpiresAt,
|
|
144
|
+
Provider: transaction.Provider,
|
|
145
|
+
CodeChallenge: transaction.CodeChallenge,
|
|
146
|
+
Nonce: transaction.Nonce,
|
|
147
|
+
}
|
|
148
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
149
|
+
Where("token_hash = ?", stateHash).
|
|
150
|
+
Assign(map[string]any{
|
|
151
|
+
"user_id": uuid.Nil,
|
|
152
|
+
"kind": kindOAuthState,
|
|
153
|
+
"expires_at": transaction.ExpiresAt,
|
|
154
|
+
"provider": transaction.Provider,
|
|
155
|
+
"code_challenge": transaction.CodeChallenge,
|
|
156
|
+
"nonce": transaction.Nonce,
|
|
157
|
+
}).FirstOrCreate(&row).Error
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func (s *PgTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash string) (ports.LoginTransaction, bool, error) {
|
|
161
|
+
var transaction ports.LoginTransaction
|
|
162
|
+
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
163
|
+
`DELETE FROM user_svc.auth_tokens
|
|
164
|
+
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
165
|
+
RETURNING provider, code_challenge, nonce, expires_at`,
|
|
166
|
+
stateHash, kindOAuthState).Row().Scan(
|
|
167
|
+
&transaction.Provider,
|
|
168
|
+
&transaction.CodeChallenge,
|
|
169
|
+
&transaction.Nonce,
|
|
170
|
+
&transaction.ExpiresAt,
|
|
171
|
+
)
|
|
172
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
173
|
+
return ports.LoginTransaction{}, false, nil
|
|
174
|
+
}
|
|
175
|
+
if err != nil {
|
|
176
|
+
return ports.LoginTransaction{}, false, err
|
|
177
|
+
}
|
|
178
|
+
return transaction, true, nil
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func (s *PgTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
182
|
+
return s.recovery.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func (s *PgTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
186
|
+
return s.recovery.consume(ctx, tokenHash, kindPasswordSet)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func (s *PgTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
190
|
+
return s.recovery.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func (s *PgTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
194
|
+
return s.recovery.consume(ctx, tokenHash, kindEmailVerify)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func (s *PgTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
|
|
198
|
+
return s.recovery.withTransaction(ctx, fn)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// DeleteExpired drops rows nothing can use any more. Correctness never depends
|
|
202
|
+
// on it — every read already filters on expires_at — so it is purely about
|
|
203
|
+
// table size, and calling it on a timer from cmd/worker (or a cron) is enough.
|
|
204
|
+
//
|
|
205
|
+
// ponytail: not scheduled for you. Wire it up the day the table is big enough
|
|
206
|
+
// to notice; a scaffold that silently runs background sweeps nobody asked for
|
|
207
|
+
// is harder to reason about than one line you add when it matters.
|
|
208
|
+
func (s *PgTokenStore) DeleteExpired(ctx context.Context) (int64, error) {
|
|
209
|
+
res := tx.From(ctx, s.db).WithContext(ctx).
|
|
210
|
+
Where("expires_at <= now()").
|
|
211
|
+
Delete(&AuthToken{})
|
|
212
|
+
return res.RowsAffected, res.Error
|
|
213
|
+
}
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package postgres
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"{{goModule}}/internal/app/user/ports"
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
"gorm.io/driver/postgres"
|
|
12
|
+
"gorm.io/gorm"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// tokenStoreDBForTest opens a real database instead of reusing the repository
|
|
16
|
+
// test transaction: concurrent consumers must use separate PostgreSQL
|
|
17
|
+
// connections to exercise DELETE ... RETURNING's row-level serialization.
|
|
18
|
+
func tokenStoreDBForTest(t *testing.T) *gorm.DB {
|
|
19
|
+
t.Helper()
|
|
20
|
+
dsn := os.Getenv("TEST_DB_DSN")
|
|
21
|
+
if dsn == "" {
|
|
22
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
23
|
+
t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
|
|
24
|
+
}
|
|
25
|
+
t.Skip("token-store integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
|
29
|
+
if err != nil {
|
|
30
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
31
|
+
t.Fatalf("open required token-store database: %v", err)
|
|
32
|
+
}
|
|
33
|
+
t.Skipf("token-store integration test skipped: %v", err)
|
|
34
|
+
}
|
|
35
|
+
sqlDB, err := db.DB()
|
|
36
|
+
if err != nil {
|
|
37
|
+
t.Fatalf("get token-store SQL handle: %v", err)
|
|
38
|
+
}
|
|
39
|
+
if err := sqlDB.Ping(); err != nil {
|
|
40
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
41
|
+
t.Fatalf("ping required token-store database: %v", err)
|
|
42
|
+
}
|
|
43
|
+
t.Skipf("token-store integration test skipped: %v", err)
|
|
44
|
+
}
|
|
45
|
+
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
46
|
+
return db
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func TestPgTokenStore_ConsumeRefreshToken_ConcurrentRealDatabase(t *testing.T) {
|
|
50
|
+
db := tokenStoreDBForTest(t)
|
|
51
|
+
store := NewPgTokenStore(db)
|
|
52
|
+
ctx := context.Background()
|
|
53
|
+
userID := uuid.New()
|
|
54
|
+
hash := "real-pg-refresh-" + uuid.NewString()
|
|
55
|
+
if err := store.SetRefreshToken(ctx, hash, refreshTokenRecordForTest(userID)); err != nil {
|
|
56
|
+
t.Fatalf("seed refresh token: %v", err)
|
|
57
|
+
}
|
|
58
|
+
t.Cleanup(func() {
|
|
59
|
+
_ = db.Exec("DELETE FROM user_svc.auth_tokens WHERE token_hash = ?", hash).Error
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const callers = 32
|
|
63
|
+
type result struct {
|
|
64
|
+
token ports.RefreshTokenRecord
|
|
65
|
+
ok bool
|
|
66
|
+
err error
|
|
67
|
+
}
|
|
68
|
+
start := make(chan struct{})
|
|
69
|
+
results := make(chan result, callers)
|
|
70
|
+
for i := 0; i < callers; i++ {
|
|
71
|
+
go func() {
|
|
72
|
+
<-start
|
|
73
|
+
token, ok, err := store.ConsumeRefreshToken(ctx, hash)
|
|
74
|
+
results <- result{token: token, ok: ok, err: err}
|
|
75
|
+
}()
|
|
76
|
+
}
|
|
77
|
+
close(start)
|
|
78
|
+
|
|
79
|
+
winners := 0
|
|
80
|
+
for i := 0; i < callers; i++ {
|
|
81
|
+
got := <-results
|
|
82
|
+
if got.err != nil {
|
|
83
|
+
t.Fatalf("concurrent consume: %v", got.err)
|
|
84
|
+
}
|
|
85
|
+
if got.ok {
|
|
86
|
+
winners++
|
|
87
|
+
if got.token.UserID != userID {
|
|
88
|
+
t.Fatalf("winner returned user %s, want %s", got.token.UserID, userID)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if winners != 1 {
|
|
93
|
+
t.Fatalf("expected exactly one real-database refresh winner, got %d", winners)
|
|
94
|
+
}
|
|
95
|
+
if _, ok, err := store.GetRefreshToken(ctx, hash); err != nil || ok {
|
|
96
|
+
t.Fatalf("consumed refresh token remained active: ok=%t err=%v", ok, err)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func refreshTokenRecordForTest(userID uuid.UUID) ports.RefreshTokenRecord {
|
|
101
|
+
now := time.Now()
|
|
102
|
+
return ports.RefreshTokenRecord{UserID: userID, ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(24 * time.Hour)}
|
|
103
|
+
}
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package postgres
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"errors"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"{{goModule}}/internal/app/user/ports"
|
|
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
|
+
var _ ports.RecoveryTokenStore = (*recoveryTokenStore)(nil)
|
|
29
|
+
|
|
30
|
+
func NewRecoveryTokenStore(db *gorm.DB) ports.RecoveryTokenStore {
|
|
31
|
+
return newRecoveryTokenStore(db)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func newRecoveryTokenStore(db *gorm.DB) *recoveryTokenStore {
|
|
35
|
+
return &recoveryTokenStore{db: db}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (s *recoveryTokenStore) withTransaction(ctx context.Context, fn func(context.Context) error) error {
|
|
39
|
+
return tx.Do(ctx, s.db, fn)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func (s *recoveryTokenStore) put(ctx context.Context, hash, kind string, userID uuid.UUID, ttl time.Duration) error {
|
|
43
|
+
expiresAt := time.Now().Add(ttl)
|
|
44
|
+
row := AuthToken{TokenHash: hash, UserID: userID, Kind: kind, ExpiresAt: expiresAt}
|
|
45
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
46
|
+
Where("token_hash = ?", hash).
|
|
47
|
+
Assign(map[string]any{"user_id": userID, "kind": kind, "expires_at": expiresAt}).
|
|
48
|
+
FirstOrCreate(&row).Error
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func (s *recoveryTokenStore) consume(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
|
|
52
|
+
var userID uuid.UUID
|
|
53
|
+
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
54
|
+
`DELETE FROM user_svc.auth_tokens
|
|
55
|
+
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
56
|
+
RETURNING user_id`, hash, kind).Row().Scan(&userID)
|
|
57
|
+
if errors.Is(err, sql.ErrNoRows) {
|
|
58
|
+
return uuid.Nil, false, nil
|
|
59
|
+
}
|
|
60
|
+
if err != nil {
|
|
61
|
+
return uuid.Nil, false, err
|
|
62
|
+
}
|
|
63
|
+
return userID, true, nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func (s *recoveryTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
67
|
+
return s.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func (s *recoveryTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
71
|
+
return s.consume(ctx, tokenHash, kindPasswordSet)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func (s *recoveryTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
75
|
+
return s.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func (s *recoveryTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
79
|
+
return s.consume(ctx, tokenHash, kindEmailVerify)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func (s *recoveryTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
|
|
83
|
+
return s.withTransaction(ctx, fn)
|
|
84
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Redis-backed tokenStore — installed by `add auth --store redis`.
|
|
2
|
+
// The Postgres-backed alternative lives in tokenstore_pg.go; only one of the
|
|
3
|
+
// two is ever written into a project, and Service never learns which.
|
|
4
|
+
package redisadapter
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"context"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"fmt"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"{{goModule}}/internal/app/user/adapters/outbound/postgres"
|
|
13
|
+
"{{goModule}}/internal/app/user/ports"
|
|
14
|
+
|
|
15
|
+
"github.com/google/uuid"
|
|
16
|
+
"github.com/redis/go-redis/v9"
|
|
17
|
+
"gorm.io/gorm"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const (
|
|
21
|
+
// The shared {refresh} hash tag keeps all keys touched by the Lua rotation
|
|
22
|
+
// script on one Redis Cluster slot as well as making the operation atomic.
|
|
23
|
+
refreshKeyPrefix = "user:{refresh}:" // +hash -> ports.RefreshTokenRecord, TTL = ExpiresAt
|
|
24
|
+
refreshUserKeyPrefix = "user:{refresh}:user:" // +userID -> SET of active token hashes
|
|
25
|
+
refreshUsedKeyPrefix = "user:{refresh}:used:" // +hash -> ports.RefreshTokenRecord, TTL = source token expiry
|
|
26
|
+
oauthStateKeyPrefix = "user:{oauth}:state:"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
type RedisTokenStore struct {
|
|
30
|
+
rdb *redis.Client
|
|
31
|
+
recovery ports.RecoveryTokenStore
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func NewRedisTokenStore(rdb *redis.Client, db *gorm.DB) *RedisTokenStore {
|
|
35
|
+
return &RedisTokenStore{rdb: rdb, recovery: postgres.NewRecoveryTokenStore(db)}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
var _ ports.RefreshTokenStore = (*RedisTokenStore)(nil)
|
|
39
|
+
var _ ports.OAuthTransactionStore = (*RedisTokenStore)(nil)
|
|
40
|
+
var _ ports.RecoveryTokenStore = (*RedisTokenStore)(nil)
|
|
41
|
+
|
|
42
|
+
var consumeRefreshScript = redis.NewScript(`
|
|
43
|
+
local token = redis.call("GET", KEYS[1])
|
|
44
|
+
local ttl = redis.call("PTTL", KEYS[1])
|
|
45
|
+
if not token or ttl < 1 then
|
|
46
|
+
-- An empty bulk string maps to a normal "not found" result in go-redis;
|
|
47
|
+
-- Lua false would be surfaced as redis.Nil and look like an infrastructure
|
|
48
|
+
-- failure to Service.Refresh.
|
|
49
|
+
return ""
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
-- Decode and validate the fields used by this script before mutating anything.
|
|
53
|
+
-- Redis Lua does not roll back earlier writes when a later command errors; the
|
|
54
|
+
-- old order could therefore delete a corrupt active token and then fail while
|
|
55
|
+
-- decoding it, losing the only evidence needed to investigate or retry safely.
|
|
56
|
+
local decoded, token_data = pcall(cjson.decode, token)
|
|
57
|
+
if not decoded or type(token_data) ~= "table" or
|
|
58
|
+
type(token_data.user_id) ~= "string" or token_data.user_id == "" or
|
|
59
|
+
type(token_data.expires_at) ~= "string" or token_data.expires_at == "" or
|
|
60
|
+
type(token_data.absolute_expires_at) ~= "string" or token_data.absolute_expires_at == "" then
|
|
61
|
+
return redis.error_reply("invalid refresh token record")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
redis.call("DEL", KEYS[1])
|
|
65
|
+
redis.call("SREM", KEYS[3] .. token_data.user_id, ARGV[1])
|
|
66
|
+
redis.call("SET", KEYS[2], token, "PX", ttl)
|
|
67
|
+
return token
|
|
68
|
+
`)
|
|
69
|
+
|
|
70
|
+
var revokeAllRefreshScript = redis.NewScript(`
|
|
71
|
+
local hashes = redis.call("SMEMBERS", KEYS[1])
|
|
72
|
+
for _, hash in ipairs(hashes) do
|
|
73
|
+
redis.call("DEL", KEYS[2] .. hash)
|
|
74
|
+
end
|
|
75
|
+
redis.call("DEL", KEYS[1])
|
|
76
|
+
return #hashes
|
|
77
|
+
`)
|
|
78
|
+
|
|
79
|
+
func (s *RedisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token ports.RefreshTokenRecord) error {
|
|
80
|
+
ttl := time.Until(token.ExpiresAt)
|
|
81
|
+
if token.UserID == uuid.Nil || ttl <= 0 || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
|
|
82
|
+
return fmt.Errorf("refresh token expiry is invalid")
|
|
83
|
+
}
|
|
84
|
+
payload, err := json.Marshal(token)
|
|
85
|
+
if err != nil {
|
|
86
|
+
return fmt.Errorf("encode refresh token record: %w", err)
|
|
87
|
+
}
|
|
88
|
+
pipe := s.rdb.TxPipeline()
|
|
89
|
+
pipe.Set(ctx, refreshKeyPrefix+tokenHash, payload, ttl)
|
|
90
|
+
pipe.SAdd(ctx, refreshUserKeyPrefix+token.UserID.String(), tokenHash)
|
|
91
|
+
_, err = pipe.Exec(ctx)
|
|
92
|
+
return err
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func (s *RedisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
96
|
+
raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
|
|
97
|
+
if err == redis.Nil {
|
|
98
|
+
return uuid.Nil, false, nil
|
|
99
|
+
}
|
|
100
|
+
if err != nil {
|
|
101
|
+
return uuid.Nil, false, err
|
|
102
|
+
}
|
|
103
|
+
token, err := decodeRefreshTokenRecord(raw)
|
|
104
|
+
if err != nil {
|
|
105
|
+
return uuid.Nil, false, fmt.Errorf("decode refresh token record: %w", err)
|
|
106
|
+
}
|
|
107
|
+
return token.UserID, true, nil
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ConsumeRefreshToken uses one Lua script for active-token deletion,
|
|
111
|
+
// per-user-session cleanup, and the reuse tombstone. A concurrent caller
|
|
112
|
+
// cannot observe the active value after another caller has won the rotation.
|
|
113
|
+
func (s *RedisTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string) (ports.RefreshTokenRecord, bool, error) {
|
|
114
|
+
result, err := consumeRefreshScript.Run(ctx, s.rdb,
|
|
115
|
+
[]string{refreshKeyPrefix + tokenHash, refreshUsedKeyPrefix + tokenHash, refreshUserKeyPrefix},
|
|
116
|
+
tokenHash,
|
|
117
|
+
).Result()
|
|
118
|
+
if err != nil {
|
|
119
|
+
return ports.RefreshTokenRecord{}, false, err
|
|
120
|
+
}
|
|
121
|
+
raw, ok := result.(string)
|
|
122
|
+
if !ok || raw == "" {
|
|
123
|
+
return ports.RefreshTokenRecord{}, false, nil
|
|
124
|
+
}
|
|
125
|
+
token, err := decodeRefreshTokenRecord(raw)
|
|
126
|
+
if err != nil {
|
|
127
|
+
return ports.RefreshTokenRecord{}, false, fmt.Errorf("decode consumed refresh token record: %w", err)
|
|
128
|
+
}
|
|
129
|
+
return token, true, nil
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func (s *RedisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
|
|
133
|
+
pipe := s.rdb.TxPipeline()
|
|
134
|
+
pipe.Del(ctx, refreshKeyPrefix+tokenHash)
|
|
135
|
+
pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
|
|
136
|
+
_, err := pipe.Exec(ctx)
|
|
137
|
+
return err
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// RevokeAllRefreshTokens snapshots the per-user session set, deletes every
|
|
141
|
+
// active refresh token, and removes the set in one Lua invocation. This is
|
|
142
|
+
// important because SetRefreshToken updates two keys: a SMEMBERS followed by
|
|
143
|
+
// a separate pipeline could miss a token added between those operations and
|
|
144
|
+
// leave an active token with no session-set member.
|
|
145
|
+
func (s *RedisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
|
|
146
|
+
setKey := refreshUserKeyPrefix + userID.String()
|
|
147
|
+
_, err := revokeAllRefreshScript.Run(ctx, s.rdb,
|
|
148
|
+
[]string{setKey, refreshKeyPrefix},
|
|
149
|
+
userID.String(),
|
|
150
|
+
).Result()
|
|
151
|
+
return err
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func (s *RedisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
155
|
+
raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
|
|
156
|
+
if err == redis.Nil {
|
|
157
|
+
return uuid.Nil, false, nil
|
|
158
|
+
}
|
|
159
|
+
if err != nil {
|
|
160
|
+
return uuid.Nil, false, err
|
|
161
|
+
}
|
|
162
|
+
token, err := decodeRefreshTokenRecord(raw)
|
|
163
|
+
if err != nil {
|
|
164
|
+
return uuid.Nil, false, fmt.Errorf("decode used refresh token record: %w", err)
|
|
165
|
+
}
|
|
166
|
+
return token.UserID, true, nil
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func (s *RedisTokenStore) SetLoginTransaction(ctx context.Context, stateHash string, transaction ports.LoginTransaction) error {
|
|
170
|
+
if !transaction.ExpiresAt.After(time.Now()) {
|
|
171
|
+
return fmt.Errorf("oauth login transaction expiry is invalid")
|
|
172
|
+
}
|
|
173
|
+
payload, err := json.Marshal(transaction)
|
|
174
|
+
if err != nil {
|
|
175
|
+
return fmt.Errorf("encode oauth login transaction: %w", err)
|
|
176
|
+
}
|
|
177
|
+
return s.rdb.Set(ctx, oauthStateKeyPrefix+stateHash, payload, time.Until(transaction.ExpiresAt)).Err()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func (s *RedisTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash string) (ports.LoginTransaction, bool, error) {
|
|
181
|
+
raw, err := s.rdb.GetDel(ctx, oauthStateKeyPrefix+stateHash).Result()
|
|
182
|
+
if err == redis.Nil {
|
|
183
|
+
return ports.LoginTransaction{}, false, nil
|
|
184
|
+
}
|
|
185
|
+
if err != nil {
|
|
186
|
+
return ports.LoginTransaction{}, false, err
|
|
187
|
+
}
|
|
188
|
+
var transaction ports.LoginTransaction
|
|
189
|
+
if err := json.Unmarshal([]byte(raw), &transaction); err != nil {
|
|
190
|
+
return ports.LoginTransaction{}, false, fmt.Errorf("decode oauth login transaction: %w", err)
|
|
191
|
+
}
|
|
192
|
+
return transaction, true, nil
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func decodeRefreshTokenRecord(raw string) (ports.RefreshTokenRecord, error) {
|
|
196
|
+
var token ports.RefreshTokenRecord
|
|
197
|
+
if err := json.Unmarshal([]byte(raw), &token); err != nil {
|
|
198
|
+
return ports.RefreshTokenRecord{}, err
|
|
199
|
+
}
|
|
200
|
+
if token.UserID == uuid.Nil || token.ExpiresAt.IsZero() || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
|
|
201
|
+
return ports.RefreshTokenRecord{}, fmt.Errorf("refresh token record is incomplete")
|
|
202
|
+
}
|
|
203
|
+
return token, nil
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func (s *RedisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
207
|
+
return s.recovery.SetPasswordResetToken(ctx, tokenHash, userID, ttl)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ConsumePasswordResetToken is one-time-use by construction: the SQL DELETE
|
|
211
|
+
// runs inside the same transaction as the user update.
|
|
212
|
+
func (s *RedisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
213
|
+
return s.recovery.ConsumePasswordResetToken(ctx, tokenHash)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func (s *RedisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
217
|
+
return s.recovery.SetEmailVerifyToken(ctx, tokenHash, userID, ttl)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ConsumeEmailVerifyToken is one-time-use by construction, same as
|
|
221
|
+
// ConsumePasswordResetToken — the SQL delete participates in the transaction.
|
|
222
|
+
func (s *RedisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
223
|
+
return s.recovery.ConsumeEmailVerifyToken(ctx, tokenHash)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func (s *RedisTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
|
|
227
|
+
return s.recovery.WithTransaction(ctx, fn)
|
|
228
|
+
}
|