@nakedev/go-scaffold 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +59 -1
- package/dist/commands/method.js +3 -0
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +19 -2
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +66 -3
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/openapi-patcher.js +16 -0
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/version.js +24 -0
- package/package.json +2 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/Makefile.hbs +30 -5
- package/templates/create/base/README.md.hbs +36 -8
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +13 -0
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/handler.go.hbs +20 -3
- package/templates/generate/module/handler_test.go.hbs +49 -6
- package/templates/generate/module/minimal/handler.go.hbs +21 -3
- package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- package/dist/utils/module-paths.js +0 -33
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"log/slog"
|
|
8
|
+
"net/http"
|
|
9
|
+
"strings"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"{{goModule}}/internal/app/user/model"
|
|
13
|
+
"{{goModule}}/internal/shared/apperror"
|
|
14
|
+
"{{goModule}}/internal/shared/config"
|
|
15
|
+
"{{goModule}}/internal/shared/dberr"
|
|
16
|
+
"{{goModule}}/internal/shared/id"
|
|
17
|
+
|
|
18
|
+
"github.com/google/uuid"
|
|
19
|
+
"golang.org/x/crypto/bcrypt"
|
|
20
|
+
"golang.org/x/oauth2"
|
|
21
|
+
"golang.org/x/oauth2/google"
|
|
22
|
+
"gorm.io/gorm"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// repository = what the service needs from the data layer (declared on the
|
|
26
|
+
// consumer side, so it can be faked in tests).
|
|
27
|
+
type repository interface {
|
|
28
|
+
FindByEmail(ctx context.Context, email string) (*model.User, error)
|
|
29
|
+
FindByID(ctx context.Context, id uuid.UUID) (*model.User, error)
|
|
30
|
+
UpdateUser(ctx context.Context, u *model.User) error
|
|
31
|
+
FindAll(ctx context.Context, limit, offset int) ([]model.User, error)
|
|
32
|
+
FindIdentity(ctx context.Context, userID uuid.UUID, provider model.Provider) (*model.Identity, error)
|
|
33
|
+
FindIdentityByProviderUID(ctx context.Context, provider model.Provider, providerUID string) (*model.Identity, error)
|
|
34
|
+
CreateUserWithIdentity(ctx context.Context, u *model.User, i *model.Identity) error
|
|
35
|
+
CreateIdentity(ctx context.Context, i *model.Identity) error
|
|
36
|
+
UpdateIdentity(ctx context.Context, i *model.Identity) error
|
|
37
|
+
// go-scaffold:user-repository-interface
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// mailer = what the service needs to send an email — satisfied by
|
|
41
|
+
// platform/mail's AsyncClient (enqueues onto cmd/worker instead of blocking
|
|
42
|
+
// the request on SMTP).
|
|
43
|
+
type mailer interface {
|
|
44
|
+
Send(to, subject, body string) error
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// go-scaffold:user-interfaces
|
|
48
|
+
|
|
49
|
+
type Service struct {
|
|
50
|
+
repo repository
|
|
51
|
+
tokens tokenStore
|
|
52
|
+
mailer mailer
|
|
53
|
+
jwtSecret string
|
|
54
|
+
accessTTL time.Duration
|
|
55
|
+
refreshTTL time.Duration
|
|
56
|
+
resetTTL time.Duration
|
|
57
|
+
resetURL string
|
|
58
|
+
verifyTTL time.Duration
|
|
59
|
+
verifyURL string
|
|
60
|
+
googleOAuth *oauth2.Config
|
|
61
|
+
// go-scaffold:user-service-fields
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func NewService(
|
|
65
|
+
repo repository,
|
|
66
|
+
tokens tokenStore,
|
|
67
|
+
mailer mailer,
|
|
68
|
+
cfg config.Config,
|
|
69
|
+
// go-scaffold:user-service-params
|
|
70
|
+
) *Service {
|
|
71
|
+
return &Service{
|
|
72
|
+
repo: repo,
|
|
73
|
+
tokens: tokens,
|
|
74
|
+
mailer: mailer,
|
|
75
|
+
jwtSecret: cfg.JWTSecret,
|
|
76
|
+
accessTTL: cfg.JWTAccessTTL,
|
|
77
|
+
refreshTTL: cfg.JWTRefreshTTL,
|
|
78
|
+
resetTTL: cfg.PasswordResetTTL,
|
|
79
|
+
resetURL: cfg.PasswordResetURL,
|
|
80
|
+
verifyTTL: cfg.EmailVerifyTTL,
|
|
81
|
+
verifyURL: cfg.EmailVerifyURL,
|
|
82
|
+
googleOAuth: &oauth2.Config{
|
|
83
|
+
ClientID: cfg.GoogleClientID,
|
|
84
|
+
ClientSecret: cfg.GoogleClientSecret,
|
|
85
|
+
RedirectURL: cfg.GoogleRedirectURL,
|
|
86
|
+
Scopes: []string{"openid", "email", "profile"},
|
|
87
|
+
Endpoint: google.Endpoint,
|
|
88
|
+
},
|
|
89
|
+
// go-scaffold:user-service-init
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func (s *Service) Register(ctx context.Context, in registerInput) (*authResponse, error) {
|
|
94
|
+
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
|
95
|
+
if err != nil {
|
|
96
|
+
return nil, apperror.NewInternal()
|
|
97
|
+
}
|
|
98
|
+
hashStr := string(hash)
|
|
99
|
+
|
|
100
|
+
u := &model.User{ID: id.New(), Email: in.Email, Name: in.Name}
|
|
101
|
+
i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
|
|
102
|
+
if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
|
|
103
|
+
if dberr.IsDuplicate(err) {
|
|
104
|
+
return nil, errEmailTaken()
|
|
105
|
+
}
|
|
106
|
+
return nil, apperror.NewInternal()
|
|
107
|
+
}
|
|
108
|
+
// best-effort: a mail failure shouldn't block registration — the user can
|
|
109
|
+
// always ask for another link via ResendVerificationEmail.
|
|
110
|
+
s.sendVerificationEmail(ctx, u)
|
|
111
|
+
return s.issueTokens(ctx, u)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func (s *Service) Login(ctx context.Context, in loginInput) (*authResponse, error) {
|
|
115
|
+
u, err := s.repo.FindByEmail(ctx, in.Email)
|
|
116
|
+
if err != nil {
|
|
117
|
+
return nil, errInvalidCredentials()
|
|
118
|
+
}
|
|
119
|
+
ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
|
|
120
|
+
if err != nil || ident.PasswordHash == nil {
|
|
121
|
+
return nil, errInvalidCredentials()
|
|
122
|
+
}
|
|
123
|
+
if err := bcrypt.CompareHashAndPassword([]byte(*ident.PasswordHash), []byte(in.Password)); err != nil {
|
|
124
|
+
return nil, errInvalidCredentials()
|
|
125
|
+
}
|
|
126
|
+
return s.issueTokens(ctx, u)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Refresh rotates a refresh token: the presented one is consumed (deleted +
|
|
130
|
+
// tombstoned as "used") and a fresh pair is issued. If the presented token
|
|
131
|
+
// isn't active but WAS already used, that means this exact raw value got
|
|
132
|
+
// replayed after rotation — i.e. it leaked — so every session for that user
|
|
133
|
+
// is revoked, not just this one.
|
|
134
|
+
func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*authResponse, error) {
|
|
135
|
+
hash := hashToken(rawRefreshToken)
|
|
136
|
+
userID, ok, err := s.tokens.GetRefreshToken(ctx, hash)
|
|
137
|
+
if err != nil {
|
|
138
|
+
return nil, apperror.NewInternal()
|
|
139
|
+
}
|
|
140
|
+
if !ok {
|
|
141
|
+
if reusedBy, used, uerr := s.tokens.IsRefreshTokenUsed(ctx, hash); uerr == nil && used {
|
|
142
|
+
_ = s.tokens.RevokeAllRefreshTokens(ctx, reusedBy)
|
|
143
|
+
}
|
|
144
|
+
return nil, errInvalidToken()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if err := s.tokens.DeleteRefreshToken(ctx, hash, userID); err != nil {
|
|
148
|
+
return nil, apperror.NewInternal()
|
|
149
|
+
}
|
|
150
|
+
if err := s.tokens.MarkRefreshTokenUsed(ctx, hash, userID, s.refreshTTL); err != nil {
|
|
151
|
+
return nil, apperror.NewInternal()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
u, err := s.repo.FindByID(ctx, userID)
|
|
155
|
+
if err != nil {
|
|
156
|
+
return nil, errInvalidToken()
|
|
157
|
+
}
|
|
158
|
+
return s.issueTokens(ctx, u)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Logout is intentional, not reuse — no tombstoning, and idempotent (a
|
|
162
|
+
// missing/already-gone token still succeeds).
|
|
163
|
+
func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error {
|
|
164
|
+
if rawRefreshToken == "" {
|
|
165
|
+
return nil
|
|
166
|
+
}
|
|
167
|
+
hash := hashToken(rawRefreshToken)
|
|
168
|
+
userID, ok, err := s.tokens.GetRefreshToken(ctx, hash)
|
|
169
|
+
if err != nil || !ok {
|
|
170
|
+
return nil
|
|
171
|
+
}
|
|
172
|
+
return s.tokens.DeleteRefreshToken(ctx, hash, userID)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// LogoutAll is the authenticated, self-service version of what ResetPassword
|
|
176
|
+
// already does automatically: end every session for this user, not just the
|
|
177
|
+
// one making the request — e.g. "log out everywhere" after a lost device.
|
|
178
|
+
func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
|
|
179
|
+
return s.tokens.RevokeAllRefreshTokens(ctx, userID)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*model.User, error) {
|
|
183
|
+
u, err := s.repo.FindByID(ctx, userID)
|
|
184
|
+
if err != nil {
|
|
185
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
186
|
+
return nil, errNotFound()
|
|
187
|
+
}
|
|
188
|
+
return nil, apperror.NewInternal()
|
|
189
|
+
}
|
|
190
|
+
return u, nil
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// List is unused until an admin-facing route calls it (see `add rbac`'s
|
|
194
|
+
// GET /users) — kept here rather than gated behind a patch since it's a
|
|
195
|
+
// generically useful piece of data access, same as UpdateUser.
|
|
196
|
+
func (s *Service) List(ctx context.Context, limit, offset int) ([]model.User, error) {
|
|
197
|
+
items, err := s.repo.FindAll(ctx, limit, offset)
|
|
198
|
+
if err != nil {
|
|
199
|
+
return nil, apperror.NewInternal()
|
|
200
|
+
}
|
|
201
|
+
return items, nil
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ForgotPassword always succeeds from the caller's point of view — whether
|
|
205
|
+
// the email exists or not — so a login-attempt-shaped probe can't be used to
|
|
206
|
+
// enumerate registered accounts. Only if the email resolves to a local
|
|
207
|
+
// (password-based) identity does it actually issue+email a reset token.
|
|
208
|
+
func (s *Service) ForgotPassword(ctx context.Context, email string) error {
|
|
209
|
+
u, err := s.repo.FindByEmail(ctx, email)
|
|
210
|
+
if err != nil {
|
|
211
|
+
return nil
|
|
212
|
+
}
|
|
213
|
+
ident, err := s.repo.FindIdentity(ctx, u.ID, model.ProviderLocal)
|
|
214
|
+
if err != nil || ident.PasswordHash == nil {
|
|
215
|
+
return nil // google-only account has no password to reset
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
raw, err := randomToken()
|
|
219
|
+
if err != nil {
|
|
220
|
+
return apperror.NewInternal()
|
|
221
|
+
}
|
|
222
|
+
if err := s.tokens.SetPasswordResetToken(ctx, hashToken(raw), u.ID, s.resetTTL); err != nil {
|
|
223
|
+
return apperror.NewInternal()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
link := s.resetURL + "?token=" + raw
|
|
227
|
+
if err := s.mailer.Send(u.Email, "Reset your password", "Reset your password: "+link); err != nil {
|
|
228
|
+
// token's already stored — a mail failure shouldn't fail the request,
|
|
229
|
+
// just get logged so it's visible operationally.
|
|
230
|
+
slog.Error("send password reset email", "error", err)
|
|
231
|
+
}
|
|
232
|
+
return nil
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ResetPassword consumes a one-time reset token (GETDEL — see
|
|
236
|
+
// tokenStore.ConsumePasswordResetToken), sets the new password, and revokes
|
|
237
|
+
// every existing session for that user: a password reset should end every
|
|
238
|
+
// session an attacker (or the legitimate user on another device) still holds.
|
|
239
|
+
func (s *Service) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
|
|
240
|
+
userID, ok, err := s.tokens.ConsumePasswordResetToken(ctx, hashToken(rawToken))
|
|
241
|
+
if err != nil {
|
|
242
|
+
return apperror.NewInternal()
|
|
243
|
+
}
|
|
244
|
+
if !ok {
|
|
245
|
+
return errInvalidToken()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
249
|
+
if err != nil {
|
|
250
|
+
return apperror.NewInternal()
|
|
251
|
+
}
|
|
252
|
+
hashStr := string(hash)
|
|
253
|
+
|
|
254
|
+
ident, err := s.repo.FindIdentity(ctx, userID, model.ProviderLocal)
|
|
255
|
+
if err != nil {
|
|
256
|
+
return apperror.NewInternal()
|
|
257
|
+
}
|
|
258
|
+
ident.PasswordHash = &hashStr
|
|
259
|
+
if err := s.repo.UpdateIdentity(ctx, ident); err != nil {
|
|
260
|
+
return apperror.NewInternal()
|
|
261
|
+
}
|
|
262
|
+
return s.tokens.RevokeAllRefreshTokens(ctx, userID)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// sendVerificationEmail issues a one-time token and emails the link — called
|
|
266
|
+
// from Register (best-effort) and ResendVerificationEmail. Not exported:
|
|
267
|
+
// callers that want to send one go through one of those two, which decide
|
|
268
|
+
// whether it's appropriate to (e.g. ResendVerificationEmail checks the user
|
|
269
|
+
// isn't already verified first).
|
|
270
|
+
func (s *Service) sendVerificationEmail(ctx context.Context, u *model.User) {
|
|
271
|
+
raw, err := randomToken()
|
|
272
|
+
if err != nil {
|
|
273
|
+
slog.Error("generate email verification token", "error", err)
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
if err := s.tokens.SetEmailVerifyToken(ctx, hashToken(raw), u.ID, s.verifyTTL); err != nil {
|
|
277
|
+
slog.Error("store email verification token", "error", err)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
link := s.verifyURL + "?token=" + raw
|
|
281
|
+
if err := s.mailer.Send(u.Email, "Verify your email", "Verify your email: "+link); err != nil {
|
|
282
|
+
slog.Error("send email verification email", "error", err)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ResendVerificationEmail is the authenticated counterpart to the automatic
|
|
287
|
+
// send in Register — for when the first email never arrived or its token
|
|
288
|
+
// expired. Rejects an already-verified user instead of silently no-op'ing:
|
|
289
|
+
// unlike ForgotPassword this is authenticated, so there's no enumeration
|
|
290
|
+
// concern in telling the caller their own account's real state.
|
|
291
|
+
func (s *Service) ResendVerificationEmail(ctx context.Context, userID uuid.UUID) error {
|
|
292
|
+
u, err := s.repo.FindByID(ctx, userID)
|
|
293
|
+
if err != nil {
|
|
294
|
+
return apperror.NewInternal()
|
|
295
|
+
}
|
|
296
|
+
if u.EmailVerified {
|
|
297
|
+
return errAlreadyVerified()
|
|
298
|
+
}
|
|
299
|
+
s.sendVerificationEmail(ctx, u)
|
|
300
|
+
return nil
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// VerifyEmail consumes a one-time verification token (GETDEL — see
|
|
304
|
+
// tokenStore.ConsumeEmailVerifyToken) and marks the user verified.
|
|
305
|
+
func (s *Service) VerifyEmail(ctx context.Context, rawToken string) error {
|
|
306
|
+
userID, ok, err := s.tokens.ConsumeEmailVerifyToken(ctx, hashToken(rawToken))
|
|
307
|
+
if err != nil {
|
|
308
|
+
return apperror.NewInternal()
|
|
309
|
+
}
|
|
310
|
+
if !ok {
|
|
311
|
+
return errInvalidToken()
|
|
312
|
+
}
|
|
313
|
+
u, err := s.repo.FindByID(ctx, userID)
|
|
314
|
+
if err != nil {
|
|
315
|
+
return apperror.NewInternal()
|
|
316
|
+
}
|
|
317
|
+
u.EmailVerified = true
|
|
318
|
+
return s.repo.UpdateUser(ctx, u)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// GoogleLoginURL signs a short-lived CSRF state token and builds the
|
|
322
|
+
// redirect URL — stateless, no server-side row for the state (see jwt.go).
|
|
323
|
+
func (s *Service) GoogleLoginURL() (string, error) {
|
|
324
|
+
state, err := s.issueOAuthState()
|
|
325
|
+
if err != nil {
|
|
326
|
+
return "", apperror.NewInternal()
|
|
327
|
+
}
|
|
328
|
+
return s.googleOAuth.AuthCodeURL(state), nil
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
type googleUserInfo struct {
|
|
332
|
+
Sub string `json:"sub"`
|
|
333
|
+
Email string `json:"email"`
|
|
334
|
+
EmailVerified bool `json:"email_verified"`
|
|
335
|
+
Name string `json:"name"`
|
|
336
|
+
Picture string `json:"picture"`
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
func (s *Service) GoogleCallback(ctx context.Context, code, state string) (*authResponse, error) {
|
|
340
|
+
if err := s.verifyOAuthState(state); err != nil {
|
|
341
|
+
return nil, err
|
|
342
|
+
}
|
|
343
|
+
tok, err := s.googleOAuth.Exchange(ctx, code)
|
|
344
|
+
if err != nil {
|
|
345
|
+
return nil, errInvalidToken()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
resp, err := s.googleOAuth.Client(ctx, tok).Get("https://openidconnect.googleapis.com/v1/userinfo")
|
|
349
|
+
if err != nil {
|
|
350
|
+
return nil, apperror.NewInternal()
|
|
351
|
+
}
|
|
352
|
+
defer func() { _ = resp.Body.Close() }()
|
|
353
|
+
if resp.StatusCode != http.StatusOK {
|
|
354
|
+
return nil, apperror.NewInternal()
|
|
355
|
+
}
|
|
356
|
+
var info googleUserInfo
|
|
357
|
+
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
358
|
+
return nil, apperror.NewInternal()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
u, err := s.findOrCreateGoogleUser(ctx, info)
|
|
362
|
+
if err != nil {
|
|
363
|
+
return nil, err
|
|
364
|
+
}
|
|
365
|
+
return s.issueTokens(ctx, u)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// findOrCreateGoogleUser: (1) an existing google identity for this sub wins
|
|
369
|
+
// outright; (2) failing that, ONLY if Google says the email is verified, link
|
|
370
|
+
// a new google identity onto an existing local account with that email — an
|
|
371
|
+
// unverified email can't be used to take over someone else's account; (3)
|
|
372
|
+
// otherwise, brand new user + identity.
|
|
373
|
+
func (s *Service) findOrCreateGoogleUser(ctx context.Context, info googleUserInfo) (*model.User, error) {
|
|
374
|
+
if ident, err := s.repo.FindIdentityByProviderUID(ctx, model.ProviderGoogle, info.Sub); err == nil {
|
|
375
|
+
return s.repo.FindByID(ctx, ident.UserID)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
providerUID := info.Sub
|
|
379
|
+
if info.EmailVerified {
|
|
380
|
+
if u, err := s.repo.FindByEmail(ctx, info.Email); err == nil {
|
|
381
|
+
ident := &model.Identity{ID: id.New(), UserID: u.ID, Provider: model.ProviderGoogle, ProviderUID: &providerUID}
|
|
382
|
+
if err := s.repo.CreateIdentity(ctx, ident); err != nil {
|
|
383
|
+
return nil, apperror.NewInternal()
|
|
384
|
+
}
|
|
385
|
+
return u, nil
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
u := &model.User{ID: id.New(), Email: info.Email, Name: info.Name, AvatarURL: info.Picture, EmailVerified: info.EmailVerified}
|
|
390
|
+
ident := &model.Identity{ID: id.New(), Provider: model.ProviderGoogle, ProviderUID: &providerUID}
|
|
391
|
+
if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
|
|
392
|
+
if dberr.IsDuplicate(err) {
|
|
393
|
+
return nil, errEmailTaken()
|
|
394
|
+
}
|
|
395
|
+
return nil, apperror.NewInternal()
|
|
396
|
+
}
|
|
397
|
+
return u, nil
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// EnsureUser is idempotent: an existing user with this email is returned
|
|
401
|
+
// as-is (password untouched — cmd/seed shouldn't silently reset a real
|
|
402
|
+
// password on every deploy), otherwise a new local user+identity is
|
|
403
|
+
// created. Used by cmd/seed only — nothing in the HTTP API calls this.
|
|
404
|
+
func (s *Service) EnsureUser(ctx context.Context, email, password, name string) (*model.User, error) {
|
|
405
|
+
email = strings.ToLower(strings.TrimSpace(email))
|
|
406
|
+
if u, err := s.repo.FindByEmail(ctx, email); err == nil {
|
|
407
|
+
return u, nil
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
411
|
+
if err != nil {
|
|
412
|
+
return nil, apperror.NewInternal()
|
|
413
|
+
}
|
|
414
|
+
hashStr := string(hash)
|
|
415
|
+
|
|
416
|
+
u := &model.User{ID: id.New(), Email: email, Name: name}
|
|
417
|
+
i := &model.Identity{ID: id.New(), Provider: model.ProviderLocal, PasswordHash: &hashStr}
|
|
418
|
+
if err := s.repo.CreateUserWithIdentity(ctx, u, i); err != nil {
|
|
419
|
+
return nil, apperror.NewInternal()
|
|
420
|
+
}
|
|
421
|
+
return u, nil
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// go-scaffold:user-service-methods
|
|
425
|
+
|
|
426
|
+
func (s *Service) issueTokens(ctx context.Context, u *model.User) (*authResponse, error) {
|
|
427
|
+
access, err := s.issueAccessToken(
|
|
428
|
+
u.ID,
|
|
429
|
+
// go-scaffold:issue-access-token-args
|
|
430
|
+
)
|
|
431
|
+
if err != nil {
|
|
432
|
+
return nil, apperror.NewInternal()
|
|
433
|
+
}
|
|
434
|
+
refresh, err := randomToken()
|
|
435
|
+
if err != nil {
|
|
436
|
+
return nil, apperror.NewInternal()
|
|
437
|
+
}
|
|
438
|
+
if err := s.tokens.SetRefreshToken(ctx, hashToken(refresh), u.ID, s.refreshTTL); err != nil {
|
|
439
|
+
return nil, apperror.NewInternal()
|
|
440
|
+
}
|
|
441
|
+
return &authResponse{
|
|
442
|
+
AccessToken: access,
|
|
443
|
+
RefreshToken: refresh,
|
|
444
|
+
TokenType: "Bearer",
|
|
445
|
+
ExpiresIn: int(s.accessTTL.Seconds()),
|
|
446
|
+
}, nil
|
|
447
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
package user
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"net/http"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"{{goModule}}/internal/app/user/model"
|
|
11
|
+
"{{goModule}}/internal/shared/apperror"
|
|
12
|
+
"{{goModule}}/internal/shared/config"
|
|
13
|
+
|
|
14
|
+
"github.com/google/uuid"
|
|
15
|
+
"gorm.io/gorm"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// fakeTokenStore = in-memory mock of tokenStore, mirroring redisTokenStore's
|
|
19
|
+
// three-map shape (active / used-tombstone / per-user session set) closely
|
|
20
|
+
// enough to exercise rotation + reuse-detection without a real Redis.
|
|
21
|
+
type fakeTokenStore struct {
|
|
22
|
+
active map[string]uuid.UUID
|
|
23
|
+
used map[string]uuid.UUID
|
|
24
|
+
sessions map[uuid.UUID]map[string]struct{}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func newFakeTokenStore() *fakeTokenStore {
|
|
28
|
+
return &fakeTokenStore{
|
|
29
|
+
active: map[string]uuid.UUID{},
|
|
30
|
+
used: map[string]uuid.UUID{},
|
|
31
|
+
sessions: map[uuid.UUID]map[string]struct{}{},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
|
|
36
|
+
f.active[hash] = userID
|
|
37
|
+
if f.sessions[userID] == nil {
|
|
38
|
+
f.sessions[userID] = map[string]struct{}{}
|
|
39
|
+
}
|
|
40
|
+
f.sessions[userID][hash] = struct{}{}
|
|
41
|
+
return nil
|
|
42
|
+
}
|
|
43
|
+
func (f *fakeTokenStore) GetRefreshToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
44
|
+
id, ok := f.active[hash]
|
|
45
|
+
return id, ok, nil
|
|
46
|
+
}
|
|
47
|
+
func (f *fakeTokenStore) DeleteRefreshToken(_ context.Context, hash string, userID uuid.UUID) error {
|
|
48
|
+
delete(f.active, hash)
|
|
49
|
+
delete(f.sessions[userID], hash)
|
|
50
|
+
return nil
|
|
51
|
+
}
|
|
52
|
+
func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.UUID) error {
|
|
53
|
+
for h := range f.sessions[userID] {
|
|
54
|
+
delete(f.active, h)
|
|
55
|
+
}
|
|
56
|
+
delete(f.sessions, userID)
|
|
57
|
+
return nil
|
|
58
|
+
}
|
|
59
|
+
func (f *fakeTokenStore) MarkRefreshTokenUsed(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
|
|
60
|
+
f.used[hash] = userID
|
|
61
|
+
return nil
|
|
62
|
+
}
|
|
63
|
+
func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
64
|
+
id, ok := f.used[hash]
|
|
65
|
+
return id, ok, nil
|
|
66
|
+
}
|
|
67
|
+
func (f *fakeTokenStore) SetPasswordResetToken(context.Context, string, uuid.UUID, time.Duration) error {
|
|
68
|
+
return nil
|
|
69
|
+
}
|
|
70
|
+
func (f *fakeTokenStore) ConsumePasswordResetToken(context.Context, string) (uuid.UUID, bool, error) {
|
|
71
|
+
return uuid.Nil, false, nil
|
|
72
|
+
}
|
|
73
|
+
func (f *fakeTokenStore) SetEmailVerifyToken(context.Context, string, uuid.UUID, time.Duration) error {
|
|
74
|
+
return nil
|
|
75
|
+
}
|
|
76
|
+
func (f *fakeTokenStore) ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error) {
|
|
77
|
+
return uuid.Nil, false, nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// fakeRepo = mock of the repository interface — only FindByID is exercised
|
|
81
|
+
// by the tests below (Refresh looks the user up after validating the
|
|
82
|
+
// token), the rest just satisfy the interface.
|
|
83
|
+
type fakeRepo struct {
|
|
84
|
+
user *model.User
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func (f *fakeRepo) FindByEmail(context.Context, string) (*model.User, error) {
|
|
88
|
+
return nil, gorm.ErrRecordNotFound
|
|
89
|
+
}
|
|
90
|
+
func (f *fakeRepo) FindByID(_ context.Context, id uuid.UUID) (*model.User, error) {
|
|
91
|
+
if f.user != nil && f.user.ID == id {
|
|
92
|
+
return f.user, nil
|
|
93
|
+
}
|
|
94
|
+
return nil, gorm.ErrRecordNotFound
|
|
95
|
+
}
|
|
96
|
+
func (f *fakeRepo) UpdateUser(context.Context, *model.User) error { return nil }
|
|
97
|
+
func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.User, error) { return nil, nil }
|
|
98
|
+
func (f *fakeRepo) FindIdentity(context.Context, uuid.UUID, model.Provider) (*model.Identity, error) {
|
|
99
|
+
return nil, gorm.ErrRecordNotFound
|
|
100
|
+
}
|
|
101
|
+
func (f *fakeRepo) FindIdentityByProviderUID(context.Context, model.Provider, string) (*model.Identity, error) {
|
|
102
|
+
return nil, gorm.ErrRecordNotFound
|
|
103
|
+
}
|
|
104
|
+
func (f *fakeRepo) CreateUserWithIdentity(context.Context, *model.User, *model.Identity) error { return nil }
|
|
105
|
+
func (f *fakeRepo) CreateIdentity(context.Context, *model.Identity) error { return nil }
|
|
106
|
+
func (f *fakeRepo) UpdateIdentity(context.Context, *model.Identity) error { return nil }
|
|
107
|
+
|
|
108
|
+
type fakeMailer struct{}
|
|
109
|
+
|
|
110
|
+
func (fakeMailer) Send(string, string, string) error { return nil }
|
|
111
|
+
|
|
112
|
+
// go-scaffold:user-service-test-types
|
|
113
|
+
|
|
114
|
+
// newTestService is the one NewService call site every test below shares —
|
|
115
|
+
// keeping it singular means `add rbac`'s marker patch only has one place to
|
|
116
|
+
// add the roleChecker arg it introduces (patching every call site
|
|
117
|
+
// individually would need a marker-per-callsite, easy to miss one).
|
|
118
|
+
func newTestService(repo repository, tokens tokenStore) *Service {
|
|
119
|
+
return NewService(
|
|
120
|
+
repo,
|
|
121
|
+
tokens,
|
|
122
|
+
fakeMailer{},
|
|
123
|
+
config.Config{JWTSecret: "test-secret", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour},
|
|
124
|
+
// go-scaffold:user-service-test-args
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func status(t *testing.T, err error) int {
|
|
129
|
+
t.Helper()
|
|
130
|
+
var appErr *apperror.AppError
|
|
131
|
+
if !errors.As(err, &appErr) {
|
|
132
|
+
t.Fatalf("expected *apperror.AppError, got %T: %v", err, err)
|
|
133
|
+
}
|
|
134
|
+
return appErr.HTTPStatus
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func TestService_Refresh_RotatesTheToken(t *testing.T) {
|
|
138
|
+
ctx := context.Background()
|
|
139
|
+
userID := uuid.New()
|
|
140
|
+
tokens := newFakeTokenStore()
|
|
141
|
+
rawOld := "old-refresh-token"
|
|
142
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(rawOld), userID, time.Hour); err != nil {
|
|
143
|
+
t.Fatalf("seed: %v", err)
|
|
144
|
+
}
|
|
145
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
146
|
+
|
|
147
|
+
auth, err := svc.Refresh(ctx, rawOld)
|
|
148
|
+
if err != nil {
|
|
149
|
+
t.Fatalf("unexpected error: %v", err)
|
|
150
|
+
}
|
|
151
|
+
if auth.RefreshToken == "" || auth.RefreshToken == rawOld {
|
|
152
|
+
t.Fatalf("expected a new, different refresh token, got %q", auth.RefreshToken)
|
|
153
|
+
}
|
|
154
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOld)); ok {
|
|
155
|
+
t.Fatal("expected the old refresh token to be consumed (no longer active) after rotation")
|
|
156
|
+
}
|
|
157
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(auth.RefreshToken)); !ok {
|
|
158
|
+
t.Fatal("expected the newly rotated refresh token to be active")
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing.T) {
|
|
163
|
+
ctx := context.Background()
|
|
164
|
+
userID := uuid.New()
|
|
165
|
+
tokens := newFakeTokenStore()
|
|
166
|
+
rawOld := "old-refresh-token"
|
|
167
|
+
rawOtherSession := "another-devices-refresh-token"
|
|
168
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(rawOld), userID, time.Hour); err != nil {
|
|
169
|
+
t.Fatalf("seed: %v", err)
|
|
170
|
+
}
|
|
171
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(rawOtherSession), userID, time.Hour); err != nil {
|
|
172
|
+
t.Fatalf("seed: %v", err)
|
|
173
|
+
}
|
|
174
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
|
|
175
|
+
|
|
176
|
+
// rotate rawOld once — legitimate use, consumes + tombstones it
|
|
177
|
+
if _, err := svc.Refresh(ctx, rawOld); err != nil {
|
|
178
|
+
t.Fatalf("first refresh: unexpected error: %v", err)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// replay the now-rotated-out raw value — this is what happens if it leaked
|
|
182
|
+
_, err := svc.Refresh(ctx, rawOld)
|
|
183
|
+
if got := status(t, err); got != http.StatusUnauthorized {
|
|
184
|
+
t.Fatalf("want 401 replaying a rotated-out token, got %d", got)
|
|
185
|
+
}
|
|
186
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOtherSession)); ok {
|
|
187
|
+
t.Fatal("expected reuse of a rotated-out token to revoke every session for that user, but another session's token is still active")
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func TestService_Refresh_UnknownTokenIsRejectedWithoutRevoking(t *testing.T) {
|
|
192
|
+
ctx := context.Background()
|
|
193
|
+
userID := uuid.New()
|
|
194
|
+
tokens := newFakeTokenStore()
|
|
195
|
+
rawLegit := "a-legit-session-token"
|
|
196
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(rawLegit), userID, time.Hour); err != nil {
|
|
197
|
+
t.Fatalf("seed: %v", err)
|
|
198
|
+
}
|
|
199
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
|
|
200
|
+
|
|
201
|
+
_, err := svc.Refresh(ctx, "never-issued-token")
|
|
202
|
+
if got := status(t, err); got != http.StatusUnauthorized {
|
|
203
|
+
t.Fatalf("want 401 for an unrecognized token, got %d", got)
|
|
204
|
+
}
|
|
205
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawLegit)); !ok {
|
|
206
|
+
t.Fatal("a never-issued token shouldn't revoke unrelated sessions")
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T) {
|
|
211
|
+
ctx := context.Background()
|
|
212
|
+
userID := uuid.New()
|
|
213
|
+
otherUserID := uuid.New()
|
|
214
|
+
tokens := newFakeTokenStore()
|
|
215
|
+
rawA := "user-session-a"
|
|
216
|
+
rawB := "user-session-b"
|
|
217
|
+
rawOther := "other-users-session"
|
|
218
|
+
for raw, id := range map[string]uuid.UUID{rawA: userID, rawB: userID, rawOther: otherUserID} {
|
|
219
|
+
if err := tokens.SetRefreshToken(ctx, hashToken(raw), id, time.Hour); err != nil {
|
|
220
|
+
t.Fatalf("seed: %v", err)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
|
|
224
|
+
|
|
225
|
+
if err := svc.LogoutAll(ctx, userID); err != nil {
|
|
226
|
+
t.Fatalf("unexpected error: %v", err)
|
|
227
|
+
}
|
|
228
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawA)); ok {
|
|
229
|
+
t.Fatal("expected session A to be revoked")
|
|
230
|
+
}
|
|
231
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawB)); ok {
|
|
232
|
+
t.Fatal("expected session B to be revoked")
|
|
233
|
+
}
|
|
234
|
+
if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOther)); !ok {
|
|
235
|
+
t.Fatal("expected a different user's session to be untouched by LogoutAll")
|
|
236
|
+
}
|
|
237
|
+
}
|