@nakedev/go-scaffold 0.5.3 → 0.5.5
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 +4 -0
- package/dist/commands/auth.js +19 -12
- package/dist/commands/method.js +2 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/utils/auth-patcher.js +10 -1
- package/dist/utils/hexagonal-method-patcher.js +12 -1
- package/package.json +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +10 -3
- package/templates/add/auth/docs/users-me-identity-local-link.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +7 -3
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +27 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +1 -1
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +40 -17
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +231 -74
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +11 -8
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +4 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +5 -5
- package/templates/add/auth/internal/app/user/application/identities.go.hbs +40 -2
- package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +12 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +3 -3
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +1 -1
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +1 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +7 -1
- package/templates/add/auth/migrations/create_external_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_external_identities.up.sql.hbs +12 -0
- package/templates/add/auth/migrations/create_password_credentials.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_password_credentials.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_user_emails.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_user_emails.up.sql.hbs +14 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +0 -11
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs +52 -0
- package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/application/dto.go.hbs +10 -10
- package/templates/create/base/AGENTS.md.hbs +7 -1
- package/templates/create/base/README.md.hbs +3 -1
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +0 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +0 -15
|
@@ -24,6 +24,9 @@ func NewRecovery(repo ports.UserRepository, tokens ports.RecoveryTokenStore, has
|
|
|
24
24
|
// ResetPassword consumes the token and updates the local identity in one
|
|
25
25
|
// unit of work. The token adapter owns the transaction boundary.
|
|
26
26
|
func (r *Recovery) ResetPassword(ctx context.Context, tokenHash, newPassword string) (uuid.UUID, error) {
|
|
27
|
+
if err := validatePassword(newPassword); err != nil {
|
|
28
|
+
return uuid.Nil, err
|
|
29
|
+
}
|
|
27
30
|
hash, err := r.hasher.Hash(newPassword)
|
|
28
31
|
if err != nil {
|
|
29
32
|
return uuid.Nil, fmt.Errorf("hash password: %w", err)
|
|
@@ -92,6 +92,17 @@ func normalizeEmail(email string) string {
|
|
|
92
92
|
return strings.ToLower(strings.TrimSpace(email))
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// bcrypt accepts at most 72 bytes. Enforce the same bound in the application
|
|
96
|
+
// layer so every caller (HTTP, seed, jobs, and future transports) gets the
|
|
97
|
+
// same safe contract instead of a provider-specific hashing failure.
|
|
98
|
+
func validatePassword(password string) error {
|
|
99
|
+
length := len([]byte(password))
|
|
100
|
+
if length < 8 || length > 72 {
|
|
101
|
+
return errInvalidPassword()
|
|
102
|
+
}
|
|
103
|
+
return nil
|
|
104
|
+
}
|
|
105
|
+
|
|
95
106
|
// SetRole validates the role against the role catalog before persisting it.
|
|
96
107
|
func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string) (*domain.User, error) {
|
|
97
108
|
u, err := s.repo.FindByID(ctx, userID)
|
|
@@ -119,6 +130,7 @@ func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string
|
|
|
119
130
|
// transports depend on this capability set instead of the concrete service.
|
|
120
131
|
type ServicePort interface {
|
|
121
132
|
ListIdentities(context.Context, uuid.UUID) ([]IdentityResponse, error)
|
|
133
|
+
LinkLocalIdentity(context.Context, uuid.UUID, string) error
|
|
122
134
|
BeginIdentityLink(context.Context, uuid.UUID, string, LoginStartInput) (*Authorization, error)
|
|
123
135
|
ExchangeIdentityLink(context.Context, uuid.UUID, string, LoginExchangeInput) (*IdentityResponse, error)
|
|
124
136
|
UnlinkIdentity(context.Context, uuid.UUID, string) error
|
|
@@ -423,14 +423,14 @@ func (f *fakeRepo) ListIdentities(_ context.Context, userID uuid.UUID) ([]domain
|
|
|
423
423
|
}
|
|
424
424
|
return out, nil
|
|
425
425
|
}
|
|
426
|
-
func (f *fakeRepo) FindIdentityByProviderUID(_ context.Context,
|
|
426
|
+
func (f *fakeRepo) FindIdentityByProviderUID(_ context.Context, issuer, providerUID string) (*domain.Identity, error) {
|
|
427
427
|
for i := range f.identities {
|
|
428
|
-
if f.identities[i].
|
|
428
|
+
if f.identities[i].Issuer == issuer && f.identities[i].ProviderUID != nil && *f.identities[i].ProviderUID == providerUID {
|
|
429
429
|
copy := f.identities[i]
|
|
430
430
|
return ©, nil
|
|
431
431
|
}
|
|
432
432
|
}
|
|
433
|
-
if f.identity != nil && f.identity.
|
|
433
|
+
if f.identity != nil && f.identity.Issuer == issuer && f.identity.ProviderUID != nil && *f.identity.ProviderUID == providerUID {
|
|
434
434
|
copy := *f.identity
|
|
435
435
|
return ©, nil
|
|
436
436
|
}
|
|
@@ -49,6 +49,9 @@ func (s *Service) EnsureUser(ctx context.Context, email, password, name string)
|
|
|
49
49
|
} else if !errors.Is(err, domain.ErrNotFound) {
|
|
50
50
|
return nil, fmt.Errorf("find existing user: %w", err)
|
|
51
51
|
}
|
|
52
|
+
if err := validatePassword(password); err != nil {
|
|
53
|
+
return nil, err
|
|
54
|
+
}
|
|
52
55
|
|
|
53
56
|
hash, err := s.passwords.Hash(password)
|
|
54
57
|
if err != nil {
|
|
@@ -6,6 +6,7 @@ var (
|
|
|
6
6
|
ErrNotFound = errors.New("user not found")
|
|
7
7
|
ErrConflict = errors.New("user conflict")
|
|
8
8
|
ErrInvalidCredential = errors.New("invalid credentials")
|
|
9
|
+
ErrInvalidPassword = errors.New("password must be between 8 and 72 bytes")
|
|
9
10
|
ErrInvalidToken = errors.New("invalid or expired token")
|
|
10
11
|
ErrEmailTaken = errors.New("email already registered")
|
|
11
12
|
ErrTooManyAttempts = errors.New("too many failed attempts")
|
|
@@ -17,7 +17,7 @@ type UserRepository interface {
|
|
|
17
17
|
FindAll(context.Context, int, int) ([]domain.User, error)
|
|
18
18
|
FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
|
|
19
19
|
ListIdentities(context.Context, uuid.UUID) ([]domain.Identity, error)
|
|
20
|
-
FindIdentityByProviderUID(context.Context,
|
|
20
|
+
FindIdentityByProviderUID(context.Context, string, string) (*domain.Identity, error)
|
|
21
21
|
CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error
|
|
22
22
|
CreateIdentity(context.Context, *domain.Identity) error
|
|
23
23
|
UpdateIdentity(context.Context, *domain.Identity) error
|
|
@@ -217,6 +217,7 @@ func (p *Provider) Complete(ctx context.Context, in application.LoginCompleteInp
|
|
|
217
217
|
}
|
|
218
218
|
return application.ExternalIdentity{
|
|
219
219
|
Provider: p.Name(),
|
|
220
|
+
Issuer: p.issuer,
|
|
220
221
|
Subject: claims.Subject,
|
|
221
222
|
Email: email,
|
|
222
223
|
EmailVerified: info.EmailVerified || claims.EmailVerified,
|
|
@@ -26,7 +26,13 @@ func (r *RedisLimiter) Allow(ctx context.Context, key string, limit int, window
|
|
|
26
26
|
return true
|
|
27
27
|
}
|
|
28
28
|
if count == 1 {
|
|
29
|
-
r.rdb.Expire(ctx, redisKey, window)
|
|
29
|
+
if err := r.rdb.Expire(ctx, redisKey, window).Err(); err != nil {
|
|
30
|
+
// Never leave a counter without a TTL: an expired window must not
|
|
31
|
+
// become a permanent lockout after a transient Redis failure. The
|
|
32
|
+
// limiter is intentionally fail-open, so discard this best-effort
|
|
33
|
+
// counter and let the endpoint's own controls handle the request.
|
|
34
|
+
_ = r.rdb.Del(ctx, redisKey).Err()
|
|
35
|
+
}
|
|
30
36
|
}
|
|
31
37
|
return count <= int64(limit)
|
|
32
38
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DROP TABLE IF EXISTS user_svc.external_identities;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
CREATE TABLE user_svc.external_identities (
|
|
2
|
+
id UUID PRIMARY KEY,
|
|
3
|
+
user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
|
|
4
|
+
provider VARCHAR(50) NOT NULL,
|
|
5
|
+
issuer TEXT NOT NULL,
|
|
6
|
+
subject TEXT NOT NULL,
|
|
7
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
8
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
CREATE UNIQUE INDEX idx_external_identities_issuer_subject ON user_svc.external_identities (issuer, subject);
|
|
12
|
+
CREATE UNIQUE INDEX idx_external_identities_user_provider ON user_svc.external_identities (user_id, provider);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DROP TABLE IF EXISTS user_svc.password_credentials;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CREATE TABLE user_svc.password_credentials (
|
|
2
|
+
id UUID PRIMARY KEY,
|
|
3
|
+
user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
|
|
4
|
+
password_hash TEXT NOT NULL,
|
|
5
|
+
hash_algorithm VARCHAR(32) NOT NULL DEFAULT 'bcrypt',
|
|
6
|
+
password_changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
7
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
8
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
CREATE UNIQUE INDEX idx_password_credentials_user ON user_svc.password_credentials (user_id);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DROP TABLE IF EXISTS user_svc.user_emails;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
CREATE TABLE user_svc.user_emails (
|
|
2
|
+
id UUID PRIMARY KEY,
|
|
3
|
+
user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
|
|
4
|
+
email VARCHAR(255) NOT NULL,
|
|
5
|
+
email_normalized VARCHAR(255) NOT NULL,
|
|
6
|
+
is_primary BOOLEAN NOT NULL DEFAULT true,
|
|
7
|
+
verified_at TIMESTAMPTZ,
|
|
8
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
9
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE UNIQUE INDEX idx_user_emails_normalized ON user_svc.user_emails (email_normalized);
|
|
13
|
+
CREATE UNIQUE INDEX idx_user_emails_primary ON user_svc.user_emails (user_id) WHERE is_primary;
|
|
14
|
+
CREATE INDEX idx_user_emails_user ON user_svc.user_emails (user_id);
|
|
@@ -2,20 +2,9 @@ CREATE SCHEMA IF NOT EXISTS user_svc;
|
|
|
2
2
|
|
|
3
3
|
CREATE TABLE user_svc.users (
|
|
4
4
|
id UUID PRIMARY KEY,
|
|
5
|
-
email VARCHAR(255) NOT NULL,
|
|
6
5
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
|
7
6
|
avatar_url TEXT NOT NULL DEFAULT '',
|
|
8
|
-
email_verified BOOLEAN NOT NULL DEFAULT false,
|
|
9
7
|
role VARCHAR(20) NOT NULL DEFAULT 'staff',
|
|
10
8
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
11
9
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
12
10
|
);
|
|
13
|
-
|
|
14
|
-
-- A unique INDEX, not an inline UNIQUE constraint, and named to match the
|
|
15
|
-
-- `uniqueIndex:idx_users_email` tag on the outbound persistence model exactly. Postgres names an
|
|
16
|
-
-- inline UNIQUE "users_email_key"; the development bootstrap then sees
|
|
17
|
-
-- uniqueness it didn't create and tries to drop it under its own
|
|
18
|
-
-- name, which fails the boot with
|
|
19
|
-
-- constraint "uni_users_email" of relation "users" does not exist
|
|
20
|
-
-- for anyone who ran `make migrate-up` first — which `add rbac` tells you to.
|
|
21
|
-
CREATE UNIQUE INDEX idx_users_email ON user_svc.users (email);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package httpadapter
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"time"
|
|
5
|
+
|
|
6
|
+
"{{goModule}}/internal/app/role/application"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Request DTOs belong to the inbound adapter. Keep JSON names and binding
|
|
10
|
+
// tags out of application inputs so role use cases remain transport-neutral.
|
|
11
|
+
type createInput struct {
|
|
12
|
+
Code string `json:"code"`
|
|
13
|
+
Name string `json:"name"`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func toCreateInput(in createInput) application.CreateInput {
|
|
17
|
+
return application.CreateInput{Code: in.Code, Name: in.Name}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type setPermissionsInput struct {
|
|
21
|
+
PermissionCodes *[]string `json:"permission_codes"`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func toSetPermissionsInput(in setPermissionsInput) application.SetPermissionsInput {
|
|
25
|
+
return application.SetPermissionsInput{PermissionCodes: in.PermissionCodes}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Response DTOs belong to the inbound adapter too. Application responses are
|
|
29
|
+
// deliberately free of JSON tags and are mapped explicitly before encoding.
|
|
30
|
+
type roleResponse struct {
|
|
31
|
+
Code string `json:"code"`
|
|
32
|
+
Name string `json:"name"`
|
|
33
|
+
IsSystem bool `json:"is_system"`
|
|
34
|
+
Permissions []string `json:"permissions"`
|
|
35
|
+
CreatedAt time.Time `json:"created_at"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func toRoleResponse(out application.RoleResponse) roleResponse {
|
|
39
|
+
return roleResponse{
|
|
40
|
+
Code: out.Code, Name: out.Name, IsSystem: out.IsSystem,
|
|
41
|
+
Permissions: out.Permissions, CreatedAt: out.CreatedAt,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type permissionResponse struct {
|
|
46
|
+
Code string `json:"code"`
|
|
47
|
+
Description string `json:"description"`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func toPermissionResponse(out application.PermissionResponse) permissionResponse {
|
|
51
|
+
return permissionResponse{Code: out.Code, Description: out.Description}
|
|
52
|
+
}
|
|
@@ -45,44 +45,45 @@ func (h *Handler) list(c *gin.Context) {
|
|
|
45
45
|
c.Error(toHTTPError(err))
|
|
46
46
|
return
|
|
47
47
|
}
|
|
48
|
-
out := make([]
|
|
48
|
+
out := make([]roleResponse, len(items))
|
|
49
49
|
for i := range items {
|
|
50
|
-
out[i] = application.ToRoleResponse(items[i])
|
|
50
|
+
out[i] = toRoleResponse(application.ToRoleResponse(items[i]))
|
|
51
51
|
}
|
|
52
52
|
c.JSON(http.StatusOK, p.Response(out))
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
func (h *Handler) create(c *gin.Context) {
|
|
56
|
-
var in
|
|
56
|
+
var in createInput
|
|
57
57
|
if err := c.ShouldBindJSON(&in); err != nil {
|
|
58
58
|
c.Error(httpx.BindErr(err))
|
|
59
59
|
return
|
|
60
60
|
}
|
|
61
|
-
item, err := h.svc.Create(c.Request.Context(), in)
|
|
61
|
+
item, err := h.svc.Create(c.Request.Context(), toCreateInput(in))
|
|
62
62
|
if err != nil {
|
|
63
63
|
c.Error(toHTTPError(err))
|
|
64
64
|
return
|
|
65
65
|
}
|
|
66
|
-
c.JSON(http.StatusCreated, application.ToRoleResponse(*item))
|
|
66
|
+
c.JSON(http.StatusCreated, toRoleResponse(application.ToRoleResponse(*item)))
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
func (h *Handler) setPermissions(c *gin.Context) {
|
|
70
|
-
var in
|
|
70
|
+
var in setPermissionsInput
|
|
71
71
|
if err := c.ShouldBindJSON(&in); err != nil {
|
|
72
72
|
c.Error(httpx.BindErr(err))
|
|
73
73
|
return
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
appInput := toSetPermissionsInput(in)
|
|
76
|
+
if appInput.PermissionCodes == nil {
|
|
76
77
|
c.Error(apperror.NewValidation("invalid role input", map[string]string{"permission_codes": "is required"}))
|
|
77
78
|
return
|
|
78
79
|
}
|
|
79
|
-
item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *
|
|
80
|
+
item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *appInput.PermissionCodes)
|
|
80
81
|
if err != nil {
|
|
81
82
|
c.Error(toHTTPError(err))
|
|
82
83
|
return
|
|
83
84
|
}
|
|
84
85
|
h.authz.Invalidate(item.Role.Code)
|
|
85
|
-
c.JSON(http.StatusOK, application.ToRoleResponse(*item))
|
|
86
|
+
c.JSON(http.StatusOK, toRoleResponse(application.ToRoleResponse(*item)))
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
func (h *Handler) delete(c *gin.Context) {
|
|
@@ -101,9 +102,9 @@ func (h *Handler) listPermissions(c *gin.Context) {
|
|
|
101
102
|
c.Error(toHTTPError(err))
|
|
102
103
|
return
|
|
103
104
|
}
|
|
104
|
-
out := make([]
|
|
105
|
+
out := make([]permissionResponse, len(items))
|
|
105
106
|
for i := range items {
|
|
106
|
-
out[i] = application.ToPermissionResponse(items[i])
|
|
107
|
+
out[i] = toPermissionResponse(application.ToPermissionResponse(items[i]))
|
|
107
108
|
}
|
|
108
109
|
c.JSON(http.StatusOK, p.Response(out))
|
|
109
110
|
}
|
|
@@ -9,12 +9,12 @@ import (
|
|
|
9
9
|
const PermRoleManage = "role:manage"
|
|
10
10
|
|
|
11
11
|
type CreateInput struct {
|
|
12
|
-
Code string
|
|
13
|
-
Name string
|
|
12
|
+
Code string
|
|
13
|
+
Name string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
type SetPermissionsInput struct {
|
|
17
|
-
PermissionCodes *[]string
|
|
17
|
+
PermissionCodes *[]string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
type RoleListItem struct {
|
|
@@ -23,16 +23,16 @@ type RoleListItem struct {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
type RoleResponse struct {
|
|
26
|
-
Code string
|
|
27
|
-
Name string
|
|
28
|
-
IsSystem bool
|
|
29
|
-
Permissions []string
|
|
30
|
-
CreatedAt time.Time
|
|
26
|
+
Code string
|
|
27
|
+
Name string
|
|
28
|
+
IsSystem bool
|
|
29
|
+
Permissions []string
|
|
30
|
+
CreatedAt time.Time
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
type PermissionResponse struct {
|
|
34
|
-
Code string
|
|
35
|
-
Description string
|
|
34
|
+
Code string
|
|
35
|
+
Description string
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
func ToRoleResponse(item RoleListItem) RoleResponse {
|
|
@@ -187,13 +187,19 @@ approval before changing their contract.
|
|
|
187
187
|
refresh tokens or token hashes. The current access token carries the session
|
|
188
188
|
ID as `sid` so the adapter can mark the current device.
|
|
189
189
|
- Authenticated users can manage login identities through
|
|
190
|
-
`GET /users/me/identities`, `POST /users/me/identities
|
|
190
|
+
`GET /users/me/identities`, `POST /users/me/identities/local`, and
|
|
191
|
+
`POST /users/me/identities/:provider/link`,
|
|
191
192
|
`POST /users/me/identities/:provider/link/exchange`, and
|
|
192
193
|
`DELETE /users/me/identities/:provider`. The link transaction is bound to
|
|
193
194
|
the current user as well as provider/state/PKCE/nonce; it can never create a
|
|
194
195
|
session or silently move a provider account between users. Return only safe
|
|
195
196
|
provider metadata, allow at most one identity per provider per user, and
|
|
196
197
|
refuse to unlink the last remaining login method.
|
|
198
|
+
- Auth persistence keeps account profile, email addresses, password
|
|
199
|
+
credentials, and external provider identities in separate tables. Resolve an
|
|
200
|
+
external login by `(issuer, subject)`; never use an email address as the
|
|
201
|
+
provider identity key. Adding a password to a Google/OIDC account must write
|
|
202
|
+
a password credential for that existing user.
|
|
197
203
|
- Password reset and email verification consume a one-time token in the same
|
|
198
204
|
retry-safe transaction as the user/identity update. A failed post-commit
|
|
199
205
|
session revocation must not make a successful reset impossible to retry.
|
|
@@ -201,7 +201,9 @@ timestamps are returned.
|
|
|
201
201
|
They can also list safe login-provider metadata with
|
|
202
202
|
`GET /users/me/identities`, link a provider through the authenticated
|
|
203
203
|
`/users/me/identities/:provider/link` start/exchange flow, and unlink a
|
|
204
|
-
provider with `DELETE /users/me/identities/:provider
|
|
204
|
+
provider with `DELETE /users/me/identities/:provider`. For a Google/OIDC-only
|
|
205
|
+
account, `POST /users/me/identities/local` adds an email/password credential to
|
|
206
|
+
the same account; it does not create a second user. The last login method
|
|
205
207
|
cannot be removed.
|
|
206
208
|
For `cross-site`, configure HTTPS origins, `COOKIE_SAMESITE=none`,
|
|
207
209
|
`COOKIE_SECURE=true`, and an exact allowed Origin; the API applies an Origin
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
DROP TABLE IF EXISTS user_svc.identities;
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
CREATE TABLE user_svc.identities (
|
|
2
|
-
id UUID PRIMARY KEY,
|
|
3
|
-
user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
|
|
4
|
-
provider VARCHAR(20) NOT NULL,
|
|
5
|
-
password_hash TEXT,
|
|
6
|
-
provider_uid TEXT,
|
|
7
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
8
|
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
9
|
-
);
|
|
10
|
-
|
|
11
|
-
-- Named unique indexes matching model.Identity's own
|
|
12
|
-
-- `uniqueIndex:idx_identities_*` tags — see create_users.up.sql for why an
|
|
13
|
-
-- anonymous UNIQUE constraint breaks schema parity.
|
|
14
|
-
CREATE UNIQUE INDEX idx_identities_user_provider ON user_svc.identities (user_id, provider);
|
|
15
|
-
CREATE UNIQUE INDEX idx_identities_provider_uid ON user_svc.identities (provider, provider_uid);
|