diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index b42827f9..b63c88c7 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -515,7 +515,7 @@ jobs: adminfront-tests: needs: lint if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }} - runs-on: playwright + runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Checkout code @@ -594,7 +594,7 @@ jobs: devfront-tests: needs: lint if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }} - runs-on: playwright + runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index 08b09822..855f72ef 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,7 @@ up-front-dev: up-infra up-ory up-backend @echo "Dev stack is up (infra + ory + backend)." # --- 종료 (Down) --- -down-all: +down: @echo "Stopping ALL stacks (infra + ory + app)..." docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down diff --git a/README.md b/README.md index 83deef35..f0485cc2 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,27 @@ **Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다. -## 버그 대응 대원칙 (필수) -- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first) -- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다. -- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다. -- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다. -- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다. +## 📂 프로젝트 구조 (Project Structure) +``` +baron_sso/ +├── backend/ # Go Fiber 애플리케이션 +│ ├── cmd/server/ # 진입점 (Entry point) +│ ├── internal/ # 도메인, 핸들러, 저장소(Repository) +│ └── Dockerfile +├── userfront/ # Flutter 애플리케이션 +│ ├── src/ # UI 및 로직 +│ └── pubspec.yaml +├── adminfront/ # React 기반 관리 +│ ├── src/ # UI 및 로직 +│ └── pubspec.yaml +├── gateway/ # Nginx 기반 Gateway (UserFront 프록시) +├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse) +├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse) +├── docker-compose.yaml # 앱 서비스 (Front, Back) +├── .env.sample # 환경 설정 템플릿 +└── README.md # 본 파일 +``` * Ory Stack으로 모든 구성요소를 self-hosting 합니다. * Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다. * Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다. @@ -20,6 +34,7 @@ * AdminFront: 사용자 관리 등 Admin 기능 * DevFront: RP 관리 등 개발자 기능 + ## 🏗 아키텍처 (Architecture) ### 0. Ory Stack @@ -88,6 +103,85 @@ flowchart 2.1 향후 App Push 등 2차 인증 강화수단 검토 필요 3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환 +### 5. Headless Login ID/Password Flow +- 목적: headless login을 허용한 클라이언트가 자체 로그인 화면에서 `ID/password`를 수집하되, Baron Backend가 OIDC 로그인 흐름만 계속 진행하고 RP에는 `sessionJwt`를 직접 넘기지 않습니다. +- 대상 엔드포인트: `POST /api/v1/auth/headless/password/login` +- 관련 구현: + - `backend/internal/handler/auth_handler.go` + - `backend/internal/domain/hydra_models.go` + - `backend/internal/handler/auth_handler_login_test.go` + +#### 호출 순서 +1. RP 브라우저가 Hydra Public의 `/oauth2/auth`를 호출해 OIDC 인증을 시작합니다. +2. Hydra가 로그인 단계로 넘긴 `login_challenge`를 RP가 확보합니다. +3. RP backend가 자기 private key로 `client_assertion` JWT를 서명합니다. +4. RP backend가 Baron Backend의 `POST /api/v1/auth/headless/password/login`에 `client_id`, `client_assertion`, `login_challenge`, `loginId`, `password`를 전송합니다. +5. Baron Backend가 Hydra login request와 RP 설정을 검증한 뒤 Kratos sign-in 및 Hydra login accept를 수행합니다. +6. 성공 시 Baron Backend는 `redirectTo`만 반환하고, RP 브라우저는 그 URL로 이동해 OIDC 흐름을 이어갑니다. + +#### 요청 바디 +```json +{ + "client_id": "headless-login-client", + "client_assertion": "", + "login_challenge": "", + "loginId": "employee001", + "password": "secret" +} +``` + +#### 성공 응답 +```json +{ + "status": "ok", + "provider": "ory", + "redirectTo": "https://rp.example.com/callback?code=..." +} +``` + +#### RP / Hydra 선행 조건 +- Hydra login request의 `client.client_id`와 요청 바디의 `client_id`가 반드시 같아야 합니다. +- client가 headless login 선행 조건을 만족해야 합니다. + - `headless_token_endpoint_auth_method == "private_key_jwt"` 또는 top-level `token_endpoint_auth_method == "private_key_jwt"` + - `headless_jwks_uri` 또는 `headless_jwks`가 존재해야 합니다. +- `headless_login_enabled == true`가 필요합니다. +- `metadata.status == "inactive"`인 client는 차단됩니다. + +#### `client_assertion` 규칙 +- 구현상 `client_assertion`은 현재 필수입니다. +- JWT claim의 `iss`와 `sub`는 모두 `client_id`와 같아야 합니다. +- `exp`는 현재 시각 이후여야 합니다. +- `nbf`, `iat`가 있으면 미래 시각이면 안 됩니다. +- `aud`는 다음 둘 중 하나와 일치해야 합니다. + - `https:///api/v1/auth/headless/password/login` + - `/api/v1/auth/headless/password/login` +- 서명 검증용 public key는 `headless_jwks_uri` 또는 `headless_jwks`에서 읽습니다. + +#### 일반 로그인과의 차이 +- `POST /api/v1/auth/password/login` + - UserFront 기본 비밀번호 로그인용입니다. + - `login_challenge`가 없으면 `sessionJwt`를 반환합니다. + - `login_challenge`가 있으면 Hydra accept 후 `redirectTo`를 반환합니다. +- `POST /api/v1/auth/headless/password/login` + - headless login 허용 클라이언트 전용입니다. + - `client_assertion` 검증이 추가됩니다. + - 항상 `sessionJwt` 없이 `redirectTo`만 반환합니다. + +#### 실패 패턴 요약 +- `400 bad_request` + - 필수 필드 누락 + - `client_assertion` 누락 +- `401 invalid_client_assertion` + - JWKS 조회 실패 + - 서명 불일치 + - `aud`/`iss`/`sub`/`exp` 검증 실패 +- `403 forbidden` + - `client_id` 불일치 + - `headless_login_enabled` 미설정 + - inactive client +- `401 password_or_email_mismatch` + - 사용자 인증 실패 + ### 전체 연결 구조도 @@ -416,34 +510,12 @@ cd devfront npm install npm run dev ``` - --- -## 📂 프로젝트 구조 (Project Structure) -``` -baron_sso/ -├── backend/ # Go Fiber 애플리케이션 -│ ├── cmd/server/ # 진입점 (Entry point) -│ ├── internal/ # 도메인, 핸들러, 저장소(Repository) -│ └── Dockerfile -├── userfront/ # Flutter 애플리케이션 -│ ├── src/ # UI 및 로직 -│ └── pubspec.yaml -├── adminfront/ # React 기반 관리 -│ ├── src/ # UI 및 로직 -│ └── pubspec.yaml -├── gateway/ # Nginx 기반 Gateway (UserFront 프록시) -├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse) -├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse) -├── docker-compose.yaml # 앱 서비스 (Front, Back) -├── .env.sample # 환경 설정 템플릿 -└── README.md # 본 파일 -``` - -## 📝 상태 및 로드맵 (Status & Roadmap) -- [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료) -- [x] **Phase 2**: Backend Audit API 구현 (일부 완료) -- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정) -- [ ] **Phase 4**: adminfront 기능 추가 (예정) -- [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정) +## 버그 대응 대원칙 (필수) +- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first) +- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다. +- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다. +- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다. +- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다. diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index fc5730fe..f53aa1ac 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,24 @@ 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 +68,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 +286,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 +316,12 @@ 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 +346,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 +361,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 +901,7 @@ function UserDetailPage() { -
+

