1
0
forked from baron/baron-sso

Merge branch 'dev' into feat/id_login

This commit is contained in:
2026-04-01 13:40:45 +09:00
41 changed files with 2079 additions and 397 deletions

View File

@@ -515,7 +515,7 @@ jobs:
adminfront-tests: adminfront-tests:
needs: lint needs: lint
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }} if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
runs-on: playwright runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Checkout code - name: Checkout code
@@ -594,7 +594,7 @@ jobs:
devfront-tests: devfront-tests:
needs: lint needs: lint
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }} if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
runs-on: playwright runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -62,7 +62,7 @@ up-front-dev: up-infra up-ory up-backend
@echo "Dev stack is up (infra + ory + backend)." @echo "Dev stack is up (infra + ory + backend)."
# --- 종료 (Down) --- # --- 종료 (Down) ---
down-all: down:
@echo "Stopping ALL stacks (infra + ory + app)..." @echo "Stopping ALL stacks (infra + ory + app)..."
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down

140
README.md
View File

@@ -2,13 +2,27 @@
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다. **Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
## 버그 대응 대원칙 (필수) ## 📂 프로젝트 구조 (Project Structure)
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
```
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 합니다. * Ory Stack으로 모든 구성요소를 self-hosting 합니다.
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다. * Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다. * Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
@@ -20,6 +34,7 @@
* AdminFront: 사용자 관리 등 Admin 기능 * AdminFront: 사용자 관리 등 Admin 기능
* DevFront: RP 관리 등 개발자 기능 * DevFront: RP 관리 등 개발자 기능
## 🏗 아키텍처 (Architecture) ## 🏗 아키텍처 (Architecture)
### 0. Ory Stack ### 0. Ory Stack
@@ -88,6 +103,85 @@ flowchart
2.1 향후 App Push 등 2차 인증 강화수단 검토 필요 2.1 향후 App Push 등 2차 인증 강화수단 검토 필요
3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환 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": "<signed-jwt>",
"login_challenge": "<hydra-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://<backend-origin>/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 install
npm run dev npm run dev
``` ```
--- ---
## 📂 프로젝트 구조 (Project Structure)
``` ## 버그 대응 대원칙 (필수)
baron_sso/ - 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
├── backend/ # Go Fiber 애플리케이션 - 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
│ ├── cmd/server/ # 진입점 (Entry point) - 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository) - “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
│ └── Dockerfile - 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
├── 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**: 대시보드 및 통합 런처 구현 (예정)

View File

