diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index aa8d52ab..91831c41 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -499,6 +499,22 @@ export async function updateUser(userId: string, payload: UserUpdateRequest) { return data; } +export type PasswordPolicyResponse = { + minLength?: number; + lowercase?: boolean; + uppercase?: boolean; + number?: boolean; + nonAlphanumeric?: boolean; + minCharacterTypes?: number; +}; + +export async function fetchPasswordPolicy() { + const { data } = await apiClient.get( + "/v1/auth/password/policy", + ); + return data; +} + export async function deleteUser(userId: string) { await apiClient.delete(`/v1/admin/users/${userId}`); } diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 3c469c7b..d9b919f8 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -1203,12 +1203,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } } - finalLoginID := extractTraitString(traits, "id") + explicitLoginID := strings.TrimSpace(extractTraitString(traits, "id")) userEmail := extractTraitString(traits, "email") - userPhone := extractTraitString(traits, "phone") - if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil { + userPhone := extractTraitString(traits, "phone_number") + if err := domain.ValidateLoginID(explicitLoginID, userEmail, userPhone); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } + finalLoginID := resolvePasswordLoginID(traits) state := normalizeKratosState(req.Status) @@ -1234,7 +1235,10 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } if req.Password != nil && *req.Password != "" { - if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil { + if h.OryProvider == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available") + } + if err := h.OryProvider.UpdateUserPassword(finalLoginID, *req.Password, nil); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } @@ -1508,6 +1512,16 @@ func extractTraitString(traits map[string]interface{}, key string) string { return "" } +func resolvePasswordLoginID(traits map[string]interface{}) string { + if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" { + return loginID + } + if email := strings.TrimSpace(extractTraitString(traits, "email")); email != "" { + return email + } + return strings.TrimSpace(extractTraitString(traits, "phone_number")) +} + // syncLoginID ensures that the 'id' trait (used as Kratos identifier) is in sync with the configured custom field. func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) { if loginIDField == "" || loginIDField == "id" { diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 58d88eb6..945db35e 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -488,6 +488,117 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) { }) } +func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockOry := new(MockOryProvider) + mockTenant := new(MockTenantServiceForUser) + h := &UserHandler{ + KratosAdmin: mockKratos, + OryProvider: mockOry, + TenantService: mockTenant, + } + + app.Put("/users/:id", func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin}) + return h.UpdateUser(c) + }) + + userID := "u-1" + mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "id": "dyddus1210", + "email": "dyddus1210@gmail.com", + "companyCode": "test-tenant", + }, + }, nil).Once() + + mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{ + ID: "t-1", + Slug: "test-tenant", + }, nil) + mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once() + + mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { + return traits["id"] == "dyddus1210" + }), "").Return(&service.KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "id": "dyddus1210", + "email": "dyddus1210@gmail.com", + }, + }, nil).Once() + + mockOry.On("UpdateUserPassword", "dyddus1210", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once() + + payload := map[string]interface{}{ + "password": "asdfzxcv1234!", + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req) + assert.Equal(t, 200, resp.StatusCode) + mockOry.AssertExpectations(t) + mockKratos.AssertNotCalled(t, "UpdateIdentityPassword", mock.Anything, mock.Anything, mock.Anything) +} + +func TestUserHandler_UpdateUser_PasswordFallsBackToEmail(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockOry := new(MockOryProvider) + mockTenant := new(MockTenantServiceForUser) + h := &UserHandler{ + KratosAdmin: mockKratos, + OryProvider: mockOry, + TenantService: mockTenant, + } + + app.Put("/users/:id", func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin}) + return h.UpdateUser(c) + }) + + userID := "u-2" + mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "email": "dyddus1210@gmail.com", + "companyCode": "test-tenant", + }, + }, nil).Once() + + mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{ + ID: "t-1", + Slug: "test-tenant", + }, nil) + mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once() + + mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { + return traits["email"] == "dyddus1210@gmail.com" + }), "").Return(&service.KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "email": "dyddus1210@gmail.com", + }, + }, nil).Once() + + mockOry.On("UpdateUserPassword", "dyddus1210@gmail.com", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once() + + payload := map[string]interface{}{ + "password": "asdfzxcv1234!", + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req) + assert.Equal(t, 200, resp.StatusCode) + mockOry.AssertExpectations(t) +} + func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) { t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) { app := fiber.New() diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go index d5dce360..0ea81b5e 100644 --- a/backend/internal/service/kratos_admin_service.go +++ b/backend/internal/service/kratos_admin_service.go @@ -11,14 +11,20 @@ import ( "os" "strings" "time" + + "golang.org/x/crypto/bcrypt" ) type KratosIdentity struct { - ID string `json:"id"` - Traits map[string]interface{} `json:"traits"` - State string `json:"state,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID string `json:"id"` + SchemaID string `json:"schema_id,omitempty"` + Traits map[string]interface{} `json:"traits"` + State string `json:"state,omitempty"` + MetadataAdmin interface{} `json:"metadata_admin,omitempty"` + MetadataPublic interface{} `json:"metadata_public,omitempty"` + ExternalID string `json:"external_id,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } type KratosAdminService interface { @@ -172,20 +178,54 @@ func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID stri } func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { - patchOps := []map[string]interface{}{ - { - "op": "add", - "path": "/credentials/password/config/password", - "value": newPassword, - }, - } - body, _ := json.Marshal(patchOps) - endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body)) + identity, err := s.GetIdentity(ctx, identityID) if err != nil { return err } - req.Header.Set("Content-Type", "application/json-patch+json") + if identity == nil { + return fmt.Errorf("kratos admin identity not found: %s", identityID) + } + + hashedPassword, err := hashPasswordForKratosAdmin(newPassword) + if err != nil { + return err + } + + payload := map[string]interface{}{ + "schema_id": identity.SchemaID, + "traits": identity.Traits, + "state": identity.State, + "credentials": map[string]interface{}{ + "password": map[string]interface{}{ + "config": map[string]string{ + "hashed_password": hashedPassword, + }, + }, + }, + } + if payload["schema_id"] == "" { + payload["schema_id"] = "default" + } + if payload["state"] == "" { + payload["state"] = "active" + } + if identity.MetadataAdmin != nil { + payload["metadata_admin"] = identity.MetadataAdmin + } + if identity.MetadataPublic != nil { + payload["metadata_public"] = identity.MetadataPublic + } + if identity.ExternalID != "" { + payload["external_id"] = identity.ExternalID + } + + body, _ := json.Marshal(payload) + endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") resp, err := s.httpClient().Do(req) if err != nil { @@ -199,6 +239,14 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit return nil } +func hashPasswordForKratosAdmin(password string) (string, error) { + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hashed), nil +} + func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error { endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go index a4e639cb..88dbffd6 100644 --- a/backend/internal/service/ory_service.go +++ b/backend/internal/service/ory_service.go @@ -14,6 +14,8 @@ import ( "os" "strings" "time" + + "golang.org/x/crypto/bcrypt" ) // OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다. @@ -711,20 +713,53 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID) } - patchOps := []map[string]interface{}{ - { - "op": "add", - "path": "/credentials/password/config/password", - "value": newPassword, - }, + identity, err := o.getIdentity(identityID) + if err != nil { + return fmt.Errorf("ory provider: load identity failed: %w", err) + } + if identity == nil { + return fmt.Errorf("ory provider: identity payload missing for loginID=%s", loginID) } - body, _ := json.Marshal(patchOps) - req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) + hashedPassword, err := hashPasswordForKratos(newPassword) + if err != nil { + return fmt.Errorf("ory provider: hash password failed: %w", err) + } + + payload := map[string]interface{}{ + "schema_id": identity.SchemaID, + "traits": identity.Traits, + "state": identity.State, + "credentials": map[string]interface{}{ + "password": map[string]interface{}{ + "config": map[string]string{ + "hashed_password": hashedPassword, + }, + }, + }, + } + if payload["schema_id"] == "" { + payload["schema_id"] = "default" + } + if payload["state"] == "" { + payload["state"] = "active" + } + if identity.MetadataAdmin != nil { + payload["metadata_admin"] = identity.MetadataAdmin + } + if identity.MetadataPublic != nil { + payload["metadata_public"] = identity.MetadataPublic + } + if identity.ExternalID != "" { + payload["external_id"] = identity.ExternalID + } + + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) if err != nil { return fmt.Errorf("ory provider: build request failed: %w", err) } - req.Header.Set("Content-Type", "application/json-patch+json") + req.Header.Set("Content-Type", "application/json") resp, err := o.httpClient().Do(req) if err != nil { @@ -789,6 +824,41 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) { return identities[0].ID, nil } +func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil) + if err != nil { + return nil, err + } + + resp, err := o.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("ory provider: get identity failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var identity KratosIdentity + if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { + return nil, err + } + return &identity, nil +} + +func hashPasswordForKratos(password string) (string, error) { + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hashed), nil +} + func (o *OryProvider) httpClient() *http.Client { if o.HTTPClient != nil { return o.HTTPClient diff --git a/backend/internal/service/ory_service_test.go b/backend/internal/service/ory_service_test.go index 314f9eb0..f7791089 100644 --- a/backend/internal/service/ory_service_test.go +++ b/backend/internal/service/ory_service_test.go @@ -45,18 +45,38 @@ func TestUpdateUserPassword_Success(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet: - q := r.URL.Query() - if got := q.Get("credentials_identifier"); got != loginID { - t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got) + if r.URL.Path == "/admin/identities" { + q := r.URL.Query() + if got := q.Get("credentials_identifier"); got != loginID { + t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got) + } + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"id": identityID}, + }) + return } - _ = json.NewEncoder(w).Encode([]map[string]string{ - {"id": identityID}, + if r.URL.Path != "/admin/identities/"+identityID { + t.Fatalf("unexpected identity lookup path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": identityID, + "schema_id": "default", + "state": "active", + "traits": map[string]interface{}{ + "email": loginID, + }, }) return - case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch: + case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPut: body, _ := io.ReadAll(r.Body) - if !strings.Contains(string(body), newPassword) { - t.Fatalf("payload missing new password, body=%s", string(body)) + if !strings.Contains(string(body), "\"hashed_password\"") { + t.Fatalf("payload missing hashed_password, body=%s", string(body)) + } + if strings.Contains(string(body), newPassword) { + t.Fatalf("payload must not contain plain password, body=%s", string(body)) + } + if !strings.Contains(string(body), "\"schema_id\":\"default\"") { + t.Fatalf("payload missing schema_id, body=%s", string(body)) } w.WriteHeader(http.StatusOK) return @@ -99,11 +119,25 @@ func TestUpdateUserPassword_ServerError(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet: - _ = json.NewEncoder(w).Encode([]map[string]string{ - {"id": "abc"}, - }) - return - case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch: + if r.URL.Path == "/admin/identities" { + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"id": "abc"}, + }) + return + } + if r.URL.Path == "/admin/identities/abc" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "abc", + "schema_id": "default", + "state": "active", + "traits": map[string]interface{}{ + "email": "user@example.com", + }, + }) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPut: http.Error(w, "boom", http.StatusInternalServerError) return default: