@nakedev/go-scaffold 0.1.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +133 -44
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +21 -11
- package/dist/commands/method.js +32 -3
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +366 -63
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +17 -2
- package/dist/utils/module-location.js +37 -1
- package/dist/utils/naming.js +50 -2
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/package.json +3 -5
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +71 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +21 -7
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +43 -13
- package/templates/create/base/README.md.hbs +45 -8
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +0 -4
- package/templates/create/features/docs/patterns.md.hbs +82 -8
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler_test.go.hbs +8 -1
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -1
- package/templates/generate/module/model/model.go.hbs +10 -1
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +30 -0
- package/templates/generate/module/service.go.hbs +10 -1
- package/templates/generate/module/service_test.go.hbs +45 -0
- package/dist/commands/remove.js +0 -88
- package/scripts/smoke-test.mjs +0 -2058
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
- package/tests/integration/default-module.test.mjs +0 -46
- package/tests/integration/generator-naming.test.mjs +0 -81
- package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
- package/tests/integration/legacy-method-compat.test.mjs +0 -222
- package/tests/integration/remove-module.test.mjs +0 -58
- package/tests/unit/naming.test.mjs +0 -94
- package/tests/unit/smoke-isolation.test.mjs +0 -35
|
@@ -5,19 +5,17 @@ import (
|
|
|
5
5
|
"time"
|
|
6
6
|
|
|
7
7
|
"github.com/google/uuid"
|
|
8
|
-
"github.com/redis/go-redis/v9"
|
|
9
8
|
)
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
// consumer-side so it can be faked in tests without a real Redis.
|
|
10
|
+
// tokenStore is the short-lived-token surface Service needs — declared
|
|
11
|
+
// consumer-side so it can be faked in tests, and so the backing store is a
|
|
12
|
+
// choice rather than a hard dependency. Two implementations ship with the
|
|
13
|
+
// scaffold and exactly one is written into a project:
|
|
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.
|
|
21
19
|
type tokenStore interface {
|
|
22
20
|
SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
23
21
|
GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
@@ -30,130 +28,3 @@ type tokenStore interface {
|
|
|
30
28
|
SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
|
|
31
29
|
ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
|
|
32
30
|
}
|
|
33
|
-
|
|
34
|
-
type redisTokenStore struct {
|
|
35
|
-
rdb *redis.Client
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
func NewRedisTokenStore(rdb *redis.Client) *redisTokenStore {
|
|
39
|
-
return &redisTokenStore{rdb: rdb}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
43
|
-
pipe := s.rdb.TxPipeline()
|
|
44
|
-
pipe.Set(ctx, refreshKeyPrefix+tokenHash, userID.String(), ttl)
|
|
45
|
-
pipe.SAdd(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
|
|
46
|
-
_, err := pipe.Exec(ctx)
|
|
47
|
-
return err
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
51
|
-
raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
|
|
52
|
-
if err == redis.Nil {
|
|
53
|
-
return uuid.Nil, false, nil
|
|
54
|
-
}
|
|
55
|
-
if err != nil {
|
|
56
|
-
return uuid.Nil, false, err
|
|
57
|
-
}
|
|
58
|
-
id, err := uuid.Parse(raw)
|
|
59
|
-
if err != nil {
|
|
60
|
-
return uuid.Nil, false, err
|
|
61
|
-
}
|
|
62
|
-
return id, true, nil
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
|
|
66
|
-
pipe := s.rdb.TxPipeline()
|
|
67
|
-
pipe.Del(ctx, refreshKeyPrefix+tokenHash)
|
|
68
|
-
pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
|
|
69
|
-
_, err := pipe.Exec(ctx)
|
|
70
|
-
return err
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// RevokeAllRefreshTokens walks the per-user session set and deletes every
|
|
74
|
-
// active refresh token for that user — used when reuse of an already-rotated
|
|
75
|
-
// token is detected (see Service.Refresh): that means the raw token leaked,
|
|
76
|
-
// so every session, not just the replayed one, is treated as compromised.
|
|
77
|
-
//
|
|
78
|
-
// ponytail: the per-user set has no per-member TTL cleanup of its own — a
|
|
79
|
-
// member outlives its key's TTL as a stale entry until the next revoke or
|
|
80
|
-
// rotation touches it. Self-heals over time; revisit if a single user's
|
|
81
|
-
// session count grows large enough to matter.
|
|
82
|
-
func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
|
|
83
|
-
setKey := refreshUserKeyPrefix + userID.String()
|
|
84
|
-
hashes, err := s.rdb.SMembers(ctx, setKey).Result()
|
|
85
|
-
if err != nil {
|
|
86
|
-
return err
|
|
87
|
-
}
|
|
88
|
-
if len(hashes) == 0 {
|
|
89
|
-
return nil
|
|
90
|
-
}
|
|
91
|
-
pipe := s.rdb.TxPipeline()
|
|
92
|
-
for _, h := range hashes {
|
|
93
|
-
pipe.Del(ctx, refreshKeyPrefix+h)
|
|
94
|
-
}
|
|
95
|
-
pipe.Del(ctx, setKey)
|
|
96
|
-
_, err = pipe.Exec(ctx)
|
|
97
|
-
return err
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
func (s *redisTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
101
|
-
return s.rdb.Set(ctx, refreshUsedKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
105
|
-
raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
|
|
106
|
-
if err == redis.Nil {
|
|
107
|
-
return uuid.Nil, false, nil
|
|
108
|
-
}
|
|
109
|
-
if err != nil {
|
|
110
|
-
return uuid.Nil, false, err
|
|
111
|
-
}
|
|
112
|
-
id, err := uuid.Parse(raw)
|
|
113
|
-
if err != nil {
|
|
114
|
-
return uuid.Nil, false, err
|
|
115
|
-
}
|
|
116
|
-
return id, true, nil
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
func (s *redisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
120
|
-
return s.rdb.Set(ctx, pwresetKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// ConsumePasswordResetToken is one-time-use by construction: GETDEL is
|
|
124
|
-
// atomic, so a token can't be raced into being consumed twice.
|
|
125
|
-
func (s *redisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
126
|
-
raw, err := s.rdb.GetDel(ctx, pwresetKeyPrefix+tokenHash).Result()
|
|
127
|
-
if err == redis.Nil {
|
|
128
|
-
return uuid.Nil, false, nil
|
|
129
|
-
}
|
|
130
|
-
if err != nil {
|
|
131
|
-
return uuid.Nil, false, err
|
|
132
|
-
}
|
|
133
|
-
id, err := uuid.Parse(raw)
|
|
134
|
-
if err != nil {
|
|
135
|
-
return uuid.Nil, false, err
|
|
136
|
-
}
|
|
137
|
-
return id, true, nil
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
func (s *redisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
141
|
-
return s.rdb.Set(ctx, emailVerifyKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ConsumeEmailVerifyToken is one-time-use by construction, same as
|
|
145
|
-
// ConsumePasswordResetToken — GETDEL is atomic.
|
|
146
|
-
func (s *redisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
147
|
-
raw, err := s.rdb.GetDel(ctx, emailVerifyKeyPrefix+tokenHash).Result()
|
|
148
|
-
if err == redis.Nil {
|
|
149
|
-
return uuid.Nil, false, nil
|
|
150
|
-
}
|
|
151
|
-
if err != nil {
|
|
152
|
-
return uuid.Nil, false, err
|
|
153
|
-
}
|
|
154
|
-
id, err := uuid.Parse(raw)
|
|
155
|
-
if err != nil {
|
|
156
|
-
return uuid.Nil, false, err
|
|
157
|
-
}
|
|
158
|
-
return id, true, nil
|
|
159
|
-
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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 user
|
|
10
|
+
|
|
11
|
+
import (
|
|
12
|
+
"context"
|
|
13
|
+
"database/sql"
|
|
14
|
+
"errors"
|
|
15
|
+
"time"
|
|
16
|
+
|
|
17
|
+
"{{goModule}}/internal/app/user/model"
|
|
18
|
+
"{{goModule}}/internal/shared/tx"
|
|
19
|
+
|
|
20
|
+
"github.com/google/uuid"
|
|
21
|
+
"gorm.io/gorm"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const (
|
|
25
|
+
kindRefresh = "refresh"
|
|
26
|
+
kindRefreshUsed = "refresh_used"
|
|
27
|
+
kindPasswordSet = "pwreset"
|
|
28
|
+
kindEmailVerify = "emailverify"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
type pgTokenStore struct {
|
|
32
|
+
db *gorm.DB
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func NewPgTokenStore(db *gorm.DB) *pgTokenStore { return &pgTokenStore{db: db} }
|
|
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
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// lookup finds a live token of the given kind. Expiry is enforced here rather
|
|
49
|
+
// than by a sweep, so a row that outlived its TTL is already invisible.
|
|
50
|
+
func (s *pgTokenStore) lookup(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
|
|
51
|
+
var row model.AuthToken
|
|
52
|
+
err := tx.From(ctx, s.db).WithContext(ctx).
|
|
53
|
+
Where("token_hash = ? AND kind = ? AND expires_at > now()", hash, kind).
|
|
54
|
+
Take(&row).Error
|
|
55
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
56
|
+
return uuid.Nil, false, nil
|
|
57
|
+
}
|
|
58
|
+
if err != nil {
|
|
59
|
+
return uuid.Nil, false, err
|
|
60
|
+
}
|
|
61
|
+
return row.UserID, true, nil
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// consume deletes and returns in one statement, which is what makes a
|
|
65
|
+
// one-time token actually one-time: two requests racing on the same token
|
|
66
|
+
// cannot both come away with a user id.
|
|
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
|
|
75
|
+
}
|
|
76
|
+
if err != nil {
|
|
77
|
+
return uuid.Nil, false, err
|
|
78
|
+
}
|
|
79
|
+
return userID, true, nil
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func (s *pgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
83
|
+
return s.put(ctx, tokenHash, kindRefresh, userID, ttl)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func (s *pgTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
87
|
+
return s.lookup(ctx, tokenHash, kindRefresh)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// DeleteRefreshToken removes the row outright and leaves nothing behind — see
|
|
91
|
+
// the AuthToken doc comment for why logout must not leave a tombstone.
|
|
92
|
+
func (s *pgTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, _ uuid.UUID) error {
|
|
93
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
94
|
+
Where("token_hash = ? AND kind = ?", tokenHash, kindRefresh).
|
|
95
|
+
Delete(&model.AuthToken{}).Error
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// RevokeAllRefreshTokens is one statement here because Postgres can query by
|
|
99
|
+
// value — the Redis store has to keep a separate per-user set to answer this,
|
|
100
|
+
// and that set accumulates stale members its own comment calls out.
|
|
101
|
+
// Tombstones are left in place so reuse detection still works afterwards.
|
|
102
|
+
func (s *pgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
|
|
103
|
+
return tx.From(ctx, s.db).WithContext(ctx).
|
|
104
|
+
Where("user_id = ? AND kind = ?", userID, kindRefresh).
|
|
105
|
+
Delete(&model.AuthToken{}).Error
|
|
106
|
+
}
|
|
107
|
+
|
|
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
|
+
func (s *pgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
113
|
+
return s.lookup(ctx, tokenHash, kindRefreshUsed)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
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)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func (s *pgTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
121
|
+
return s.consume(ctx, tokenHash, kindPasswordSet)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
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)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func (s *pgTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
129
|
+
return s.consume(ctx, tokenHash, kindEmailVerify)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// DeleteExpired drops rows nothing can use any more. Correctness never depends
|
|
133
|
+
// on it — every read already filters on expires_at — so it is purely about
|
|
134
|
+
// table size, and calling it on a timer from cmd/worker (or a cron) is enough.
|
|
135
|
+
//
|
|
136
|
+
// ponytail: not scheduled for you. Wire it up the day the table is big enough
|
|
137
|
+
// to notice; a scaffold that silently runs background sweeps nobody asked for
|
|
138
|
+
// is harder to reason about than one line you add when it matters.
|
|
139
|
+
func (s *pgTokenStore) DeleteExpired(ctx context.Context) (int64, error) {
|
|
140
|
+
res := tx.From(ctx, s.db).WithContext(ctx).
|
|
141
|
+
Where("expires_at <= now()").
|
|
142
|
+
Delete(&model.AuthToken{})
|
|
143
|
+
return res.RowsAffected, res.Error
|
|
144
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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 user
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"context"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
"github.com/redis/go-redis/v9"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const (
|
|
15
|
+
refreshKeyPrefix = "user:refresh:" // +hash -> userID, TTL = refreshTTL
|
|
16
|
+
refreshUserKeyPrefix = "user:refresh:user:" // +userID -> SET of active token hashes
|
|
17
|
+
refreshUsedKeyPrefix = "user:refresh:used:" // +hash -> userID, TTL = refreshTTL, reuse-detection tombstone
|
|
18
|
+
pwresetKeyPrefix = "user:pwreset:" // +hash -> userID, TTL = resetTTL, GETDEL on consume
|
|
19
|
+
emailVerifyKeyPrefix = "user:emailverify:" // +hash -> userID, TTL = emailVerifyTTL, GETDEL on consume
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
type redisTokenStore struct {
|
|
23
|
+
rdb *redis.Client
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func NewRedisTokenStore(rdb *redis.Client) *redisTokenStore {
|
|
27
|
+
return &redisTokenStore{rdb: rdb}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (s *redisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
31
|
+
pipe := s.rdb.TxPipeline()
|
|
32
|
+
pipe.Set(ctx, refreshKeyPrefix+tokenHash, userID.String(), ttl)
|
|
33
|
+
pipe.SAdd(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
|
|
34
|
+
_, err := pipe.Exec(ctx)
|
|
35
|
+
return err
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (s *redisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
39
|
+
raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
|
|
40
|
+
if err == redis.Nil {
|
|
41
|
+
return uuid.Nil, false, nil
|
|
42
|
+
}
|
|
43
|
+
if err != nil {
|
|
44
|
+
return uuid.Nil, false, err
|
|
45
|
+
}
|
|
46
|
+
id, err := uuid.Parse(raw)
|
|
47
|
+
if err != nil {
|
|
48
|
+
return uuid.Nil, false, err
|
|
49
|
+
}
|
|
50
|
+
return id, true, nil
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func (s *redisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
|
|
54
|
+
pipe := s.rdb.TxPipeline()
|
|
55
|
+
pipe.Del(ctx, refreshKeyPrefix+tokenHash)
|
|
56
|
+
pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
|
|
57
|
+
_, err := pipe.Exec(ctx)
|
|
58
|
+
return err
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// RevokeAllRefreshTokens walks the per-user session set and deletes every
|
|
62
|
+
// active refresh token for that user — used when reuse of an already-rotated
|
|
63
|
+
// token is detected (see Service.Refresh): that means the raw token leaked,
|
|
64
|
+
// so every session, not just the replayed one, is treated as compromised.
|
|
65
|
+
//
|
|
66
|
+
// ponytail: the per-user set has no per-member TTL cleanup of its own — a
|
|
67
|
+
// member outlives its key's TTL as a stale entry until the next revoke or
|
|
68
|
+
// rotation touches it. Self-heals over time; revisit if a single user's
|
|
69
|
+
// session count grows large enough to matter.
|
|
70
|
+
func (s *redisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
|
|
71
|
+
setKey := refreshUserKeyPrefix + userID.String()
|
|
72
|
+
hashes, err := s.rdb.SMembers(ctx, setKey).Result()
|
|
73
|
+
if err != nil {
|
|
74
|
+
return err
|
|
75
|
+
}
|
|
76
|
+
if len(hashes) == 0 {
|
|
77
|
+
return nil
|
|
78
|
+
}
|
|
79
|
+
pipe := s.rdb.TxPipeline()
|
|
80
|
+
for _, h := range hashes {
|
|
81
|
+
pipe.Del(ctx, refreshKeyPrefix+h)
|
|
82
|
+
}
|
|
83
|
+
pipe.Del(ctx, setKey)
|
|
84
|
+
_, err = pipe.Exec(ctx)
|
|
85
|
+
return err
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func (s *redisTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
89
|
+
return s.rdb.Set(ctx, refreshUsedKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func (s *redisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
93
|
+
raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
|
|
94
|
+
if err == redis.Nil {
|
|
95
|
+
return uuid.Nil, false, nil
|
|
96
|
+
}
|
|
97
|
+
if err != nil {
|
|
98
|
+
return uuid.Nil, false, err
|
|
99
|
+
}
|
|
100
|
+
id, err := uuid.Parse(raw)
|
|
101
|
+
if err != nil {
|
|
102
|
+
return uuid.Nil, false, err
|
|
103
|
+
}
|
|
104
|
+
return id, true, nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func (s *redisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
108
|
+
return s.rdb.Set(ctx, pwresetKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ConsumePasswordResetToken is one-time-use by construction: GETDEL is
|
|
112
|
+
// atomic, so a token can't be raced into being consumed twice.
|
|
113
|
+
func (s *redisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
114
|
+
raw, err := s.rdb.GetDel(ctx, pwresetKeyPrefix+tokenHash).Result()
|
|
115
|
+
if err == redis.Nil {
|
|
116
|
+
return uuid.Nil, false, nil
|
|
117
|
+
}
|
|
118
|
+
if err != nil {
|
|
119
|
+
return uuid.Nil, false, err
|
|
120
|
+
}
|
|
121
|
+
id, err := uuid.Parse(raw)
|
|
122
|
+
if err != nil {
|
|
123
|
+
return uuid.Nil, false, err
|
|
124
|
+
}
|
|
125
|
+
return id, true, nil
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func (s *redisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
|
|
129
|
+
return s.rdb.Set(ctx, emailVerifyKeyPrefix+tokenHash, userID.String(), ttl).Err()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ConsumeEmailVerifyToken is one-time-use by construction, same as
|
|
133
|
+
// ConsumePasswordResetToken — GETDEL is atomic.
|
|
134
|
+
func (s *redisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
|
|
135
|
+
raw, err := s.rdb.GetDel(ctx, emailVerifyKeyPrefix+tokenHash).Result()
|
|
136
|
+
if err == redis.Nil {
|
|
137
|
+
return uuid.Nil, false, nil
|
|
138
|
+
}
|
|
139
|
+
if err != nil {
|
|
140
|
+
return uuid.Nil, false, err
|
|
141
|
+
}
|
|
142
|
+
id, err := uuid.Parse(raw)
|
|
143
|
+
if err != nil {
|
|
144
|
+
return uuid.Nil, false, err
|
|
145
|
+
}
|
|
146
|
+
return id, true, nil
|
|
147
|
+
}
|
|
@@ -1,40 +1,42 @@
|
|
|
1
1
|
package middleware
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"context"
|
|
4
5
|
"net/http"
|
|
5
6
|
"time"
|
|
6
7
|
|
|
7
8
|
"{{goModule}}/internal/shared/apperror"
|
|
8
9
|
|
|
9
10
|
"github.com/gin-gonic/gin"
|
|
10
|
-
"github.com/redis/go-redis/v9"
|
|
11
11
|
)
|
|
12
12
|
|
|
13
|
+
// Limiter counts requests per key within a window. Two implementations ship
|
|
14
|
+
// with the scaffold and which one a project gets follows `add auth --store`:
|
|
15
|
+
// in-memory alongside the Postgres token store, Redis alongside the Redis one.
|
|
16
|
+
// They are paired on purpose — "exact across pods" is one decision, not two.
|
|
17
|
+
type Limiter interface {
|
|
18
|
+
// Allow records one hit for key and reports whether it stayed within
|
|
19
|
+
// limit. An implementation that cannot reach its backing store should
|
|
20
|
+
// return true: a limiter outage must not take login down with it.
|
|
21
|
+
Allow(ctx context.Context, key string, limit int, window time.Duration) bool
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
// RateLimit throttles requests per client IP to `limit` within `window`,
|
|
14
25
|
// keyed by `name` (a per-route label so /auth/login and /auth/register don't
|
|
15
|
-
// share a budget) — a fixed-window counter
|
|
26
|
+
// share a budget) — a fixed-window counter.
|
|
16
27
|
//
|
|
17
28
|
// ponytail: fixed-window, not sliding — simple and correct, the tradeoff is
|
|
18
29
|
// a client can burst up to ~2x limit right at a window boundary (limit
|
|
19
30
|
// requests just before it resets, then limit more right after). Good enough
|
|
20
|
-
// for throttling
|
|
21
|
-
//
|
|
22
|
-
|
|
31
|
+
// for throttling spam, not a hard quota; move to a sliding-window log if that
|
|
32
|
+
// boundary burst ever actually matters.
|
|
33
|
+
//
|
|
34
|
+
// This is the coarse layer. It keys on c.ClientIP(), which a distributed
|
|
35
|
+
// attacker simply spreads across — the control that actually stops credential
|
|
36
|
+
// stuffing is the per-account lockout in the user service, not this.
|
|
37
|
+
func RateLimit(l Limiter, name string, limit int, window time.Duration) gin.HandlerFunc {
|
|
23
38
|
return func(c *gin.Context) {
|
|
24
|
-
|
|
25
|
-
ctx := c.Request.Context()
|
|
26
|
-
|
|
27
|
-
count, err := rdb.Incr(ctx, key).Result()
|
|
28
|
-
if err != nil {
|
|
29
|
-
// fail open: a Redis blip shouldn't take down login/register
|
|
30
|
-
// entirely — the endpoint's own logic is still the real guard.
|
|
31
|
-
c.Next()
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
if count == 1 {
|
|
35
|
-
rdb.Expire(ctx, key, window)
|
|
36
|
-
}
|
|
37
|
-
if count > int64(limit) {
|
|
39
|
+
if !l.Allow(c.Request.Context(), name+":"+c.ClientIP(), limit, window) {
|
|
38
40
|
c.Error(apperror.New(http.StatusTooManyRequests, "RATE_LIMITED", "too many requests, try again later"))
|
|
39
41
|
c.Abort()
|
|
40
42
|
return
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"sync"
|
|
6
|
+
"time"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// MemoryLimiter keeps the counters in this process. It is the limiter that
|
|
10
|
+
// ships alongside the Postgres token store, so a project can run auth with no
|
|
11
|
+
// service beyond its own database.
|
|
12
|
+
//
|
|
13
|
+
// ponytail: per-process budget. Behind N replicas a client effectively gets
|
|
14
|
+
// N x limit, because nothing is shared. That is a deliberate trade: this layer
|
|
15
|
+
// only damps casual abuse, and the control that has to be exact — failed-login
|
|
16
|
+
// lockout — lives in Postgres where every replica sees the same counter.
|
|
17
|
+
// Switch to `add auth --store redis` if you need this layer exact too.
|
|
18
|
+
//
|
|
19
|
+
// Deliberately a fixed-window counter written by hand rather than
|
|
20
|
+
// golang.org/x/time/rate: the Redis limiter is a fixed window, and keeping the
|
|
21
|
+
// two identical means changing --store never changes what a client observes.
|
|
22
|
+
// A token bucket would have been fewer lines and different behaviour.
|
|
23
|
+
type MemoryLimiter struct {
|
|
24
|
+
mu sync.Mutex
|
|
25
|
+
windows map[string]*rateWindow
|
|
26
|
+
lastSweep time.Time
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type rateWindow struct {
|
|
30
|
+
count int
|
|
31
|
+
resetAt time.Time
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func NewMemoryLimiter() *MemoryLimiter {
|
|
35
|
+
return &MemoryLimiter{windows: map[string]*rateWindow{}, lastSweep: time.Now()}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (m *MemoryLimiter) Allow(_ context.Context, key string, limit int, window time.Duration) bool {
|
|
39
|
+
now := time.Now()
|
|
40
|
+
|
|
41
|
+
m.mu.Lock()
|
|
42
|
+
defer m.mu.Unlock()
|
|
43
|
+
|
|
44
|
+
// Sweep at most once per window: every entry is dead within one window of
|
|
45
|
+
// its last use, so this is enough to stop the map growing with every
|
|
46
|
+
// distinct client IP, without paying an O(n) walk on each request.
|
|
47
|
+
if now.Sub(m.lastSweep) > window {
|
|
48
|
+
for k, w := range m.windows {
|
|
49
|
+
if now.After(w.resetAt) {
|
|
50
|
+
delete(m.windows, k)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
m.lastSweep = now
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
w, ok := m.windows[key]
|
|
57
|
+
if !ok || now.After(w.resetAt) {
|
|
58
|
+
m.windows[key] = &rateWindow{count: 1, resetAt: now.Add(window)}
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
w.count++
|
|
62
|
+
return w.count <= limit
|
|
63
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"time"
|
|
6
|
+
|
|
7
|
+
"github.com/redis/go-redis/v9"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// RedisLimiter shares one counter across every replica, so `limit` means the
|
|
11
|
+
// same thing whether you run one pod or ten. It ships alongside the Redis
|
|
12
|
+
// token store (`add auth --store redis`).
|
|
13
|
+
type RedisLimiter struct {
|
|
14
|
+
rdb *redis.Client
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func NewRedisLimiter(rdb *redis.Client) *RedisLimiter { return &RedisLimiter{rdb: rdb} }
|
|
18
|
+
|
|
19
|
+
func (r *RedisLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) bool {
|
|
20
|
+
redisKey := "ratelimit:" + key
|
|
21
|
+
|
|
22
|
+
count, err := r.rdb.Incr(ctx, redisKey).Result()
|
|
23
|
+
if err != nil {
|
|
24
|
+
// fail open: a Redis blip shouldn't take down login/register entirely
|
|
25
|
+
// — the endpoint's own logic is still the real guard.
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
if count == 1 {
|
|
29
|
+
r.rdb.Expire(ctx, redisKey, window)
|
|
30
|
+
}
|
|
31
|
+
return count <= int64(limit)
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DROP TABLE IF EXISTS user_svc.auth_tokens;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Short-lived auth tokens, for projects scaffolded with `--store postgres`.
|
|
2
|
+
-- Only the SHA-256 hash is stored; see model/authtoken.go for what `kind`
|
|
3
|
+
-- means and why the rotation tombstone is a separate row.
|
|
4
|
+
CREATE TABLE user_svc.auth_tokens (
|
|
5
|
+
token_hash TEXT PRIMARY KEY,
|
|
6
|
+
user_id UUID NOT NULL,
|
|
7
|
+
kind VARCHAR(20) NOT NULL,
|
|
8
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
9
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
-- Named to match model.AuthToken's own `index:` tags exactly — GORM's
|
|
13
|
+
-- AutoMigrate (dev) reconciles against these names, and an index the
|
|
14
|
+
-- migration named differently is what stops the app booting.
|
|
15
|
+
CREATE INDEX idx_auth_tokens_user_kind ON user_svc.auth_tokens (user_id, kind);
|
|
16
|
+
CREATE INDEX idx_auth_tokens_expires_at ON user_svc.auth_tokens (expires_at);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
DROP TABLE IF EXISTS identities;
|
|
1
|
+
DROP TABLE IF EXISTS user_svc.identities;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
CREATE TABLE identities (
|
|
1
|
+
CREATE TABLE user_svc.identities (
|
|
2
2
|
id UUID PRIMARY KEY,
|
|
3
|
-
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
3
|
+
user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
|
|
4
4
|
provider VARCHAR(20) NOT NULL,
|
|
5
5
|
password_hash TEXT,
|
|
6
6
|
provider_uid TEXT,
|
|
7
7
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
8
|
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
9
|
-
UNIQUE (user_id, provider),
|
|
10
|
-
UNIQUE (provider, provider_uid)
|
|
8
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
11
9
|
);
|
|
10
|
+
|
|
11
|
+
-- Named unique indexes matching model.Identity's own
|
|
12
|
+
-- `uniqueIndex:idx_identities_*` tags — see create_users.up.sql for why an
|
|
13
|
+
-- anonymous UNIQUE constraint breaks AutoMigrate.
|
|
14
|
+
CREATE UNIQUE INDEX idx_identities_user_provider ON user_svc.identities (user_id, provider);
|
|
15
|
+
CREATE UNIQUE INDEX idx_identities_provider_uid ON user_svc.identities (provider, provider_uid);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DROP TABLE IF EXISTS user_svc.login_throttle;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Failed-attempt counter per account. Keyed on a hash of the submitted email
|
|
2
|
+
-- so it works for addresses that have no account (otherwise throttling itself
|
|
3
|
+
-- leaks which addresses are registered) and so this table is not a user list.
|
|
4
|
+
-- See model/loginthrottle.go for why this exists separately from the rate limiter.
|
|
5
|
+
CREATE TABLE user_svc.login_throttle (
|
|
6
|
+
email_hash TEXT PRIMARY KEY,
|
|
7
|
+
failures INTEGER NOT NULL DEFAULT 0,
|
|
8
|
+
locked_until TIMESTAMPTZ,
|
|
9
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
10
|
+
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
DROP TABLE IF EXISTS users;
|
|
1
|
+
DROP TABLE IF EXISTS user_svc.users;
|