From 33afe1eddfec9c6ffedf112edfcbb302d76d75e3 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Tue, 31 Mar 2026 10:44:04 +0900 Subject: [PATCH 01/10] fix(auth): separate pkce and headless trusted rp config --- backend/internal/domain/hydra_models.go | 49 ++++++++- backend/internal/domain/hydra_models_test.go | 22 ++++ backend/internal/handler/auth_handler.go | 8 +- .../handler/auth_handler_link_test.go | 18 ++-- .../handler/auth_handler_login_test.go | 25 +++-- backend/internal/handler/dev_handler.go | 101 ++++++++++++++++-- backend/internal/handler/dev_handler_test.go | 14 +-- .../features/clients/ClientGeneralPage.tsx | 99 +++++++++++++---- 8 files changed, 274 insertions(+), 62 deletions(-) diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go index 0923d9a4..e00f58ab 100644 --- a/backend/internal/domain/hydra_models.go +++ b/backend/internal/domain/hydra_models.go @@ -1,6 +1,16 @@ package domain -import "time" +import ( + "strings" + "time" +) + +const ( + MetadataHeadlessLoginEnabled = "headless_login_enabled" + MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method" + MetadataHeadlessJWKSURI = "headless_jwks_uri" + MetadataHeadlessJWKS = "headless_jwks" +) type HydraClient struct { ClientID string `json:"client_id"` @@ -20,11 +30,42 @@ type HydraClient struct { func (c *HydraClient) IsTrustedRP() bool { // A Trusted RP must have a public key registered (URI or Inline) // and use private_key_jwt for token endpoint authentication. - hasPublicKey := c.JWKSUri != "" || c.JWKS != nil - isPrivateKeyJwt := c.TokenEndpointAuthMethod == "private_key_jwt" + hasPublicKey := c.HeadlessJWKSURI() != "" || c.HeadlessJWKS() != nil + isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt" return hasPublicKey && isPrivateKeyJwt } +func (c *HydraClient) HeadlessTokenEndpointAuthMethod() string { + if c.Metadata != nil { + if raw, ok := c.Metadata[MetadataHeadlessTokenEndpointAuthMethod].(string); ok { + if value := strings.TrimSpace(raw); value != "" { + return value + } + } + } + return strings.TrimSpace(c.TokenEndpointAuthMethod) +} + +func (c *HydraClient) HeadlessJWKSURI() string { + if c.Metadata != nil { + if raw, ok := c.Metadata[MetadataHeadlessJWKSURI].(string); ok { + if value := strings.TrimSpace(raw); value != "" { + return value + } + } + } + return strings.TrimSpace(c.JWKSUri) +} + +func (c *HydraClient) HeadlessJWKS() interface{} { + if c.Metadata != nil { + if value, ok := c.Metadata[MetadataHeadlessJWKS]; ok && value != nil { + return value + } + } + return c.JWKS +} + func (c *HydraClient) IsHeadlessLoginEnabled() bool { if !c.IsTrustedRP() { return false @@ -32,7 +73,7 @@ func (c *HydraClient) IsHeadlessLoginEnabled() bool { if c.Metadata == nil { return false } - val, ok := c.Metadata["headless_login_enabled"] + val, ok := c.Metadata[MetadataHeadlessLoginEnabled] if !ok { return false } diff --git a/backend/internal/domain/hydra_models_test.go b/backend/internal/domain/hydra_models_test.go index 7d1af640..317ada11 100644 --- a/backend/internal/domain/hydra_models_test.go +++ b/backend/internal/domain/hydra_models_test.go @@ -3,6 +3,28 @@ package domain import "testing" func TestHydraClient_TrustedRPFlags(t *testing.T) { + t.Run("metadata-backed headless trusted rp is supported", func(t *testing.T) { + client := HydraClient{ + TokenEndpointAuthMethod: "none", + Metadata: map[string]any{ + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks": map[string]any{ + "keys": []map[string]any{{ + "kty": "RSA", + }}, + }, + }, + } + + if !client.IsTrustedRP() { + t.Fatalf("expected metadata-backed trusted rp") + } + if !client.IsHeadlessLoginEnabled() { + t.Fatalf("expected metadata-backed headless login enabled") + } + }) + t.Run("inline jwks with private_key_jwt and headless enabled", func(t *testing.T) { client := HydraClient{ TokenEndpointAuthMethod: "private_key_jwt", diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index bd961dd8..62943600 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1699,14 +1699,14 @@ func containsHeadlessAudience(expected []string, actual headlessAssertionAud) bo func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient) (*jose.JSONWebKeySet, error) { var raw []byte switch { - case client.JWKS != nil: - data, err := json.Marshal(client.JWKS) + case client.HeadlessJWKS() != nil: + data, err := json.Marshal(client.HeadlessJWKS()) if err != nil { return nil, fmt.Errorf("failed to encode jwks: %w", err) } raw = data - case strings.TrimSpace(client.JWKSUri) != "": - req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(client.JWKSUri), nil) + case client.HeadlessJWKSURI() != "": + req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.HeadlessJWKSURI(), nil) if err != nil { return nil, fmt.Errorf("failed to build jwks request: %w", err) } diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index 08b663d3..39c28c6d 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -171,11 +171,12 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) { Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKS: jwks, + TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ - "status": "active", - "headless_login_enabled": true, + "status": "active", + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks": jwks, }, }, }) @@ -232,11 +233,12 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKS: jwks, + TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ - "status": "active", - "headless_login_enabled": true, + "status": "active", + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks": jwks, }, }, }) diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 4cdf17c5..391841e5 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -306,11 +306,12 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) { Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKSUri: jwksServer.URL + "/.well-known/jwks.json", + TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ - "status": "active", - "headless_login_enabled": true, + "status": "active", + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", }, }, }) @@ -524,10 +525,11 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) { Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKSUri: "https://rp.example.com/.well-known/jwks.json", + TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ - "status": "active", + "status": "active", + "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", + "headless_token_endpoint_auth_method": "private_key_jwt", }, }, }) @@ -576,11 +578,12 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) { Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "other-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKSUri: "https://rp.example.com/.well-known/jwks.json", + TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ - "status": "active", - "headless_login_enabled": true, + "status": "active", + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", }, }, }) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index c96e6fda..1fddc6d9 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -891,6 +891,13 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { tokenAuthMethod = "client_secret_basic" } } + tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig( + clientType, + tokenAuthMethod, + valueOr(req.JwksUri, ""), + req.Jwks, + metadata, + ) clientReq := domain.HydraClient{ ClientID: clientID, @@ -900,8 +907,8 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { ResponseTypes: responseTypes, Scope: strings.Join(scopes, " "), TokenEndpointAuthMethod: tokenAuthMethod, - JWKSUri: valueOr(req.JwksUri, ""), - JWKS: req.Jwks, + JWKSUri: jwksURI, + JWKS: jwks, Metadata: metadata, } @@ -1044,6 +1051,23 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { } metadata["status"] = status } + resolvedClientType := currentSummary.Type + if clientType != "" { + resolvedClientType = clientType + } + resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod) + resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri) + resolvedJWKS := req.Jwks + if req.Jwks == nil { + resolvedJWKS = current.JWKS + } + resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig( + resolvedClientType, + resolvedTokenAuthMethod, + resolvedJWKSURI, + resolvedJWKS, + metadata, + ) updated := domain.HydraClient{ ClientID: current.ClientID, @@ -1052,14 +1076,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes), ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes), Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))), - TokenEndpointAuthMethod: resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod), - JWKSUri: valueOr(req.JwksUri, current.JWKSUri), - JWKS: req.Jwks, + TokenEndpointAuthMethod: resolvedTokenAuthMethod, + JWKSUri: resolvedJWKSURI, + JWKS: resolvedJWKS, Metadata: metadata, } - if req.Jwks == nil { - updated.JWKS = current.JWKS - } if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { return errorJSON(c, fiber.StatusForbidden, err.Error()) } @@ -1676,6 +1697,70 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { } } +func readMetadataStringValue(metadata map[string]interface{}, key string) string { + if metadata == nil { + return "" + } + raw, _ := metadata[key].(string) + return strings.TrimSpace(raw) +} + +func readMetadataBoolValue(metadata map[string]interface{}, key string) bool { + if metadata == nil { + return false + } + value, _ := metadata[key].(bool) + return value +} + +func normalizeHeadlessClientConfig( + clientType string, + tokenAuthMethod string, + jwksURI string, + jwks interface{}, + metadata map[string]interface{}, +) (string, string, interface{}, map[string]interface{}) { + if metadata == nil { + metadata = map[string]interface{}{} + } + + headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) + if clientType == "pkce" && headlessEnabled { + headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod) + if headlessTokenAuthMethod == "" && !strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "none") { + headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod) + } + if headlessTokenAuthMethod == "" { + headlessTokenAuthMethod = "private_key_jwt" + } + metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod + + headlessJWKSURI := readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI) + if headlessJWKSURI == "" && strings.TrimSpace(jwksURI) != "" { + headlessJWKSURI = strings.TrimSpace(jwksURI) + } + if headlessJWKSURI != "" { + metadata[domain.MetadataHeadlessJWKSURI] = headlessJWKSURI + } else { + delete(metadata, domain.MetadataHeadlessJWKSURI) + } + + if _, ok := metadata[domain.MetadataHeadlessJWKS]; !ok && jwks != nil { + metadata[domain.MetadataHeadlessJWKS] = jwks + } + if metadata[domain.MetadataHeadlessJWKS] == nil { + delete(metadata, domain.MetadataHeadlessJWKS) + } + + return "none", "", nil, metadata + } + + delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod) + delete(metadata, domain.MetadataHeadlessJWKSURI) + delete(metadata, domain.MetadataHeadlessJWKS) + return tokenAuthMethod, jwksURI, jwks, metadata +} + func defaultClientScopes() []string { return []string{"openid", "profile", "email"} } diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 334c3e57..7b9fbb20 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -676,9 +676,10 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) { resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) - assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) - assert.NotNil(t, captured.JWKS) - assert.True(t, captured.IsTrustedRP()) + assert.Equal(t, "none", captured.TokenEndpointAuthMethod) + assert.Nil(t, captured.JWKS) + assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"]) + assert.NotNil(t, captured.Metadata["headless_jwks"]) assert.True(t, captured.IsHeadlessLoginEnabled()) assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"]) @@ -754,9 +755,10 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) { resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) - assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) - assert.True(t, captured.IsTrustedRP()) + assert.Equal(t, "none", captured.TokenEndpointAuthMethod) + assert.Equal(t, "", captured.JWKSUri) + assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"]) + assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"]) assert.True(t, captured.IsHeadlessLoginEnabled()) assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) } diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index fce5a6ac..18ba1210 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -72,6 +72,17 @@ function readMetadataString( return typeof value === "string" ? value : ""; } +function readMetadataObject( + metadata: Record, + key: string, +): Record | undefined { + const value = metadata[key]; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + function isValidUrl(value: string): boolean { try { const url = new URL(value); @@ -150,15 +161,42 @@ function ClientGeneralPage() { setStatus(client.status); setInitialStatus(client.status); + const metadata = client.metadata ?? {}; + if (typeof metadata.description === "string") + setDescription(metadata.description); + if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url); + + const headlessEnabled = !!metadata.headless_login_enabled; + setHeadlessLoginEnabled(headlessEnabled); + const savedAuthMethod = client.tokenEndpointAuthMethod || (client.type === "pkce" ? "none" : "client_secret_basic"); - if (isTokenEndpointAuthMethod(savedAuthMethod)) { - setTokenEndpointAuthMethod(savedAuthMethod); + const headlessAuthMethod = readMetadataString( + metadata, + "headless_token_endpoint_auth_method", + ); + const selectedAuthMethod = + headlessEnabled && isTokenEndpointAuthMethod(headlessAuthMethod) + ? headlessAuthMethod + : savedAuthMethod; + if (isTokenEndpointAuthMethod(selectedAuthMethod)) { + setTokenEndpointAuthMethod(selectedAuthMethod); } - if (client.jwksUri) { + const headlessJwksUri = readMetadataString(metadata, "headless_jwks_uri"); + const headlessJwks = readMetadataObject(metadata, "headless_jwks"); + if (headlessJwksUri) { + setJwksUri(headlessJwksUri); + setJwksText(""); + setJwksSource("uri"); + } else if (headlessJwks) { + setJwksText(JSON.stringify(headlessJwks, null, 2)); + setJwksUri(""); + setJwksSource("inline"); + } else if (client.jwksUri) { setJwksUri(client.jwksUri); + setJwksText(""); setJwksSource("uri"); } else if (client.jwks) { setJwksText( @@ -166,18 +204,16 @@ function ClientGeneralPage() { ? client.jwks : JSON.stringify(client.jwks, null, 2), ); + setJwksUri(""); + setJwksSource("inline"); + } else { + setJwksUri(""); + setJwksText(""); setJwksSource("inline"); } - const metadata = client.metadata ?? {}; - if (typeof metadata.description === "string") - setDescription(metadata.description); - if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url); - - setHeadlessLoginEnabled(!!metadata.headless_login_enabled); - // Fallbacks from metadata if top-level fields are empty - if (!client.tokenEndpointAuthMethod) { + if (!client.tokenEndpointAuthMethod && !headlessEnabled) { const metaAuth = readMetadataString( metadata, "token_endpoint_auth_method", @@ -187,7 +223,7 @@ function ClientGeneralPage() { } } - if (!client.jwksUri && !client.jwks) { + if (!client.jwksUri && !client.jwks && !headlessEnabled) { const metaJwksUri = readMetadataString(metadata, "jwks_uri"); if (metaJwksUri) { setJwksUri(metaJwksUri); @@ -342,11 +378,7 @@ function ClientGeneralPage() { const scopeNames = scopes.map((scope) => scope.name).filter(Boolean); let finalJwks: ClientUpsertRequest["jwks"]; - if ( - tokenEndpointAuthMethod === "private_key_jwt" && - jwksSource === "inline" && - trimmedJwksText - ) { + if (jwksSource === "inline" && trimmedJwksText) { try { finalJwks = JSON.parse(trimmedJwksText); } catch (e) { @@ -354,23 +386,48 @@ function ClientGeneralPage() { } } + const effectiveTokenEndpointAuthMethod = + clientType === "pkce" && headlessLoginEnabled + ? "none" + : tokenEndpointAuthMethod; + const payload: ClientUpsertRequest = { name, type: clientType, scopes: scopeNames, - tokenEndpointAuthMethod, + tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod, jwksUri: - tokenEndpointAuthMethod === "private_key_jwt" && jwksSource === "uri" + effectiveTokenEndpointAuthMethod === "private_key_jwt" && + jwksSource === "uri" ? trimmedJwksUri : undefined, - jwks: finalJwks, + jwks: + effectiveTokenEndpointAuthMethod === "private_key_jwt" + ? finalJwks + : undefined, metadata: { description, logo_url: logoUrl, structured_scopes: scopes, - token_endpoint_auth_method: tokenEndpointAuthMethod, + token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, request_object_signing_alg: trimmedRequestObjectSigningAlg, headless_login_enabled: headlessLoginEnabled, + headless_token_endpoint_auth_method: + clientType === "pkce" && headlessLoginEnabled + ? tokenEndpointAuthMethod + : undefined, + headless_jwks_uri: + clientType === "pkce" && + headlessLoginEnabled && + jwksSource === "uri" + ? trimmedJwksUri + : undefined, + headless_jwks: + clientType === "pkce" && + headlessLoginEnabled && + jwksSource === "inline" + ? finalJwks + : undefined, }, }; From 468ca475edb449818f04dc3d0ed6b3226cac4de4 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 31 Mar 2026 10:15:06 +0900 Subject: [PATCH 02/10] =?UTF-8?q?=EB=B3=B8=EC=9D=B8=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/users/UserDetailPage.tsx | 383 +++++++++++++++++- adminfront/src/locales/en.toml | 16 + adminfront/src/locales/ko.toml | 16 + adminfront/src/locales/template.toml | 16 + 4 files changed, 410 insertions(+), 21 deletions(-) diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index fc5730fe..de2e495b 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -5,6 +5,8 @@ import { BadgeCheck, Building2, Copy, + Eye, + EyeOff, Key, Loader2, Mail, @@ -32,16 +34,19 @@ import { } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; import { toast } from "../../components/ui/use-toast"; import { type UserSummary, type UserUpdateRequest, deleteUser, + fetchPasswordPolicy, fetchMe, fetchTenants, fetchUser, updateUser, } from "../../lib/adminApi"; +import type { PasswordPolicyResponse } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import { generateSecurePassword } from "../../lib/utils"; @@ -58,6 +63,116 @@ type UserFormValues = Omit & { metadata: Record>; }; +type PasswordResetMode = "generated" | "manual"; + +const PASSWORD_RESET_MIN_LENGTH = 12; + +function buildPasswordPolicyDescription(policy?: PasswordPolicyResponse) { + const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH; + const minTypes = policy?.minCharacterTypes ?? 0; + const requiresLower = policy?.lowercase ?? true; + const requiresUpper = policy?.uppercase ?? false; + const requiresNumber = policy?.number ?? true; + const requiresSymbol = policy?.nonAlphanumeric ?? true; + + const parts = [ + t("msg.userfront.signup.policy.min_length", "최소 {{count}}자 이상", { + count: String(minLength), + }), + ]; + + if (minTypes > 0) { + parts.push( + t( + "msg.userfront.signup.policy.min_types", + "영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상", + { count: String(minTypes) }, + ), + ); + } + if (requiresLower) { + parts.push(t("msg.userfront.signup.policy.lowercase", "소문자")); + } + if (requiresUpper) { + parts.push(t("msg.userfront.signup.policy.uppercase", "대문자")); + } + if (requiresNumber) { + parts.push(t("msg.userfront.signup.policy.number", "숫자")); + } + if (requiresSymbol) { + parts.push(t("msg.userfront.signup.policy.symbol", "특수문자")); + } + + return parts.join(", "); +} + +function validateManualPassword( + password: string, + policy?: PasswordPolicyResponse, +) { + if (password.trim().length === 0) { + return t( + "msg.admin.users.detail.password_manual_required", + "비밀번호를 입력해 주세요.", + ); + } + + const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH; + if (password.length < minLength) { + return t( + "msg.userfront.reset.error.min_length", + "비밀번호는 최소 {{count}}자 이상이어야 합니다.", + { count: String(minLength) }, + ); + } + + const hasLower = /[a-z]/.test(password); + const hasUpper = /[A-Z]/.test(password); + const hasNumber = /[0-9]/.test(password); + const hasSymbol = /[\W_]/.test(password); + let typeCount = 0; + if (hasLower) typeCount++; + if (hasUpper) typeCount++; + if (hasNumber) typeCount++; + if (hasSymbol) typeCount++; + + const minTypes = policy?.minCharacterTypes ?? 0; + if (minTypes > 0 && typeCount < minTypes) { + return t( + "msg.userfront.reset.error.min_types", + "비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.", + { count: String(minTypes) }, + ); + } + + if ((policy?.lowercase ?? true) && !hasLower) { + return t( + "msg.userfront.reset.error.lowercase", + "최소 1개 이상의 소문자를 포함해야 합니다.", + ); + } + if ((policy?.uppercase ?? false) && !hasUpper) { + return t( + "msg.userfront.reset.error.uppercase", + "최소 1개 이상의 대문자를 포함해야 합니다.", + ); + } + if ((policy?.number ?? true) && !hasNumber) { + return t( + "msg.userfront.reset.error.number", + "최소 1개 이상의 숫자를 포함해야 합니다.", + ); + } + if ((policy?.nonAlphanumeric ?? true) && !hasSymbol) { + return t( + "msg.userfront.reset.error.symbol", + "최소 1개 이상의 특수문자를 포함해야 합니다.", + ); + } + + return null; +} + function TenantMetadataFields({ tenant, schema, @@ -166,6 +281,15 @@ function UserDetailPage() { const [generatedPassword, setGeneratedPassword] = React.useState< string | null >(null); + const [passwordResetMode, setPasswordResetMode] = + React.useState("generated"); + const [manualPassword, setManualPassword] = React.useState(""); + const [manualPasswordConfirm, setManualPasswordConfirm] = React.useState(""); + const [isManualPasswordVisible, setIsManualPasswordVisible] = + React.useState(false); + const [passwordResetError, setPasswordResetError] = React.useState< + string | null + >(null); const { data: profile } = useQuery({ queryKey: ["me"], @@ -187,6 +311,10 @@ function UserDetailPage() { queryFn: () => fetchTenants(100, 0), }); const tenants = tenantsData?.items ?? []; + const { data: passwordPolicy, isLoading: isPasswordPolicyLoading } = useQuery({ + queryKey: ["password-policy"], + queryFn: fetchPasswordPolicy, + }); const { register, @@ -211,11 +339,13 @@ function UserDetailPage() { const isAdmin = profile?.role === "super_admin" || profile?.role === "tenant_admin"; + const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id); const resetPasswordMutation = useMutation({ mutationFn: (newPass: string) => updateUser(userId, { password: newPass }), onSuccess: (_, newPass) => { setGeneratedPassword(newPass); + setPasswordResetError(null); toast.success( t( "msg.admin.users.detail.password_generated", @@ -224,20 +354,67 @@ function UserDetailPage() { ); }, onError: (err: AxiosError<{ error?: string }>) => { - toast.error( + const message = err.response?.data?.error || - t("msg.admin.users.detail.update_error", "수정에 실패했습니다."), - ); + t("msg.admin.users.detail.update_error", "수정에 실패했습니다."); + setPasswordResetError(message); + toast.error(message); }, }); - const handleGeneratePassword = () => { + const handleOpenPasswordReset = () => { + if (isSelf) { + return; + } setIsPasswordResetOpen(true); setGeneratedPassword(null); + setPasswordResetMode("generated"); + setManualPassword(""); + setManualPasswordConfirm(""); + setIsManualPasswordVisible(false); + setPasswordResetError(null); }; - const confirmGeneratePassword = () => { - const newPass = generateSecurePassword(); + const handleClosePasswordReset = () => { + setIsPasswordResetOpen(false); + setGeneratedPassword(null); + setPasswordResetMode("generated"); + setManualPassword(""); + setManualPasswordConfirm(""); + setIsManualPasswordVisible(false); + setPasswordResetError(null); + }; + + const confirmPasswordReset = () => { + if (isSelf) { + return; + } + + let newPass = manualPassword; + + if (passwordResetMode === "manual") { + const validationError = validateManualPassword( + manualPassword, + passwordPolicy, + ); + if (validationError) { + setPasswordResetError(validationError); + return; + } + if (manualPassword !== manualPasswordConfirm) { + setPasswordResetError( + t( + "msg.userfront.reset.error.mismatch", + "비밀번호가 일치하지 않습니다.", + ), + ); + return; + } + } else { + newPass = generateSecurePassword(); + } + + setPasswordResetError(null); resetPasswordMutation.mutate(newPass); }; @@ -717,7 +894,7 @@ function UserDetailPage() { -
+

{t( @@ -726,44 +903,205 @@ function UserDetailPage() { )}

- 사용자의 비밀번호를 강제로 재설정하고 새 비밀번호를 - 생성합니다. + {t( + "msg.admin.users.detail.reset_password_help", + "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다.", + )}

-
- {isPasswordResetOpen && !generatedPassword && ( -
-

+ {isSelf && ( +

+

{t( - "msg.admin.users.detail.reset_password_confirm", - "정말로 이 사용자의 비밀번호를 초기화하시겠습니까? 기존 비밀번호로는 즉시 로그인할 수 없게 됩니다.", + "msg.admin.users.detail.self_password_reset_blocked", + "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요.", )}

+
+ )} + + {isPasswordResetOpen && !generatedPassword && !isSelf && ( +
+ { + setPasswordResetMode(value as PasswordResetMode); + setPasswordResetError(null); + }} + > + + + {t( + "ui.admin.users.detail.password_mode_generated", + "자동 생성", + )} + + + {t( + "ui.admin.users.detail.password_mode_manual", + "수동 입력", + )} + + + +

+ {t( + "msg.admin.users.detail.password_generated_help", + "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다.", + )} +

+
+ +

+ {isPasswordPolicyLoading + ? t( + "msg.userfront.signup.policy.loading", + "비밀번호 정책을 불러오는 중입니다...", + ) + : t( + "msg.userfront.signup.policy.summary", + "보안 정책: {{rules}}", + { + rules: buildPasswordPolicyDescription( + passwordPolicy, + ), + }, + )} +

+
+
+ { + setManualPassword(event.target.value); + if (passwordResetError) { + setPasswordResetError(null); + } + }} + /> + + +
+
+
+
+ { + setManualPasswordConfirm(event.target.value); + if (passwordResetError) { + setPasswordResetError(null); + } + }} + /> + + +
+
+
+
+ {passwordResetError && ( +

+ {passwordResetError} +

+ )}
@@ -774,7 +1112,10 @@ function UserDetailPage() {

- Generated Password + {t( + "ui.admin.users.detail.password_result_title", + "Reset Password", + )}

{generatedPassword} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index dbca6282..a4b19113 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -288,6 +288,12 @@ name_required = "Name Required" [msg.admin.users.detail.security] password_hint = "Password Hint" +password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately." +password_manual_help = "Enter and apply a password with at least {{count}} characters." +password_manual_min_length = "Password must be at least {{count}} characters long." +password_manual_required = "Please enter a password." +reset_password_help = "Force-reset the user's password and apply either an auto-generated password or a manually entered one." +self_password_reset_blocked = "Please change your own password from the UserFront settings page." [msg.admin.users.list] delete_confirm = "Delete Confirm" @@ -1094,6 +1100,16 @@ password = "Password" password_placeholder = "Password Placeholder" title = "Security Settings" +[ui.admin.users.detail] +manual_password = "New Password" +manual_password_placeholder = "Enter a new password" +password_mode_generated = "Auto Generate" +password_mode_manual = "Manual Input" +password_result_title = "Reset Password" +reset_password = "Reset & Set" +reset_password_apply = "Apply Password" +toggle_password_visibility = "Toggle password visibility" + [ui.admin.users.detail.tenants_section] additional = "Additional Affiliated/Manageable Tenants" primary = "Representative Affiliated Tenant" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 843717bc..92ad133a 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -288,6 +288,12 @@ name_required = "이름은 필수입니다." [msg.admin.users.detail.security] password_hint = "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다." +password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다." +password_manual_help = "최소 {{count}}자 이상의 비밀번호를 직접 입력해 적용합니다." +password_manual_min_length = "비밀번호는 최소 {{count}}자 이상이어야 합니다." +password_manual_required = "비밀번호를 입력해 주세요." +reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다." +self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요." [msg.admin.users.list] delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?" @@ -1094,6 +1100,16 @@ password = "비밀번호 변경" password_placeholder = "변경할 경우에만 입력" title = "보안 설정" +[ui.admin.users.detail] +manual_password = "새 비밀번호" +manual_password_placeholder = "새 비밀번호를 입력하세요" +password_mode_generated = "자동 생성" +password_mode_manual = "수동 입력" +password_result_title = "Reset Password" +reset_password = "초기화 및 설정" +reset_password_apply = "비밀번호 적용" +toggle_password_visibility = "비밀번호 표시 전환" + [ui.admin.users.detail.tenants_section] additional = "추가 소속/관리 테넌트" primary = "대표 소속 테넌트" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 853b4355..5766218e 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -288,6 +288,12 @@ name_required = "" [msg.admin.users.detail.security] password_hint = "" +password_generated_help = "" +password_manual_help = "" +password_manual_min_length = "" +password_manual_required = "" +reset_password_help = "" +self_password_reset_blocked = "" [msg.admin.users.list] delete_confirm = "" @@ -1094,6 +1100,16 @@ password = "" password_placeholder = "" title = "" +[ui.admin.users.detail] +manual_password = "" +manual_password_placeholder = "" +password_mode_generated = "" +password_mode_manual = "" +password_result_title = "" +reset_password = "" +reset_password_apply = "" +toggle_password_visibility = "" + [ui.admin.users.detail.tenants_section] additional = "" primary = "" From 4d8b9d9f879f7f671807d2852e596983c7d1cc43 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 31 Mar 2026 10:17:27 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/reset_password_screen.dart | 5 +- .../presentation/pages/profile_page.dart | 132 ++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart index 938831a2..f2951e1c 100644 --- a/userfront/lib/features/auth/presentation/reset_password_screen.dart +++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart @@ -68,6 +68,7 @@ class _ResetPasswordScreenState extends State { } Future _handlePasswordReset() async { + if (_isLoading) return; if (_formKey.currentState?.validate() != true) return; if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) { @@ -76,6 +77,7 @@ class _ResetPasswordScreenState extends State { } setState(() => _isLoading = true); + bool isSuccess = false; try { await AuthProxyService.completePasswordReset( @@ -84,6 +86,7 @@ class _ResetPasswordScreenState extends State { newPassword: _passwordController.text, ); + isSuccess = true; if (mounted) { ToastService.success(tr('msg.userfront.reset.success')); context.go(buildLocalizedSigninPath(Uri.base)); @@ -98,7 +101,7 @@ class _ResetPasswordScreenState extends State { ); } } finally { - if (mounted) { + if (mounted && !isSuccess) { setState(() => _isLoading = false); } } diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index c489e2a8..fb29e7d7 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -5,6 +5,7 @@ import 'package:logging/logging.dart'; import 'package:userfront/i18n.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/i18n/locale_utils.dart'; +import '../../../../core/services/auth_proxy_service.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/toast_service.dart'; @@ -54,10 +55,80 @@ class _ProfilePageState extends ConsumerState { bool _showCurrentPassword = false; bool _showNewPassword = false; bool _showConfirmPassword = false; + Map? _passwordPolicy; + bool _isPasswordPolicyLoading = false; @override void initState() { super.initState(); + _loadPasswordPolicy(); + } + + Future _loadPasswordPolicy() async { + setState(() { + _isPasswordPolicyLoading = true; + }); + try { + final policy = await AuthProxyService.fetchPasswordPolicy(); + if (mounted) { + setState(() { + _passwordPolicy = policy; + }); + } + } catch (_) { + // 정책 조회 실패 시 기본 검증 규칙 사용 + } finally { + if (mounted) { + setState(() { + _isPasswordPolicyLoading = false; + }); + } + } + } + + String _buildPasswordPolicyDescription() { + if (_isPasswordPolicyLoading) { + return tr('msg.userfront.signup.policy.loading'); + } + + final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12; + final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0; + final requiresLower = _passwordPolicy?['lowercase'] ?? true; + final requiresUpper = _passwordPolicy?['uppercase'] ?? false; + final requiresNumber = _passwordPolicy?['number'] ?? true; + final requiresSymbol = _passwordPolicy?['nonAlphanumeric'] ?? true; + + final parts = [ + tr( + 'msg.userfront.signup.policy.min_length', + params: {'count': '$minLength'}, + ), + ]; + if (minTypes > 0) { + parts.add( + tr( + 'msg.userfront.signup.policy.min_types', + params: {'count': '$minTypes'}, + ), + ); + } + if (requiresLower) { + parts.add(tr('msg.userfront.signup.policy.lowercase')); + } + if (requiresUpper) { + parts.add(tr('msg.userfront.signup.policy.uppercase')); + } + if (requiresNumber) { + parts.add(tr('msg.userfront.signup.policy.number')); + } + if (requiresSymbol) { + parts.add(tr('msg.userfront.signup.policy.symbol')); + } + + return tr( + 'msg.userfront.signup.policy.summary', + params: {'rules': parts.join(", ")}, + ); } void _debugLog( @@ -267,6 +338,62 @@ class _ProfilePageState extends ConsumerState { ); return; } + + final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12; + final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0; + final hasLower = RegExp(r'[a-z]').hasMatch(newPassword); + final hasUpper = RegExp(r'[A-Z]').hasMatch(newPassword); + final hasNumber = RegExp(r'[0-9]').hasMatch(newPassword); + final hasSymbol = RegExp(r'[\W_]').hasMatch(newPassword); + int typeCount = 0; + if (hasLower) typeCount++; + if (hasUpper) typeCount++; + if (hasNumber) typeCount++; + if (hasSymbol) typeCount++; + + if (newPassword.length < minLength) { + setState( + () => _passwordError = tr( + 'msg.userfront.reset.error.min_length', + params: {'count': '$minLength'}, + ), + ); + return; + } + if (minTypes > 0 && typeCount < minTypes) { + setState( + () => _passwordError = tr( + 'msg.userfront.reset.error.min_types', + params: {'count': '$minTypes'}, + ), + ); + return; + } + if ((_passwordPolicy?['lowercase'] ?? true) && !hasLower) { + setState( + () => _passwordError = tr('msg.userfront.reset.error.lowercase'), + ); + return; + } + if ((_passwordPolicy?['uppercase'] ?? false) && !hasUpper) { + setState( + () => _passwordError = tr('msg.userfront.reset.error.uppercase'), + ); + return; + } + if ((_passwordPolicy?['number'] ?? true) && !hasNumber) { + setState( + () => _passwordError = tr('msg.userfront.reset.error.number'), + ); + return; + } + if ((_passwordPolicy?['nonAlphanumeric'] ?? true) && !hasSymbol) { + setState( + () => _passwordError = tr('msg.userfront.reset.error.symbol'), + ); + return; + } + if (newPassword != confirmPassword) { setState( () => _passwordError = tr('msg.userfront.profile.password.mismatch'), @@ -853,6 +980,11 @@ class _ProfilePageState extends ConsumerState { tr('msg.userfront.profile.password.subtitle'), style: const TextStyle(color: Color(0xFF6B7280)), ), + const SizedBox(height: 8), + Text( + _buildPasswordPolicyDescription(), + style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12), + ), const SizedBox(height: 16), TextField( controller: _currentPasswordController, From 2364ff59d2386158ac5327d34131345722a410ef Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 31 Mar 2026 10:28:27 +0900 Subject: [PATCH 04/10] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B3=80=EA=B2=BD=EC=9D=84=20Kra?= =?UTF-8?q?tos=20=ED=95=B4=EC=8B=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/lib/adminApi.ts | 16 +++ backend/internal/handler/user_handler.go | 22 +++- backend/internal/handler/user_handler_test.go | 111 ++++++++++++++++++ .../internal/service/kratos_admin_service.go | 80 ++++++++++--- backend/internal/service/ory_service.go | 88 ++++++++++++-- backend/internal/service/ory_service_test.go | 60 ++++++++-- 6 files changed, 335 insertions(+), 42 deletions(-) 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: From df145b295731fc1ca6a75ecfba41d10b5a4fa194 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 31 Mar 2026 10:44:37 +0900 Subject: [PATCH 05/10] =?UTF-8?q?Trusted=20RP=20=EB=AA=85=EC=B9=AD?= =?UTF-8?q?=EC=9D=84=20Headless=20Login=EC=9C=BC=EB=A1=9C=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientGeneralPage.tsx | 6 +++--- devfront/src/locales/en.toml | 10 +++++----- devfront/src/locales/ko.toml | 8 ++++---- devfront/tests/devfront-clients-lifecycle.spec.ts | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 18ba1210..752aa731 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -976,7 +976,7 @@ function ClientGeneralPage() { > {t( "ui.dev.clients.general.security.trusted_rp_enable", - "Trusted RP (자체 로그인 UI 사용)", + "Headless Login (자체 로그인 UI 사용)", )}

@@ -998,7 +998,7 @@ function ClientGeneralPage() { - {/* 4. Public Key Registration (Trusted RP) */} + {/* 4. Public Key Registration (Headless Login) */} {clientType === "pkce" && headlessLoginEnabled && ( @@ -1013,7 +1013,7 @@ function ClientGeneralPage() { {t( "msg.dev.clients.general.public_key.subtitle", - "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.", + "Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다.", )}

diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 74fa2cf2..600a137b 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -390,12 +390,12 @@ subtitle = "Define the permission scopes this application can request." private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers." pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory." subtitle = "Select application type. Security level determines authentication method." -trusted_help = "Operate as a trusted RP using private_key_jwt and public key registration. Headless login is only available for this profile." +trusted_help = "Operate as Headless Login using private_key_jwt and public key registration. Headless login is only available for this profile." [msg.dev.clients.general.public_key] auth_method_client_secret_basic_help = "Standard authentication method for server-side applications." auth_method_none_help = "Use this for PKCE-based public clients." -auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for trusted RP bootstrap and JAR verification." +auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for Headless Login bootstrap and JAR verification." guide_example = "Recommended example: https://rp.example.com/.well-known/jwks.json" guide_intro = "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key." guide_step_1 = "Generate a key pair on the RP server and keep the private key only in the RP backend." @@ -406,7 +406,7 @@ jwks_inline_help = "Prefer the SSH-RSA public key format first. If you paste an jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json" request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login." source_help = "Register the JWKS URI served by the RP so Baron can verify the public key." -subtitle = "Manage the public key and headless login settings required for trusted RP evaluation." +subtitle = "Manage the public key and headless login settings required for Headless Login evaluation." [msg.dev.clients.general.public_key.validation] headless_requires_alg = "Headless login requires a Request Object Signing Algorithm." @@ -1392,9 +1392,9 @@ delete = "Delete" [ui.dev.clients.general.security] private = "Server Side App" pkce = "PKCE" -trusted = "Trusted RP" +trusted = "Headless Login" title = "Security Settings" -trusted_rp_enable = "Trusted RP (Custom Login UI)" +trusted_rp_enable = "Headless Login (Custom Login UI)" trusted_rp_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page." [ui.dev.clients.general.public_key] diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 55d174e6..bc1670c7 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -390,12 +390,12 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다." pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다." private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다." subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다." -trusted_help = "private_key_jwt와 공개키 등록을 사용해 trusted RP로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다." +trusted_help = "private_key_jwt와 공개키 등록을 사용해 Headless Login으로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다." [msg.dev.clients.general.public_key] auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다." auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다." -auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다." +auth_method_private_key_jwt_help = "Headless Login bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다." guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json" guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다." guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다." @@ -406,7 +406,7 @@ jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json" request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다." source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다." -subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다." +subtitle = "Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다." [msg.dev.clients.general.public_key.validation] headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다." @@ -1393,7 +1393,7 @@ delete = "삭제" private = "Server side App" pkce = "PKCE" title = "보안 설정" -trusted_rp_enable = "Trusted RP (자체 로그인 UI 사용)" +trusted_rp_enable = "Headless Login (자체 로그인 UI 사용)" trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다." diff --git a/devfront/tests/devfront-clients-lifecycle.spec.ts b/devfront/tests/devfront-clients-lifecycle.spec.ts index 689df680..31af52f1 100644 --- a/devfront/tests/devfront-clients-lifecycle.spec.ts +++ b/devfront/tests/devfront-clients-lifecycle.spec.ts @@ -123,7 +123,7 @@ test.describe("DevFront clients lifecycle", () => { ).toHaveValue(/https:\/\/after\.example\.com\/callback/); }); - test("pkce trusted rp with inline ssh-rsa key should persist mapped payload", async ({ + test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({ page, }) => { const state = { @@ -139,7 +139,7 @@ test.describe("DevFront clients lifecycle", () => { await page .getByRole("switch", { - name: /Trusted RP \(자체 로그인 UI 사용\)|Trusted RP \(Custom Login UI\)/i, + name: /Headless Login \(자체 로그인 UI 사용\)|Headless Login \(Custom Login UI\)/i, }) .click(); From 68114eea661ef0ecc52b3b8b9d1ddbdc49cdb797 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 31 Mar 2026 11:17:55 +0900 Subject: [PATCH 06/10] =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95=20=EC=A4=91=EB=B3=B5=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=9A=94=EC=B2=AD=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 4 + backend/internal/domain/sms_models.go | 1 + backend/internal/handler/auth_handler.go | 49 +++-- .../handler/auth_handler_link_test.go | 24 ++- backend/internal/handler/auth_handler_test.go | 193 ++++++++++++++++++ backend/internal/handler/common_test.go | 2 + backend/internal/service/sms_service.go | 41 +++- backend/internal/service/sms_service_test.go | 26 +++ 8 files changed, 309 insertions(+), 31 deletions(-) create mode 100644 backend/internal/service/sms_service_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 61ff20d2..3610600b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -539,8 +539,12 @@ func main() { auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage) + auth.Get("/password/reset/v/:token", authHandler.VerifyPasswordResetPage) + auth.Get("/password/reset/ve", authHandler.VerifyPasswordResetPage) // [Added] Use POST for actual verification triggered by the user auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken) + auth.Post("/password/reset/v/:token", authHandler.ProcessPasswordResetToken) + auth.Post("/password/reset/ve", authHandler.ProcessPasswordResetToken) auth.Post("/password/reset/complete", authHandler.CompletePasswordReset) auth.Get("/password/policy", authHandler.GetPasswordPolicy) auth.Post("/sms", authHandler.SendSms) diff --git a/backend/internal/domain/sms_models.go b/backend/internal/domain/sms_models.go index 53956273..c12fcd45 100644 --- a/backend/internal/domain/sms_models.go +++ b/backend/internal/domain/sms_models.go @@ -11,6 +11,7 @@ type NaverSmsRequest struct { ContentType string `json:"contentType"` CountryCode string `json:"countryCode"` From string `json:"from"` + Subject string `json:"subject,omitempty"` Content string `json:"content"` Messages []SmsMessage `json:"messages"` } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 62943600..f2653faa 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -66,18 +66,20 @@ const ( loginFlowLink = "link" // Durations - defaultExpiration = 5 * time.Minute - signupStateExpiration = 10 * time.Minute - signupBlockDuration = 10 * time.Minute - maxSignupFailures = 5 - emailCodeTTL = 5 * time.Minute - smsCodeTTL = 3 * time.Minute - prefixPwdResetToken = "pwdreset_token:" - pwdResetExpiration = 15 * time.Minute - minPollInterval = 2 * time.Second - loginCodeExpiration = 10 * time.Minute - linkResendCooldown = 60 * time.Second - prefixDrySend = "dry_send:" + defaultExpiration = 5 * time.Minute + signupStateExpiration = 10 * time.Minute + signupBlockDuration = 10 * time.Minute + maxSignupFailures = 5 + emailCodeTTL = 5 * time.Minute + smsCodeTTL = 3 * time.Minute + prefixPwdResetToken = "pwdreset_token:" + prefixPwdResetUsed = "pwdreset_used:" + pwdResetExpiration = 15 * time.Minute + pwdResetUsedExpiration = 2 * time.Minute + minPollInterval = 2 * time.Second + loginCodeExpiration = 10 * time.Minute + linkResendCooldown = 60 * time.Second + prefixDrySend = "dry_send:" headlessJWKSFetchTTL = 5 * time.Second ) @@ -2368,9 +2370,9 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { } userfrontURL := h.resolveUserfrontURL(c) - // [Changed] Point to Backend API for verification (which then redirects to Frontend) - redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL) - ale.RedirectTo = redirectURL + // 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다. + // 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다. + verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL) // 내부 토큰 발급 + 우리 채널로 전송 resetToken := GenerateSecureToken(32) @@ -2390,7 +2392,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token") } - resetLink := fmt.Sprintf("%s/reset-password?token=%s", userfrontURL, resetToken) + resetLink := fmt.Sprintf("%s/%s", verifyBaseURL, resetToken) ale.RedirectTo = resetLink ale.Operation = "SendPasswordReset" ale.Log(slog.LevelInfo, "Initiating password reset via internal token") @@ -2456,6 +2458,9 @@ func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error { if token == "" { token = c.Query("t") } + if token == "" { + token = c.Params("token") + } if token == "" { return c.Status(fiber.StatusBadRequest).SendString("Missing token") @@ -2509,6 +2514,9 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { token = c.Query("t") } } + if token == "" { + token = c.Params("token") + } ale.Token = token if token == "" { @@ -2583,6 +2591,14 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { if resetToken != "" { val, err := h.RedisService.Get(prefixPwdResetToken + resetToken) if err != nil || strings.TrimSpace(val) == "" { + if usedLoginID, usedErr := h.RedisService.Get(prefixPwdResetUsed + resetToken); usedErr == nil && strings.TrimSpace(usedLoginID) != "" { + ale.Status = fiber.StatusOK + ale.LatencyMs = time.Since(startTime) + ale.Token = resetToken + ale.LoginIDs["loginId"] = strings.TrimSpace(usedLoginID) + ale.Log(slog.LevelInfo, "Duplicate reset completion ignored after successful use") + return c.JSON(fiber.Map{"message": "Password has been reset successfully."}) + } ale.Status = fiber.StatusUnauthorized ale.LatencyMs = time.Since(startTime) ale.ProviderError = "Invalid or expired reset token" @@ -2652,6 +2668,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID)) if resetToken != "" { _ = h.RedisService.Delete(prefixPwdResetToken + resetToken) + _ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration) } return c.JSON(fiber.Map{"message": "Password has been reset successfully."}) } diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index 39c28c6d..69387188 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -16,13 +16,29 @@ import ( ) // Mock services -type mockEmailService struct{} +type mockEmailService struct { + lastTo string + lastSubject string + lastBody string +} -func (m *mockEmailService) SendEmail(to, subject, body string) error { return nil } +func (m *mockEmailService) SendEmail(to, subject, body string) error { + m.lastTo = to + m.lastSubject = subject + m.lastBody = body + return nil +} -type mockSmsService struct{} +type mockSmsService struct { + lastTo string + lastContent string +} -func (m *mockSmsService) SendSms(to, content string) error { return nil } +func (m *mockSmsService) SendSms(to, content string) error { + m.lastTo = to + m.lastContent = content + return nil +} func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App { app := fiber.New() diff --git a/backend/internal/handler/auth_handler_test.go b/backend/internal/handler/auth_handler_test.go index 87a10b64..11ddbf63 100644 --- a/backend/internal/handler/auth_handler_test.go +++ b/backend/internal/handler/auth_handler_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -254,6 +255,65 @@ func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *test } } +func TestCompletePasswordReset_DuplicateTokenSubmitIsIdempotent(t *testing.T) { + const resetToken = "dup-token" + const loginID = "user@example.com" + const newPassword = "StrongPass1!" + + redis := &testRedisRepo{ + values: map[string]string{ + prefixPwdResetToken + resetToken: loginID, + }, + } + idp := &mockIdpProvider{ + userExists: true, + err: nil, + } + h := &AuthHandler{ + RedisService: redis, + IdpProvider: idp, + } + app := newResetFlowTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "newPassword": newPassword, + }) + url := fmt.Sprintf( + "/api/v1/auth/password/reset/complete?token=%s", + resetToken, + ) + + firstReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + firstReq.Header.Set("Content-Type", "application/json") + firstResp, err := app.Test(firstReq) + if err != nil { + t.Fatalf("first request failed: %v", err) + } + defer firstResp.Body.Close() + + if firstResp.StatusCode != http.StatusOK { + t.Fatalf("expected first response to be 200, got %d", firstResp.StatusCode) + } + if idp.updateCallCount != 1 { + t.Fatalf("expected first request to update password once, got %d", idp.updateCallCount) + } + + secondReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + secondReq.Header.Set("Content-Type", "application/json") + secondResp, err := app.Test(secondReq) + if err != nil { + t.Fatalf("second request failed: %v", err) + } + defer secondResp.Body.Close() + + if secondResp.StatusCode != http.StatusOK { + t.Fatalf("expected duplicate response to be 200, got %d", secondResp.StatusCode) + } + if idp.updateCallCount != 1 { + t.Fatalf("expected duplicate request not to update password again, got %d", idp.updateCallCount) + } +} + func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) { const token = "tok-enc" const loginID = "user+alias@example.com" @@ -295,6 +355,102 @@ func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) { } } +func TestPasswordResetVerifyAlias_AcceptsShortVePath(t *testing.T) { + const token = "tok-ve" + const loginID = "user@example.com" + + redis := &testRedisRepo{ + values: map[string]string{ + prefixPwdResetToken + token: loginID, + }, + } + h := &AuthHandler{ + RedisService: redis, + } + + app := fiber.New() + app.Get("/api/v1/auth/password/reset/ve", h.VerifyPasswordResetPage) + app.Post("/api/v1/auth/password/reset/ve", h.ProcessPasswordResetToken) + + getReq := httptest.NewRequest( + http.MethodGet, + "/api/v1/auth/password/reset/ve?token="+token, + nil, + ) + getResp, err := app.Test(getReq) + if err != nil { + t.Fatalf("get request failed: %v", err) + } + defer getResp.Body.Close() + + if getResp.StatusCode != http.StatusOK { + t.Fatalf("expected alias GET to return 200, got %d", getResp.StatusCode) + } + + postReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/password/reset/ve?token="+token, + nil, + ) + postResp, err := app.Test(postReq) + if err != nil { + t.Fatalf("post request failed: %v", err) + } + defer postResp.Body.Close() + + if postResp.StatusCode != http.StatusFound { + t.Fatalf("expected alias POST to return 302, got %d", postResp.StatusCode) + } +} + +func TestPasswordResetVerifyPathToken_AcceptsShortVPath(t *testing.T) { + const token = "tok-path" + const loginID = "user@example.com" + + redis := &testRedisRepo{ + values: map[string]string{ + prefixPwdResetToken + token: loginID, + }, + } + h := &AuthHandler{ + RedisService: redis, + } + + app := fiber.New() + app.Get("/api/v1/auth/password/reset/v/:token", h.VerifyPasswordResetPage) + app.Post("/api/v1/auth/password/reset/v/:token", h.ProcessPasswordResetToken) + + getReq := httptest.NewRequest( + http.MethodGet, + "/api/v1/auth/password/reset/v/"+token, + nil, + ) + getResp, err := app.Test(getReq) + if err != nil { + t.Fatalf("get request failed: %v", err) + } + defer getResp.Body.Close() + + if getResp.StatusCode != http.StatusOK { + t.Fatalf("expected path-token GET to return 200, got %d", getResp.StatusCode) + } + + postReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/password/reset/v/"+token, + nil, + ) + postResp, err := app.Test(postReq) + if err != nil { + t.Fatalf("post request failed: %v", err) + } + defer postResp.Body.Close() + + if postResp.StatusCode != http.StatusFound { + t.Fatalf("expected path-token POST to return 302, got %d", postResp.StatusCode) + } +} + func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) { h := &AuthHandler{} app := newResetInitAppWithErrorCodeEnricher(h) @@ -326,3 +482,40 @@ func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) t.Fatalf("expected code=bad_request, got %v", got["code"]) } } + +func TestInitiatePasswordReset_SmsContainsVerifyLink(t *testing.T) { + t.Setenv("USERFRONT_URL", "https://sss.hmac.kr") + + redis := &testRedisRepo{values: map[string]string{}} + smsSvc := &mockSmsService{} + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{}, + SmsService: smsSvc, + } + + app := fiber.New() + app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset) + + body, _ := json.Marshal(map[string]string{ + "loginId": "01012345678", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if !strings.Contains(smsSvc.lastContent, "/api/v1/auth/password/reset/v/") { + t.Fatalf("expected SMS to contain short path verify link, got %q", smsSvc.lastContent) + } + if strings.Contains(smsSvc.lastContent, "/reset-password?token=") { + t.Fatalf("expected direct reset-password link to be removed, got %q", smsSvc.lastContent) + } +} diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 73cbdc73..32bd1d21 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -21,6 +21,7 @@ type mockIdpProvider struct { err error initiateLinkErr error updateCalled bool + updateCallCount int updatedLoginID string updatedPassword string } @@ -68,6 +69,7 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { m.updateCalled = true + m.updateCallCount++ m.updatedLoginID = loginID m.updatedPassword = newPassword return m.err diff --git a/backend/internal/service/sms_service.go b/backend/internal/service/sms_service.go index c50a739a..986a1706 100644 --- a/backend/internal/service/sms_service.go +++ b/backend/internal/service/sms_service.go @@ -17,6 +17,8 @@ import ( "time" ) +const naverSMSMaxBytes = 90 + type SmsServiceImpl struct { accessKey string secretKey string @@ -46,17 +48,11 @@ func (s *SmsServiceImpl) SendSms(to, content string) error { // Naver SENS API requires phone number without '+' sanitizedTo := strings.Replace(to, "+", "", 1) - reqBody := domain.NaverSmsRequest{ - Type: "SMS", - ContentType: "COMM", - CountryCode: "82", - From: s.senderPhone, - Content: content, - Messages: []domain.SmsMessage{ - { - To: sanitizedTo, - }, - }, + reqBody := buildNaverSmsRequest(s.senderPhone, sanitizedTo, content) + if reqBody.Type == "LMS" { + slog.Info("[SmsService] Upgrading message type to LMS due to content length", + "bytes", len([]byte(content)), + ) } jsonBody, err := json.Marshal(reqBody) @@ -100,6 +96,29 @@ func (s *SmsServiceImpl) SendSms(to, content string) error { return nil } +func buildNaverSmsRequest(senderPhone, sanitizedTo, content string) domain.NaverSmsRequest { + requestType := "SMS" + subject := "" + if len([]byte(content)) > naverSMSMaxBytes { + requestType = "LMS" + subject = "[Baron 로그인]" + } + + return domain.NaverSmsRequest{ + Type: requestType, + ContentType: "COMM", + CountryCode: "82", + From: senderPhone, + Subject: subject, + Content: content, + Messages: []domain.SmsMessage{ + { + To: sanitizedTo, + }, + }, + } +} + func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) { space := " " newLine := "\n" diff --git a/backend/internal/service/sms_service_test.go b/backend/internal/service/sms_service_test.go new file mode 100644 index 00000000..c9b2f1b9 --- /dev/null +++ b/backend/internal/service/sms_service_test.go @@ -0,0 +1,26 @@ +package service + +import "testing" + +func TestBuildNaverSmsRequest_UsesSMSForShortContent(t *testing.T) { + req := buildNaverSmsRequest("0262857755", "821012345678", "123456") + + if req.Type != "SMS" { + t.Fatalf("expected SMS, got %s", req.Type) + } + if req.Subject != "" { + t.Fatalf("expected empty subject for SMS, got %q", req.Subject) + } +} + +func TestBuildNaverSmsRequest_UsesLMSForLongContent(t *testing.T) { + content := "[Baron 로그인] 비밀번호 재설정 링크: http://sso-test.hmac.kr/api/v1/auth/password/reset/v/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + req := buildNaverSmsRequest("0262857755", "821012345678", content) + + if req.Type != "LMS" { + t.Fatalf("expected LMS, got %s", req.Type) + } + if req.Subject == "" { + t.Fatal("expected LMS subject to be set") + } +} From 98bb6be54957bc50f01fe52178e26b3b41f7b51e Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 31 Mar 2026 11:45:00 +0900 Subject: [PATCH 07/10] =?UTF-8?q?code=20check=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/users/UserDetailPage.tsx | 37 +++++++++++-------- .../internal/service/kratos_admin_service.go | 18 ++++----- locales/en.toml | 9 +++++ locales/ko.toml | 9 +++++ locales/template.toml | 9 +++++ .../presentation/pages/profile_page.dart | 8 +--- 6 files changed, 60 insertions(+), 30 deletions(-) diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index de2e495b..f53aa1ac 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -34,7 +34,12 @@ import { } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "../../components/ui/tabs"; import { toast } from "../../components/ui/use-toast"; import { type UserSummary, @@ -311,10 +316,12 @@ function UserDetailPage() { queryFn: () => fetchTenants(100, 0), }); const tenants = tenantsData?.items ?? []; - const { data: passwordPolicy, isLoading: isPasswordPolicyLoading } = useQuery({ - queryKey: ["password-policy"], - queryFn: fetchPasswordPolicy, - }); + const { data: passwordPolicy, isLoading: isPasswordPolicyLoading } = useQuery( + { + queryKey: ["password-policy"], + queryFn: fetchPasswordPolicy, + }, + ); const { register, @@ -978,9 +985,10 @@ function UserDetailPage() { "msg.userfront.signup.policy.summary", "보안 정책: {{rules}}", { - rules: buildPasswordPolicyDescription( - passwordPolicy, - ), + rules: + buildPasswordPolicyDescription( + passwordPolicy, + ), }, )}

@@ -988,9 +996,7 @@ function UserDetailPage() {
- {t("ui.userfront.reset.new_password", "새 비밀번호")} + {t( + "ui.userfront.reset.new_password", + "새 비밀번호", + )}