{t( @@ -726,44 +910,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 +1119,10 @@ function UserDetailPage() {

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

{generatedPassword} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 96986673..857c038d 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -527,6 +527,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/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 = "" 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/hydra_models.go b/backend/internal/domain/hydra_models.go index 0923d9a4..965b0eb4 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"` @@ -17,22 +27,53 @@ type HydraClient struct { Metadata map[string]interface{} `json:"metadata,omitempty"` } -func (c *HydraClient) IsTrustedRP() bool { - // A Trusted RP must have a public key registered (URI or Inline) +func (c *HydraClient) SupportsHeadlessLogin() bool { + // A headless login client 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() { + if !c.SupportsHeadlessLogin() { return false } 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..dd5b25ec 100644 --- a/backend/internal/domain/hydra_models_test.go +++ b/backend/internal/domain/hydra_models_test.go @@ -2,7 +2,29 @@ package domain import "testing" -func TestHydraClient_TrustedRPFlags(t *testing.T) { +func TestHydraClient_HeadlessLoginFlags(t *testing.T) { + t.Run("metadata-backed headless login client 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.SupportsHeadlessLogin() { + t.Fatalf("expected metadata-backed headless login client") + } + 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", @@ -16,15 +38,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) { }, } - if !client.IsTrustedRP() { - t.Fatalf("expected trusted rp") + if !client.SupportsHeadlessLogin() { + t.Fatalf("expected headless login client") } if !client.IsHeadlessLoginEnabled() { t.Fatalf("expected headless login enabled") } }) - t.Run("jwks uri without private_key_jwt is not trusted", func(t *testing.T) { + t.Run("jwks uri without private_key_jwt does not support headless login", func(t *testing.T) { client := HydraClient{ TokenEndpointAuthMethod: "none", JWKSUri: "https://rp.example.com/.well-known/jwks.json", @@ -33,15 +55,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) { }, } - if client.IsTrustedRP() { - t.Fatalf("expected untrusted rp") + if client.SupportsHeadlessLogin() { + t.Fatalf("expected headless login prerequisites to be missing") } if client.IsHeadlessLoginEnabled() { - t.Fatalf("expected headless login disabled when client is not trusted") + t.Fatalf("expected headless login disabled when prerequisites are missing") } }) - t.Run("trusted rp without boolean metadata flag is not headless enabled", func(t *testing.T) { + t.Run("headless login client without boolean metadata flag is not enabled", func(t *testing.T) { client := HydraClient{ TokenEndpointAuthMethod: "private_key_jwt", JWKSUri: "https://rp.example.com/.well-known/jwks.json", @@ -50,8 +72,8 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) { }, } - if !client.IsTrustedRP() { - t.Fatalf("expected trusted rp") + if !client.SupportsHeadlessLogin() { + t.Fatalf("expected headless login client") } if client.IsHeadlessLoginEnabled() { t.Fatalf("expected headless login disabled for non-bool metadata") 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 b4b74db6..d1db34ac 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -66,19 +66,21 @@ 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:" - headlessJWKSFetchTTL = 5 * time.Second + 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 ) type AuthHandler struct { @@ -1741,14 +1743,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) } @@ -1768,7 +1770,7 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC } raw = body default: - return nil, fmt.Errorf("trusted rp public key is not configured") + return nil, fmt.Errorf("headless login public key is not configured") } var keySet jose.JSONWebKeySet @@ -1776,7 +1778,7 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC return nil, fmt.Errorf("failed to decode jwks: %w", err) } if len(keySet.Keys) == 0 { - return nil, fmt.Errorf("trusted rp jwks has no keys") + return nil, fmt.Errorf("headless login jwks has no keys") } return &keySet, nil } @@ -2410,9 +2412,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) @@ -2432,7 +2434,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") @@ -2498,6 +2500,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") @@ -2551,6 +2556,9 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { token = c.Query("t") } } + if token == "" { + token = c.Params("token") + } ale.Token = token if token == "" { @@ -2625,6 +2633,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" @@ -2694,6 +2710,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 08b663d3..f07652cd 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() @@ -156,7 +172,7 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) { assert.Equal(t, "expired_token", got["code"]) } -func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) { +func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} privateKey, jwks := mustHeadlessRSAJWK(t) @@ -170,12 +186,13 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) { _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ Challenge: "challenge-123", Client: domain.HydraClient{ - ClientID: "trusted-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKS: jwks, + ClientID: "headless-login-client", + 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, }, }, }) @@ -198,8 +215,8 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) { t.Setenv("USERFRONT_URL", "http://userfront.test") body, _ := json.Marshal(map[string]string{ - "client_id": "trusted-rp", - "client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"), + "client_id": "headless-login-client", + "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"), "loginId": "010-1234-5678", "login_challenge": "challenge-123", }) @@ -231,12 +248,13 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ Challenge: "challenge-123", Client: domain.HydraClient{ - ClientID: "trusted-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKS: jwks, + ClientID: "headless-login-client", + 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, }, }, }) @@ -266,8 +284,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { t.Setenv("USERFRONT_URL", "http://userfront.test") initBody, _ := json.Marshal(map[string]string{ - "client_id": "trusted-rp", - "client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"), + "client_id": "headless-login-client", + "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"), "loginId": "010-1234-5678", "login_challenge": "challenge-123", }) @@ -300,8 +318,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) pollBody, _ := json.Marshal(map[string]string{ - "client_id": "trusted-rp", - "client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/poll"), + "client_id": "headless-login-client", + "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"), "pendingRef": pendingRef, }) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody)) diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 4cdf17c5..38fc4f9c 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -284,7 +284,7 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) { } } -func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) { +func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ SessionToken: &domain.Token{JWT: "valid-jwt"}, @@ -305,12 +305,13 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) { json.NewEncoder(w).Encode(domain.HydraLoginRequest{ Challenge: "challenge-123", Client: domain.HydraClient{ - ClientID: "trusted-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKSUri: jwksServer.URL + "/.well-known/jwks.json", + ClientID: "headless-login-client", + 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", }, }, }) @@ -338,11 +339,11 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) { clientAssertion := mustHeadlessClientAssertion( t, privateKey, - "trusted-rp", + "headless-login-client", "http://example.com/api/v1/auth/headless/password/login", ) body, _ := json.Marshal(map[string]string{ - "client_id": "trusted-rp", + "client_id": "headless-login-client", "client_assertion": clientAssertion, "loginId": "employee001", "password": "password", @@ -389,7 +390,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) { json.NewEncoder(w).Encode(domain.HydraLoginRequest{ Challenge: "challenge-123", Client: domain.HydraClient{ - ClientID: "trusted-rp", + ClientID: "headless-login-client", TokenEndpointAuthMethod: "private_key_jwt", JWKS: map[string]any{ "keys": []map[string]any{}, @@ -420,7 +421,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) { app := newHeadlessPasswordLoginTestApp(h) body, _ := json.Marshal(map[string]string{ - "client_id": "trusted-rp", + "client_id": "headless-login-client", "loginId": "employee001", "password": "password", "login_challenge": "challenge-123", @@ -459,7 +460,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { json.NewEncoder(w).Encode(domain.HydraLoginRequest{ Challenge: "challenge-123", Client: domain.HydraClient{ - ClientID: "trusted-rp", + ClientID: "headless-login-client", TokenEndpointAuthMethod: "private_key_jwt", JWKS: jwks, Metadata: map[string]interface{}{ @@ -490,11 +491,11 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) { clientAssertion := mustHeadlessClientAssertion( t, invalidKey, - "trusted-rp", + "headless-login-client", "http://example.com/api/v1/auth/headless/password/login", ) body, _ := json.Marshal(map[string]string{ - "client_id": "trusted-rp", + "client_id": "headless-login-client", "client_assertion": clientAssertion, "loginId": "employee001", "password": "password", @@ -523,11 +524,12 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) { json.NewEncoder(w).Encode(domain.HydraLoginRequest{ Challenge: "challenge-123", Client: domain.HydraClient{ - ClientID: "trusted-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKSUri: "https://rp.example.com/.well-known/jwks.json", + ClientID: "headless-login-client", + 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", }, }, }) @@ -547,7 +549,7 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) { app := newHeadlessPasswordLoginTestApp(h) body, _ := json.Marshal(map[string]string{ - "client_id": "trusted-rp", + "client_id": "headless-login-client", "loginId": "employee001", "password": "password", "login_challenge": "challenge-123", @@ -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", }, }, }) @@ -600,7 +603,7 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) { app := newHeadlessPasswordLoginTestApp(h) body, _ := json.Marshal(map[string]string{ - "client_id": "trusted-rp", + "client_id": "headless-login-client", "loginId": "employee001", "password": "password", "login_challenge": "challenge-123", 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/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..f98da60d 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -611,7 +611,7 @@ func TestDevHandler_NoAuditNoAction(t *testing.T) { }) } -func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) { +func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) { var captured domain.HydraClient transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -653,7 +653,7 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) { app.Post("/api/v1/dev/clients", h.CreateClient) body, _ := json.Marshal(map[string]any{ - "name": "Trusted RP App", + "name": "Headless Login App", "type": "pkce", "redirectUris": []string{"https://rp.example.com/callback"}, "scopes": []string{"openid", "profile"}, @@ -676,22 +676,23 @@ 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"]) } -func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) { +func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) { var captured domain.HydraClient transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - if r.Method == http.MethodGet && r.URL.Path == "/clients/client-trusted" { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" { return httpJSONAny(r, http.StatusOK, map[string]any{ - "client_id": "client-trusted", - "client_name": "Trusted Before", + "client_id": "client-headless-login", + "client_name": "Headless Login Before", "redirect_uris": []string{"https://before.example.com/callback"}, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, @@ -702,7 +703,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) { }, }), nil } - if r.Method == http.MethodPut && r.URL.Path == "/clients/client-trusted" { + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" { body, err := io.ReadAll(r.Body) assert.NoError(t, err) err = json.Unmarshal(body, &captured) @@ -740,7 +741,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) { app.Put("/api/v1/dev/clients/:id", h.UpdateClient) body, _ := json.Marshal(map[string]any{ - "name": "Trusted After", + "name": "Headless Login After", "type": "pkce", "tokenEndpointAuthMethod": "private_key_jwt", "jwksUri": "https://rp.example.com/.well-known/jwks.json", @@ -749,14 +750,15 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) { "request_object_signing_alg": "RS256", }, }) - req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-trusted", bytes.NewReader(body)) + req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") 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/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 13733228..25755f2c 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -342,7 +342,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { } } } - + // Fallback: Try syncing based on the tenant namespaces being updated if !synced && h.TenantService != nil { for k := range req.Metadata { @@ -1249,10 +1249,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } finalLoginID := 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) @@ -1336,7 +1337,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()) } } @@ -1618,6 +1622,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 == "" { @@ -1645,7 +1659,7 @@ func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantI } // 3. Check merged traits (which includes existing metadata) - // Important: Skip this if loginIDField is "id" because traits["id"] is the TARGET, + // Important: Skip this if loginIDField is "id" because traits["id"] is the TARGET, // and we don't want to sync "id" to "id" if we already checked metadata. if loginID == "" && loginIDField != "id" { // Existing trait (flat) 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/handlerregression/dev_handler_trusted_secret_test.go b/backend/internal/handlerregression/dev_handler_headless_secret_test.go similarity index 91% rename from backend/internal/handlerregression/dev_handler_trusted_secret_test.go rename to backend/internal/handlerregression/dev_handler_headless_secret_test.go index 5a525fac..76ac0a9e 100644 --- a/backend/internal/handlerregression/dev_handler_trusted_secret_test.go +++ b/backend/internal/handlerregression/dev_handler_headless_secret_test.go @@ -425,16 +425,16 @@ func TestRotateClientSecret_PersistsForLaterDetailFetch(t *testing.T) { } } -func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) { +func TestUpdateClient_HeadlessLoginSecretPersistsForLaterDetailFetch(t *testing.T) { getCount := 0 transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - if r.Method == http.MethodGet && r.URL.Path == "/clients/client-trusted" { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" { getCount++ if getCount == 1 { return httpJSONAny(r, http.StatusOK, map[string]any{ - "client_id": "client-trusted", - "client_name": "Trusted Before", + "client_id": "client-headless-login", + "client_name": "Headless Login Before", "redirect_uris": []string{"https://before.example.com/callback"}, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, @@ -447,14 +447,14 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) { } return httpJSONAny(r, http.StatusOK, map[string]any{ - "client_id": "client-trusted", - "client_name": "Trusted After", - "redirect_uris": []string{"https://trusted.example.com/callback"}, + "client_id": "client-headless-login", + "client_name": "Headless Login After", + "redirect_uris": []string{"https://headless.example.com/callback"}, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile", "token_endpoint_auth_method": "private_key_jwt", - "jwks_uri": "https://trusted.example.com/jwks.json", + "jwks_uri": "https://headless.example.com/jwks.json", "metadata": map[string]any{ "status": "active", "headless_login_enabled": true, @@ -463,17 +463,17 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) { }), nil } - if r.Method == http.MethodPut && r.URL.Path == "/clients/client-trusted" { + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" { return httpJSONAny(r, http.StatusOK, map[string]any{ - "client_id": "client-trusted", - "client_name": "Trusted After", - "client_secret": "trusted-secret", - "redirect_uris": []string{"https://trusted.example.com/callback"}, + "client_id": "client-headless-login", + "client_name": "Headless Login After", + "client_secret": "headless-secret", + "redirect_uris": []string{"https://headless.example.com/callback"}, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, "scope": "openid profile", "token_endpoint_auth_method": "private_key_jwt", - "jwks_uri": "https://trusted.example.com/jwks.json", + "jwks_uri": "https://headless.example.com/jwks.json", "metadata": map[string]any{ "status": "active", "headless_login_enabled": true, @@ -507,16 +507,16 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) { app.Get("/api/v1/dev/clients/:id", h.GetClient) updateBody, _ := json.Marshal(map[string]any{ - "name": "Trusted After", - "redirectUris": []string{"https://trusted.example.com/callback"}, + "name": "Headless Login After", + "redirectUris": []string{"https://headless.example.com/callback"}, "tokenEndpointAuthMethod": "private_key_jwt", - "jwksUri": "https://trusted.example.com/jwks.json", + "jwksUri": "https://headless.example.com/jwks.json", "metadata": map[string]any{ "headless_login_enabled": true, "request_object_signing_alg": "RS256", }, }) - updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-trusted", bytes.NewReader(updateBody)) + updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(updateBody)) updateReq.Header.Set("Content-Type", "application/json") updateResp, err := app.Test(updateReq, -1) @@ -527,20 +527,20 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) { t.Fatalf("expected update 200, got %d", updateResp.StatusCode) } - storedSecret, _ := secretRepo.GetByID(context.Background(), "client-trusted") - if storedSecret != "trusted-secret" { - t.Fatalf("expected postgres secret trusted-secret, got %q", storedSecret) + storedSecret, _ := secretRepo.GetByID(context.Background(), "client-headless-login") + if storedSecret != "headless-secret" { + t.Fatalf("expected postgres secret headless-secret, got %q", storedSecret) } - redisSecret, err := redisRepo.Get("client_secret:client-trusted") + redisSecret, err := redisRepo.Get("client_secret:client-headless-login") if err != nil { t.Fatalf("expected redis secret, got error: %v", err) } - if redisSecret != "trusted-secret" { - t.Fatalf("expected redis secret trusted-secret, got %q", redisSecret) + if redisSecret != "headless-secret" { + t.Fatalf("expected redis secret headless-secret, got %q", redisSecret) } - getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-trusted", nil) + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-headless-login", nil) getResp, err := app.Test(getReq, -1) if err != nil { t.Fatalf("get request failed: %v", err) @@ -557,7 +557,7 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) { if err := json.NewDecoder(getResp.Body).Decode(&payload); err != nil { t.Fatalf("decode response: %v", err) } - if payload.Client.ClientSecret != "trusted-secret" { - t.Fatalf("expected detail secret trusted-secret, got %q", payload.Client.ClientSecret) + if payload.Client.ClientSecret != "headless-secret" { + t.Fatalf("expected detail secret headless-secret, got %q", payload.Client.ClientSecret) } } diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go index d5dce360..35141017 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: 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") + } +} diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index fce5a6ac..02c36e70 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, }, }; @@ -915,22 +972,22 @@ function ClientGeneralPage() {

{t( - "ui.dev.clients.general.security.trusted_rp_enable_help", + "ui.dev.clients.general.security.headless_login_enable_help", "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.", )}

@@ -941,7 +998,7 @@ function ClientGeneralPage() { - {/* 4. Public Key Registration (Trusted RP) */} + {/* 4. Public Key Registration (Headless Login) */} {clientType === "pkce" && headlessLoginEnabled && ( @@ -956,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..3e07d97c 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." +headless_login_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,10 +1392,10 @@ delete = "Delete" [ui.dev.clients.general.security] private = "Server Side App" pkce = "PKCE" -trusted = "Trusted RP" +headless_login = "Headless Login" title = "Security Settings" -trusted_rp_enable = "Trusted RP (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." +headless_login_enable = "Headless Login (Custom Login UI)" +headless_login_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] auth_method = "Token Endpoint Auth Method" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 55d174e6..2fb2acc4 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은 이 프로필에서만 사용할 수 있습니다." +headless_login_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,8 +1393,8 @@ delete = "삭제" private = "Server side App" pkce = "PKCE" title = "보안 설정" -trusted_rp_enable = "Trusted RP (자체 로그인 UI 사용)" -trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다." +headless_login_enable = "Headless Login (자체 로그인 UI 사용)" +headless_login_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다." [ui.dev.clients.general.public_key] diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 69cea372..cbd1eb5d 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -390,7 +390,7 @@ subtitle = "" private_help = "" pkce_help = "" subtitle = "" -trusted_help = "" +headless_login_help = "" [msg.dev.clients.general.public_key] auth_method_client_secret_basic_help = "" @@ -1392,8 +1392,8 @@ delete = "" private = "" pkce = "" title = "" -trusted_rp_enable = "" -trusted_rp_enable_help = "" +headless_login_enable = "" +headless_login_enable_help = "" [ui.dev.clients.general.public_key] auth_method = "" diff --git a/devfront/tests/devfront-clients-lifecycle.spec.ts b/devfront/tests/devfront-clients-lifecycle.spec.ts index 689df680..e5c8fba8 100644 --- a/devfront/tests/devfront-clients-lifecycle.spec.ts +++ b/devfront/tests/devfront-clients-lifecycle.spec.ts @@ -123,23 +123,23 @@ 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 ({ - page, + test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({ + page, }) => { const state = { clients: [ - makeClient("client-trusted", { name: "Trusted App", type: "pkce" }), + makeClient("client-headless-login", { name: "Headless Login App", type: "pkce" }), ], consents: [] as Consent[], auditLogsByCursor: undefined, }; await installDevApiMock(page, state); - await page.goto("/clients/client-trusted/settings"); + await page.goto("/clients/client-headless-login/settings"); 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(); @@ -158,15 +158,20 @@ test.describe("DevFront clients lifecycle", () => { await expect .poll(() => state.clients[0]?.tokenEndpointAuthMethod) - .toBe("private_key_jwt"); + .toBe("none"); await expect .poll(() => state.clients[0]?.metadata?.headless_login_enabled) .toBe(true); + await expect + .poll( + () => state.clients[0]?.metadata?.headless_token_endpoint_auth_method, + ) + .toBe("private_key_jwt"); await expect .poll( () => ( - state.clients[0]?.jwks as { + state.clients[0]?.metadata?.headless_jwks as { keys?: Array<{ kty?: string; alg?: string }>; } )?.keys?.[0]?.kty, @@ -176,7 +181,7 @@ test.describe("DevFront clients lifecycle", () => { .poll( () => ( - state.clients[0]?.jwks as { + state.clients[0]?.metadata?.headless_jwks as { keys?: Array<{ kty?: string; alg?: string }>; } )?.keys?.[0]?.alg, diff --git a/docs/superpowers/plans/2026-03-30-headless-password-login-backend-api.md b/docs/superpowers/plans/2026-03-30-headless-password-login-backend-api.md new file mode 100644 index 00000000..855f260d --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-headless-password-login-backend-api.md @@ -0,0 +1,77 @@ +# Headless Password Login Backend API Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Trusted RP에 한해 `login_challenge + loginId + password`를 받아 OIDC 로그인 흐름을 계속할 수 있는 백엔드 API를 추가합니다. + +**Architecture:** 기존 `AuthHandler.PasswordLogin`의 IDP sign-in, identity resolve, Hydra login accept 흐름을 재사용 가능한 helper로 정리하고, 신규 endpoint에서는 Hydra client의 trusted/headless 상태를 추가 검증합니다. 성공 응답은 `sessionJwt`를 숨기고 `redirectTo`만 반환해 RP 브라우저가 기존 OIDC 흐름을 이어가도록 합니다. + +**Tech Stack:** Go, Fiber, Ory Hydra Admin API, Kratos/Ory provider, testify + +--- + +### Task 1: Failing Test 추가 + +**Files:** +- Modify: `backend/internal/handler/auth_handler_login_test.go` + +- [ ] **Step 1: headless trusted RP 성공 케이스 테스트 추가** + +`POST /api/v1/auth/headless/password/login` 호출 시 trusted + headless enabled client면 `redirectTo`만 반환하는 테스트를 추가합니다. + +- [ ] **Step 2: headless 미허용/불일치 케이스 테스트 추가** + +아래 케이스를 각각 추가합니다. +- headless flag 없음 +- trusted RP 조건 미충족 +- request body `client_id`와 Hydra login request client 불일치 +- inactive client + +- [ ] **Step 3: 테스트 단독 실행으로 실패 확인** + +Run: `go test ./backend/internal/handler -run 'TestHeadlessPasswordLogin'` + +Expected: 신규 route 또는 handler 부재로 FAIL + +### Task 2: 신규 API 최소 구현 + +**Files:** +- Modify: `backend/cmd/server/main.go` +- Modify: `backend/internal/handler/auth_handler.go` + +- [ ] **Step 1: route 추가** + +`/api/v1/auth/headless/password/login` route를 등록합니다. + +- [ ] **Step 2: request body 및 검증 helper 추가** + +`client_id`, `login_challenge`, `loginId`, `password`를 받는 handler를 추가하고, Hydra login request의 client 일치 여부와 trusted/headless/inactive 상태를 검증합니다. + +- [ ] **Step 3: 공통 로그인 처리 helper로 기존 로직 재사용** + +기존 `PasswordLogin`의 sign-in, identity resolve, Hydra accept 흐름을 helper로 분리하거나 최소 중복으로 재사용합니다. + +- [ ] **Step 4: 성공 응답을 headless 정책에 맞게 고정** + +성공 시 `redirectTo`, `status`, `provider`만 응답하고 `sessionJwt`는 반환하지 않도록 합니다. + +### Task 3: 검증 및 회귀 확인 + +**Files:** +- Modify: `backend/internal/handler/auth_handler_login_test.go` + +- [ ] **Step 1: 신규 테스트 재실행** + +Run: `go test ./backend/internal/handler -run 'TestHeadlessPasswordLogin'` + +Expected: PASS + +- [ ] **Step 2: 기존 password login 관련 회귀 테스트 실행** + +Run: `go test ./backend/internal/handler -run 'TestPasswordLogin'` + +Expected: PASS + +- [ ] **Step 3: 이슈 업데이트** + +`#480`에 테스트 결과와 남은 후속 범위(전화번호 링크 승인형 분리)를 코멘트로 남깁니다. diff --git a/locales/en.toml b/locales/en.toml index 24bac601..23712c87 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -313,6 +313,10 @@ not_found = "Not Found" update_error = "Failed to User Edit." update_success = "Update Success" password_generated = "A secure password has been generated." +password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately." +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.detail.form] field_required = "Required." @@ -1155,6 +1159,11 @@ back = "Back" edit_title = "Edit Title" title = "User Details" generate_password = "Auto Generate" +password_mode_generated = "Auto Generate" +password_mode_manual = "Manual Entry" +password_result_title = "Reset Password" +reset_password_apply = "Apply Password" +toggle_password_visibility = "Toggle password visibility" [ui.admin.users.detail.breadcrumb] section = "Users" diff --git a/locales/ko.toml b/locales/ko.toml index 4007efc5..dccb8e84 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -685,6 +685,10 @@ not_found = "사용자를 찾을 수 없습니다." update_error = "사용자 수정에 실패했습니다." update_success = "사용자 정보가 수정되었습니다." password_generated = "안전한 비밀번호가 생성되었습니다." +password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다." +password_manual_required = "비밀번호를 입력해 주세요." +reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다." +self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요." [msg.admin.users.list] delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?" @@ -1145,6 +1149,11 @@ back = "목록으로 돌아가기" edit_title = "정보 수정" title = "사용자 상세" generate_password = "자동 생성" +password_mode_generated = "자동 생성" +password_mode_manual = "수동 입력" +password_result_title = "Reset Password" +reset_password_apply = "비밀번호 적용" +toggle_password_visibility = "비밀번호 표시 전환" [ui.admin.users.list] add = "사용자 추가" diff --git a/locales/template.toml b/locales/template.toml index 8d8f9555..2ed62bbb 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -685,6 +685,10 @@ not_found = "" update_error = "" update_success = "" password_generated = "" +password_generated_help = "" +password_manual_required = "" +reset_password_help = "" +self_password_reset_blocked = "" [msg.admin.users.list] delete_confirm = "" @@ -1145,6 +1149,11 @@ back = "" edit_title = "" title = "" generate_password = "" +password_mode_generated = "" +password_mode_manual = "" +password_result_title = "" +reset_password_apply = "" +toggle_password_visibility = "" [ui.admin.users.list] add = "" diff --git a/userfront-e2e/tests/password-and-reset.spec.ts b/userfront-e2e/tests/password-and-reset.spec.ts index 93664d83..09728c53 100644 --- a/userfront-e2e/tests/password-and-reset.spec.ts +++ b/userfront-e2e/tests/password-and-reset.spec.ts @@ -7,32 +7,94 @@ type RequestCapture = { clientLogs: string[]; }; -const SIGNIN_PASSWORD_TAB_X = 522; -const SIGNIN_TAB_Y = 158; -const SIGNIN_LOGIN_ID_X = 640; -const SIGNIN_LOGIN_ID_Y = 245; -const SIGNIN_PASSWORD_X = 640; -const SIGNIN_PASSWORD_Y = 311; -const SIGNIN_SUBMIT_X = 640; -const SIGNIN_SUBMIT_Y = 381; +async function enableFlutterAccessibility(page: Page): Promise { + await page.waitForTimeout(300); + const button = page.getByRole('button', { name: 'Enable accessibility' }); + if (await button.count()) { + await button.click({ force: true }); + const placeholder = page.locator('flt-semantics-placeholder'); + if (await placeholder.count()) { + await placeholder.first().click({ force: true }); + } + await page.waitForTimeout(800); + } +} -const RESET_NEW_PASSWORD_X = 640; -const RESET_NEW_PASSWORD_Y = 382; -const RESET_CONFIRM_PASSWORD_X = 640; -const RESET_CONFIRM_PASSWORD_Y = 464; -const RESET_SUBMIT_X = 640; -const RESET_SUBMIT_Y = 534; +type ScreenCoords = { + signinPasswordTabX: number; + signinTabY: number; + signinLoginIdX: number; + signinLoginIdY: number; + signinPasswordX: number; + signinPasswordY: number; + signinSubmitX: number; + signinSubmitY: number; + resetNewPasswordX: number; + resetNewPasswordY: number; + resetConfirmPasswordX: number; + resetConfirmPasswordY: number; + resetSubmitX: number; + resetSubmitY: number; +}; + +const desktopCoords: ScreenCoords = { + signinPasswordTabX: 522, + signinTabY: 158, + signinLoginIdX: 640, + signinLoginIdY: 245, + signinPasswordX: 640, + signinPasswordY: 311, + signinSubmitX: 640, + signinSubmitY: 381, + resetNewPasswordX: 640, + resetNewPasswordY: 382, + resetConfirmPasswordX: 640, + resetConfirmPasswordY: 464, + resetSubmitX: 640, + resetSubmitY: 534, +}; + +const mobileCoords: ScreenCoords = { + signinPasswordTabX: 90, + signinTabY: 158, + signinLoginIdX: 206, + signinLoginIdY: 268, + signinPasswordX: 206, + signinPasswordY: 334, + signinSubmitX: 206, + signinSubmitY: 399, + resetNewPasswordX: 206, + resetNewPasswordY: 382, + resetConfirmPasswordX: 206, + resetConfirmPasswordY: 464, + resetSubmitX: 206, + resetSubmitY: 534, +}; + +function coordsFor(page: Page): ScreenCoords { + const viewport = page.viewportSize(); + return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords; +} + +function isMobileProject(page: Page): boolean { + const viewport = page.viewportSize(); + return (viewport?.width ?? 1280) <= 500; +} async function clickPasswordTab(page: Page): Promise { + if (isMobileProject(page)) { + return; + } + const coords = coordsFor(page); await page.waitForTimeout(900); const pane = page.locator('flt-glass-pane'); await pane.click({ - position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y }, + position: { x: coords.signinPasswordTabX, y: coords.signinTabY }, force: true, }); await page.waitForTimeout(120); await pane.click({ - position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y }, + position: { x: coords.signinPasswordTabX, y: coords.signinTabY }, force: true, }); await page.waitForTimeout(200); @@ -47,6 +109,68 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise< await page.keyboard.type(value); } +async function fillPasswordLoginForm( + page: Page, + loginId: string, + password: string, +): Promise { + if (isMobileProject(page)) { + await enableFlutterAccessibility(page); + const inputs = page.getByRole('textbox'); + await inputs.nth(0).fill(loginId); + await inputs.nth(1).fill(password); + return; + } + const coords = coordsFor(page); + await fillAt(page, coords.signinLoginIdX, coords.signinLoginIdY, loginId); + await fillAt(page, coords.signinPasswordX, coords.signinPasswordY, password); +} + +async function submitPasswordLogin(page: Page): Promise { + if (isMobileProject(page)) { + await page.getByRole('button', { name: '로그인' }).click({ force: true }); + return; + } + const coords = coordsFor(page); + await page.locator('flt-glass-pane').click({ + position: { x: coords.signinSubmitX, y: coords.signinSubmitY }, + force: true, + }); +} + +async function fillResetPasswordForm(page: Page, password: string): Promise { + if (isMobileProject(page)) { + await enableFlutterAccessibility(page); + await page + .getByRole('textbox', { name: /^새 비밀번호$/ }) + .fill(password); + await page + .getByRole('textbox', { name: /^새 비밀번호 확인$/ }) + .fill(password); + return; + } + const coords = coordsFor(page); + await fillAt(page, coords.resetNewPasswordX, coords.resetNewPasswordY, password); + await fillAt( + page, + coords.resetConfirmPasswordX, + coords.resetConfirmPasswordY, + password, + ); +} + +async function submitResetPassword(page: Page): Promise { + if (isMobileProject(page)) { + await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true }); + return; + } + const coords = coordsFor(page); + await page.locator('flt-glass-pane').click({ + position: { x: coords.resetSubmitX, y: coords.resetSubmitY }, + force: true, + }); +} + async function mockAuthApis(page: Page, capture: RequestCapture): Promise { await page.route('**/api/v1/**', async (route: Route) => { const requestUrl = new URL(route.request().url()); @@ -187,17 +311,17 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise test.describe('UserFront WASM password login and reset', () => { test.skip(({ isMobile }) => isMobile, 'Desktop only (hardcoded coordinates)'); test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => { + test.skip( + isMobileProject(page), + 'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.', + ); const capture: RequestCapture = { clientLogs: [] }; await mockAuthApis(page, capture); await page.goto('/ko/signin'); await clickPasswordTab(page); - await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com'); - await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'ValidPass1!'); - await page.locator('flt-glass-pane').click({ - position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y }, - force: true, - }); + await fillPasswordLoginForm(page, 'e2e@example.com', 'ValidPass1!'); + await submitPasswordLogin(page); await expect(page).toHaveURL(/\/ko\/dashboard$/); @@ -211,17 +335,17 @@ test.describe('UserFront WASM password login and reset', () => { }); test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => { + test.skip( + isMobileProject(page), + 'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.', + ); const capture: RequestCapture = { clientLogs: [] }; await mockAuthApis(page, capture); await page.goto('/ko/signin'); await clickPasswordTab(page); - await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com'); - await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'WrongPass1!'); - await page.locator('flt-glass-pane').click({ - position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y }, - force: true, - }); + await fillPasswordLoginForm(page, 'e2e@example.com', 'WrongPass1!'); + await submitPasswordLogin(page); await expect(page).toHaveURL(/\/ko\/signin$/); await expect @@ -247,17 +371,8 @@ test.describe('UserFront WASM password login and reset', () => { await page.goto('/ko/reset-password?token=reset-token-e2e'); await policyLoaded; await page.waitForTimeout(900); - await fillAt(page, RESET_NEW_PASSWORD_X, RESET_NEW_PASSWORD_Y, 'ValidPass1!A'); - await fillAt( - page, - RESET_CONFIRM_PASSWORD_X, - RESET_CONFIRM_PASSWORD_Y, - 'ValidPass1!A', - ); - await page.locator('flt-glass-pane').click({ - position: { x: RESET_SUBMIT_X, y: RESET_SUBMIT_Y }, - force: true, - }); + await fillResetPasswordForm(page, 'ValidPass1!A'); + await submitResetPassword(page); await expect .poll( diff --git a/userfront-e2e/tests/profile-department.spec.ts b/userfront-e2e/tests/profile-department.spec.ts index c0e2439d..979319f0 100644 --- a/userfront-e2e/tests/profile-department.spec.ts +++ b/userfront-e2e/tests/profile-department.spec.ts @@ -6,12 +6,50 @@ type ProfileState = { putBodies: Array>; }; -const PROFILE_DEPARTMENT_EDIT_X = 1170; -const PROFILE_DEPARTMENT_EDIT_Y = 680; -const PROFILE_DEPARTMENT_INPUT_X = 110; -const PROFILE_DEPARTMENT_INPUT_Y = 685; -const PROFILE_BLUR_X = 200; -const PROFILE_BLUR_Y = 260; +async function enableFlutterAccessibility(page: Page): Promise { + const button = page.getByRole('button', { name: 'Enable accessibility' }); + if (await button.count()) { + await button.click({ force: true }); + await page.waitForTimeout(200); + } +} + +type ProfileCoords = { + departmentEditX: number; + departmentEditY: number; + departmentInputX: number; + departmentInputY: number; + blurX: number; + blurY: number; +}; + +const desktopCoords: ProfileCoords = { + departmentEditX: 1170, + departmentEditY: 680, + departmentInputX: 110, + departmentInputY: 685, + blurX: 200, + blurY: 260, +}; + +const mobileCoords: ProfileCoords = { + departmentEditX: 350, + departmentEditY: 680, + departmentInputX: 110, + departmentInputY: 685, + blurX: 200, + blurY: 260, +}; + +function coordsFor(page: Page): ProfileCoords { + const viewport = page.viewportSize(); + return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords; +} + +function isMobileProject(page: Page): boolean { + const viewport = page.viewportSize(); + return (viewport?.width ?? 1280) <= 500; +} async function seedTokenLogin(page: Page): Promise { await page.addInitScript(() => { @@ -32,26 +70,56 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise< } async function openDepartmentEditor(page: Page): Promise { + if (isMobileProject(page)) { + await enableFlutterAccessibility(page); + await page + .getByRole('group', { name: '소속 QA' }) + .getByRole('button', { name: '편집' }) + .click({ force: true }); + await page.waitForTimeout(200); + return; + } + const coords = coordsFor(page); await page.locator('flt-glass-pane').click({ - position: { x: PROFILE_DEPARTMENT_EDIT_X, y: PROFILE_DEPARTMENT_EDIT_Y }, + position: { x: coords.departmentEditX, y: coords.departmentEditY }, force: true, }); await page.waitForTimeout(200); } async function blurDepartmentEditor(page: Page): Promise { + if (isMobileProject(page)) { + await page.getByRole('textbox', { name: '소속' }).blur(); + await page.waitForTimeout(250); + return; + } + const coords = coordsFor(page); await page.locator('flt-glass-pane').click({ - position: { x: PROFILE_BLUR_X, y: PROFILE_BLUR_Y }, + position: { x: coords.blurX, y: coords.blurY }, force: true, }); await page.waitForTimeout(250); } async function submitDepartmentEditor(page: Page): Promise { + if (isMobileProject(page)) { + await page.getByRole('textbox', { name: '소속' }).press('Enter'); + await page.waitForTimeout(250); + return; + } await page.keyboard.press('Enter'); await page.waitForTimeout(250); } +async function fillDepartmentField(page: Page, value: string): Promise { + if (isMobileProject(page)) { + await page.getByRole('textbox', { name: '소속' }).fill(value); + return; + } + const coords = coordsFor(page); + await fillAt(page, coords.departmentInputX, coords.departmentInputY, value); +} + async function mockProfileApis(page: Page, state: ProfileState): Promise { await page.route('**/api/v1/**', async (route: Route) => { const request = route.request(); @@ -176,7 +244,7 @@ test.describe('UserFront WASM profile department editing', () => { await waitForInitialProfileLoad(state); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated'); + await fillDepartmentField(page, 'QA-Updated'); await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(1); @@ -203,7 +271,7 @@ test.describe('UserFront WASM profile department editing', () => { await waitForInitialProfileLoad(state); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Repro'); + await fillDepartmentField(page, 'QA-Repro'); await page.reload(); await expect(page).toHaveURL(/\/ko\/profile$/); @@ -230,7 +298,7 @@ test.describe('UserFront WASM profile department editing', () => { await waitForInitialProfileLoad(state); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA'); + await fillDepartmentField(page, 'QA'); await blurDepartmentEditor(page); expect(state.putBodies).toHaveLength(0); @@ -248,7 +316,7 @@ test.describe('UserFront WASM profile department editing', () => { await waitForInitialProfileLoad(state); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, ''); + await fillDepartmentField(page, ''); await blurDepartmentEditor(page); expect(state.putBodies).toHaveLength(0); @@ -267,7 +335,7 @@ test.describe('UserFront WASM profile department editing', () => { await waitForInitialProfileLoad(state); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1'); + await fillDepartmentField(page, 'QA-1'); await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(1); @@ -276,7 +344,7 @@ test.describe('UserFront WASM profile department editing', () => { await page.waitForTimeout(1200); await openDepartmentEditor(page); - await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2'); + await fillDepartmentField(page, 'QA-2'); await submitDepartmentEditor(page); await expect.poll(() => state.putBodies.length).toBe(2); diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 3d3fc2ad..ec300fe3 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -91,7 +91,7 @@ class _LoginScreenState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this, initialIndex: 1); + _tabController = TabController(length: 3, vsync: this, initialIndex: 0); _tabController.addListener(_handleTabSelection); _drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && 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..80b80ae3 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,58 @@ 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'), @@ -645,6 +768,7 @@ class _ProfilePageState extends ConsumerState { }) { final isEditing = _editingField == field; final displayValue = value.isEmpty ? '-' : value; + final isCompact = MediaQuery.of(context).size.width < 640; if (!isEditing) { return ListTile( @@ -661,57 +785,64 @@ class _ProfilePageState extends ConsumerState { final hasChanged = _hasFieldChanged(profile, field); + final inputField = TextField( + key: Key('profile-$field-input'), + controller: controller, + focusNode: field == 'name' ? _nameFocus : _departmentFocus, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _saveField(profile), + onChanged: (_) { + setState(() { + _fieldSaveError = null; + }); + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: label, + errorText: _fieldSaveError, + ), + ); + final saveButton = ElevatedButton( + key: Key('profile-$field-save-button'), + onPressed: isUpdating || !hasChanged || _isSavingField + ? null + : () => _saveField(profile), + child: _isSavingField + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(tr('ui.common.save')), + ); + final cancelButton = OutlinedButton( + key: Key('profile-$field-cancel-button'), + onPressed: isUpdating || _isSavingField + ? null + : () => _cancelEditing(profile), + child: Text(tr('ui.common.cancel')), + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextField( - key: Key('profile-$field-input'), - controller: controller, - focusNode: field == 'name' ? _nameFocus : _departmentFocus, - textInputAction: TextInputAction.done, - onSubmitted: (_) => _saveField(profile), - onChanged: (_) { - setState(() { - _fieldSaveError = null; - }); - }, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: label, - errorText: _fieldSaveError, - ), - ), - ), - const SizedBox(width: 12), - ElevatedButton( - key: Key('profile-$field-save-button'), - onPressed: isUpdating || !hasChanged || _isSavingField - ? null - : () => _saveField(profile), - child: _isSavingField - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(tr('ui.common.save')), - ), - const SizedBox(width: 8), - OutlinedButton( - key: Key('profile-$field-cancel-button'), - onPressed: isUpdating || _isSavingField - ? null - : () => _cancelEditing(profile), - child: Text(tr('ui.common.cancel')), - ), - ], - ), + if (isCompact) ...[ + inputField, + const SizedBox(height: 12), + Wrap(spacing: 8, runSpacing: 8, children: [saveButton, cancelButton]), + ] else + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: inputField), + const SizedBox(width: 12), + saveButton, + const SizedBox(width: 8), + cancelButton, + ], + ), ], ); } @@ -853,6 +984,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,