@@ -5,6 +5,8 @@ import {
BadgeCheck, BadgeCheck,
Building2, Building2,
Copy, Copy,
Eye,
EyeOff,
Key, Key,
Loader2, Loader2,
Mail, Mail,
@@ -32,16 +34,24 @@ import {
} from "../../components/ui/card"; } from "../../components/ui/card";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label"; import { Label } from "../../components/ui/label";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../components/ui/tabs";
import { toast } from "../../components/ui/use-toast"; import { toast } from "../../components/ui/use-toast";
import { import {
type UserSummary, type UserSummary,
type UserUpdateRequest, type UserUpdateRequest,
deleteUser, deleteUser,
fetchPasswordPolicy,
fetchMe, fetchMe,
fetchTenants, fetchTenants,
fetchUser, fetchUser,
updateUser, updateUser,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { generateSecurePassword } from "../../lib/utils"; import { generateSecurePassword } from "../../lib/utils";
@@ -58,6 +68,116 @@ type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
metadata: Record<string, Record<string, string | number | boolean>>; metadata: Record<string, Record<string, string | number | boolean>>;
}; };
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({ function TenantMetadataFields({
tenant, tenant,
schema, schema,
@@ -166,6 +286,15 @@ function UserDetailPage() {
const [generatedPassword, setGeneratedPassword] = React.useState< const [generatedPassword, setGeneratedPassword] = React.useState<
string | null string | null
>(null); >(null);
const [passwordResetMode, setPasswordResetMode] =
React.useState<PasswordResetMode>("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({ const { data: profile } = useQuery({
queryKey: ["me"], queryKey: ["me"],
@@ -187,6 +316,12 @@ function UserDetailPage() {
queryFn: () => fetchTenants(100, 0), queryFn: () => fetchTenants(100, 0),
}); });
const tenants = tenantsData?.items ?? []; const tenants = tenantsData?.items ?? [];
const { data: passwordPolicy, isLoading: isPasswordPolicyLoading } = useQuery(
{
queryKey: ["password-policy"],
queryFn: fetchPasswordPolicy,
},
);
const { const {
register, register,
@@ -211,11 +346,13 @@ function UserDetailPage() {
const isAdmin = const isAdmin =
profile?.role === "super_admin" || profile?.role === "tenant_admin"; profile?.role === "super_admin" || profile?.role === "tenant_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const resetPasswordMutation = useMutation({ const resetPasswordMutation = useMutation({
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }), mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
onSuccess: (_, newPass) => { onSuccess: (_, newPass) => {
setGeneratedPassword(newPass); setGeneratedPassword(newPass);
setPasswordResetError(null);
toast.success( toast.success(
t( t(
"msg.admin.users.detail.password_generated", "msg.admin.users.detail.password_generated",
@@ -224,20 +361,67 @@ function UserDetailPage() {
); );
}, },
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>) => {
toast.error( const message =
err.response?.data?.error || 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); setIsPasswordResetOpen(true);
setGeneratedPassword(null); setGeneratedPassword(null);
setPasswordResetMode("generated");
setManualPassword("");
setManualPasswordConfirm("");
setIsManualPasswordVisible(false);
setPasswordResetError(null);
}; };
const confirmGeneratePassword = () => { const handleClosePasswordReset = () => {
const newPass = generateSecurePassword(); 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); resetPasswordMutation.mutate(newPass);
}; };
@@ -717,7 +901,7 @@ function UserDetailPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/20"> <div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{t( {t(
@@ -726,44 +910,205 @@ function UserDetailPage() {
)} )}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t(
. "msg.admin.users.detail.reset_password_help",
"사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다.",
)}
</p> </p>
</div> </div>
<Button variant="outline" onClick={handleGeneratePassword}> <Button
variant="outline"
onClick={handleOpenPasswordReset}
disabled={isSelf}
>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.reset_password", "초기화 및 생성")} {t("ui.admin.users.detail.reset_password", "초기화 및 설정")}
</Button> </Button>
</div> </div>
{isPasswordResetOpen && !generatedPassword && ( {isSelf && (
<div className="p-4 border rounded-lg bg-destructive/5 space-y-4"> <div className="rounded-lg border px-4 py-3">
<p className="text-sm"> <p className="text-sm text-muted-foreground">
{t( {t(
"msg.admin.users.detail.reset_password_confirm", "msg.admin.users.detail.self_password_reset_blocked",
"정말로 이 사용자의 비밀번호를 초기화하시겠습니까? 기존 비밀번호로는 즉시 로그인할 수 없게 됩니다.", "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요.",
)} )}
</p> </p>
</div>
)}
{isPasswordResetOpen && !generatedPassword && !isSelf && (
<div className="space-y-4 rounded-lg border px-4 py-4">
<Tabs
value={passwordResetMode}
onValueChange={(value) => {
setPasswordResetMode(value as PasswordResetMode);
setPasswordResetError(null);
}}
>
<TabsList className="grid w-full grid-cols-2 rounded-lg bg-muted p-1">
<TabsTrigger
value="generated"
className="bg-slate-200 font-normal text-foreground data-[state=inactive]:bg-slate-200 data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"
>
{t(
"ui.admin.users.detail.password_mode_generated",
"자동 생성",
)}
</TabsTrigger>
<TabsTrigger
value="manual"
className="bg-slate-200 font-normal text-foreground data-[state=inactive]:bg-slate-200 data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"
>
{t(
"ui.admin.users.detail.password_mode_manual",
"수동 입력",
)}
</TabsTrigger>
</TabsList>
<TabsContent value="generated" className="space-y-2">
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.users.detail.password_generated_help",
"보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다.",
)}
</p>
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<p className="text-sm text-muted-foreground">
{isPasswordPolicyLoading
? t(
"msg.userfront.signup.policy.loading",
"비밀번호 정책을 불러오는 중입니다...",
)
: t(
"msg.userfront.signup.policy.summary",
"보안 정책: {{rules}}",
{
rules:
buildPasswordPolicyDescription(
passwordPolicy,
),
},
)}
</p>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="manualPassword"
type={isManualPasswordVisible ? "text" : "password"}
value={manualPassword}
placeholder=" "
className="peer pr-12 pt-5"
onChange={(event) => {
setManualPassword(event.target.value);
if (passwordResetError) {
setPasswordResetError(null);
}
}}
/>
<label
htmlFor="manualPassword"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 bg-background px-1 text-sm text-muted-foreground transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:translate-y-[-50%] peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-0 peer-[&:not(:placeholder-shown)]:translate-y-[-50%] peer-[&:not(:placeholder-shown)]:text-xs"
>
{t(
"ui.userfront.reset.new_password",
"새 비밀번호",
)}
</label>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
onClick={() =>
setIsManualPasswordVisible((prev) => !prev)
}
aria-label={t(
"ui.admin.users.detail.toggle_password_visibility",
"비밀번호 표시 전환",
)}
>
{isManualPasswordVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="manualPasswordConfirm"
type={isManualPasswordVisible ? "text" : "password"}
value={manualPasswordConfirm}
placeholder=" "
className="peer pr-12 pt-5"
onChange={(event) => {
setManualPasswordConfirm(event.target.value);
if (passwordResetError) {
setPasswordResetError(null);
}
}}
/>
<label
htmlFor="manualPasswordConfirm"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 bg-background px-1 text-sm text-muted-foreground transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:translate-y-[-50%] peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-0 peer-[&:not(:placeholder-shown)]:translate-y-[-50%] peer-[&:not(:placeholder-shown)]:text-xs"
>
{t(
"ui.userfront.reset.confirm_password",
"새 비밀번호 확인",
)}
</label>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
onClick={() =>
setIsManualPasswordVisible((prev) => !prev)
}
aria-label={t(
"ui.admin.users.detail.toggle_password_visibility",
"비밀번호 표시 전환",
)}
>
{isManualPasswordVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
</TabsContent>
</Tabs>
{passwordResetError && (
<p className="text-sm text-destructive">
{passwordResetError}
</p>
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setIsPasswordResetOpen(false)} onClick={handleClosePasswordReset}
> >
{t("ui.common.cancel", "취소")} {t("ui.common.cancel", "취소")}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={confirmGeneratePassword} onClick={confirmPasswordReset}
disabled={resetPasswordMutation.isPending} disabled={resetPasswordMutation.isPending}
> >
{resetPasswordMutation.isPending && ( {resetPasswordMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
)} )}
{t( {t(
"ui.admin.users.detail.reset_password", "ui.admin.users.detail.reset_password_apply",
"초기화 및 생성", "비밀번호 적용",
)} )}
</Button> </Button>
</div> </div>
@@ -774,7 +1119,10 @@ function UserDetailPage() {
<div className="p-4 border border-dashed rounded-lg bg-yellow-500/5 flex flex-wrap items-center justify-between gap-4"> <div className="p-4 border border-dashed rounded-lg bg-yellow-500/5 flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs font-bold text-yellow-600 uppercase tracking-wider"> <p className="text-xs font-bold text-yellow-600 uppercase tracking-wider">
Generated Password {t(
"ui.admin.users.detail.password_result_title",
"Reset Password",
)}
</p> </p>
<p className="font-mono text-lg font-bold"> <p className="font-mono text-lg font-bold">
{generatedPassword} {generatedPassword}

View File

@@ -527,6 +527,22 @@ export async function updateUser(userId: string, payload: UserUpdateRequest) {
return data; 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<PasswordPolicyResponse>(
"/v1/auth/password/policy",
);
return data;
}
export async function deleteUser(userId: string) { export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`); await apiClient.delete(`/v1/admin/users/${userId}`);
} }

View File

@@ -288,6 +288,12 @@ name_required = "Name Required"
[msg.admin.users.detail.security] [msg.admin.users.detail.security]
password_hint = "Password Hint" 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] [msg.admin.users.list]
delete_confirm = "Delete Confirm" delete_confirm = "Delete Confirm"
@@ -1094,6 +1100,16 @@ password = "Password"
password_placeholder = "Password Placeholder" password_placeholder = "Password Placeholder"
title = "Security Settings" 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] [ui.admin.users.detail.tenants_section]
additional = "Additional Affiliated/Manageable Tenants" additional = "Additional Affiliated/Manageable Tenants"
primary = "Representative Affiliated Tenant" primary = "Representative Affiliated Tenant"

View File

@@ -288,6 +288,12 @@ name_required = "이름은 필수입니다."
[msg.admin.users.detail.security] [msg.admin.users.detail.security]
password_hint = "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다." 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] [msg.admin.users.list]
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?" delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
@@ -1094,6 +1100,16 @@ password = "비밀번호 변경"
password_placeholder = "변경할 경우에만 입력" password_placeholder = "변경할 경우에만 입력"
title = "보안 설정" 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] [ui.admin.users.detail.tenants_section]
additional = "추가 소속/관리 테넌트" additional = "추가 소속/관리 테넌트"
primary = "대표 소속 테넌트" primary = "대표 소속 테넌트"

View File

@@ -288,6 +288,12 @@ name_required = ""
[msg.admin.users.detail.security] [msg.admin.users.detail.security]
password_hint = "" 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] [msg.admin.users.list]
delete_confirm = "" delete_confirm = ""
@@ -1094,6 +1100,16 @@ password = ""
password_placeholder = "" password_placeholder = ""
title = "" 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] [ui.admin.users.detail.tenants_section]
additional = "" additional = ""
primary = "" primary = ""

View File

@@ -539,8 +539,12 @@ func main() {
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption // [Changed] Use Interstitial Page for GET to prevent Scanner consumption
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage) 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 // [Added] Use POST for actual verification triggered by the user
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken) 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.Post("/password/reset/complete", authHandler.CompletePasswordReset)
auth.Get("/password/policy", authHandler.GetPasswordPolicy) auth.Get("/password/policy", authHandler.GetPasswordPolicy)
auth.Post("/sms", authHandler.SendSms) auth.Post("/sms", authHandler.SendSms)

View File

@@ -1,6 +1,16 @@
package domain 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 { type HydraClient struct {
ClientID string `json:"client_id"` ClientID string `json:"client_id"`
@@ -17,22 +27,53 @@ type HydraClient struct {
Metadata map[string]interface{} `json:"metadata,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"`
} }
func (c *HydraClient) IsTrustedRP() bool { func (c *HydraClient) SupportsHeadlessLogin() bool {
// A Trusted RP must have a public key registered (URI or Inline) // A headless login client must have a public key registered (URI or Inline)
// and use private_key_jwt for token endpoint authentication. // and use private_key_jwt for token endpoint authentication.
hasPublicKey := c.JWKSUri != "" || c.JWKS != nil hasPublicKey := c.HeadlessJWKSURI() != "" || c.HeadlessJWKS() != nil
isPrivateKeyJwt := c.TokenEndpointAuthMethod == "private_key_jwt" isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt"
return hasPublicKey && isPrivateKeyJwt 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 { func (c *HydraClient) IsHeadlessLoginEnabled() bool {
if !c.IsTrustedRP() { if !c.SupportsHeadlessLogin() {
return false return false
} }
if c.Metadata == nil { if c.Metadata == nil {
return false return false
} }
val, ok := c.Metadata["headless_login_enabled"] val, ok := c.Metadata[MetadataHeadlessLoginEnabled]
if !ok { if !ok {
return false return false
} }

View File

@@ -2,7 +2,29 @@ package domain
import "testing" 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) { t.Run("inline jwks with private_key_jwt and headless enabled", func(t *testing.T) {
client := HydraClient{ client := HydraClient{
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "private_key_jwt",
@@ -16,15 +38,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
}, },
} }
if !client.IsTrustedRP() { if !client.SupportsHeadlessLogin() {
t.Fatalf("expected trusted rp") t.Fatalf("expected headless login client")
} }
if !client.IsHeadlessLoginEnabled() { if !client.IsHeadlessLoginEnabled() {
t.Fatalf("expected headless login enabled") 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{ client := HydraClient{
TokenEndpointAuthMethod: "none", TokenEndpointAuthMethod: "none",
JWKSUri: "https://rp.example.com/.well-known/jwks.json", JWKSUri: "https://rp.example.com/.well-known/jwks.json",
@@ -33,15 +55,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
}, },
} }
if client.IsTrustedRP() { if client.SupportsHeadlessLogin() {
t.Fatalf("expected untrusted rp") t.Fatalf("expected headless login prerequisites to be missing")
} }
if client.IsHeadlessLoginEnabled() { 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{ client := HydraClient{
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "private_key_jwt",
JWKSUri: "https://rp.example.com/.well-known/jwks.json", JWKSUri: "https://rp.example.com/.well-known/jwks.json",
@@ -50,8 +72,8 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
}, },
} }
if !client.IsTrustedRP() { if !client.SupportsHeadlessLogin() {
t.Fatalf("expected trusted rp") t.Fatalf("expected headless login client")
} }
if client.IsHeadlessLoginEnabled() { if client.IsHeadlessLoginEnabled() {
t.Fatalf("expected headless login disabled for non-bool metadata") t.Fatalf("expected headless login disabled for non-bool metadata")

View File

@@ -11,6 +11,7 @@ type NaverSmsRequest struct {
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
CountryCode string `json:"countryCode"` CountryCode string `json:"countryCode"`
From string `json:"from"` From string `json:"from"`
Subject string `json:"subject,omitempty"`
Content string `json:"content"` Content string `json:"content"`
Messages []SmsMessage `json:"messages"` Messages []SmsMessage `json:"messages"`
} }

View File

@@ -73,7 +73,9 @@ const (
emailCodeTTL = 5 * time.Minute emailCodeTTL = 5 * time.Minute
smsCodeTTL = 3 * time.Minute smsCodeTTL = 3 * time.Minute
prefixPwdResetToken = "pwdreset_token:" prefixPwdResetToken = "pwdreset_token:"
prefixPwdResetUsed = "pwdreset_used:"
pwdResetExpiration = 15 * time.Minute pwdResetExpiration = 15 * time.Minute
pwdResetUsedExpiration = 2 * time.Minute
minPollInterval = 2 * time.Second minPollInterval = 2 * time.Second
loginCodeExpiration = 10 * time.Minute loginCodeExpiration = 10 * time.Minute
linkResendCooldown = 60 * time.Second linkResendCooldown = 60 * time.Second
@@ -1741,14 +1743,14 @@ func containsHeadlessAudience(expected []string, actual headlessAssertionAud) bo
func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient) (*jose.JSONWebKeySet, error) { func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient) (*jose.JSONWebKeySet, error) {
var raw []byte var raw []byte
switch { switch {
case client.JWKS != nil: case client.HeadlessJWKS() != nil:
data, err := json.Marshal(client.JWKS) data, err := json.Marshal(client.HeadlessJWKS())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encode jwks: %w", err) return nil, fmt.Errorf("failed to encode jwks: %w", err)
} }
raw = data raw = data
case strings.TrimSpace(client.JWKSUri) != "": case client.HeadlessJWKSURI() != "":
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(client.JWKSUri), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.HeadlessJWKSURI(), nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to build jwks request: %w", err) 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 raw = body
default: 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 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) return nil, fmt.Errorf("failed to decode jwks: %w", err)
} }
if len(keySet.Keys) == 0 { 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 return &keySet, nil
} }
@@ -2410,9 +2412,9 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
} }
userfrontURL := h.resolveUserfrontURL(c) userfrontURL := h.resolveUserfrontURL(c)
// [Changed] Point to Backend API for verification (which then redirects to Frontend) // 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다.
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL) // 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다.
ale.RedirectTo = redirectURL verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL)
// 내부 토큰 발급 + 우리 채널로 전송 // 내부 토큰 발급 + 우리 채널로 전송
resetToken := GenerateSecureToken(32) 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") 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.RedirectTo = resetLink
ale.Operation = "SendPasswordReset" ale.Operation = "SendPasswordReset"
ale.Log(slog.LevelInfo, "Initiating password reset via internal token") ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
@@ -2498,6 +2500,9 @@ func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error {
if token == "" { if token == "" {
token = c.Query("t") token = c.Query("t")
} }
if token == "" {
token = c.Params("token")
}
if token == "" { if token == "" {
return c.Status(fiber.StatusBadRequest).SendString("Missing 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") token = c.Query("t")
} }
} }
if token == "" {
token = c.Params("token")
}
ale.Token = token ale.Token = token
if token == "" { if token == "" {
@@ -2625,6 +2633,14 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
if resetToken != "" { if resetToken != "" {
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken) val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
if err != nil || strings.TrimSpace(val) == "" { 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.Status = fiber.StatusUnauthorized
ale.LatencyMs = time.Since(startTime) ale.LatencyMs = time.Since(startTime)
ale.ProviderError = "Invalid or expired reset token" 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)) ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
if resetToken != "" { if resetToken != "" {
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken) _ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
_ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration)
} }
return c.JSON(fiber.Map{"message": "Password has been reset successfully."}) return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
} }

View File

@@ -16,13 +16,29 @@ import (
) )
// Mock services // 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 { func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
app := fiber.New() app := fiber.New()
@@ -156,7 +172,7 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
assert.Equal(t, "expired_token", got["code"]) 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)} redis := &mockRedisRepo{data: make(map[string]string)}
privateKey, jwks := mustHeadlessRSAJWK(t) privateKey, jwks := mustHeadlessRSAJWK(t)
@@ -170,12 +186,13 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "none",
JWKS: jwks,
Metadata: map[string]interface{}{ Metadata: map[string]interface{}{
"status": "active", "status": "active",
"headless_login_enabled": true, "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") t.Setenv("USERFRONT_URL", "http://userfront.test")
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"), "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
"loginId": "010-1234-5678", "loginId": "010-1234-5678",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",
}) })
@@ -231,12 +248,13 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "none",
JWKS: jwks,
Metadata: map[string]interface{}{ Metadata: map[string]interface{}{
"status": "active", "status": "active",
"headless_login_enabled": true, "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") t.Setenv("USERFRONT_URL", "http://userfront.test")
initBody, _ := json.Marshal(map[string]string{ initBody, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"), "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
"loginId": "010-1234-5678", "loginId": "010-1234-5678",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",
}) })
@@ -300,8 +318,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
pollBody, _ := json.Marshal(map[string]string{ pollBody, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/poll"), "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"),
"pendingRef": pendingRef, "pendingRef": pendingRef,
}) })
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody)) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody))

View File

@@ -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 := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt"}, SessionToken: &domain.Token{JWT: "valid-jwt"},
@@ -305,12 +305,13 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{ json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "none",
JWKSUri: jwksServer.URL + "/.well-known/jwks.json",
Metadata: map[string]interface{}{ Metadata: map[string]interface{}{
"status": "active", "status": "active",
"headless_login_enabled": true, "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( clientAssertion := mustHeadlessClientAssertion(
t, t,
privateKey, privateKey,
"trusted-rp", "headless-login-client",
"http://example.com/api/v1/auth/headless/password/login", "http://example.com/api/v1/auth/headless/password/login",
) )
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"client_assertion": clientAssertion, "client_assertion": clientAssertion,
"loginId": "employee001", "loginId": "employee001",
"password": "password", "password": "password",
@@ -389,7 +390,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{ json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "private_key_jwt",
JWKS: map[string]any{ JWKS: map[string]any{
"keys": []map[string]any{}, "keys": []map[string]any{},
@@ -420,7 +421,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
app := newHeadlessPasswordLoginTestApp(h) app := newHeadlessPasswordLoginTestApp(h)
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"loginId": "employee001", "loginId": "employee001",
"password": "password", "password": "password",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",
@@ -459,7 +460,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{ json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "private_key_jwt",
JWKS: jwks, JWKS: jwks,
Metadata: map[string]interface{}{ Metadata: map[string]interface{}{
@@ -490,11 +491,11 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
clientAssertion := mustHeadlessClientAssertion( clientAssertion := mustHeadlessClientAssertion(
t, t,
invalidKey, invalidKey,
"trusted-rp", "headless-login-client",
"http://example.com/api/v1/auth/headless/password/login", "http://example.com/api/v1/auth/headless/password/login",
) )
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"client_assertion": clientAssertion, "client_assertion": clientAssertion,
"loginId": "employee001", "loginId": "employee001",
"password": "password", "password": "password",
@@ -523,11 +524,12 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
json.NewEncoder(w).Encode(domain.HydraLoginRequest{ json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "trusted-rp", ClientID: "headless-login-client",
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "none",
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
Metadata: map[string]interface{}{ 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) app := newHeadlessPasswordLoginTestApp(h)
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"loginId": "employee001", "loginId": "employee001",
"password": "password", "password": "password",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",
@@ -576,11 +578,12 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) {
Challenge: "challenge-123", Challenge: "challenge-123",
Client: domain.HydraClient{ Client: domain.HydraClient{
ClientID: "other-rp", ClientID: "other-rp",
TokenEndpointAuthMethod: "private_key_jwt", TokenEndpointAuthMethod: "none",
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
Metadata: map[string]interface{}{ Metadata: map[string]interface{}{
"status": "active", "status": "active",
"headless_login_enabled": true, "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) app := newHeadlessPasswordLoginTestApp(h)
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"client_id": "trusted-rp", "client_id": "headless-login-client",
"loginId": "employee001", "loginId": "employee001",
"password": "password", "password": "password",
"login_challenge": "challenge-123", "login_challenge": "challenge-123",

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "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) { func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
const token = "tok-enc" const token = "tok-enc"
const loginID = "user+alias@example.com" 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) { func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
h := &AuthHandler{} h := &AuthHandler{}
app := newResetInitAppWithErrorCodeEnricher(h) app := newResetInitAppWithErrorCodeEnricher(h)
@@ -326,3 +482,40 @@ func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T)
t.Fatalf("expected code=bad_request, got %v", got["code"]) 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)
}
}

View File

@@ -21,6 +21,7 @@ type mockIdpProvider struct {
err error err error
initiateLinkErr error initiateLinkErr error
updateCalled bool updateCalled bool
updateCallCount int
updatedLoginID string updatedLoginID string
updatedPassword 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 { func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
m.updateCalled = true m.updateCalled = true
m.updateCallCount++
m.updatedLoginID = loginID m.updatedLoginID = loginID
m.updatedPassword = newPassword m.updatedPassword = newPassword
return m.err return m.err

View File

@@ -891,6 +891,13 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
tokenAuthMethod = "client_secret_basic" tokenAuthMethod = "client_secret_basic"
} }
} }
tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig(
clientType,
tokenAuthMethod,
valueOr(req.JwksUri, ""),
req.Jwks,
metadata,
)
clientReq := domain.HydraClient{ clientReq := domain.HydraClient{
ClientID: clientID, ClientID: clientID,
@@ -900,8 +907,8 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
ResponseTypes: responseTypes, ResponseTypes: responseTypes,
Scope: strings.Join(scopes, " "), Scope: strings.Join(scopes, " "),
TokenEndpointAuthMethod: tokenAuthMethod, TokenEndpointAuthMethod: tokenAuthMethod,
JWKSUri: valueOr(req.JwksUri, ""), JWKSUri: jwksURI,
JWKS: req.Jwks, JWKS: jwks,
Metadata: metadata, Metadata: metadata,
} }
@@ -1044,6 +1051,23 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
} }
metadata["status"] = status 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{ updated := domain.HydraClient{
ClientID: current.ClientID, ClientID: current.ClientID,
@@ -1052,14 +1076,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes), GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes), ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))), Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
TokenEndpointAuthMethod: resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod), TokenEndpointAuthMethod: resolvedTokenAuthMethod,
JWKSUri: valueOr(req.JwksUri, current.JWKSUri), JWKSUri: resolvedJWKSURI,
JWKS: req.Jwks, JWKS: resolvedJWKS,
Metadata: metadata, Metadata: metadata,
} }
if req.Jwks == nil {
updated.JWKS = current.JWKS
}
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
return errorJSON(c, fiber.StatusForbidden, err.Error()) 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 { func defaultClientScopes() []string {
return []string{"openid", "profile", "email"} return []string{"openid", "profile", "email"}
} }

View File

@@ -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 var captured domain.HydraClient
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { 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) app.Post("/api/v1/dev/clients", h.CreateClient)
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"name": "Trusted RP App", "name": "Headless Login App",
"type": "pkce", "type": "pkce",
"redirectUris": []string{"https://rp.example.com/callback"}, "redirectUris": []string{"https://rp.example.com/callback"},
"scopes": []string{"openid", "profile"}, "scopes": []string{"openid", "profile"},
@@ -676,22 +676,23 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.StatusCreated, resp.StatusCode)
assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
assert.NotNil(t, captured.JWKS) assert.Nil(t, captured.JWKS)
assert.True(t, captured.IsTrustedRP()) 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.True(t, captured.IsHeadlessLoginEnabled())
assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"]) 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 var captured domain.HydraClient
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { 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{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted", "client_id": "client-headless-login",
"client_name": "Trusted Before", "client_name": "Headless Login Before",
"redirect_uris": []string{"https://before.example.com/callback"}, "redirect_uris": []string{"https://before.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"}, "grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"}, "response_types": []string{"code"},
@@ -702,7 +703,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
}, },
}), nil }), 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) body, err := io.ReadAll(r.Body)
assert.NoError(t, err) assert.NoError(t, err)
err = json.Unmarshal(body, &captured) err = json.Unmarshal(body, &captured)
@@ -740,7 +741,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
app.Put("/api/v1/dev/clients/:id", h.UpdateClient) app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"name": "Trusted After", "name": "Headless Login After",
"type": "pkce", "type": "pkce",
"tokenEndpointAuthMethod": "private_key_jwt", "tokenEndpointAuthMethod": "private_key_jwt",
"jwksUri": "https://rp.example.com/.well-known/jwks.json", "jwksUri": "https://rp.example.com/.well-known/jwks.json",
@@ -749,14 +750,15 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
"request_object_signing_alg": "RS256", "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") req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req, -1) resp, _ := app.Test(req, -1)
assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) assert.Equal(t, "", captured.JWKSUri)
assert.True(t, captured.IsTrustedRP()) 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.True(t, captured.IsHeadlessLoginEnabled())
assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
} }

View File

@@ -1249,10 +1249,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
} }
finalLoginID := extractTraitString(traits, "id") finalLoginID := extractTraitString(traits, "id")
userEmail := extractTraitString(traits, "email") userEmail := extractTraitString(traits, "email")
userPhone := extractTraitString(traits, "phone") userPhone := extractTraitString(traits, "phone_number")
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil { if err := domain.ValidateLoginID(explicitLoginID, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error()) return errorJSON(c, fiber.StatusBadRequest, err.Error())
} }
finalLoginID := resolvePasswordLoginID(traits)
state := normalizeKratosState(req.Status) state := normalizeKratosState(req.Status)
@@ -1336,7 +1337,10 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
} }
if req.Password != nil && *req.Password != "" { 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()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
} }
@@ -1618,6 +1622,16 @@ func extractTraitString(traits map[string]interface{}, key string) string {
return "" 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. // 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) { func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) {
if loginIDField == "" { if loginIDField == "" {

View File

@@ -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) { func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) { t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
app := fiber.New() app := fiber.New()

View File

@@ -425,16 +425,16 @@ func TestRotateClientSecret_PersistsForLaterDetailFetch(t *testing.T) {
} }
} }
func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) { func TestUpdateClient_HeadlessLoginSecretPersistsForLaterDetailFetch(t *testing.T) {
getCount := 0 getCount := 0
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { 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++ getCount++
if getCount == 1 { if getCount == 1 {
return httpJSONAny(r, http.StatusOK, map[string]any{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted", "client_id": "client-headless-login",
"client_name": "Trusted Before", "client_name": "Headless Login Before",
"redirect_uris": []string{"https://before.example.com/callback"}, "redirect_uris": []string{"https://before.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"}, "grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"}, "response_types": []string{"code"},
@@ -447,14 +447,14 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
} }
return httpJSONAny(r, http.StatusOK, map[string]any{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted", "client_id": "client-headless-login",
"client_name": "Trusted After", "client_name": "Headless Login After",
"redirect_uris": []string{"https://trusted.example.com/callback"}, "redirect_uris": []string{"https://headless.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"}, "grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"}, "response_types": []string{"code"},
"scope": "openid profile", "scope": "openid profile",
"token_endpoint_auth_method": "private_key_jwt", "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{ "metadata": map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
@@ -463,17 +463,17 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
}), nil }), 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{ return httpJSONAny(r, http.StatusOK, map[string]any{
"client_id": "client-trusted", "client_id": "client-headless-login",
"client_name": "Trusted After", "client_name": "Headless Login After",
"client_secret": "trusted-secret", "client_secret": "headless-secret",
"redirect_uris": []string{"https://trusted.example.com/callback"}, "redirect_uris": []string{"https://headless.example.com/callback"},
"grant_types": []string{"authorization_code", "refresh_token"}, "grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"}, "response_types": []string{"code"},
"scope": "openid profile", "scope": "openid profile",
"token_endpoint_auth_method": "private_key_jwt", "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{ "metadata": map[string]any{
"status": "active", "status": "active",
"headless_login_enabled": true, "headless_login_enabled": true,
@@ -507,16 +507,16 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
app.Get("/api/v1/dev/clients/:id", h.GetClient) app.Get("/api/v1/dev/clients/:id", h.GetClient)
updateBody, _ := json.Marshal(map[string]any{ updateBody, _ := json.Marshal(map[string]any{
"name": "Trusted After", "name": "Headless Login After",
"redirectUris": []string{"https://trusted.example.com/callback"}, "redirectUris": []string{"https://headless.example.com/callback"},
"tokenEndpointAuthMethod": "private_key_jwt", "tokenEndpointAuthMethod": "private_key_jwt",
"jwksUri": "https://trusted.example.com/jwks.json", "jwksUri": "https://headless.example.com/jwks.json",
"metadata": map[string]any{ "metadata": map[string]any{
"headless_login_enabled": true, "headless_login_enabled": true,
"request_object_signing_alg": "RS256", "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") updateReq.Header.Set("Content-Type", "application/json")
updateResp, err := app.Test(updateReq, -1) 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) t.Fatalf("expected update 200, got %d", updateResp.StatusCode)
} }
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-trusted") storedSecret, _ := secretRepo.GetByID(context.Background(), "client-headless-login")
if storedSecret != "trusted-secret" { if storedSecret != "headless-secret" {
t.Fatalf("expected postgres secret trusted-secret, got %q", storedSecret) 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 { if err != nil {
t.Fatalf("expected redis secret, got error: %v", err) t.Fatalf("expected redis secret, got error: %v", err)
} }
if redisSecret != "trusted-secret" { if redisSecret != "headless-secret" {
t.Fatalf("expected redis secret trusted-secret, got %q", redisSecret) 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) getResp, err := app.Test(getReq, -1)
if err != nil { if err != nil {
t.Fatalf("get request failed: %v", err) 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 { if err := json.NewDecoder(getResp.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err) t.Fatalf("decode response: %v", err)
} }
if payload.Client.ClientSecret != "trusted-secret" { if payload.Client.ClientSecret != "headless-secret" {
t.Fatalf("expected detail secret trusted-secret, got %q", payload.Client.ClientSecret) t.Fatalf("expected detail secret headless-secret, got %q", payload.Client.ClientSecret)
} }
} }

View File

@@ -11,12 +11,18 @@ import (
"os" "os"
"strings" "strings"
"time" "time"
"golang.org/x/crypto/bcrypt"
) )
type KratosIdentity struct { type KratosIdentity struct {
ID string `json:"id"` ID string `json:"id"`
SchemaID string `json:"schema_id,omitempty"`
Traits map[string]interface{} `json:"traits"` Traits map[string]interface{} `json:"traits"`
State string `json:"state,omitempty"` 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"` CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
} }
@@ -172,20 +178,54 @@ func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID stri
} }
func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
patchOps := []map[string]interface{}{ identity, err := s.GetIdentity(ctx, identityID)
{
"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))
if err != nil { if err != nil {
return err 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) resp, err := s.httpClient().Do(req)
if err != nil { if err != nil {
@@ -199,6 +239,14 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
return nil 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 { func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)

View File

@@ -14,6 +14,8 @@ import (
"os" "os"
"strings" "strings"
"time" "time"
"golang.org/x/crypto/bcrypt"
) )
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다. // 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) return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
} }
patchOps := []map[string]interface{}{ identity, err := o.getIdentity(identityID)
{ if err != nil {
"op": "add", return fmt.Errorf("ory provider: load identity failed: %w", err)
"path": "/credentials/password/config/password", }
"value": newPassword, if identity == nil {
}, return fmt.Errorf("ory provider: identity payload missing for loginID=%s", loginID)
} }
body, _ := json.Marshal(patchOps) hashedPassword, err := hashPasswordForKratos(newPassword)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) 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 { if err != nil {
return fmt.Errorf("ory provider: build request failed: %w", err) 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) resp, err := o.httpClient().Do(req)
if err != nil { if err != nil {
@@ -789,6 +824,41 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
return identities[0].ID, nil 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 { func (o *OryProvider) httpClient() *http.Client {
if o.HTTPClient != nil { if o.HTTPClient != nil {
return o.HTTPClient return o.HTTPClient

View File

@@ -45,6 +45,7 @@ func TestUpdateUserPassword_Success(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet: case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
if r.URL.Path == "/admin/identities" {
q := r.URL.Query() q := r.URL.Query()
if got := q.Get("credentials_identifier"); got != loginID { if got := q.Get("credentials_identifier"); got != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got) t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
@@ -53,10 +54,29 @@ func TestUpdateUserPassword_Success(t *testing.T) {
{"id": identityID}, {"id": identityID},
}) })
return return
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch: }
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.MethodPut:
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), newPassword) { if !strings.Contains(string(body), "\"hashed_password\"") {
t.Fatalf("payload missing new password, body=%s", string(body)) 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) w.WriteHeader(http.StatusOK)
return return
@@ -99,11 +119,25 @@ func TestUpdateUserPassword_ServerError(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet: case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
if r.URL.Path == "/admin/identities" {
_ = json.NewEncoder(w).Encode([]map[string]string{ _ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "abc"}, {"id": "abc"},
}) })
return return
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch: }
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) http.Error(w, "boom", http.StatusInternalServerError)
return return
default: default:

View File

@@ -17,6 +17,8 @@ import (
"time" "time"
) )
const naverSMSMaxBytes = 90
type SmsServiceImpl struct { type SmsServiceImpl struct {
accessKey string accessKey string
secretKey string secretKey string
@@ -46,17 +48,11 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
// Naver SENS API requires phone number without '+' // Naver SENS API requires phone number without '+'
sanitizedTo := strings.Replace(to, "+", "", 1) sanitizedTo := strings.Replace(to, "+", "", 1)
reqBody := domain.NaverSmsRequest{ reqBody := buildNaverSmsRequest(s.senderPhone, sanitizedTo, content)
Type: "SMS", if reqBody.Type == "LMS" {
ContentType: "COMM", slog.Info("[SmsService] Upgrading message type to LMS due to content length",
CountryCode: "82", "bytes", len([]byte(content)),
From: s.senderPhone, )
Content: content,
Messages: []domain.SmsMessage{
{
To: sanitizedTo,
},
},
} }
jsonBody, err := json.Marshal(reqBody) jsonBody, err := json.Marshal(reqBody)
@@ -100,6 +96,29 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
return nil 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) { func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
space := " " space := " "
newLine := "\n" newLine := "\n"

View File

@@ -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")
}
}

View File

@@ -72,6 +72,17 @@ function readMetadataString(
return typeof value === "string" ? value : ""; return typeof value === "string" ? value : "";
} }
function readMetadataObject(
metadata: Record<string, unknown>,
key: string,
): Record<string, unknown> | undefined {
const value = metadata[key];
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function isValidUrl(value: string): boolean { function isValidUrl(value: string): boolean {
try { try {
const url = new URL(value); const url = new URL(value);
@@ -150,15 +161,42 @@ function ClientGeneralPage() {
setStatus(client.status); setStatus(client.status);
setInitialStatus(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 = const savedAuthMethod =
client.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod ||
(client.type === "pkce" ? "none" : "client_secret_basic"); (client.type === "pkce" ? "none" : "client_secret_basic");
if (isTokenEndpointAuthMethod(savedAuthMethod)) { const headlessAuthMethod = readMetadataString(
setTokenEndpointAuthMethod(savedAuthMethod); 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); setJwksUri(client.jwksUri);
setJwksText("");
setJwksSource("uri"); setJwksSource("uri");
} else if (client.jwks) { } else if (client.jwks) {
setJwksText( setJwksText(
@@ -166,18 +204,16 @@ function ClientGeneralPage() {
? client.jwks ? client.jwks
: JSON.stringify(client.jwks, null, 2), : JSON.stringify(client.jwks, null, 2),
); );
setJwksUri("");
setJwksSource("inline");
} else {
setJwksUri("");
setJwksText("");
setJwksSource("inline"); 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 // Fallbacks from metadata if top-level fields are empty
if (!client.tokenEndpointAuthMethod) { if (!client.tokenEndpointAuthMethod && !headlessEnabled) {
const metaAuth = readMetadataString( const metaAuth = readMetadataString(
metadata, metadata,
"token_endpoint_auth_method", "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"); const metaJwksUri = readMetadataString(metadata, "jwks_uri");
if (metaJwksUri) { if (metaJwksUri) {
setJwksUri(metaJwksUri); setJwksUri(metaJwksUri);
@@ -342,11 +378,7 @@ function ClientGeneralPage() {
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean); const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
let finalJwks: ClientUpsertRequest["jwks"]; let finalJwks: ClientUpsertRequest["jwks"];
if ( if (jwksSource === "inline" && trimmedJwksText) {
tokenEndpointAuthMethod === "private_key_jwt" &&
jwksSource === "inline" &&
trimmedJwksText
) {
try { try {
finalJwks = JSON.parse(trimmedJwksText); finalJwks = JSON.parse(trimmedJwksText);
} catch (e) { } catch (e) {
@@ -354,23 +386,48 @@ function ClientGeneralPage() {
} }
} }
const effectiveTokenEndpointAuthMethod =
clientType === "pkce" && headlessLoginEnabled
? "none"
: tokenEndpointAuthMethod;
const payload: ClientUpsertRequest = { const payload: ClientUpsertRequest = {
name, name,
type: clientType, type: clientType,
scopes: scopeNames, scopes: scopeNames,
tokenEndpointAuthMethod, tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
jwksUri: jwksUri:
tokenEndpointAuthMethod === "private_key_jwt" && jwksSource === "uri" effectiveTokenEndpointAuthMethod === "private_key_jwt" &&
jwksSource === "uri"
? trimmedJwksUri ? trimmedJwksUri
: undefined, : undefined,
jwks: finalJwks, jwks:
effectiveTokenEndpointAuthMethod === "private_key_jwt"
? finalJwks
: undefined,
metadata: { metadata: {
description, description,
logo_url: logoUrl, logo_url: logoUrl,
structured_scopes: scopes, structured_scopes: scopes,
token_endpoint_auth_method: tokenEndpointAuthMethod, token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
request_object_signing_alg: trimmedRequestObjectSigningAlg, request_object_signing_alg: trimmedRequestObjectSigningAlg,
headless_login_enabled: headlessLoginEnabled, 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() {
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label <Label
className="text-xs font-bold cursor-pointer" className="text-xs font-bold cursor-pointer"
htmlFor="trusted-rp-toggle" htmlFor="headless-login-toggle"
> >
{t( {t(
"ui.dev.clients.general.security.trusted_rp_enable", "ui.dev.clients.general.security.headless_login_enable",
"Trusted RP (자체 로그인 UI 사용)", "Headless Login (자체 로그인 UI 사용)",
)} )}
</Label> </Label>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{t( {t(
"ui.dev.clients.general.security.trusted_rp_enable_help", "ui.dev.clients.general.security.headless_login_enable_help",
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.", "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
)} )}
</p> </p>
</div> </div>
<Switch <Switch
id="trusted-rp-toggle" id="headless-login-toggle"
checked={headlessLoginEnabled} checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle} onCheckedChange={handleHeadlessToggle}
/> />
@@ -941,7 +998,7 @@ function ClientGeneralPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* 4. Public Key Registration (Trusted RP) */} {/* 4. Public Key Registration (Headless Login) */}
{clientType === "pkce" && headlessLoginEnabled && ( {clientType === "pkce" && headlessLoginEnabled && (
<Card className="glass-panel border-primary/20"> <Card className="glass-panel border-primary/20">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -956,7 +1013,7 @@ function ClientGeneralPage() {
<CardDescription> <CardDescription>
{t( {t(
"msg.dev.clients.general.public_key.subtitle", "msg.dev.clients.general.public_key.subtitle",
"Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.", "Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다.",
)} )}
</CardDescription> </CardDescription>
</div> </div>

View File

@@ -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." 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." 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." 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] [msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications." 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_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_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_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." 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" 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." 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." 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] [msg.dev.clients.general.public_key.validation]
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm." headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
@@ -1392,10 +1392,10 @@ delete = "Delete"
[ui.dev.clients.general.security] [ui.dev.clients.general.security]
private = "Server Side App" private = "Server Side App"
pkce = "PKCE" pkce = "PKCE"
trusted = "Trusted RP" headless_login = "Headless Login"
title = "Security Settings" title = "Security Settings"
trusted_rp_enable = "Trusted RP (Custom Login UI)" headless_login_enable = "Headless Login (Custom Login UI)"
trusted_rp_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page." 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] [ui.dev.clients.general.public_key]
auth_method = "Token Endpoint Auth Method" auth_method = "Token Endpoint Auth Method"

View File

@@ -390,12 +390,12 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다." pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다." private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다." 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] [msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다." auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다." 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_example = "권장 예시: https://rp.example.com/.well-known/jwks.json"
guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다." guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다."
guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다." 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" jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다." request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다." source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다." subtitle = "Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다."
[msg.dev.clients.general.public_key.validation] [msg.dev.clients.general.public_key.validation]
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다." headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
@@ -1393,8 +1393,8 @@ delete = "삭제"
private = "Server side App" private = "Server side App"
pkce = "PKCE" pkce = "PKCE"
title = "보안 설정" title = "보안 설정"
trusted_rp_enable = "Trusted RP (자체 로그인 UI 사용)" headless_login_enable = "Headless Login (자체 로그인 UI 사용)"
trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다." headless_login_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
[ui.dev.clients.general.public_key] [ui.dev.clients.general.public_key]

View File

@@ -390,7 +390,7 @@ subtitle = ""
private_help = "" private_help = ""
pkce_help = "" pkce_help = ""
subtitle = "" subtitle = ""
trusted_help = "" headless_login_help = ""
[msg.dev.clients.general.public_key] [msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "" auth_method_client_secret_basic_help = ""
@@ -1392,8 +1392,8 @@ delete = ""
private = "" private = ""
pkce = "" pkce = ""
title = "" title = ""
trusted_rp_enable = "" headless_login_enable = ""
trusted_rp_enable_help = "" headless_login_enable_help = ""
[ui.dev.clients.general.public_key] [ui.dev.clients.general.public_key]
auth_method = "" auth_method = ""

View File

@@ -123,23 +123,23 @@ test.describe("DevFront clients lifecycle", () => {
).toHaveValue(/https:\/\/after\.example\.com\/callback/); ).toHaveValue(/https:\/\/after\.example\.com\/callback/);
}); });
test("pkce trusted rp with inline ssh-rsa key should persist mapped payload", async ({ test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({
page, page,
}) => { }) => {
const state = { const state = {
clients: [ clients: [
makeClient("client-trusted", { name: "Trusted App", type: "pkce" }), makeClient("client-headless-login", { name: "Headless Login App", type: "pkce" }),
], ],
consents: [] as Consent[], consents: [] as Consent[],
auditLogsByCursor: undefined, auditLogsByCursor: undefined,
}; };
await installDevApiMock(page, state); await installDevApiMock(page, state);
await page.goto("/clients/client-trusted/settings"); await page.goto("/clients/client-headless-login/settings");
await page await page
.getByRole("switch", { .getByRole("switch", {
name: /Trusted RP \(자체 로그인 UI 사용\)|Trusted RP \(Custom Login UI\)/i, name: /Headless Login \(자체 로그인 UI 사용\)|Headless Login \(Custom Login UI\)/i,
}) })
.click(); .click();
@@ -158,15 +158,20 @@ test.describe("DevFront clients lifecycle", () => {
await expect await expect
.poll(() => state.clients[0]?.tokenEndpointAuthMethod) .poll(() => state.clients[0]?.tokenEndpointAuthMethod)
.toBe("private_key_jwt"); .toBe("none");
await expect await expect
.poll(() => state.clients[0]?.metadata?.headless_login_enabled) .poll(() => state.clients[0]?.metadata?.headless_login_enabled)
.toBe(true); .toBe(true);
await expect
.poll(
() => state.clients[0]?.metadata?.headless_token_endpoint_auth_method,
)
.toBe("private_key_jwt");
await expect await expect
.poll( .poll(
() => () =>
( (
state.clients[0]?.jwks as { state.clients[0]?.metadata?.headless_jwks as {
keys?: Array<{ kty?: string; alg?: string }>; keys?: Array<{ kty?: string; alg?: string }>;
} }
)?.keys?.[0]?.kty, )?.keys?.[0]?.kty,
@@ -176,7 +181,7 @@ test.describe("DevFront clients lifecycle", () => {
.poll( .poll(
() => () =>
( (
state.clients[0]?.jwks as { state.clients[0]?.metadata?.headless_jwks as {
keys?: Array<{ kty?: string; alg?: string }>; keys?: Array<{ kty?: string; alg?: string }>;
} }
)?.keys?.[0]?.alg, )?.keys?.[0]?.alg,

View File

@@ -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`에 테스트 결과와 남은 후속 범위(전화번호 링크 승인형 분리)를 코멘트로 남깁니다.

View File

@@ -313,6 +313,10 @@ not_found = "Not Found"
update_error = "Failed to User Edit." update_error = "Failed to User Edit."
update_success = "Update Success" update_success = "Update Success"
password_generated = "A secure password has been generated." 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] [msg.admin.users.detail.form]
field_required = "Required." field_required = "Required."
@@ -1155,6 +1159,11 @@ back = "Back"
edit_title = "Edit Title" edit_title = "Edit Title"
title = "User Details" title = "User Details"
generate_password = "Auto Generate" 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] [ui.admin.users.detail.breadcrumb]
section = "Users" section = "Users"

View File

@@ -685,6 +685,10 @@ not_found = "사용자를 찾을 수 없습니다."
update_error = "사용자 수정에 실패했습니다." update_error = "사용자 수정에 실패했습니다."
update_success = "사용자 정보가 수정되었습니다." update_success = "사용자 정보가 수정되었습니다."
password_generated = "안전한 비밀번호가 생성되었습니다." password_generated = "안전한 비밀번호가 생성되었습니다."
password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다."
password_manual_required = "비밀번호를 입력해 주세요."
reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다."
self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요."
[msg.admin.users.list] [msg.admin.users.list]
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?" delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
@@ -1145,6 +1149,11 @@ back = "목록으로 돌아가기"
edit_title = "정보 수정" edit_title = "정보 수정"
title = "사용자 상세" title = "사용자 상세"
generate_password = "자동 생성" generate_password = "자동 생성"
password_mode_generated = "자동 생성"
password_mode_manual = "수동 입력"
password_result_title = "Reset Password"
reset_password_apply = "비밀번호 적용"
toggle_password_visibility = "비밀번호 표시 전환"
[ui.admin.users.list] [ui.admin.users.list]
add = "사용자 추가" add = "사용자 추가"

View File

@@ -685,6 +685,10 @@ not_found = ""
update_error = "" update_error = ""
update_success = "" update_success = ""
password_generated = "" password_generated = ""
password_generated_help = ""
password_manual_required = ""
reset_password_help = ""
self_password_reset_blocked = ""
[msg.admin.users.list] [msg.admin.users.list]
delete_confirm = "" delete_confirm = ""
@@ -1145,6 +1149,11 @@ back = ""
edit_title = "" edit_title = ""
title = "" title = ""
generate_password = "" generate_password = ""
password_mode_generated = ""
password_mode_manual = ""
password_result_title = ""
reset_password_apply = ""
toggle_password_visibility = ""
[ui.admin.users.list] [ui.admin.users.list]
add = "" add = ""

View File

@@ -7,32 +7,94 @@ type RequestCapture = {
clientLogs: string[]; clientLogs: string[];
}; };
const SIGNIN_PASSWORD_TAB_X = 522; async function enableFlutterAccessibility(page: Page): Promise<void> {
const SIGNIN_TAB_Y = 158; await page.waitForTimeout(300);
const SIGNIN_LOGIN_ID_X = 640; const button = page.getByRole('button', { name: 'Enable accessibility' });
const SIGNIN_LOGIN_ID_Y = 245; if (await button.count()) {
const SIGNIN_PASSWORD_X = 640; await button.click({ force: true });
const SIGNIN_PASSWORD_Y = 311; const placeholder = page.locator('flt-semantics-placeholder');
const SIGNIN_SUBMIT_X = 640; if (await placeholder.count()) {
const SIGNIN_SUBMIT_Y = 381; await placeholder.first().click({ force: true });
}
await page.waitForTimeout(800);
}
}
const RESET_NEW_PASSWORD_X = 640; type ScreenCoords = {
const RESET_NEW_PASSWORD_Y = 382; signinPasswordTabX: number;
const RESET_CONFIRM_PASSWORD_X = 640; signinTabY: number;
const RESET_CONFIRM_PASSWORD_Y = 464; signinLoginIdX: number;
const RESET_SUBMIT_X = 640; signinLoginIdY: number;
const RESET_SUBMIT_Y = 534; 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<void> { async function clickPasswordTab(page: Page): Promise<void> {
if (isMobileProject(page)) {
return;
}
const coords = coordsFor(page);
await page.waitForTimeout(900); await page.waitForTimeout(900);
const pane = page.locator('flt-glass-pane'); const pane = page.locator('flt-glass-pane');
await pane.click({ await pane.click({
position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y }, position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
force: true, force: true,
}); });
await page.waitForTimeout(120); await page.waitForTimeout(120);
await pane.click({ await pane.click({
position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y }, position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
force: true, force: true,
}); });
await page.waitForTimeout(200); 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); await page.keyboard.type(value);
} }
async function fillPasswordLoginForm(
page: Page,
loginId: string,
password: string,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> { async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void> {
await page.route('**/api/v1/**', async (route: Route) => { await page.route('**/api/v1/**', async (route: Route) => {
const requestUrl = new URL(route.request().url()); const requestUrl = new URL(route.request().url());
@@ -187,17 +311,17 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void>
test.describe('UserFront WASM password login and reset', () => { test.describe('UserFront WASM password login and reset', () => {
test.skip(({ isMobile }) => isMobile, 'Desktop only (hardcoded coordinates)'); test.skip(({ isMobile }) => isMobile, 'Desktop only (hardcoded coordinates)');
test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => { 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: [] }; const capture: RequestCapture = { clientLogs: [] };
await mockAuthApis(page, capture); await mockAuthApis(page, capture);
await page.goto('/ko/signin'); await page.goto('/ko/signin');
await clickPasswordTab(page); await clickPasswordTab(page);
await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com'); await fillPasswordLoginForm(page, 'e2e@example.com', 'ValidPass1!');
await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'ValidPass1!'); await submitPasswordLogin(page);
await page.locator('flt-glass-pane').click({
position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y },
force: true,
});
await expect(page).toHaveURL(/\/ko\/dashboard$/); await expect(page).toHaveURL(/\/ko\/dashboard$/);
@@ -211,17 +335,17 @@ test.describe('UserFront WASM password login and reset', () => {
}); });
test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => { 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: [] }; const capture: RequestCapture = { clientLogs: [] };
await mockAuthApis(page, capture); await mockAuthApis(page, capture);
await page.goto('/ko/signin'); await page.goto('/ko/signin');
await clickPasswordTab(page); await clickPasswordTab(page);
await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com'); await fillPasswordLoginForm(page, 'e2e@example.com', 'WrongPass1!');
await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'WrongPass1!'); await submitPasswordLogin(page);
await page.locator('flt-glass-pane').click({
position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y },
force: true,
});
await expect(page).toHaveURL(/\/ko\/signin$/); await expect(page).toHaveURL(/\/ko\/signin$/);
await expect 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 page.goto('/ko/reset-password?token=reset-token-e2e');
await policyLoaded; await policyLoaded;
await page.waitForTimeout(900); await page.waitForTimeout(900);
await fillAt(page, RESET_NEW_PASSWORD_X, RESET_NEW_PASSWORD_Y, 'ValidPass1!A'); await fillResetPasswordForm(page, 'ValidPass1!A');
await fillAt( await submitResetPassword(page);
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 expect await expect
.poll( .poll(

View File

@@ -6,12 +6,50 @@ type ProfileState = {
putBodies: Array<Record<string, unknown>>; putBodies: Array<Record<string, unknown>>;
}; };
const PROFILE_DEPARTMENT_EDIT_X = 1170; async function enableFlutterAccessibility(page: Page): Promise<void> {
const PROFILE_DEPARTMENT_EDIT_Y = 680; const button = page.getByRole('button', { name: 'Enable accessibility' });
const PROFILE_DEPARTMENT_INPUT_X = 110; if (await button.count()) {
const PROFILE_DEPARTMENT_INPUT_Y = 685; await button.click({ force: true });
const PROFILE_BLUR_X = 200; await page.waitForTimeout(200);
const PROFILE_BLUR_Y = 260; }
}
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<void> { async function seedTokenLogin(page: Page): Promise<void> {
await page.addInitScript(() => { 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<void> { async function openDepartmentEditor(page: Page): Promise<void> {
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({ 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, force: true,
}); });
await page.waitForTimeout(200); await page.waitForTimeout(200);
} }
async function blurDepartmentEditor(page: Page): Promise<void> { async function blurDepartmentEditor(page: Page): Promise<void> {
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({ 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, force: true,
}); });
await page.waitForTimeout(250); await page.waitForTimeout(250);
} }
async function submitDepartmentEditor(page: Page): Promise<void> { async function submitDepartmentEditor(page: Page): Promise<void> {
if (isMobileProject(page)) {
await page.getByRole('textbox', { name: '소속' }).press('Enter');
await page.waitForTimeout(250);
return;
}
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await page.waitForTimeout(250); await page.waitForTimeout(250);
} }
async function fillDepartmentField(page: Page, value: string): Promise<void> {
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<void> { async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
await page.route('**/api/v1/**', async (route: Route) => { await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request(); const request = route.request();
@@ -176,7 +244,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state); await waitForInitialProfileLoad(state);
await openDepartmentEditor(page); 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 submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1); await expect.poll(() => state.putBodies.length).toBe(1);
@@ -203,7 +271,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state); await waitForInitialProfileLoad(state);
await openDepartmentEditor(page); 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 page.reload();
await expect(page).toHaveURL(/\/ko\/profile$/); await expect(page).toHaveURL(/\/ko\/profile$/);
@@ -230,7 +298,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state); await waitForInitialProfileLoad(state);
await openDepartmentEditor(page); await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA'); await fillDepartmentField(page, 'QA');
await blurDepartmentEditor(page); await blurDepartmentEditor(page);
expect(state.putBodies).toHaveLength(0); expect(state.putBodies).toHaveLength(0);
@@ -248,7 +316,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state); await waitForInitialProfileLoad(state);
await openDepartmentEditor(page); await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, ''); await fillDepartmentField(page, '');
await blurDepartmentEditor(page); await blurDepartmentEditor(page);
expect(state.putBodies).toHaveLength(0); expect(state.putBodies).toHaveLength(0);
@@ -267,7 +335,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state); await waitForInitialProfileLoad(state);
await openDepartmentEditor(page); 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 submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1); await expect.poll(() => state.putBodies.length).toBe(1);
@@ -276,7 +344,7 @@ test.describe('UserFront WASM profile department editing', () => {
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
await openDepartmentEditor(page); 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 submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(2); await expect.poll(() => state.putBodies.length).toBe(2);

View File

@@ -91,7 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this, initialIndex: 1); _tabController = TabController(length: 3, vsync: this, initialIndex: 0);
_tabController.addListener(_handleTabSelection); _tabController.addListener(_handleTabSelection);
_drySendEnabled = _drySendEnabled =
_parseBoolParam(Uri.base.queryParameters['drySend']) && _parseBoolParam(Uri.base.queryParameters['drySend']) &&

View File

@@ -68,6 +68,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
} }
Future<void> _handlePasswordReset() async { Future<void> _handlePasswordReset() async {
if (_isLoading) return;
if (_formKey.currentState?.validate() != true) return; if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) && if ((_loginId == null || _loginId!.isEmpty) &&
(_token == null || _token!.isEmpty)) { (_token == null || _token!.isEmpty)) {
@@ -76,6 +77,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
} }
setState(() => _isLoading = true); setState(() => _isLoading = true);
bool isSuccess = false;
try { try {
await AuthProxyService.completePasswordReset( await AuthProxyService.completePasswordReset(
@@ -84,6 +86,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
newPassword: _passwordController.text, newPassword: _passwordController.text,
); );
isSuccess = true;
if (mounted) { if (mounted) {
ToastService.success(tr('msg.userfront.reset.success')); ToastService.success(tr('msg.userfront.reset.success'));
context.go(buildLocalizedSigninPath(Uri.base)); context.go(buildLocalizedSigninPath(Uri.base));
@@ -98,7 +101,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
); );
} }
} finally { } finally {
if (mounted) { if (mounted && !isSuccess) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
} }

View File

@@ -5,6 +5,7 @@ import 'package:logging/logging.dart';
import 'package:userfront/i18n.dart'; import 'package:userfront/i18n.dart';
import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/i18n/locale_utils.dart'; import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/auth_token_store.dart';
import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart'; import '../../../../core/ui/toast_service.dart';
@@ -54,10 +55,80 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
bool _showCurrentPassword = false; bool _showCurrentPassword = false;
bool _showNewPassword = false; bool _showNewPassword = false;
bool _showConfirmPassword = false; bool _showConfirmPassword = false;
Map<String, dynamic>? _passwordPolicy;
bool _isPasswordPolicyLoading = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadPasswordPolicy();
}
Future<void> _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 = <String>[
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( void _debugLog(
@@ -267,6 +338,58 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
); );
return; 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) { if (newPassword != confirmPassword) {
setState( setState(
() => _passwordError = tr('msg.userfront.profile.password.mismatch'), () => _passwordError = tr('msg.userfront.profile.password.mismatch'),
@@ -645,6 +768,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}) { }) {
final isEditing = _editingField == field; final isEditing = _editingField == field;
final displayValue = value.isEmpty ? '-' : value; final displayValue = value.isEmpty ? '-' : value;
final isCompact = MediaQuery.of(context).size.width < 640;
if (!isEditing) { if (!isEditing) {
return ListTile( return ListTile(
@@ -661,16 +785,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final hasChanged = _hasFieldChanged(profile, field); final hasChanged = _hasFieldChanged(profile, field);
return Column( final inputField = TextField(
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'), key: Key('profile-$field-input'),
controller: controller, controller: controller,
focusNode: field == 'name' ? _nameFocus : _departmentFocus, focusNode: field == 'name' ? _nameFocus : _departmentFocus,
@@ -686,10 +801,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
hintText: label, hintText: label,
errorText: _fieldSaveError, errorText: _fieldSaveError,
), ),
), );
), final saveButton = ElevatedButton(
const SizedBox(width: 12),
ElevatedButton(
key: Key('profile-$field-save-button'), key: Key('profile-$field-save-button'),
onPressed: isUpdating || !hasChanged || _isSavingField onPressed: isUpdating || !hasChanged || _isSavingField
? null ? null
@@ -701,15 +814,33 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: Text(tr('ui.common.save')), : Text(tr('ui.common.save')),
), );
const SizedBox(width: 8), final cancelButton = OutlinedButton(
OutlinedButton(
key: Key('profile-$field-cancel-button'), key: Key('profile-$field-cancel-button'),
onPressed: isUpdating || _isSavingField onPressed: isUpdating || _isSavingField
? null ? null
: () => _cancelEditing(profile), : () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')), child: Text(tr('ui.common.cancel')),
), );
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
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<ProfilePage> {
tr('msg.userfront.profile.password.subtitle'), tr('msg.userfront.profile.password.subtitle'),
style: const TextStyle(color: Color(0xFF6B7280)), style: const TextStyle(color: Color(0xFF6B7280)),
), ),
const SizedBox(height: 8),
Text(
_buildPasswordPolicyDescription(),
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12),
),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _currentPasswordController, controller: _currentPasswordController,