@nakedev/go-scaffold 0.5.1 → 0.5.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/dist/commands/auth.js +11 -2
- package/dist/index.js +1 -1
- package/dist/templates/auth-manifest.js +4 -0
- package/dist/utils/rbac-patcher.js +12 -4
- package/package.json +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +54 -0
- package/templates/add/auth/docs/users-me-identities.yaml.hbs +13 -0
- package/templates/add/auth/docs/users-me-identity-link-exchange.yaml.hbs +26 -0
- package/templates/add/auth/docs/users-me-identity-link.yaml.hbs +25 -0
- package/templates/add/auth/docs/users-me-identity.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-session.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-sessions.yaml.hbs +13 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +62 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +7 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +80 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +6 -2
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +3 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +34 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +30 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +3 -3
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +6 -1
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +43 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +46 -7
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +32 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +16 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +42 -13
- package/templates/add/auth/internal/app/user/application/identities.go.hbs +97 -0
- package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +69 -0
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +5 -2
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +2 -2
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +13 -4
- package/templates/add/auth/internal/app/user/application/service.go.hbs +6 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +107 -5
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +62 -8
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +3 -1
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +19 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +9 -3
- package/templates/add/auth/internal/shared/middleware/auth_test.go.hbs +48 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +5 -1
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +2 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +9 -0
- package/templates/create/base/AGENTS.md.hbs +14 -0
- package/templates/create/base/README.md.hbs +9 -0
- package/templates/create/features/docs/architecture.md.hbs +5 -5
- package/templates/create/features/docs/techstack.md.hbs +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/user/domain"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestService_IdentityLinkBindsOAuthTransactionToCaller(t *testing.T) {
|
|
14
|
+
provider, svc := validFakeProvider(t)
|
|
15
|
+
userID := uuid.New()
|
|
16
|
+
otherUserID := uuid.New()
|
|
17
|
+
|
|
18
|
+
if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
|
|
19
|
+
t.Fatalf("begin identity link: %v", err)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_, err := svc.ExchangeIdentityLink(context.Background(), otherUserID, provider.Name(), validLoginExchange())
|
|
23
|
+
if got := oauthErrorCode(t, err); got != OAuthStateInvalid {
|
|
24
|
+
t.Fatalf("oauth code = %q, want %q", got, OAuthStateInvalid)
|
|
25
|
+
}
|
|
26
|
+
if provider.completeCnt != 0 {
|
|
27
|
+
t.Fatal("provider must not receive a transaction bound to another user")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func TestService_IdentityLinkIsIdempotentForSameProviderSubject(t *testing.T) {
|
|
32
|
+
provider, svc := validFakeProvider(t)
|
|
33
|
+
userID := uuid.New()
|
|
34
|
+
|
|
35
|
+
if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
|
|
36
|
+
t.Fatalf("begin first identity link: %v", err)
|
|
37
|
+
}
|
|
38
|
+
first, err := svc.ExchangeIdentityLink(context.Background(), userID, provider.Name(), validLoginExchange())
|
|
39
|
+
if err != nil {
|
|
40
|
+
t.Fatalf("exchange first identity link: %v", err)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
|
|
44
|
+
t.Fatalf("begin repeated identity link: %v", err)
|
|
45
|
+
}
|
|
46
|
+
second, err := svc.ExchangeIdentityLink(context.Background(), userID, provider.Name(), validLoginExchange())
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatalf("exchange repeated identity link: %v", err)
|
|
49
|
+
}
|
|
50
|
+
if first.ID != second.ID || first.Provider != second.Provider {
|
|
51
|
+
t.Fatalf("repeated link returned a different identity: first=%+v second=%+v", first, second)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestService_UnlinkIdentityRejectsRemovingLastLoginMethod(t *testing.T) {
|
|
56
|
+
userID := uuid.New()
|
|
57
|
+
providerUID := "local-user"
|
|
58
|
+
repo := &fakeRepo{}
|
|
59
|
+
repo.identities = append(repo.identities, domain.Identity{
|
|
60
|
+
ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, ProviderUID: &providerUID,
|
|
61
|
+
})
|
|
62
|
+
svc := newTestService(repo, newFakeTokenStore())
|
|
63
|
+
|
|
64
|
+
err := svc.UnlinkIdentity(context.Background(), userID, string(domain.ProviderLocal))
|
|
65
|
+
var ruleErr *domain.RuleError
|
|
66
|
+
if !errors.As(err, &ruleErr) || ruleErr.Code != "AUTH_LAST_IDENTITY" {
|
|
67
|
+
t.Fatalf("unlink last identity error = %v, want AUTH_LAST_IDENTITY", err)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -16,8 +16,9 @@ const (
|
|
|
16
16
|
// accessClaims — see internal/shared/middleware/auth.go for why this struct
|
|
17
17
|
// is duplicated there instead of imported.
|
|
18
18
|
type accessClaims struct {
|
|
19
|
-
Typ
|
|
20
|
-
Role
|
|
19
|
+
Typ string `json:"typ"`
|
|
20
|
+
Role string `json:"role,omitempty"`
|
|
21
|
+
SessionID uuid.UUID `json:"sid,omitempty"`
|
|
21
22
|
// go-scaffold:jwt-claims
|
|
22
23
|
jwt.RegisteredClaims
|
|
23
24
|
}
|
|
@@ -25,12 +26,14 @@ type accessClaims struct {
|
|
|
25
26
|
func (s *Service) issueAccessToken(
|
|
26
27
|
userID uuid.UUID,
|
|
27
28
|
role string,
|
|
29
|
+
sessionID uuid.UUID,
|
|
28
30
|
// go-scaffold:issue-access-token-params
|
|
29
31
|
) (string, error) {
|
|
30
32
|
now := s.clock()
|
|
31
33
|
claims := accessClaims{
|
|
32
34
|
Typ: tokenTypeAccess,
|
|
33
35
|
Role: role,
|
|
36
|
+
SessionID: sessionID,
|
|
34
37
|
// go-scaffold:jwt-claims-values
|
|
35
38
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
36
39
|
Subject: userID.String(),
|
|
@@ -55,7 +55,7 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*AuthResult,
|
|
|
55
55
|
// Best-effort: a mail failure should not block registration. The user can
|
|
56
56
|
// request another link through ResendVerificationEmail.
|
|
57
57
|
s.sendVerificationEmail(ctx, u)
|
|
58
|
-
auth, err := s.issueTokens(ctx, u)
|
|
58
|
+
auth, err := s.issueTokens(ctx, u, in.Session)
|
|
59
59
|
if err != nil {
|
|
60
60
|
return nil, err
|
|
61
61
|
}
|
|
@@ -92,5 +92,5 @@ func (s *Service) Login(ctx context.Context, in LoginInput) (*AuthResult, error)
|
|
|
92
92
|
if err := s.repo.ClearLoginFailures(ctx, key); err != nil {
|
|
93
93
|
slog.Error("clear login failures", "error", err)
|
|
94
94
|
}
|
|
95
|
-
return s.completeLogin(ctx, u)
|
|
95
|
+
return s.completeLogin(ctx, u, in.Session)
|
|
96
96
|
}
|
|
@@ -86,10 +86,17 @@ func ValidateMFASettings(settings MFASettings) error {
|
|
|
86
86
|
return nil
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
func (s *Service) completeLogin(ctx context.Context, u *domain.User) (*AuthResult, error) {
|
|
89
|
+
func (s *Service) completeLogin(ctx context.Context, u *domain.User, sessions ...SessionContext) (*AuthResult, error) {
|
|
90
|
+
var session SessionContext
|
|
91
|
+
if len(sessions) > 0 {
|
|
92
|
+
session = sessions[0]
|
|
93
|
+
}
|
|
94
|
+
if session.ID == uuid.Nil {
|
|
95
|
+
session.ID = uuid.New()
|
|
96
|
+
}
|
|
90
97
|
settings := s.mfaSettings()
|
|
91
98
|
if !settings.Enabled {
|
|
92
|
-
auth, err := s.issueTokens(ctx, u)
|
|
99
|
+
auth, err := s.issueTokens(ctx, u, session)
|
|
93
100
|
if err != nil {
|
|
94
101
|
return nil, err
|
|
95
102
|
}
|
|
@@ -103,7 +110,7 @@ func (s *Service) completeLogin(ctx context.Context, u *domain.User) (*AuthResul
|
|
|
103
110
|
return nil, fmt.Errorf("read MFA enrollment: %w", err)
|
|
104
111
|
}
|
|
105
112
|
if !found || !enrollment.Enabled {
|
|
106
|
-
auth, err := s.issueTokens(ctx, u)
|
|
113
|
+
auth, err := s.issueTokens(ctx, u, session)
|
|
107
114
|
if err != nil {
|
|
108
115
|
return nil, err
|
|
109
116
|
}
|
|
@@ -116,6 +123,8 @@ func (s *Service) completeLogin(ctx context.Context, u *domain.User) (*AuthResul
|
|
|
116
123
|
}
|
|
117
124
|
if err := s.mfa.CreateChallenge(ctx, hashToken(rawChallenge), MFAChallenge{
|
|
118
125
|
UserID: u.ID,
|
|
126
|
+
SessionID: session.ID,
|
|
127
|
+
UserAgent: normalizeUserAgent(session.UserAgent),
|
|
119
128
|
ExpiresAt: s.clock().Add(settings.ChallengeTTL),
|
|
120
129
|
}); err != nil {
|
|
121
130
|
return nil, fmt.Errorf("store MFA challenge: %w", err)
|
|
@@ -285,7 +294,7 @@ func (s *Service) VerifyMFA(ctx context.Context, rawChallenge, code string) (*Au
|
|
|
285
294
|
if err != nil {
|
|
286
295
|
return nil, errInvalidCredentials()
|
|
287
296
|
}
|
|
288
|
-
return s.issueTokens(ctx, u)
|
|
297
|
+
return s.issueTokens(ctx, u, SessionContext{ID: challenge.SessionID, UserAgent: challenge.UserAgent})
|
|
289
298
|
}
|
|
290
299
|
|
|
291
300
|
func (s *Service) requireMFA() (MFASettings, error) {
|
|
@@ -118,12 +118,18 @@ func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string
|
|
|
118
118
|
// ServicePort is the inbound application boundary. HTTP, jobs, and future
|
|
119
119
|
// transports depend on this capability set instead of the concrete service.
|
|
120
120
|
type ServicePort interface {
|
|
121
|
+
ListIdentities(context.Context, uuid.UUID) ([]IdentityResponse, error)
|
|
122
|
+
BeginIdentityLink(context.Context, uuid.UUID, string, LoginStartInput) (*Authorization, error)
|
|
123
|
+
ExchangeIdentityLink(context.Context, uuid.UUID, string, LoginExchangeInput) (*IdentityResponse, error)
|
|
124
|
+
UnlinkIdentity(context.Context, uuid.UUID, string) error
|
|
121
125
|
// go-scaffold:service-interface
|
|
122
126
|
Register(context.Context, RegisterInput) (*AuthResult, error)
|
|
123
127
|
Login(context.Context, LoginInput) (*AuthResult, error)
|
|
124
128
|
Refresh(context.Context, string) (*AuthResponse, error)
|
|
125
129
|
Logout(context.Context, string) error
|
|
126
130
|
LogoutAll(context.Context, uuid.UUID) error
|
|
131
|
+
ListSessions(context.Context, uuid.UUID, uuid.UUID) ([]Session, error)
|
|
132
|
+
RevokeSession(context.Context, uuid.UUID, uuid.UUID) error
|
|
127
133
|
ForgotPassword(context.Context, string) error
|
|
128
134
|
ResetPassword(context.Context, string, string) error
|
|
129
135
|
VerifyEmail(context.Context, string) error
|
|
@@ -102,6 +102,32 @@ func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.U
|
|
|
102
102
|
delete(f.sessions, userID)
|
|
103
103
|
return nil
|
|
104
104
|
}
|
|
105
|
+
func (f *fakeTokenStore) ListRefreshSessions(_ context.Context, userID uuid.UUID) ([]RefreshSession, error) {
|
|
106
|
+
f.mu.Lock()
|
|
107
|
+
defer f.mu.Unlock()
|
|
108
|
+
out := make([]RefreshSession, 0)
|
|
109
|
+
for _, token := range f.active {
|
|
110
|
+
if token.UserID != userID || !token.ExpiresAt.After(time.Now()) {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
out = append(out, RefreshSession{
|
|
114
|
+
ID: token.SessionID, UserAgent: token.UserAgent, CreatedAt: token.CreatedAt,
|
|
115
|
+
LastUsedAt: token.LastUsedAt, ExpiresAt: token.ExpiresAt,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
return out, nil
|
|
119
|
+
}
|
|
120
|
+
func (f *fakeTokenStore) RevokeRefreshSession(_ context.Context, userID, sessionID uuid.UUID) error {
|
|
121
|
+
f.mu.Lock()
|
|
122
|
+
defer f.mu.Unlock()
|
|
123
|
+
for hash, token := range f.active {
|
|
124
|
+
if token.UserID == userID && token.SessionID == sessionID {
|
|
125
|
+
delete(f.active, hash)
|
|
126
|
+
delete(f.sessions[userID], hash)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return nil
|
|
130
|
+
}
|
|
105
131
|
func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
|
|
106
132
|
f.mu.Lock()
|
|
107
133
|
defer f.mu.Unlock()
|
|
@@ -308,6 +334,7 @@ func (f *fakeMFAStore) ConsumeRecoveryCode(_ context.Context, userID uuid.UUID,
|
|
|
308
334
|
type fakeRepo struct {
|
|
309
335
|
user *domain.User
|
|
310
336
|
identity *domain.Identity
|
|
337
|
+
identities []domain.Identity
|
|
311
338
|
updateIdentityErr error
|
|
312
339
|
updateUserErr error
|
|
313
340
|
failures map[string]int
|
|
@@ -361,20 +388,61 @@ func (f *fakeRepo) UpdateUser(_ context.Context, u *domain.User) error {
|
|
|
361
388
|
return nil
|
|
362
389
|
}
|
|
363
390
|
func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.User, error) { return nil, nil }
|
|
364
|
-
|
|
365
|
-
|
|
391
|
+
|
|
392
|
+
func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
|
|
393
|
+
for i := range f.identities {
|
|
394
|
+
if f.identities[i].UserID == userID && f.identities[i].Provider == provider {
|
|
395
|
+
copy := f.identities[i]
|
|
396
|
+
return ©, nil
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if f.identity != nil && f.identity.UserID == userID && f.identity.Provider == provider {
|
|
366
400
|
copy := *f.identity
|
|
367
401
|
return ©, nil
|
|
368
402
|
}
|
|
369
403
|
return nil, domain.ErrNotFound
|
|
370
404
|
}
|
|
371
|
-
func (f *fakeRepo)
|
|
405
|
+
func (f *fakeRepo) ListIdentities(_ context.Context, userID uuid.UUID) ([]domain.Identity, error) {
|
|
406
|
+
out := make([]domain.Identity, 0, len(f.identities)+1)
|
|
407
|
+
for _, identity := range f.identities {
|
|
408
|
+
if identity.UserID == userID {
|
|
409
|
+
out = append(out, identity)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if f.identity != nil && f.identity.UserID == userID {
|
|
413
|
+
found := false
|
|
414
|
+
for _, identity := range out {
|
|
415
|
+
if identity.ID == f.identity.ID {
|
|
416
|
+
found = true
|
|
417
|
+
break
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if !found {
|
|
421
|
+
out = append(out, *f.identity)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return out, nil
|
|
425
|
+
}
|
|
426
|
+
func (f *fakeRepo) FindIdentityByProviderUID(_ context.Context, provider domain.Provider, providerUID string) (*domain.Identity, error) {
|
|
427
|
+
for i := range f.identities {
|
|
428
|
+
if f.identities[i].Provider == provider && f.identities[i].ProviderUID != nil && *f.identities[i].ProviderUID == providerUID {
|
|
429
|
+
copy := f.identities[i]
|
|
430
|
+
return ©, nil
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if f.identity != nil && f.identity.Provider == provider && f.identity.ProviderUID != nil && *f.identity.ProviderUID == providerUID {
|
|
434
|
+
copy := *f.identity
|
|
435
|
+
return ©, nil
|
|
436
|
+
}
|
|
372
437
|
return nil, domain.ErrNotFound
|
|
373
438
|
}
|
|
374
439
|
func (f *fakeRepo) CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error {
|
|
375
440
|
return nil
|
|
376
441
|
}
|
|
377
|
-
func (f *fakeRepo) CreateIdentity(context.Context, *domain.Identity) error {
|
|
442
|
+
func (f *fakeRepo) CreateIdentity(_ context.Context, identity *domain.Identity) error {
|
|
443
|
+
f.identities = append(f.identities, *identity)
|
|
444
|
+
return nil
|
|
445
|
+
}
|
|
378
446
|
func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.Identity) error {
|
|
379
447
|
if f.updateIdentityErr != nil {
|
|
380
448
|
return f.updateIdentityErr
|
|
@@ -382,6 +450,35 @@ func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.Identity) error {
|
|
|
382
450
|
f.identity = i
|
|
383
451
|
return nil
|
|
384
452
|
}
|
|
453
|
+
func (f *fakeRepo) DeleteIdentity(_ context.Context, userID uuid.UUID, provider domain.Provider) error {
|
|
454
|
+
count := 0
|
|
455
|
+
target := -1
|
|
456
|
+
for i := range f.identities {
|
|
457
|
+
if f.identities[i].UserID != userID {
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
count++
|
|
461
|
+
if f.identities[i].Provider == provider {
|
|
462
|
+
target = i
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
legacyTarget := f.identity != nil && f.identity.UserID == userID && f.identity.Provider == provider
|
|
466
|
+
if legacyTarget {
|
|
467
|
+
count++
|
|
468
|
+
}
|
|
469
|
+
if target < 0 && !legacyTarget {
|
|
470
|
+
return domain.ErrNotFound
|
|
471
|
+
}
|
|
472
|
+
if count <= 1 {
|
|
473
|
+
return domain.ErrLastIdentity
|
|
474
|
+
}
|
|
475
|
+
if target >= 0 {
|
|
476
|
+
f.identities = append(f.identities[:target], f.identities[target+1:]...)
|
|
477
|
+
} else {
|
|
478
|
+
f.identity = nil
|
|
479
|
+
}
|
|
480
|
+
return nil
|
|
481
|
+
}
|
|
385
482
|
|
|
386
483
|
// go-scaffold:user-fake-repo-methods
|
|
387
484
|
// go-scaffold:repository-stub-methods
|
|
@@ -552,8 +649,10 @@ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
|
|
|
552
649
|
raw := "bounded-refresh-token"
|
|
553
650
|
issuedAt := time.Now()
|
|
554
651
|
absoluteExpiry := issuedAt.Add(2 * time.Hour)
|
|
652
|
+
sessionID := uuid.New()
|
|
555
653
|
if err := tokens.SetRefreshToken(ctx, hashToken(raw), RefreshTokenRecord{
|
|
556
|
-
UserID: userID,
|
|
654
|
+
UserID: userID, SessionID: sessionID, UserAgent: "test-device",
|
|
655
|
+
CreatedAt: issuedAt.Add(-time.Hour), LastUsedAt: issuedAt.Add(-time.Minute),
|
|
557
656
|
ExpiresAt: issuedAt.Add(time.Hour),
|
|
558
657
|
AbsoluteExpiresAt: absoluteExpiry,
|
|
559
658
|
}); err != nil {
|
|
@@ -576,6 +675,9 @@ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
|
|
|
576
675
|
if !rotated.ExpiresAt.Equal(absoluteExpiry) {
|
|
577
676
|
t.Fatalf("rotated inactivity expiry was not capped at the absolute expiry: got %s want %s", rotated.ExpiresAt, absoluteExpiry)
|
|
578
677
|
}
|
|
678
|
+
if rotated.SessionID != sessionID || rotated.UserAgent != "test-device" {
|
|
679
|
+
t.Fatalf("refresh rotation changed session identity/metadata: got id=%s ua=%q", rotated.SessionID, rotated.UserAgent)
|
|
680
|
+
}
|
|
579
681
|
}
|
|
580
682
|
|
|
581
683
|
func TestService_Refresh_ReuseRevokeFailureFailsClosed(t *testing.T) {
|
|
@@ -3,6 +3,8 @@ package application
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
5
|
"fmt"
|
|
6
|
+
"sort"
|
|
7
|
+
"strings"
|
|
6
8
|
"time"
|
|
7
9
|
|
|
8
10
|
"{{goModule}}/internal/app/user/domain"
|
|
@@ -36,7 +38,9 @@ func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*AuthRes
|
|
|
36
38
|
if err != nil {
|
|
37
39
|
return nil, errInvalidToken()
|
|
38
40
|
}
|
|
39
|
-
return s.issueTokens(ctx, u,
|
|
41
|
+
return s.issueTokens(ctx, u, SessionContext{
|
|
42
|
+
ID: token.SessionID, UserAgent: token.UserAgent, CreatedAt: token.CreatedAt,
|
|
43
|
+
}, token.AbsoluteExpiresAt)
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
// Logout is intentional, not reuse: a missing or already-gone token still
|
|
@@ -58,10 +62,51 @@ func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
|
|
|
58
62
|
return s.refreshTokens.RevokeAllRefreshTokens(ctx, userID)
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
func (s *Service)
|
|
65
|
+
func (s *Service) ListSessions(ctx context.Context, userID, currentSessionID uuid.UUID) ([]Session, error) {
|
|
66
|
+
items, err := s.refreshTokens.ListRefreshSessions(ctx, userID)
|
|
67
|
+
if err != nil {
|
|
68
|
+
return nil, fmt.Errorf("list refresh sessions: %w", err)
|
|
69
|
+
}
|
|
70
|
+
out := make([]Session, 0, len(items))
|
|
71
|
+
for _, item := range items {
|
|
72
|
+
out = append(out, Session{
|
|
73
|
+
ID: item.ID, UserAgent: item.UserAgent, CreatedAt: item.CreatedAt,
|
|
74
|
+
LastUsedAt: item.LastUsedAt, ExpiresAt: item.ExpiresAt,
|
|
75
|
+
Current: item.ID == currentSessionID,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
sort.SliceStable(out, func(i, j int) bool {
|
|
79
|
+
return out[i].LastUsedAt.After(out[j].LastUsedAt)
|
|
80
|
+
})
|
|
81
|
+
return out, nil
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func (s *Service) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error {
|
|
85
|
+
if userID == uuid.Nil || sessionID == uuid.Nil {
|
|
86
|
+
return fmt.Errorf("session identity is invalid")
|
|
87
|
+
}
|
|
88
|
+
return s.refreshTokens.RevokeRefreshSession(ctx, userID, sessionID)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func (s *Service) issueTokens(ctx context.Context, u *domain.User, session SessionContext, absoluteExpiresAt ...time.Time) (*AuthResponse, error) {
|
|
92
|
+
clock := s.now
|
|
93
|
+
if clock == nil {
|
|
94
|
+
clock = time.Now
|
|
95
|
+
}
|
|
96
|
+
now := clock()
|
|
97
|
+
if session.ID == uuid.Nil {
|
|
98
|
+
session.ID = uuid.New()
|
|
99
|
+
}
|
|
100
|
+
if session.CreatedAt.IsZero() {
|
|
101
|
+
session.CreatedAt = now
|
|
102
|
+
}
|
|
103
|
+
if session.UserAgent = normalizeUserAgent(session.UserAgent); session.UserAgent == "" {
|
|
104
|
+
session.UserAgent = "unknown"
|
|
105
|
+
}
|
|
62
106
|
access, err := s.issueAccessToken(
|
|
63
107
|
u.ID,
|
|
64
108
|
u.Role,
|
|
109
|
+
session.ID,
|
|
65
110
|
// go-scaffold:issue-access-token-args
|
|
66
111
|
)
|
|
67
112
|
if err != nil {
|
|
@@ -71,11 +116,6 @@ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpir
|
|
|
71
116
|
if err != nil {
|
|
72
117
|
return nil, fmt.Errorf("generate refresh token: %w", err)
|
|
73
118
|
}
|
|
74
|
-
clock := s.now
|
|
75
|
-
if clock == nil {
|
|
76
|
-
clock = time.Now
|
|
77
|
-
}
|
|
78
|
-
now := clock()
|
|
79
119
|
abs := now.Add(s.config.JWTRefreshMaxTTL)
|
|
80
120
|
if len(absoluteExpiresAt) > 0 && !absoluteExpiresAt[0].IsZero() {
|
|
81
121
|
abs = absoluteExpiresAt[0]
|
|
@@ -87,7 +127,11 @@ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpir
|
|
|
87
127
|
if !expiresAt.After(now) || !abs.After(now) {
|
|
88
128
|
return nil, fmt.Errorf("refresh token lifetime is exhausted")
|
|
89
129
|
}
|
|
90
|
-
if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), RefreshTokenRecord{
|
|
130
|
+
if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), RefreshTokenRecord{
|
|
131
|
+
UserID: u.ID, SessionID: session.ID, UserAgent: session.UserAgent,
|
|
132
|
+
CreatedAt: session.CreatedAt, LastUsedAt: now, ExpiresAt: expiresAt,
|
|
133
|
+
AbsoluteExpiresAt: abs,
|
|
134
|
+
}); err != nil {
|
|
91
135
|
return nil, fmt.Errorf("store refresh token: %w", err)
|
|
92
136
|
}
|
|
93
137
|
return &AuthResponse{
|
|
@@ -97,3 +141,13 @@ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpir
|
|
|
97
141
|
ExpiresIn: int(s.config.JWTAccessTTL.Seconds()),
|
|
98
142
|
}, nil
|
|
99
143
|
}
|
|
144
|
+
|
|
145
|
+
const maxUserAgentLength = 512
|
|
146
|
+
|
|
147
|
+
func normalizeUserAgent(value string) string {
|
|
148
|
+
value = strings.TrimSpace(value)
|
|
149
|
+
if len(value) > maxUserAgentLength {
|
|
150
|
+
return value[:maxUserAgentLength]
|
|
151
|
+
}
|
|
152
|
+
return value
|
|
153
|
+
}
|
|
@@ -8,6 +8,7 @@ type RefreshTokenStore = ports.RefreshTokenStore
|
|
|
8
8
|
type OAuthTransactionStore = ports.OAuthTransactionStore
|
|
9
9
|
type RecoveryTokenStore = ports.RecoveryTokenStore
|
|
10
10
|
type RefreshTokenRecord = ports.RefreshTokenRecord
|
|
11
|
+
type RefreshSession = ports.RefreshSession
|
|
11
12
|
type LoginTransaction = ports.LoginTransaction
|
|
12
13
|
type MFAEnrollment = ports.MFAEnrollment
|
|
13
14
|
type MFAChallenge = ports.MFAChallenge
|
|
@@ -15,7 +15,9 @@ var (
|
|
|
15
15
|
ErrMFAAlreadyEnabled = errors.New("MFA is already enabled")
|
|
16
16
|
ErrMFANotEnrolled = errors.New("MFA is not enrolled")
|
|
17
17
|
ErrMFASetupRequired = errors.New("MFA setup is required")
|
|
18
|
-
ErrUnknownRole
|
|
18
|
+
ErrUnknownRole = errors.New("unknown role")
|
|
19
|
+
ErrIdentityAlreadyLinked = errors.New("login identity is already linked")
|
|
20
|
+
ErrLastIdentity = errors.New("cannot remove the last login identity")
|
|
19
21
|
)
|
|
20
22
|
|
|
21
23
|
type RuleError struct {
|
|
@@ -16,10 +16,12 @@ type UserRepository interface {
|
|
|
16
16
|
UpdateUser(context.Context, *domain.User) error
|
|
17
17
|
FindAll(context.Context, int, int) ([]domain.User, error)
|
|
18
18
|
FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
|
|
19
|
+
ListIdentities(context.Context, uuid.UUID) ([]domain.Identity, error)
|
|
19
20
|
FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error)
|
|
20
21
|
CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error
|
|
21
22
|
CreateIdentity(context.Context, *domain.Identity) error
|
|
22
23
|
UpdateIdentity(context.Context, *domain.Identity) error
|
|
24
|
+
DeleteIdentity(context.Context, uuid.UUID, domain.Provider) error
|
|
23
25
|
LoginLockedUntil(context.Context, string) (time.Time, error)
|
|
24
26
|
RecordLoginFailure(context.Context, string, int, time.Duration) error
|
|
25
27
|
ClearLoginFailures(context.Context, string) error
|
|
@@ -36,21 +38,36 @@ type RoleChecker interface {
|
|
|
36
38
|
|
|
37
39
|
type RefreshTokenRecord struct {
|
|
38
40
|
UserID uuid.UUID `json:"user_id"`
|
|
41
|
+
SessionID uuid.UUID `json:"session_id"`
|
|
42
|
+
UserAgent string `json:"user_agent"`
|
|
43
|
+
CreatedAt time.Time `json:"created_at"`
|
|
44
|
+
LastUsedAt time.Time `json:"last_used_at"`
|
|
39
45
|
ExpiresAt time.Time `json:"expires_at"`
|
|
40
46
|
AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
type RefreshSession struct {
|
|
50
|
+
ID uuid.UUID
|
|
51
|
+
UserAgent string
|
|
52
|
+
CreatedAt time.Time
|
|
53
|
+
LastUsedAt time.Time
|
|
54
|
+
ExpiresAt time.Time
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
type RefreshTokenStore interface {
|
|
44
58
|
SetRefreshToken(context.Context, string, RefreshTokenRecord) error
|
|
45
59
|
GetRefreshToken(context.Context, string) (uuid.UUID, bool, error)
|
|
46
60
|
ConsumeRefreshToken(context.Context, string) (RefreshTokenRecord, bool, error)
|
|
47
61
|
DeleteRefreshToken(context.Context, string, uuid.UUID) error
|
|
48
62
|
RevokeAllRefreshTokens(context.Context, uuid.UUID) error
|
|
63
|
+
ListRefreshSessions(context.Context, uuid.UUID) ([]RefreshSession, error)
|
|
64
|
+
RevokeRefreshSession(context.Context, uuid.UUID, uuid.UUID) error
|
|
49
65
|
IsRefreshTokenUsed(context.Context, string) (uuid.UUID, bool, error)
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
type LoginTransaction struct {
|
|
53
69
|
Provider string `json:"provider"`
|
|
70
|
+
UserID uuid.UUID `json:"user_id"`
|
|
54
71
|
CodeChallenge string `json:"code_challenge"`
|
|
55
72
|
Nonce string `json:"nonce"`
|
|
56
73
|
ExpiresAt time.Time `json:"expires_at"`
|
|
@@ -76,6 +93,8 @@ type MFAEnrollment struct {
|
|
|
76
93
|
|
|
77
94
|
type MFAChallenge struct {
|
|
78
95
|
UserID uuid.UUID
|
|
96
|
+
SessionID uuid.UUID
|
|
97
|
+
UserAgent string
|
|
79
98
|
ExpiresAt time.Time
|
|
80
99
|
}
|
|
81
100
|
|
|
@@ -13,14 +13,17 @@ import (
|
|
|
13
13
|
|
|
14
14
|
const UserIDKey = "user_id"
|
|
15
15
|
|
|
16
|
+
const SessionIDKey = "session_id"
|
|
17
|
+
|
|
16
18
|
// go-scaffold:middleware-auth-keys
|
|
17
19
|
|
|
18
20
|
// accessClaims mirrors internal/app/user's own claims shape — duplicated
|
|
19
21
|
// rather than imported, since shared/ can never import a domain package.
|
|
20
|
-
// The two are kept in sync by convention: sub = user id, typ = "access",
|
|
21
|
-
//
|
|
22
|
+
// The two are kept in sync by convention: sub = user id, typ = "access", and
|
|
23
|
+
// sid = the stable refresh-session identity used by session management.
|
|
22
24
|
type accessClaims struct {
|
|
23
|
-
Typ
|
|
25
|
+
Typ string `json:"typ"`
|
|
26
|
+
SessionID uuid.UUID `json:"sid,omitempty"`
|
|
24
27
|
// go-scaffold:middleware-auth-claims
|
|
25
28
|
jwt.RegisteredClaims
|
|
26
29
|
}
|
|
@@ -53,6 +56,9 @@ func RequireAuth(secret string) gin.HandlerFunc {
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
c.Set(UserIDKey, userID)
|
|
59
|
+
if claims.SessionID != uuid.Nil {
|
|
60
|
+
c.Set(SessionIDKey, claims.SessionID)
|
|
61
|
+
}
|
|
56
62
|
// go-scaffold:middleware-auth-context
|
|
57
63
|
c.Next()
|
|
58
64
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package middleware
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/http/httptest"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"github.com/gin-gonic/gin"
|
|
10
|
+
"github.com/golang-jwt/jwt/v5"
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestRequireAuthExposesSessionIDFromAccessToken(t *testing.T) {
|
|
15
|
+
gin.SetMode(gin.TestMode)
|
|
16
|
+
secret := "test-secret"
|
|
17
|
+
userID := uuid.New()
|
|
18
|
+
sessionID := uuid.New()
|
|
19
|
+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims{
|
|
20
|
+
Typ: "access",
|
|
21
|
+
SessionID: sessionID,
|
|
22
|
+
RegisteredClaims: jwt.RegisteredClaims{
|
|
23
|
+
Subject: userID.String(),
|
|
24
|
+
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
25
|
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
raw, err := token.SignedString([]byte(secret))
|
|
29
|
+
if err != nil {
|
|
30
|
+
t.Fatalf("sign token: %v", err)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
router := gin.New()
|
|
34
|
+
router.Use(RequireAuth(secret))
|
|
35
|
+
router.GET("/", func(c *gin.Context) {
|
|
36
|
+
if got := c.MustGet(SessionIDKey); got != sessionID {
|
|
37
|
+
t.Fatalf("session id = %v, want %s", got, sessionID)
|
|
38
|
+
}
|
|
39
|
+
c.Status(http.StatusNoContent)
|
|
40
|
+
})
|
|
41
|
+
response := httptest.NewRecorder()
|
|
42
|
+
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
43
|
+
request.Header.Set("Authorization", "Bearer "+raw)
|
|
44
|
+
router.ServeHTTP(response, request)
|
|
45
|
+
if response.Code != http.StatusNoContent {
|
|
46
|
+
t.Fatalf("status = %d, want 204", response.Code)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -6,12 +6,15 @@ CREATE TABLE user_svc.auth_tokens (
|
|
|
6
6
|
token_hash TEXT PRIMARY KEY,
|
|
7
7
|
user_id UUID NOT NULL,
|
|
8
8
|
kind VARCHAR(20) NOT NULL,
|
|
9
|
+
session_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
|
10
|
+
user_agent TEXT NOT NULL DEFAULT '',
|
|
9
11
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
10
12
|
absolute_expires_at TIMESTAMPTZ,
|
|
11
13
|
provider VARCHAR(20) NOT NULL DEFAULT '',
|
|
12
14
|
code_challenge TEXT NOT NULL DEFAULT '',
|
|
13
15
|
nonce TEXT NOT NULL DEFAULT '',
|
|
14
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
16
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
17
|
+
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
15
18
|
);
|
|
16
19
|
|
|
17
20
|
-- Named to match model.AuthToken's own `index:` tags exactly, so the
|
|
@@ -19,3 +22,4 @@ CREATE TABLE user_svc.auth_tokens (
|
|
|
19
22
|
CREATE INDEX idx_auth_tokens_user_kind ON user_svc.auth_tokens (user_id, kind);
|
|
20
23
|
CREATE INDEX idx_auth_tokens_expires_at ON user_svc.auth_tokens (expires_at);
|
|
21
24
|
CREATE INDEX idx_auth_tokens_absolute_expires_at ON user_svc.auth_tokens (absolute_expires_at);
|
|
25
|
+
CREATE INDEX idx_auth_tokens_user_session ON user_svc.auth_tokens (user_id, session_id, last_used_at);
|
|
@@ -12,6 +12,8 @@ CREATE TABLE user_svc.mfa_enrollments (
|
|
|
12
12
|
CREATE TABLE user_svc.mfa_challenges (
|
|
13
13
|
challenge_hash TEXT PRIMARY KEY,
|
|
14
14
|
user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
|
|
15
|
+
session_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
|
|
16
|
+
user_agent TEXT NOT NULL DEFAULT '',
|
|
15
17
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
16
18
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
17
19
|
);
|
|
@@ -260,6 +260,15 @@ boundary. For browser OAuth, preserve this client-owned callback contract:
|
|
|
260
260
|
`Pragma: no-cache`.
|
|
261
261
|
Never accept a request-supplied redirect destination or place an access token,
|
|
262
262
|
refresh token, authorization code, or state in a URI.
|
|
263
|
+
- Authenticated identity management is a separate account flow, not another
|
|
264
|
+
login endpoint: list safe metadata at `GET /users/me/identities`, start and
|
|
265
|
+
finish linking at `POST /users/me/identities/:provider/link` and
|
|
266
|
+
`POST /users/me/identities/:provider/link/exchange`, and unlink with
|
|
267
|
+
`DELETE /users/me/identities/:provider`. Bind the server-side transaction to
|
|
268
|
+
the caller's user ID, never issue or replace a session during link exchange,
|
|
269
|
+
keep one identity per provider per user, and reject unlinking the last login
|
|
270
|
+
method. Provider subjects and password hashes must never cross the HTTP
|
|
271
|
+
boundary.
|
|
263
272
|
- Map provider and validation failures to controlled public codes only:
|
|
264
273
|
`oauth_denied`, `oauth_state_invalid`, `oauth_provider_unavailable`, and
|
|
265
274
|
`oauth_failed`. Do not expose raw provider descriptions or technical causes.
|