@nakedev/go-scaffold 0.4.3 → 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 +567 -513
- package/dist/commands/auth.js +12 -1
- package/dist/commands/check.js +281 -0
- package/dist/commands/create.js +2 -1
- package/dist/commands/generate.js +5 -2
- package/dist/commands/method.js +66 -70
- package/dist/commands/observability.js +4 -53
- package/dist/commands/rbac.js +19 -8
- package/dist/commands/undo.js +6 -3
- package/dist/commands/worker.js +14 -4
- package/dist/index.js +13 -1
- package/dist/templates/auth-manifest.js +46 -45
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +82 -60
- package/dist/templates/rbac-manifest.js +15 -11
- package/dist/templates/worker-manifest.js +4 -1
- package/dist/types.js +2 -0
- package/dist/utils/auth-patcher.js +22 -22
- package/dist/utils/config.js +24 -5
- package/dist/utils/docs-patcher.js +68 -0
- package/dist/utils/hexagonal-method-patcher.js +334 -0
- package/dist/utils/main-patcher.js +3 -3
- package/dist/utils/module-location.js +17 -11
- package/dist/utils/platform-patcher.js +27 -0
- package/dist/utils/rbac-patcher.js +73 -216
- package/package.json +1 -1
- package/templates/add/auth/cmd/seed/main.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/{browser_policy.go.hbs → adapters/inbound/http/browser_policy.go.hbs} +3 -3
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
- package/templates/add/auth/internal/app/user/{handler.go.hbs → adapters/inbound/http/handler.go.hbs} +103 -8
- package/templates/add/auth/internal/app/user/{handler_local.go.hbs → adapters/inbound/http/handler_local.go.hbs} +8 -7
- package/templates/add/auth/internal/app/user/{handler_mfa.go.hbs → adapters/inbound/http/handler_mfa.go.hbs} +6 -6
- package/templates/add/auth/internal/app/user/{handler_oauth.go.hbs → adapters/inbound/http/handler_oauth.go.hbs} +17 -17
- package/templates/add/auth/internal/app/user/{handler_recovery.go.hbs → adapters/inbound/http/handler_recovery.go.hbs} +4 -4
- package/templates/add/auth/internal/app/user/{handler_test.go.hbs → adapters/inbound/http/handler_test.go.hbs} +52 -31
- package/templates/add/auth/internal/app/user/{handler_user.go.hbs → adapters/inbound/http/handler_user.go.hbs} +4 -4
- package/templates/add/auth/internal/app/user/{session_cookie.go.hbs → adapters/inbound/http/session_cookie.go.hbs} +5 -3
- 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/{mfa_store.go.hbs → adapters/outbound/postgres/mfa_store.go.hbs} +22 -20
- package/templates/add/auth/internal/app/user/{mfa_store_test.go.hbs → adapters/outbound/postgres/mfa_store_test.go.hbs} +7 -7
- 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/{tokenstore_pg.go.hbs → adapters/outbound/postgres/tokenstore_pg.go.hbs} +36 -32
- package/templates/add/auth/internal/app/user/{tokenstore_pg_test.go.hbs → adapters/outbound/postgres/tokenstore_pg_test.go.hbs} +10 -3
- package/templates/add/auth/internal/app/user/{tokenstore_recovery.go.hbs → adapters/outbound/postgres/tokenstore_recovery.go.hbs} +29 -3
- package/templates/add/auth/internal/app/user/{tokenstore_redis.go.hbs → adapters/outbound/redis/tokenstore.go.hbs} +43 -36
- package/templates/add/auth/internal/app/user/{tokenstore_redis_test.go.hbs → adapters/outbound/redis/tokenstore_test.go.hbs} +14 -3
- 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/{external_login.go.hbs → application/external_login.go.hbs} +30 -40
- package/templates/add/auth/internal/app/user/{jwt.go.hbs → application/jwt.go.hbs} +6 -3
- package/templates/add/auth/internal/app/user/{local_auth.go.hbs → application/local_auth.go.hbs} +17 -19
- package/templates/add/auth/internal/app/user/{mfa_service.go.hbs → application/mfa_service.go.hbs} +27 -28
- package/templates/add/auth/internal/app/user/{mfa_service_test.go.hbs → application/mfa_service_test.go.hbs} +12 -11
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +3 -3
- package/templates/add/auth/internal/app/user/{provider_test.go.hbs → application/provider_test.go.hbs} +55 -56
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +16 -47
- package/templates/add/auth/internal/app/user/{recovery_service.go.hbs → application/recovery_service.go.hbs} +13 -15
- package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
- package/templates/add/auth/internal/app/user/{service_test.go.hbs → application/service_test.go.hbs} +94 -75
- package/templates/add/auth/internal/app/user/{sessions.go.hbs → application/sessions.go.hbs} +14 -14
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/{user_query.go.hbs → application/user_query.go.hbs} +16 -16
- package/templates/add/auth/internal/app/user/composition.go.hbs +89 -86
- 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/migrations/create_auth_tokens.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -1
- 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 +23 -10
- 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 +54 -36
- package/templates/create/base/.golangci.yml.hbs +2 -2
- package/templates/create/base/AGENTS.md.hbs +55 -31
- package/templates/create/base/README.md.hbs +77 -22
- package/templates/create/base/cmd/api/wiring.go.hbs +3 -2
- package/templates/create/base/internal/composition/doc.go.hbs +7 -0
- package/templates/create/features/docs/architecture.md.hbs +64 -26
- package/templates/create/features/docs/patterns.md.hbs +117 -90
- package/templates/create/features/docs/techstack.md.hbs +17 -2
- 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 -537
- package/templates/add/auth/internal/app/user/contracts.go.hbs +0 -88
- package/templates/add/auth/internal/app/user/dto.go.hbs +0 -134
- package/templates/add/auth/internal/app/user/errors.go.hbs +0 -68
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -45
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -32
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +0 -17
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +0 -20
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +0 -17
- package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -31
- package/templates/add/auth/internal/app/user/repository.go.hbs +0 -139
- package/templates/add/auth/internal/app/user/service.go.hbs +0 -135
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -58
- 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/commands.go.hbs +0 -95
- package/templates/generate/module/composition.go.hbs +0 -23
- package/templates/generate/module/cqrs_test.go.hbs +0 -7
- 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 -179
- package/templates/generate/module/handler_test.go.hbs +0 -174
- package/templates/generate/module/minimal/commands.go.hbs +0 -34
- package/templates/generate/module/minimal/dto.go.hbs +0 -28
- package/templates/generate/module/minimal/handler.go.hbs +0 -82
- package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
- package/templates/generate/module/minimal/queries.go.hbs +0 -45
- package/templates/generate/module/minimal/service.go.hbs +0 -71
- 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/queries.go.hbs +0 -62
- package/templates/generate/module/repository.go.hbs +0 -103
- package/templates/generate/module/service.go.hbs +0 -164
- package/templates/generate/module/service_test.go.hbs +0 -161
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// per-user session set is just a WHERE clause, and expiry is a column every
|
|
7
7
|
// read filters on rather than a TTL the store enforces. What that costs is a
|
|
8
8
|
// sweep to keep the table from growing — see DeleteExpired.
|
|
9
|
-
package
|
|
9
|
+
package postgres
|
|
10
10
|
|
|
11
11
|
import (
|
|
12
12
|
"context"
|
|
@@ -16,7 +16,7 @@ import (
|
|
|
16
16
|
"strings"
|
|
17
17
|
"time"
|
|
18
18
|
|
|
19
|
-
"{{goModule}}/internal/app/user/
|
|
19
|
+
"{{goModule}}/internal/app/user/ports"
|
|
20
20
|
"{{goModule}}/internal/shared/tx"
|
|
21
21
|
|
|
22
22
|
"github.com/google/uuid"
|
|
@@ -29,19 +29,23 @@ const (
|
|
|
29
29
|
kindOAuthState = "oauth_state"
|
|
30
30
|
)
|
|
31
31
|
|
|
32
|
-
type
|
|
32
|
+
type PgTokenStore struct {
|
|
33
33
|
db *gorm.DB
|
|
34
34
|
recovery *recoveryTokenStore
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
func NewPgTokenStore(db *gorm.DB) *
|
|
38
|
-
return &
|
|
37
|
+
func NewPgTokenStore(db *gorm.DB) *PgTokenStore {
|
|
38
|
+
return &PgTokenStore{db: db, recovery: newRecoveryTokenStore(db)}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
var _ ports.RefreshTokenStore = (*PgTokenStore)(nil)
|
|
42
|
+
var _ ports.OAuthTransactionStore = (*PgTokenStore)(nil)
|
|
43
|
+
var _ ports.RecoveryTokenStore = (*PgTokenStore)(nil)
|
|
44
|
+
|
|
41
45
|
// lookup finds a live token of the given kind. Expiry is enforced here rather
|
|
42
46
|
// than by a sweep, so a row that outlived its TTL is already invisible.
|
|
43
|
-
func (s *
|
|
44
|
-
var row
|
|
47
|
+
func (s *PgTokenStore) lookup(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
|
|
48
|
+
var row AuthToken
|
|
45
49
|
err := tx.From(ctx, s.db).WithContext(ctx).
|
|
46
50
|
Where("token_hash = ? AND kind = ? AND expires_at > now()", hash, kind).
|
|
47
51
|
Take(&row).Error
|
|
@@ -54,11 +58,11 @@ func (s *pgTokenStore) lookup(ctx context.Context, hash, kind string) (uuid.UUID
|
|
|
54
58
|
return row.UserID, true, nil
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
func (s *
|
|
61
|
+
func (s *PgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token ports.RefreshTokenRecord) error {
|
|
58
62
|
if token.UserID == uuid.Nil || token.ExpiresAt.IsZero() || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
|
|
59
63
|
return fmt.Errorf("refresh token record expiry is invalid")
|
|
60
64
|
}
|
|
61
|
-
row :=
|
|
65
|
+
row := AuthToken{
|
|
62
66
|
TokenHash: tokenHash,
|
|
63
67
|
UserID: token.UserID,
|
|
64
68
|
Kind: kindRefresh,
|
|
@@ -78,15 +82,15 @@ func (s *pgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, to
|
|
|
78
82
|
}).FirstOrCreate(&row).Error
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
func (s *
|
|
85
|
+
func (s *PgTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
82
86
|
return s.lookup(ctx, tokenHash, kindRefresh)
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
// ConsumeRefreshToken removes the active row and writes the reuse-detection
|
|
86
90
|
// tombstone in one statement. A concurrent caller can observe either the
|
|
87
91
|
// active row or the tombstone, never the gap between separate operations.
|
|
88
|
-
func (s *
|
|
89
|
-
var token
|
|
92
|
+
func (s *PgTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string) (ports.RefreshTokenRecord, bool, error) {
|
|
93
|
+
var token ports.RefreshTokenRecord
|
|
90
94
|
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
91
95
|
`WITH consumed AS (
|
|
92
96
|
DELETE FROM user_svc.auth_tokens
|
|
@@ -99,41 +103,41 @@ func (s *pgTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string
|
|
|
99
103
|
RETURNING user_id, expires_at, absolute_expires_at`,
|
|
100
104
|
tokenHash, kindRefresh, tokenHash, kindRefreshUsed).Row().Scan(&token.UserID, &token.ExpiresAt, &token.AbsoluteExpiresAt)
|
|
101
105
|
if errors.Is(err, sql.ErrNoRows) {
|
|
102
|
-
return
|
|
106
|
+
return ports.RefreshTokenRecord{}, false, nil
|
|
103
107
|
}
|
|
104
108
|
if err != nil {
|
|
105
|
-
return
|
|
109
|
+
return ports.RefreshTokenRecord{}, false, err
|
|
106
110
|
}
|
|
107
111
|
return token, true, nil
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
// DeleteRefreshToken removes the row outright and leaves nothing behind — see
|
|
111
115
|
// the AuthToken doc comment for why logout must not leave a tombstone.
|
|
112
|
-
func (s *
|
|
116
|
+
func (s *PgTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, _ uuid.UUID) error {
|
|
113
117
|
return tx.From(ctx, s.db).WithContext(ctx).
|
|
114
118
|
Where("token_hash = ? AND kind = ?", tokenHash, kindRefresh).
|
|
115
|
-
Delete(&
|
|
119
|
+
Delete(&AuthToken{}).Error
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
// RevokeAllRefreshTokens is one statement here because Postgres can query by
|
|
119
123
|
// value — the Redis store has to keep a separate per-user set to answer this,
|
|
120
124
|
// and that set accumulates stale members its own comment calls out.
|
|
121
125
|
// Tombstones are left in place so reuse detection still works afterwards.
|
|
122
|
-
func (s *
|
|
126
|
+
func (s *PgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
|
|
123
127
|
return tx.From(ctx, s.db).WithContext(ctx).
|
|
124
128
|
Where("user_id = ? AND kind = ?", userID, kindRefresh).
|
|
125
|
-
Delete(&
|
|
129
|
+
Delete(&AuthToken{}).Error
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
func (s *
|
|
132
|
+
func (s *PgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
129
133
|
return s.lookup(ctx, tokenHash, kindRefreshUsed)
|
|
130
134
|
}
|
|
131
135
|
|
|
132
|
-
func (s *
|
|
136
|
+
func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string, transaction ports.LoginTransaction) error {
|
|
133
137
|
if strings.TrimSpace(stateHash) == "" || strings.TrimSpace(transaction.Provider) == "" || strings.TrimSpace(transaction.CodeChallenge) == "" || strings.TrimSpace(transaction.Nonce) == "" || !transaction.ExpiresAt.After(time.Now()) {
|
|
134
138
|
return fmt.Errorf("oauth login transaction is invalid")
|
|
135
139
|
}
|
|
136
|
-
row :=
|
|
140
|
+
row := AuthToken{
|
|
137
141
|
TokenHash: stateHash,
|
|
138
142
|
Kind: kindOAuthState,
|
|
139
143
|
ExpiresAt: transaction.ExpiresAt,
|
|
@@ -153,8 +157,8 @@ func (s *pgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string
|
|
|
153
157
|
}).FirstOrCreate(&row).Error
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
func (s *
|
|
157
|
-
var transaction
|
|
160
|
+
func (s *PgTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash string) (ports.LoginTransaction, bool, error) {
|
|
161
|
+
var transaction ports.LoginTransaction
|
|
158
162
|
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
159
163
|
`DELETE FROM user_svc.auth_tokens
|
|
160
164
|
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
@@ -166,31 +170,31 @@ func (s *pgTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash st
|
|
|
166
170
|
&transaction.ExpiresAt,
|
|
167
171
|
)
|
|
168
172
|
if errors.Is(err, sql.ErrNoRows) {
|
|
169
|
-
return
|
|
173
|
+
return ports.LoginTransaction{}, false, nil
|
|
170
174
|
}
|
|
171
175
|
if err != nil {
|
|
172
|
-
return
|
|
176
|
+
return ports.LoginTransaction{}, false, err
|
|
173
177
|
}
|
|
174
178
|
return transaction, true, nil
|
|
175
179
|
}
|
|
176
180
|
|
|
177
|
-
func (s *
|
|
181
|
+
func (s *PgTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
178
182
|
return s.recovery.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
func (s *
|
|
185
|
+
func (s *PgTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
182
186
|
return s.recovery.consume(ctx, tokenHash, kindPasswordSet)
|
|
183
187
|
}
|
|
184
188
|
|
|
185
|
-
func (s *
|
|
189
|
+
func (s *PgTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
186
190
|
return s.recovery.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
func (s *
|
|
193
|
+
func (s *PgTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
190
194
|
return s.recovery.consume(ctx, tokenHash, kindEmailVerify)
|
|
191
195
|
}
|
|
192
196
|
|
|
193
|
-
func (s *
|
|
197
|
+
func (s *PgTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
|
|
194
198
|
return s.recovery.withTransaction(ctx, fn)
|
|
195
199
|
}
|
|
196
200
|
|
|
@@ -201,9 +205,9 @@ func (s *pgTokenStore) WithTransaction(ctx context.Context, fn func(context.Cont
|
|
|
201
205
|
// ponytail: not scheduled for you. Wire it up the day the table is big enough
|
|
202
206
|
// to notice; a scaffold that silently runs background sweeps nobody asked for
|
|
203
207
|
// is harder to reason about than one line you add when it matters.
|
|
204
|
-
func (s *
|
|
208
|
+
func (s *PgTokenStore) DeleteExpired(ctx context.Context) (int64, error) {
|
|
205
209
|
res := tx.From(ctx, s.db).WithContext(ctx).
|
|
206
210
|
Where("expires_at <= now()").
|
|
207
|
-
Delete(&
|
|
211
|
+
Delete(&AuthToken{})
|
|
208
212
|
return res.RowsAffected, res.Error
|
|
209
213
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
package
|
|
1
|
+
package postgres
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
5
|
"os"
|
|
6
6
|
"testing"
|
|
7
|
+
"time"
|
|
7
8
|
|
|
9
|
+
"{{goModule}}/internal/app/user/ports"
|
|
8
10
|
"github.com/google/uuid"
|
|
9
11
|
"gorm.io/driver/postgres"
|
|
10
12
|
"gorm.io/gorm"
|
|
@@ -50,7 +52,7 @@ func TestPgTokenStore_ConsumeRefreshToken_ConcurrentRealDatabase(t *testing.T) {
|
|
|
50
52
|
ctx := context.Background()
|
|
51
53
|
userID := uuid.New()
|
|
52
54
|
hash := "real-pg-refresh-" + uuid.NewString()
|
|
53
|
-
if err := store.SetRefreshToken(ctx, hash,
|
|
55
|
+
if err := store.SetRefreshToken(ctx, hash, refreshTokenRecordForTest(userID)); err != nil {
|
|
54
56
|
t.Fatalf("seed refresh token: %v", err)
|
|
55
57
|
}
|
|
56
58
|
t.Cleanup(func() {
|
|
@@ -59,7 +61,7 @@ func TestPgTokenStore_ConsumeRefreshToken_ConcurrentRealDatabase(t *testing.T) {
|
|
|
59
61
|
|
|
60
62
|
const callers = 32
|
|
61
63
|
type result struct {
|
|
62
|
-
token
|
|
64
|
+
token ports.RefreshTokenRecord
|
|
63
65
|
ok bool
|
|
64
66
|
err error
|
|
65
67
|
}
|
|
@@ -94,3 +96,8 @@ func TestPgTokenStore_ConsumeRefreshToken_ConcurrentRealDatabase(t *testing.T) {
|
|
|
94
96
|
t.Fatalf("consumed refresh token remained active: ok=%t err=%v", ok, err)
|
|
95
97
|
}
|
|
96
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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package postgres
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
@@ -6,7 +6,7 @@ import (
|
|
|
6
6
|
"errors"
|
|
7
7
|
"time"
|
|
8
8
|
|
|
9
|
-
"{{goModule}}/internal/app/user/
|
|
9
|
+
"{{goModule}}/internal/app/user/ports"
|
|
10
10
|
"{{goModule}}/internal/shared/tx"
|
|
11
11
|
|
|
12
12
|
"github.com/google/uuid"
|
|
@@ -25,6 +25,12 @@ type recoveryTokenStore struct {
|
|
|
25
25
|
db *gorm.DB
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
var _ ports.RecoveryTokenStore = (*recoveryTokenStore)(nil)
|
|
29
|
+
|
|
30
|
+
func NewRecoveryTokenStore(db *gorm.DB) ports.RecoveryTokenStore {
|
|
31
|
+
return newRecoveryTokenStore(db)
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
func newRecoveryTokenStore(db *gorm.DB) *recoveryTokenStore {
|
|
29
35
|
return &recoveryTokenStore{db: db}
|
|
30
36
|
}
|
|
@@ -35,7 +41,7 @@ func (s *recoveryTokenStore) withTransaction(ctx context.Context, fn func(contex
|
|
|
35
41
|
|
|
36
42
|
func (s *recoveryTokenStore) put(ctx context.Context, hash, kind string, userID uuid.UUID, ttl time.Duration) error {
|
|
37
43
|
expiresAt := time.Now().Add(ttl)
|
|
38
|
-
row :=
|
|
44
|
+
row := AuthToken{TokenHash: hash, UserID: userID, Kind: kind, ExpiresAt: expiresAt}
|
|
39
45
|
return tx.From(ctx, s.db).WithContext(ctx).
|
|
40
46
|
Where("token_hash = ?", hash).
|
|
41
47
|
Assign(map[string]any{"user_id": userID, "kind": kind, "expires_at": expiresAt}).
|
|
@@ -56,3 +62,23 @@ func (s *recoveryTokenStore) consume(ctx context.Context, hash, kind string) (uu
|
|
|
56
62
|
}
|
|
57
63
|
return userID, true, nil
|
|
58
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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Redis-backed tokenStore — installed by `add auth --store redis`.
|
|
2
2
|
// The Postgres-backed alternative lives in tokenstore_pg.go; only one of the
|
|
3
3
|
// two is ever written into a project, and Service never learns which.
|
|
4
|
-
package
|
|
4
|
+
package redisadapter
|
|
5
5
|
|
|
6
6
|
import (
|
|
7
7
|
"context"
|
|
@@ -9,6 +9,9 @@ import (
|
|
|
9
9
|
"fmt"
|
|
10
10
|
"time"
|
|
11
11
|
|
|
12
|
+
"{{goModule}}/internal/app/user/adapters/outbound/postgres"
|
|
13
|
+
"{{goModule}}/internal/app/user/ports"
|
|
14
|
+
|
|
12
15
|
"github.com/google/uuid"
|
|
13
16
|
"github.com/redis/go-redis/v9"
|
|
14
17
|
"gorm.io/gorm"
|
|
@@ -17,21 +20,25 @@ import (
|
|
|
17
20
|
const (
|
|
18
21
|
// The shared {refresh} hash tag keeps all keys touched by the Lua rotation
|
|
19
22
|
// script on one Redis Cluster slot as well as making the operation atomic.
|
|
20
|
-
refreshKeyPrefix = "user:{refresh}:" // +hash ->
|
|
23
|
+
refreshKeyPrefix = "user:{refresh}:" // +hash -> ports.RefreshTokenRecord, TTL = ExpiresAt
|
|
21
24
|
refreshUserKeyPrefix = "user:{refresh}:user:" // +userID -> SET of active token hashes
|
|
22
|
-
refreshUsedKeyPrefix = "user:{refresh}:used:" // +hash ->
|
|
25
|
+
refreshUsedKeyPrefix = "user:{refresh}:used:" // +hash -> ports.RefreshTokenRecord, TTL = source token expiry
|
|
23
26
|
oauthStateKeyPrefix = "user:{oauth}:state:"
|
|
24
27
|
)
|
|
25
28
|
|
|
26
|
-
type
|
|
29
|
+
type RedisTokenStore struct {
|
|
27
30
|
rdb *redis.Client
|
|
28
|
-
recovery
|
|
31
|
+
recovery ports.RecoveryTokenStore
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
func NewRedisTokenStore(rdb *redis.Client, db *gorm.DB) *
|
|
32
|
-
return &
|
|
34
|
+
func NewRedisTokenStore(rdb *redis.Client, db *gorm.DB) *RedisTokenStore {
|
|
35
|
+
return &RedisTokenStore{rdb: rdb, recovery: postgres.NewRecoveryTokenStore(db)}
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
var _ ports.RefreshTokenStore = (*RedisTokenStore)(nil)
|
|
39
|
+
var _ ports.OAuthTransactionStore = (*RedisTokenStore)(nil)
|
|
40
|
+
var _ ports.RecoveryTokenStore = (*RedisTokenStore)(nil)
|
|
41
|
+
|
|
35
42
|
var consumeRefreshScript = redis.NewScript(`
|
|
36
43
|
local token = redis.call("GET", KEYS[1])
|
|
37
44
|
local ttl = redis.call("PTTL", KEYS[1])
|
|
@@ -69,7 +76,7 @@ redis.call("DEL", KEYS[1])
|
|
|
69
76
|
return #hashes
|
|
70
77
|
`)
|
|
71
78
|
|
|
72
|
-
func (s *
|
|
79
|
+
func (s *RedisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token ports.RefreshTokenRecord) error {
|
|
73
80
|
ttl := time.Until(token.ExpiresAt)
|
|
74
81
|
if token.UserID == uuid.Nil || ttl <= 0 || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
|
|
75
82
|
return fmt.Errorf("refresh token expiry is invalid")
|
|
@@ -85,7 +92,7 @@ func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string,
|
|
|
85
92
|
return err
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
func (s *
|
|
95
|
+
func (s *RedisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
89
96
|
raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
|
|
90
97
|
if err == redis.Nil {
|
|
91
98
|
return uuid.Nil, false, nil
|
|
@@ -103,26 +110,26 @@ func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string)
|
|
|
103
110
|
// ConsumeRefreshToken uses one Lua script for active-token deletion,
|
|
104
111
|
// per-user-session cleanup, and the reuse tombstone. A concurrent caller
|
|
105
112
|
// cannot observe the active value after another caller has won the rotation.
|
|
106
|
-
func (s *
|
|
113
|
+
func (s *RedisTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string) (ports.RefreshTokenRecord, bool, error) {
|
|
107
114
|
result, err := consumeRefreshScript.Run(ctx, s.rdb,
|
|
108
115
|
[]string{refreshKeyPrefix + tokenHash, refreshUsedKeyPrefix + tokenHash, refreshUserKeyPrefix},
|
|
109
116
|
tokenHash,
|
|
110
117
|
).Result()
|
|
111
118
|
if err != nil {
|
|
112
|
-
return
|
|
119
|
+
return ports.RefreshTokenRecord{}, false, err
|
|
113
120
|
}
|
|
114
121
|
raw, ok := result.(string)
|
|
115
122
|
if !ok || raw == "" {
|
|
116
|
-
return
|
|
123
|
+
return ports.RefreshTokenRecord{}, false, nil
|
|
117
124
|
}
|
|
118
125
|
token, err := decodeRefreshTokenRecord(raw)
|
|
119
126
|
if err != nil {
|
|
120
|
-
return
|
|
127
|
+
return ports.RefreshTokenRecord{}, false, fmt.Errorf("decode consumed refresh token record: %w", err)
|
|
121
128
|
}
|
|
122
129
|
return token, true, nil
|
|
123
130
|
}
|
|
124
131
|
|
|
125
|
-
func (s *
|
|
132
|
+
func (s *RedisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
|
|
126
133
|
pipe := s.rdb.TxPipeline()
|
|
127
134
|
pipe.Del(ctx, refreshKeyPrefix+tokenHash)
|
|
128
135
|
pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
|
|
@@ -135,7 +142,7 @@ func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash stri
|
|
|
135
142
|
// important because SetRefreshToken updates two keys: a SMEMBERS followed by
|
|
136
143
|
// a separate pipeline could miss a token added between those operations and
|
|
137
144
|
// leave an active token with no session-set member.
|
|
138
|
-
func (s *
|
|
145
|
+
func (s *RedisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
|
|
139
146
|
setKey := refreshUserKeyPrefix + userID.String()
|
|
140
147
|
_, err := revokeAllRefreshScript.Run(ctx, s.rdb,
|
|
141
148
|
[]string{setKey, refreshKeyPrefix},
|
|
@@ -144,7 +151,7 @@ func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uui
|
|
|
144
151
|
return err
|
|
145
152
|
}
|
|
146
153
|
|
|
147
|
-
func (s *
|
|
154
|
+
func (s *RedisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
148
155
|
raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
|
|
149
156
|
if err == redis.Nil {
|
|
150
157
|
return uuid.Nil, false, nil
|
|
@@ -159,7 +166,7 @@ func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash stri
|
|
|
159
166
|
return token.UserID, true, nil
|
|
160
167
|
}
|
|
161
168
|
|
|
162
|
-
func (s *
|
|
169
|
+
func (s *RedisTokenStore) SetLoginTransaction(ctx context.Context, stateHash string, transaction ports.LoginTransaction) error {
|
|
163
170
|
if !transaction.ExpiresAt.After(time.Now()) {
|
|
164
171
|
return fmt.Errorf("oauth login transaction expiry is invalid")
|
|
165
172
|
}
|
|
@@ -170,52 +177,52 @@ func (s *redisTokenStore) SetLoginTransaction(ctx context.Context, stateHash str
|
|
|
170
177
|
return s.rdb.Set(ctx, oauthStateKeyPrefix+stateHash, payload, time.Until(transaction.ExpiresAt)).Err()
|
|
171
178
|
}
|
|
172
179
|
|
|
173
|
-
func (s *
|
|
180
|
+
func (s *RedisTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash string) (ports.LoginTransaction, bool, error) {
|
|
174
181
|
raw, err := s.rdb.GetDel(ctx, oauthStateKeyPrefix+stateHash).Result()
|
|
175
182
|
if err == redis.Nil {
|
|
176
|
-
return
|
|
183
|
+
return ports.LoginTransaction{}, false, nil
|
|
177
184
|
}
|
|
178
185
|
if err != nil {
|
|
179
|
-
return
|
|
186
|
+
return ports.LoginTransaction{}, false, err
|
|
180
187
|
}
|
|
181
|
-
var transaction
|
|
188
|
+
var transaction ports.LoginTransaction
|
|
182
189
|
if err := json.Unmarshal([]byte(raw), &transaction); err != nil {
|
|
183
|
-
return
|
|
190
|
+
return ports.LoginTransaction{}, false, fmt.Errorf("decode oauth login transaction: %w", err)
|
|
184
191
|
}
|
|
185
192
|
return transaction, true, nil
|
|
186
193
|
}
|
|
187
194
|
|
|
188
|
-
func decodeRefreshTokenRecord(raw string) (
|
|
189
|
-
var token
|
|
195
|
+
func decodeRefreshTokenRecord(raw string) (ports.RefreshTokenRecord, error) {
|
|
196
|
+
var token ports.RefreshTokenRecord
|
|
190
197
|
if err := json.Unmarshal([]byte(raw), &token); err != nil {
|
|
191
|
-
return
|
|
198
|
+
return ports.RefreshTokenRecord{}, err
|
|
192
199
|
}
|
|
193
200
|
if token.UserID == uuid.Nil || token.ExpiresAt.IsZero() || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
|
|
194
|
-
return
|
|
201
|
+
return ports.RefreshTokenRecord{}, fmt.Errorf("refresh token record is incomplete")
|
|
195
202
|
}
|
|
196
203
|
return token, nil
|
|
197
204
|
}
|
|
198
205
|
|
|
199
|
-
func (s *
|
|
200
|
-
return s.recovery.
|
|
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)
|
|
201
208
|
}
|
|
202
209
|
|
|
203
210
|
// ConsumePasswordResetToken is one-time-use by construction: the SQL DELETE
|
|
204
211
|
// runs inside the same transaction as the user update.
|
|
205
|
-
func (s *
|
|
206
|
-
return s.recovery.
|
|
212
|
+
func (s *RedisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
213
|
+
return s.recovery.ConsumePasswordResetToken(ctx, tokenHash)
|
|
207
214
|
}
|
|
208
215
|
|
|
209
|
-
func (s *
|
|
210
|
-
return s.recovery.
|
|
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)
|
|
211
218
|
}
|
|
212
219
|
|
|
213
220
|
// ConsumeEmailVerifyToken is one-time-use by construction, same as
|
|
214
221
|
// ConsumePasswordResetToken — the SQL delete participates in the transaction.
|
|
215
|
-
func (s *
|
|
216
|
-
return s.recovery.
|
|
222
|
+
func (s *RedisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
223
|
+
return s.recovery.ConsumeEmailVerifyToken(ctx, tokenHash)
|
|
217
224
|
}
|
|
218
225
|
|
|
219
|
-
func (s *
|
|
220
|
-
return s.recovery.
|
|
226
|
+
func (s *RedisTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
|
|
227
|
+
return s.recovery.WithTransaction(ctx, fn)
|
|
221
228
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package redisadapter
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
@@ -8,10 +8,21 @@ import (
|
|
|
8
8
|
"testing"
|
|
9
9
|
"time"
|
|
10
10
|
|
|
11
|
+
"{{goModule}}/internal/app/user/ports"
|
|
12
|
+
|
|
11
13
|
"github.com/google/uuid"
|
|
12
14
|
"github.com/redis/go-redis/v9"
|
|
13
15
|
)
|
|
14
16
|
|
|
17
|
+
func testRefreshTokenRecord(userID uuid.UUID) ports.RefreshTokenRecord {
|
|
18
|
+
now := time.Now()
|
|
19
|
+
return ports.RefreshTokenRecord{
|
|
20
|
+
UserID: userID,
|
|
21
|
+
ExpiresAt: now.Add(time.Hour),
|
|
22
|
+
AbsoluteExpiresAt: now.Add(24 * time.Hour),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
func redisClientForTest(t *testing.T) *redis.Client {
|
|
16
27
|
t.Helper()
|
|
17
28
|
url := os.Getenv("TEST_REDIS_URL")
|
|
@@ -73,7 +84,7 @@ func TestRedisTokenStore_ConsumeRefreshToken_ConcurrentRealRedis(t *testing.T) {
|
|
|
73
84
|
const callers = 32
|
|
74
85
|
start := make(chan struct{})
|
|
75
86
|
results := make(chan struct {
|
|
76
|
-
token
|
|
87
|
+
token ports.RefreshTokenRecord
|
|
77
88
|
ok bool
|
|
78
89
|
err error
|
|
79
90
|
}, callers)
|
|
@@ -82,7 +93,7 @@ func TestRedisTokenStore_ConsumeRefreshToken_ConcurrentRealRedis(t *testing.T) {
|
|
|
82
93
|
<-start
|
|
83
94
|
token, ok, err := store.ConsumeRefreshToken(ctx, hash)
|
|
84
95
|
results <- struct {
|
|
85
|
-
token
|
|
96
|
+
token ports.RefreshTokenRecord
|
|
86
97
|
ok bool
|
|
87
98
|
err error
|
|
88
99
|
}{token: token, ok: ok, err: err}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"time"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/app/user/ports"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Dependencies is the explicit application composition contract for auth.
|
|
10
|
+
// Each port represents one lifecycle or capability so an adapter does not
|
|
11
|
+
// have to implement unrelated token operations.
|
|
12
|
+
type Dependencies struct {
|
|
13
|
+
Repository ports.UserRepository
|
|
14
|
+
Passwords ports.PasswordHasher
|
|
15
|
+
RefreshTokens ports.RefreshTokenStore
|
|
16
|
+
OAuthTransactions ports.OAuthTransactionStore
|
|
17
|
+
RecoveryTokens ports.RecoveryTokenStore
|
|
18
|
+
MFA ports.MFAStore
|
|
19
|
+
Mailer ports.AuthMailer
|
|
20
|
+
Providers ProviderRegistry
|
|
21
|
+
Roles ports.RoleChecker
|
|
22
|
+
Clock func() time.Time
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type MFASettings struct {
|
|
26
|
+
Enabled bool
|
|
27
|
+
Issuer string
|
|
28
|
+
EncryptionKey string
|
|
29
|
+
ChallengeTTL time.Duration
|
|
30
|
+
TOTPWindow int
|
|
31
|
+
RecoveryCodeCount int
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type AuthConfig struct {
|
|
35
|
+
JWTSecret string
|
|
36
|
+
JWTAccessTTL time.Duration
|
|
37
|
+
JWTRefreshTTL time.Duration
|
|
38
|
+
JWTRefreshMaxTTL time.Duration
|
|
39
|
+
OAuthStateTTL time.Duration
|
|
40
|
+
PasswordResetTTL time.Duration
|
|
41
|
+
PasswordResetURL string
|
|
42
|
+
EmailVerifyTTL time.Duration
|
|
43
|
+
EmailVerifyURL string
|
|
44
|
+
MFA MFASettings
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// These aliases keep use-case code concise while the contracts remain owned
|
|
48
|
+
// by the module's ports package. Adapters import ports directly.
|
|
49
|
+
type UserRepository = ports.UserRepository
|
|
50
|
+
type AuthMailer = ports.AuthMailer
|
|
51
|
+
type RoleChecker = ports.RoleChecker
|
|
52
|
+
type PasswordHasher = ports.PasswordHasher
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"time"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/app/user/domain"
|
|
7
|
+
|
|
8
|
+
"github.com/google/uuid"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type RegisterInput struct {
|
|
12
|
+
Email string
|
|
13
|
+
Password string
|
|
14
|
+
Name string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type LoginInput struct {
|
|
18
|
+
Email string
|
|
19
|
+
Password string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type LoginExchangeInput struct {
|
|
23
|
+
Code string
|
|
24
|
+
State string
|
|
25
|
+
CodeVerifier string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type AuthResponse struct {
|
|
29
|
+
AccessToken string
|
|
30
|
+
RefreshToken string
|
|
31
|
+
TokenType string
|
|
32
|
+
ExpiresIn int
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type AuthResult struct {
|
|
36
|
+
*AuthResponse
|
|
37
|
+
MFAChallenge string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func TokenResult(auth *AuthResponse) *AuthResult {
|
|
41
|
+
return &AuthResult{AuthResponse: auth}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func ChallengeResult(challenge string) *AuthResult {
|
|
45
|
+
return &AuthResult{MFAChallenge: challenge}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Response is the generic application response used by generated method
|
|
49
|
+
// extensions. Auth's HTTP adapter may expose a richer meResponse, but method
|
|
50
|
+
// generation still has one stable transport-neutral mapping seam.
|
|
51
|
+
type Response struct {
|
|
52
|
+
ID uuid.UUID
|
|
53
|
+
Email string
|
|
54
|
+
Name string
|
|
55
|
+
AvatarURL string
|
|
56
|
+
EmailVerified bool
|
|
57
|
+
Role string
|
|
58
|
+
CreatedAt time.Time
|
|
59
|
+
UpdatedAt time.Time
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func ToResponse(user *domain.User) Response {
|
|
63
|
+
return Response{
|
|
64
|
+
ID: user.ID,
|
|
65
|
+
Email: user.Email,
|
|
66
|
+
Name: user.Name,
|
|
67
|
+
AvatarURL: user.AvatarURL,
|
|
68
|
+
EmailVerified: user.EmailVerified,
|
|
69
|
+
Role: user.Role,
|
|
70
|
+
CreatedAt: user.CreatedAt,
|
|
71
|
+
UpdatedAt: user.UpdatedAt,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// go-scaffold:dto
|