@nakedev/go-scaffold 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -50
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +23 -12
- package/dist/commands/method.js +94 -17
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +368 -64
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/module-manifest.js +2 -0
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +97 -18
- package/dist/utils/module-location.js +58 -0
- package/dist/utils/naming.js +125 -14
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/openapi-patcher.js +19 -1
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/dist/utils/smoke-run.js +31 -0
- package/package.json +11 -4
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +14 -12
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +52 -16
- package/templates/create/base/README.md.hbs +61 -12
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +4 -9
- package/templates/create/features/docs/patterns.md.hbs +91 -14
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler.go.hbs +18 -4
- package/templates/generate/module/handler_test.go.hbs +88 -62
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -1
- package/templates/generate/module/minimal/handler.go.hbs +8 -2
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +16 -0
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +109 -0
- package/templates/generate/module/service.go.hbs +15 -4
- package/templates/generate/module/service_test.go.hbs +113 -17
- package/dist/commands/remove.js +0 -89
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"time"
|
|
5
|
+
"context"
|
|
6
|
+
"errors"
|
|
7
|
+
"os"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"{{goModule}}/internal/app/user/model"
|
|
11
|
+
"{{goModule}}/internal/shared/dberr"
|
|
12
|
+
|
|
13
|
+
"github.com/google/uuid"
|
|
14
|
+
"gorm.io/driver/postgres"
|
|
15
|
+
"gorm.io/gorm"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// repositoryDBForTest expects the test database schema to come from the same
|
|
19
|
+
// versioned SQL migrations used in production. Unit tests stay database-free;
|
|
20
|
+
// CI sets REQUIRE_TEST_DB=true so an unavailable/unmigrated database fails
|
|
21
|
+
// instead of becoming a false-green skip. Same pattern as every generated
|
|
22
|
+
// module's repository_test.go.
|
|
23
|
+
func repositoryDBForTest(t *testing.T) *gorm.DB {
|
|
24
|
+
t.Helper()
|
|
25
|
+
dsn := os.Getenv("TEST_DB_DSN")
|
|
26
|
+
if dsn == "" {
|
|
27
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
28
|
+
t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
|
|
29
|
+
}
|
|
30
|
+
t.Skip("repository integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
|
34
|
+
if err != nil {
|
|
35
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
36
|
+
t.Fatalf("open required test database: %v", err)
|
|
37
|
+
}
|
|
38
|
+
t.Skipf("repository integration test skipped: %v", err)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
sqlDB, err := db.DB()
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatalf("get SQL database handle: %v", err)
|
|
44
|
+
}
|
|
45
|
+
if err := sqlDB.Ping(); err != nil {
|
|
46
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
47
|
+
t.Fatalf("ping required test database: %v", err)
|
|
48
|
+
}
|
|
49
|
+
t.Skipf("repository integration test skipped: %v", err)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
tx := db.Begin()
|
|
53
|
+
if tx.Error != nil {
|
|
54
|
+
t.Fatalf("begin test transaction: %v", tx.Error)
|
|
55
|
+
}
|
|
56
|
+
t.Cleanup(func() {
|
|
57
|
+
if err := tx.Rollback().Error; err != nil {
|
|
58
|
+
t.Errorf("rollback test transaction: %v", err)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
return tx
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// CreateUserWithIdentity's whole reason to be a transaction: a user that
|
|
65
|
+
// exists with no way to log in is unreachable. If the identity insert fails,
|
|
66
|
+
// the user it was meant to arrive with must vanish too — a function-backed
|
|
67
|
+
// stub can't prove this, since it never runs a real insert against a real
|
|
68
|
+
// constraint. This forces exactly that: the user insert succeeds (it's a
|
|
69
|
+
// brand new row), but the identity insert collides with an existing Google
|
|
70
|
+
// identity's provider_uid.
|
|
71
|
+
func TestRepository_CreateUserWithIdentity_RollsBackBothOnIdentityConflict(t *testing.T) {
|
|
72
|
+
repo := NewRepository(repositoryDBForTest(t))
|
|
73
|
+
ctx := context.Background()
|
|
74
|
+
|
|
75
|
+
providerUID := "conflicting-provider-uid"
|
|
76
|
+
existing := &model.User{ID: uuid.New(), Email: "first@example.com"}
|
|
77
|
+
if err := repo.CreateUserWithIdentity(ctx, existing, &model.Identity{
|
|
78
|
+
ID: uuid.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID,
|
|
79
|
+
}); err != nil {
|
|
80
|
+
t.Fatalf("seed existing user+identity: %v", err)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
blocked := &model.User{ID: uuid.New(), Email: "second@example.com"}
|
|
84
|
+
err := repo.CreateUserWithIdentity(ctx, blocked, &model.Identity{
|
|
85
|
+
ID: uuid.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID,
|
|
86
|
+
})
|
|
87
|
+
if !dberr.IsDuplicate(err) {
|
|
88
|
+
t.Fatalf("want a duplicate-key error from the conflicting provider_uid, got %v", err)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if _, findErr := repo.FindByID(ctx, blocked.ID); !errors.Is(findErr, gorm.ErrRecordNotFound) {
|
|
92
|
+
t.Fatalf("the new user must not survive a transaction whose identity insert failed, got %v", findErr)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The unique index on email is what actually stops two accounts sharing a
|
|
97
|
+
// login — Register's "email already registered" response depends on the
|
|
98
|
+
// database enforcing it, not on an application-level check (which would
|
|
99
|
+
// race). Confirms the constraint is really there and that dberr recognizes
|
|
100
|
+
// the violation shape Postgres returns.
|
|
101
|
+
func TestRepository_CreateUserWithIdentity_DuplicateEmailIsDetectable(t *testing.T) {
|
|
102
|
+
repo := NewRepository(repositoryDBForTest(t))
|
|
103
|
+
ctx := context.Background()
|
|
104
|
+
|
|
105
|
+
email := "dup@example.com"
|
|
106
|
+
if err := repo.CreateUserWithIdentity(ctx, &model.User{ID: uuid.New(), Email: email}, &model.Identity{
|
|
107
|
+
ID: uuid.New(), Provider: model.ProviderLocal,
|
|
108
|
+
}); err != nil {
|
|
109
|
+
t.Fatalf("seed first user: %v", err)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
err := repo.CreateUserWithIdentity(ctx, &model.User{ID: uuid.New(), Email: email}, &model.Identity{
|
|
113
|
+
ID: uuid.New(), Provider: model.ProviderLocal,
|
|
114
|
+
})
|
|
115
|
+
if !dberr.IsDuplicate(err) {
|
|
116
|
+
t.Fatalf("want a duplicate-key error for the reused email, got %v", err)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The backoff is computed by Postgres inside one UPSERT, so this is the only
|
|
121
|
+
// place it can be checked — a fake can agree with itself about arithmetic the
|
|
122
|
+
// database would reject.
|
|
123
|
+
func TestRepository_LoginThrottle_BacksOffThenClears(t *testing.T) {
|
|
124
|
+
db := repositoryDBForTest(t)
|
|
125
|
+
repo := NewRepository(db)
|
|
126
|
+
ctx := context.Background()
|
|
127
|
+
key := "throttle-test-" + uuid.NewString()
|
|
128
|
+
|
|
129
|
+
// the free attempts leave no lock behind
|
|
130
|
+
for i := 0; i < 3; i++ {
|
|
131
|
+
if err := repo.RecordLoginFailure(ctx, key, 3, 15*time.Minute); err != nil {
|
|
132
|
+
t.Fatalf("record failure %d: %v", i+1, err)
|
|
133
|
+
}
|
|
134
|
+
until, err := repo.LoginLockedUntil(ctx, key)
|
|
135
|
+
if err != nil {
|
|
136
|
+
t.Fatalf("read lock: %v", err)
|
|
137
|
+
}
|
|
138
|
+
if !until.IsZero() {
|
|
139
|
+
t.Fatalf("attempt %d is within the free allowance but set a lock (%s)", i+1, until)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// the next one locks, and the one after that locks for longer
|
|
144
|
+
if err := repo.RecordLoginFailure(ctx, key, 3, 15*time.Minute); err != nil {
|
|
145
|
+
t.Fatalf("record failure 4: %v", err)
|
|
146
|
+
}
|
|
147
|
+
first, err := repo.LoginLockedUntil(ctx, key)
|
|
148
|
+
if err != nil || first.IsZero() {
|
|
149
|
+
t.Fatalf("expected a lock after exceeding the free allowance, got %s (err %v)", first, err)
|
|
150
|
+
}
|
|
151
|
+
if err := repo.RecordLoginFailure(ctx, key, 3, 15*time.Minute); err != nil {
|
|
152
|
+
t.Fatalf("record failure 5: %v", err)
|
|
153
|
+
}
|
|
154
|
+
second, err := repo.LoginLockedUntil(ctx, key)
|
|
155
|
+
if err != nil {
|
|
156
|
+
t.Fatalf("read lock: %v", err)
|
|
157
|
+
}
|
|
158
|
+
if !second.After(first) {
|
|
159
|
+
t.Fatalf("the backoff did not grow: %s then %s", first, second)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// a successful login wipes the slate
|
|
163
|
+
if err := repo.ClearLoginFailures(ctx, key); err != nil {
|
|
164
|
+
t.Fatalf("clear: %v", err)
|
|
165
|
+
}
|
|
166
|
+
cleared, err := repo.LoginLockedUntil(ctx, key)
|
|
167
|
+
if err != nil || !cleared.IsZero() {
|
|
168
|
+
t.Fatalf("expected no lock after clearing, got %s (err %v)", cleared, err)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// maxLock is a ceiling, not a suggestion: without it the doubling reaches
|
|
173
|
+
// "locked for a week" in about twenty attempts.
|
|
174
|
+
func TestRepository_LoginThrottle_RespectsTheCeiling(t *testing.T) {
|
|
175
|
+
db := repositoryDBForTest(t)
|
|
176
|
+
repo := NewRepository(db)
|
|
177
|
+
ctx := context.Background()
|
|
178
|
+
key := "throttle-cap-" + uuid.NewString()
|
|
179
|
+
|
|
180
|
+
for i := 0; i < 12; i++ {
|
|
181
|
+
if err := repo.RecordLoginFailure(ctx, key, 0, 5*time.Second); err != nil {
|
|
182
|
+
t.Fatalf("record failure %d: %v", i+1, err)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
until, err := repo.LoginLockedUntil(ctx, key)
|
|
186
|
+
if err != nil {
|
|
187
|
+
t.Fatalf("read lock: %v", err)
|
|
188
|
+
}
|
|
189
|
+
if remaining := time.Until(until); remaining > 30*time.Second {
|
|
190
|
+
t.Fatalf("lock ran past its 5s ceiling: %s remaining", remaining)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -34,14 +34,18 @@ type repository interface {
|
|
|
34
34
|
CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error
|
|
35
35
|
CreateIdentity(ctx context.Context, i *model.Identity) error
|
|
36
36
|
UpdateIdentity(ctx context.Context, i *model.Identity) error
|
|
37
|
+
LoginLockedUntil(ctx context.Context, key string) (time.Time, error)
|
|
38
|
+
RecordLoginFailure(ctx context.Context, key string, freeAttempts int, maxLock time.Duration) error
|
|
39
|
+
ClearLoginFailures(ctx context.Context, key string) error
|
|
37
40
|
// go-scaffold:user-repository-interface
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
// mailer = what the service needs to send an email — satisfied by
|
|
41
44
|
// platform/mail's AsyncClient (enqueues onto cmd/worker instead of blocking
|
|
42
|
-
// the request on SMTP).
|
|
45
|
+
// the request on SMTP). Takes ctx so the enqueue can join the caller's
|
|
46
|
+
// transaction where the queue backend supports it.
|
|
43
47
|
type mailer interface {
|
|
44
|
-
Send(to, subject, body string) error
|
|
48
|
+
Send(ctx context.Context, to, subject, body string) error
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
// go-scaffold:user-interfaces
|
|
@@ -90,6 +94,52 @@ func NewService(
|
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
// normalizeEmail is applied at every boundary an address enters the service
|
|
98
|
+
// through. The column is a plain case-sensitive UNIQUE, so without this
|
|
99
|
+
// "Foo@x.com" and "foo@x.com" are two accounts that never collide — and a
|
|
100
|
+
// Google login (Google always reports lowercase) fails to find the local
|
|
101
|
+
// account it should have linked to, silently creating a second user for the
|
|
102
|
+
// same person.
|
|
103
|
+
func normalizeEmail(email string) string {
|
|
104
|
+
return strings.ToLower(strings.TrimSpace(email))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Failed-attempt policy. Constants rather than config: these are a security
|
|
108
|
+
// posture, not something to tune per environment, and every knob added here is
|
|
109
|
+
// a knob someone can quietly widen until the control stops working. Change the
|
|
110
|
+
// numbers if your threat model differs.
|
|
111
|
+
//
|
|
112
|
+
// The first loginFreeAttempts failures cost nothing — a typo shouldn't lock
|
|
113
|
+
// anyone out. After that each failure doubles the wait (1s, 2s, 4s, ...) up to
|
|
114
|
+
// loginMaxLock, which is the shape OWASP's Authentication Cheat Sheet asks for.
|
|
115
|
+
const (
|
|
116
|
+
loginFreeAttempts = 3
|
|
117
|
+
loginMaxLock = 15 * time.Minute
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// throttleKey namespaces the counter by what is being attempted, so a locked
|
|
121
|
+
// login never blocks the password-reset that would fix it — someone who forgot
|
|
122
|
+
// their password is exactly the person who trips the login counter.
|
|
123
|
+
//
|
|
124
|
+
// Hashed, because the counter must exist for addresses that have no account
|
|
125
|
+
// (otherwise "was I throttled" answers "does this account exist"), and a table
|
|
126
|
+
// of plain addresses that anyone has ever typed is a user list.
|
|
127
|
+
func throttleKey(purpose, email string) string {
|
|
128
|
+
return hashToken(purpose + ":" + normalizeEmail(email))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// throttled reports whether this key is inside its lockout window. A failure
|
|
132
|
+
// to read the counter is treated as not-throttled: this is a brake, and it
|
|
133
|
+
// should not be able to lock everybody out on its own.
|
|
134
|
+
func (s *Service) throttled(ctx context.Context, key string) bool {
|
|
135
|
+
until, err := s.repo.LoginLockedUntil(ctx, key)
|
|
136
|
+
if err != nil {
|
|
137
|
+
slog.Error("read login throttle", "error", err)
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
return !until.IsZero() && time.Now().Before(until)
|
|
141
|
+
}
|
|
142
|
+
|
|
93
143
|
func (s *Service) Register(ctx context.Context, in registerInput) (*authResponse, error) {
|
|
94
144
|
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
|
95
145
|
if err != nil {
|
|
@@ -97,7 +147,7 @@ func (s *Service) Register(ctx context.Context, in registerInput) (*authResponse
|
|
|
97
147
|
}
|
|
98
148
|
hashStr := string(hash)
|
|
99
149
|
|
|
100
|
-
u := &model.User{ID: id.New(), Email: in.Email, Name: in.Name}
|
|
150
|
+
u := &model.User{ID: id.New(), Email: normalizeEmail(in.Email), Name: in.Name}
|
|
101
151
|
i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
|
|
102
152
|
if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
|
|
103
153
|
if dberr.IsDuplicate(err) {
|
|
@@ -112,16 +162,35 @@ func (s *Service) Register(ctx context.Context, in registerInput) (*authResponse
|
|
|
112
162
|
}
|
|
113
163
|
|
|
114
164
|
func (s *Service) Login(ctx context.Context, in loginInput) (*authResponse, error) {
|
|
115
|
-
|
|
116
|
-
if
|
|
165
|
+
key := throttleKey("login", in.Email)
|
|
166
|
+
if s.throttled(ctx, key) {
|
|
167
|
+
return nil, errTooManyAttempts()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Every failure below is recorded against the same key whether or not the
|
|
171
|
+
// account exists, and answers with the same error — so the counter cannot
|
|
172
|
+
// be used to enumerate accounts either.
|
|
173
|
+
fail := func() (*authResponse, error) {
|
|
174
|
+
if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
|
|
175
|
+
slog.Error("record login failure", "error", err)
|
|
176
|
+
}
|
|
117
177
|
return nil, errInvalidCredentials()
|
|
118
178
|
}
|
|
179
|
+
|
|
180
|
+
u, err := s.repo.FindByEmail(ctx, normalizeEmail(in.Email))
|
|
181
|
+
if err != nil {
|
|
182
|
+
return fail()
|
|
183
|
+
}
|
|
119
184
|
ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
|
|
120
185
|
if err != nil || ident.PasswordHash == nil {
|
|
121
|
-
return
|
|
186
|
+
return fail()
|
|
122
187
|
}
|
|
123
188
|
if err := bcrypt.CompareHashAndPassword([]byte(*ident.PasswordHash), []byte(in.Password)); err != nil {
|
|
124
|
-
return
|
|
189
|
+
return fail()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if err := s.repo.ClearLoginFailures(ctx, key); err != nil {
|
|
193
|
+
slog.Error("clear login failures", "error", err)
|
|
125
194
|
}
|
|
126
195
|
return s.issueTokens(ctx, u)
|
|
127
196
|
}
|
|
@@ -206,7 +275,19 @@ func (s *Service) List(ctx context.Context, limit, offset int) ([]model.User, er
|
|
|
206
275
|
// enumerate registered accounts. Only if the email resolves to a local
|
|
207
276
|
// (password-based) identity does it actually issue+email a reset token.
|
|
208
277
|
func (s *Service) ForgotPassword(ctx context.Context, email string) error {
|
|
209
|
-
|
|
278
|
+
// Its own counter, so hammering this endpoint cannot lock anyone out of
|
|
279
|
+
// logging in, and a locked-out login cannot block the reset that fixes it.
|
|
280
|
+
// Every request counts here, not just failures: what this throttles is
|
|
281
|
+
// using someone else's address as a mail-bomb target.
|
|
282
|
+
key := throttleKey("pwreset", email)
|
|
283
|
+
if s.throttled(ctx, key) {
|
|
284
|
+
return nil // same answer as always — silence is the whole design here
|
|
285
|
+
}
|
|
286
|
+
if err := s.repo.RecordLoginFailure(ctx, key, loginFreeAttempts, loginMaxLock); err != nil {
|
|
287
|
+
slog.Error("record password reset attempt", "error", err)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
u, err := s.repo.FindByEmail(ctx, normalizeEmail(email))
|
|
210
291
|
if err != nil {
|
|
211
292
|
return nil
|
|
212
293
|
}
|
|
@@ -224,7 +305,7 @@ func (s *Service) ForgotPassword(ctx context.Context, email string) error {
|
|
|
224
305
|
}
|
|
225
306
|
|
|
226
307
|
link := s.resetURL + "?token=" + raw
|
|
227
|
-
if err := s.mailer.Send(u.Email, "Reset your password", "Reset your password: "+link); err != nil {
|
|
308
|
+
if err := s.mailer.Send(ctx, u.Email, "Reset your password", "Reset your password: "+link); err != nil {
|
|
228
309
|
// token's already stored — a mail failure shouldn't fail the request,
|
|
229
310
|
// just get logged so it's visible operationally.
|
|
230
311
|
slog.Error("send password reset email", "error", err)
|
|
@@ -278,7 +359,7 @@ func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
|
|
|
278
359
|
return
|
|
279
360
|
}
|
|
280
361
|
link := s.verifyURL + "?token=" + raw
|
|
281
|
-
if err := s.mailer.Send(u.Email, "Verify your email", "Verify your email: "+link); err != nil {
|
|
362
|
+
if err := s.mailer.Send(ctx, u.Email, "Verify your email", "Verify your email: "+link); err != nil {
|
|
282
363
|
slog.Error("send email verification email", "error", err)
|
|
283
364
|
}
|
|
284
365
|
}
|
|
@@ -318,14 +399,16 @@ func (s *Service) VerifyEmail(ctx context.Context, rawToken string) error {
|
|
|
318
399
|
return s.repo.UpdateUser(ctx, u)
|
|
319
400
|
}
|
|
320
401
|
|
|
321
|
-
// GoogleLoginURL signs a short-lived CSRF state token and builds the
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
402
|
+
// GoogleLoginURL signs a short-lived CSRF state token and builds the redirect
|
|
403
|
+
// URL. The second return value is the nonce the caller must set as a cookie:
|
|
404
|
+
// the callback checks it against the one inside the state (see jwt.go), which
|
|
405
|
+
// is what binds the flow to this one browser.
|
|
406
|
+
func (s *Service) GoogleLoginURL() (string, string, error) {
|
|
407
|
+
state, nonce, err := s.issueOAuthState()
|
|
325
408
|
if err != nil {
|
|
326
|
-
return "", apperror.NewInternal()
|
|
409
|
+
return "", "", apperror.NewInternal()
|
|
327
410
|
}
|
|
328
|
-
return s.googleOAuth.AuthCodeURL(state), nil
|
|
411
|
+
return s.googleOAuth.AuthCodeURL(state), nonce, nil
|
|
329
412
|
}
|
|
330
413
|
|
|
331
414
|
type googleUserInfo struct {
|
|
@@ -336,8 +419,8 @@ type googleUserInfo struct {
|
|
|
336
419
|
Picture string `json:"picture"`
|
|
337
420
|
}
|
|
338
421
|
|
|
339
|
-
func (s *Service) GoogleCallback(ctx context.Context, code, state string) (*authResponse, error) {
|
|
340
|
-
if err := s.verifyOAuthState(state); err != nil {
|
|
422
|
+
func (s *Service) GoogleCallback(ctx context.Context, code, state, nonce string) (*authResponse, error) {
|
|
423
|
+
if err := s.verifyOAuthState(state, nonce); err != nil {
|
|
341
424
|
return nil, err
|
|
342
425
|
}
|
|
343
426
|
tok, err := s.googleOAuth.Exchange(ctx, code)
|
|
@@ -376,8 +459,9 @@ func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInf
|
|
|
376
459
|
}
|
|
377
460
|
|
|
378
461
|
providerUID := info.Sub
|
|
462
|
+
email := normalizeEmail(info.Email)
|
|
379
463
|
if info.EmailVerified {
|
|
380
|
-
if u, err := s.repo.FindByEmail(ctx,
|
|
464
|
+
if u, err := s.repo.FindByEmail(ctx, email); err == nil {
|
|
381
465
|
ident := &model.Identity{ID: id.New(), UserID: u.ID, Provider: model.ProviderGoogle, ProviderUID: &providerUID}
|
|
382
466
|
if err := s.repo.CreateIdentity(ctx, ident); err != nil {
|
|
383
467
|
return nil, apperror.NewInternal()
|
|
@@ -386,7 +470,7 @@ func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInf
|
|
|
386
470
|
}
|
|
387
471
|
}
|
|
388
472
|
|
|
389
|
-
u := &model.User{ID: id.New(), Email:
|
|
473
|
+
u := &model.User{ID: id.New(), Email: email, Name: info.Name, AvatarURL: info.Picture, EmailVerified: info.EmailVerified}
|
|
390
474
|
ident := &model.Identity{ID: id.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID}
|
|
391
475
|
if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
|
|
392
476
|
if dberr.IsDuplicate(err) {
|
|
@@ -402,7 +486,7 @@ func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInf
|
|
|
402
486
|
// password on every deploy), otherwise a new local user+identity is
|
|
403
487
|
// created. Used by cmd/seed only — nothing in the HTTP API calls this.
|
|
404
488
|
func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*model.User, error) {
|
|
405
|
-
email =
|
|
489
|
+
email = normalizeEmail(email)
|
|
406
490
|
if u, err := s.repo.FindByEmail(ctx, email); err == nil {
|
|
407
491
|
return u, nil
|
|
408
492
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package user
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"fmt"
|
|
4
5
|
"context"
|
|
5
6
|
"errors"
|
|
6
7
|
"net/http"
|
|
@@ -15,7 +16,7 @@ import (
|
|
|
15
16
|
"gorm.io/gorm"
|
|
16
17
|
)
|
|
17
18
|
|
|
18
|
-
// fakeTokenStore = in-memory mock of tokenStore, mirroring
|
|
19
|
+
// fakeTokenStore = in-memory mock of tokenStore, mirroring the real store's
|
|
19
20
|
// three-map shape (active / used-tombstone / per-user session set) closely
|
|
20
21
|
// enough to exercise rotation + reuse-detection without a real Redis.
|
|
21
22
|
type fakeTokenStore struct {
|
|
@@ -82,6 +83,37 @@ func (f *fakeTokenStore) ConsumeEmailVerifyToken(context.Context, string) (uuid.
|
|
|
82
83
|
// token), the rest just satisfy the interface.
|
|
83
84
|
type fakeRepo struct {
|
|
84
85
|
user *model.User
|
|
86
|
+
failures map[string]int
|
|
87
|
+
lockedUntil map[string]time.Time
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// throttle: the fake keeps the counter in memory so the lockout path can be
|
|
91
|
+
// exercised without a database. Same shape as the real one — a key, a count,
|
|
92
|
+
// and a time — because what the tests care about is when Service decides to
|
|
93
|
+
// stop asking bcrypt anything.
|
|
94
|
+
func (f *fakeRepo) LoginLockedUntil(_ context.Context, key string) (time.Time, error) {
|
|
95
|
+
return f.lockedUntil[key], nil
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func (f *fakeRepo) RecordLoginFailure(_ context.Context, key string, freeAttempts int, maxLock time.Duration) error {
|
|
99
|
+
if f.lockedUntil == nil {
|
|
100
|
+
f.lockedUntil = map[string]time.Time{}
|
|
101
|
+
}
|
|
102
|
+
f.failures[key]++
|
|
103
|
+
if f.failures[key] > freeAttempts {
|
|
104
|
+
lock := time.Duration(1<<uint(f.failures[key]-freeAttempts-1)) * time.Second
|
|
105
|
+
if lock > maxLock {
|
|
106
|
+
lock = maxLock
|
|
107
|
+
}
|
|
108
|
+
f.lockedUntil[key] = time.Now().Add(lock)
|
|
109
|
+
}
|
|
110
|
+
return nil
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func (f *fakeRepo) ClearLoginFailures(_ context.Context, key string) error {
|
|
114
|
+
delete(f.failures, key)
|
|
115
|
+
delete(f.lockedUntil, key)
|
|
116
|
+
return nil
|
|
85
117
|
}
|
|
86
118
|
|
|
87
119
|
func (f *fakeRepo) FindByEmail(context.Context, string) (*model.User, error) {
|
|
@@ -107,7 +139,7 @@ func (f *fakeRepo) UpdateIdentity(context.Context, *model.Identity) error
|
|
|
107
139
|
|
|
108
140
|
type fakeMailer struct{}
|
|
109
141
|
|
|
110
|
-
func (fakeMailer) Send(string, string, string) error { return nil }
|
|
142
|
+
func (fakeMailer) Send(context.Context, string, string, string) error { return nil }
|
|
111
143
|
|
|
112
144
|
// go-scaffold:user-service-test-types
|
|
113
145
|
|
|
@@ -235,3 +267,50 @@ func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T)
|
|
|
235
267
|
t.Fatal("expected a different user's session to be untouched by LogoutAll")
|
|
236
268
|
}
|
|
237
269
|
}
|
|
270
|
+
|
|
271
|
+
// The control that actually stops credential stuffing: the counter follows the
|
|
272
|
+
// account, so spreading attempts across a proxy pool doesn't help.
|
|
273
|
+
func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {
|
|
274
|
+
repo := &fakeRepo{
|
|
275
|
+
user: &model.User{ID: uuid.New(), Email: "a@example.com"},
|
|
276
|
+
failures: map[string]int{},
|
|
277
|
+
}
|
|
278
|
+
svc := newTestService(repo, newFakeTokenStore())
|
|
279
|
+
in := loginInput{Email: "a@example.com", Password: "wrong"}
|
|
280
|
+
|
|
281
|
+
// the free attempts answer "wrong password", not "locked"
|
|
282
|
+
for i := 0; i < loginFreeAttempts; i++ {
|
|
283
|
+
if _, err := svc.Login(context.Background(), in); err == nil {
|
|
284
|
+
t.Fatalf("attempt %d: expected a failure", i+1)
|
|
285
|
+
} else if code(err) != "AUTH_INVALID_CREDENTIALS" {
|
|
286
|
+
t.Fatalf("attempt %d: expected AUTH_INVALID_CREDENTIALS, got %s", i+1, code(err))
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// the next one trips the lock...
|
|
291
|
+
if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_INVALID_CREDENTIALS" {
|
|
292
|
+
t.Fatalf("the attempt that trips the lock still answers as a bad password, got %s", code(err))
|
|
293
|
+
}
|
|
294
|
+
// ...and everything after it is refused before any password is checked
|
|
295
|
+
if _, err := svc.Login(context.Background(), in); code(err) != "AUTH_TOO_MANY_ATTEMPTS" {
|
|
296
|
+
t.Fatalf("expected AUTH_TOO_MANY_ATTEMPTS once locked, got %s", code(err))
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// A locked login must not lock the reset that fixes it — the person who forgot
|
|
301
|
+
// their password is exactly the person who trips the login counter.
|
|
302
|
+
func TestService_ForgotPassword_HasItsOwnCounter(t *testing.T) {
|
|
303
|
+
if throttleKey("login", "a@example.com") == throttleKey("pwreset", "a@example.com") {
|
|
304
|
+
t.Fatal("login and password-reset share a throttle key; a locked login would block recovery")
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// code pulls the AppError code out, so a test asserting on behaviour doesn't
|
|
309
|
+
// have to care how the error is wrapped.
|
|
310
|
+
func code(err error) string {
|
|
311
|
+
var appErr *apperror.AppError
|
|
312
|
+
if errors.As(err, &appErr) {
|
|
313
|
+
return appErr.Code
|
|
314
|
+
}
|
|
315
|
+
return fmt.Sprintf("%v", err)
|
|
316
|
+
}
|
|
@@ -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
|
-
}
|