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

View File

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

140
README.md
View File

@@ -2,13 +2,27 @@
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
## 버그 대응 대원칙 (필수)
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
## 📂 프로젝트 구조 (Project Structure)
```
baron_sso/
├── backend/ # Go Fiber 애플리케이션
│ ├── cmd/server/ # 진입점 (Entry point)
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
│ └── Dockerfile
├── userfront/ # Flutter 애플리케이션
│ ├── src/ # UI 및 로직
│ └── pubspec.yaml
├── adminfront/ # React 기반 관리
│ ├── src/ # UI 및 로직
│ └── pubspec.yaml
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
├── docker-compose.yaml # 앱 서비스 (Front, Back)
├── .env.sample # 환경 설정 템플릿
└── README.md # 본 파일
```
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
@@ -20,6 +34,7 @@
* AdminFront: 사용자 관리 등 Admin 기능
* DevFront: RP 관리 등 개발자 기능
## 🏗 아키텍처 (Architecture)
### 0. Ory Stack
@@ -88,6 +103,85 @@ flowchart
2.1 향후 App Push 등 2차 인증 강화수단 검토 필요
3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환
### 5. Headless Login ID/Password Flow
- 목적: headless login을 허용한 클라이언트가 자체 로그인 화면에서 `ID/password`를 수집하되, Baron Backend가 OIDC 로그인 흐름만 계속 진행하고 RP에는 `sessionJwt`를 직접 넘기지 않습니다.
- 대상 엔드포인트: `POST /api/v1/auth/headless/password/login`
- 관련 구현:
- `backend/internal/handler/auth_handler.go`
- `backend/internal/domain/hydra_models.go`
- `backend/internal/handler/auth_handler_login_test.go`
#### 호출 순서
1. RP 브라우저가 Hydra Public의 `/oauth2/auth`를 호출해 OIDC 인증을 시작합니다.
2. Hydra가 로그인 단계로 넘긴 `login_challenge`를 RP가 확보합니다.
3. RP backend가 자기 private key로 `client_assertion` JWT를 서명합니다.
4. RP backend가 Baron Backend의 `POST /api/v1/auth/headless/password/login``client_id`, `client_assertion`, `login_challenge`, `loginId`, `password`를 전송합니다.
5. Baron Backend가 Hydra login request와 RP 설정을 검증한 뒤 Kratos sign-in 및 Hydra login accept를 수행합니다.
6. 성공 시 Baron Backend는 `redirectTo`만 반환하고, RP 브라우저는 그 URL로 이동해 OIDC 흐름을 이어갑니다.
#### 요청 바디
```json
{
"client_id": "headless-login-client",
"client_assertion": "<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 run dev
```
---
## 📂 프로젝트 구조 (Project Structure)
```
baron_sso/
├── backend/ # Go Fiber 애플리케이션
│ ├── cmd/server/ # 진입점 (Entry point)
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
│ └── Dockerfile
├── userfront/ # Flutter 애플리케이션
│ ├── src/ # UI 및 로직
│ └── pubspec.yaml
├── adminfront/ # React 기반 관리
│ ├── src/ # UI 및 로직
│ └── pubspec.yaml
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
├── docker-compose.yaml # 앱 서비스 (Front, Back)
├── .env.sample # 환경 설정 템플릿
└── README.md # 본 파일
```
## 📝 상태 및 로드맵 (Status & Roadmap)
- [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료)
- [x] **Phase 2**: Backend Audit API 구현 (일부 완료)
- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정)
- [ ] **Phase 4**: adminfront 기능 추가 (예정)
- [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정)
## 버그 대응 대원칙 (필수)
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.

View File

@@ -5,6 +5,8 @@ import {
BadgeCheck,
Building2,
Copy,
Eye,
EyeOff,
Key,
Loader2,
Mail,
@@ -32,16 +34,24 @@ import {
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../components/ui/tabs";
import { toast } from "../../components/ui/use-toast";
import {
type UserSummary,
type UserUpdateRequest,
deleteUser,
fetchPasswordPolicy,
fetchMe,
fetchTenants,
fetchUser,
updateUser,
} from "../../lib/adminApi";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { generateSecurePassword } from "../../lib/utils";
@@ -58,6 +68,116 @@ type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
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({
tenant,
schema,
@@ -166,6 +286,15 @@ function UserDetailPage() {
const [generatedPassword, setGeneratedPassword] = React.useState<
string | 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({
queryKey: ["me"],
@@ -187,6 +316,12 @@ function UserDetailPage() {
queryFn: () => fetchTenants(100, 0),
});
const tenants = tenantsData?.items ?? [];
const { data: passwordPolicy, isLoading: isPasswordPolicyLoading } = useQuery(
{
queryKey: ["password-policy"],
queryFn: fetchPasswordPolicy,
},
);
const {
register,
@@ -211,11 +346,13 @@ function UserDetailPage() {
const isAdmin =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const resetPasswordMutation = useMutation({
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
onSuccess: (_, newPass) => {
setGeneratedPassword(newPass);
setPasswordResetError(null);
toast.success(
t(
"msg.admin.users.detail.password_generated",
@@ -224,20 +361,67 @@ function UserDetailPage() {
);
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
const message =
err.response?.data?.error ||
t("msg.admin.users.detail.update_error", "수정에 실패했습니다."),
);
t("msg.admin.users.detail.update_error", "수정에 실패했습니다.");
setPasswordResetError(message);
toast.error(message);
},
});
const handleGeneratePassword = () => {
const handleOpenPasswordReset = () => {
if (isSelf) {
return;
}
setIsPasswordResetOpen(true);
setGeneratedPassword(null);
setPasswordResetMode("generated");
setManualPassword("");
setManualPasswordConfirm("");
setIsManualPasswordVisible(false);
setPasswordResetError(null);
};
const confirmGeneratePassword = () => {
const newPass = generateSecurePassword();
const handleClosePasswordReset = () => {
setIsPasswordResetOpen(false);
setGeneratedPassword(null);
setPasswordResetMode("generated");
setManualPassword("");
setManualPasswordConfirm("");
setIsManualPasswordVisible(false);
setPasswordResetError(null);
};
const confirmPasswordReset = () => {
if (isSelf) {
return;
}
let newPass = manualPassword;
if (passwordResetMode === "manual") {
const validationError = validateManualPassword(
manualPassword,
passwordPolicy,
);
if (validationError) {
setPasswordResetError(validationError);
return;
}
if (manualPassword !== manualPasswordConfirm) {
setPasswordResetError(
t(
"msg.userfront.reset.error.mismatch",
"비밀번호가 일치하지 않습니다.",
),
);
return;
}
} else {
newPass = generateSecurePassword();
}
setPasswordResetError(null);
resetPasswordMutation.mutate(newPass);
};
@@ -717,7 +901,7 @@ function UserDetailPage() {
</CardTitle>
</CardHeader>
<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">
<p className="text-sm font-medium">
{t(
@@ -726,44 +910,205 @@ function UserDetailPage() {
)}
</p>
<p className="text-xs text-muted-foreground">
.
{t(
"msg.admin.users.detail.reset_password_help",
"사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다.",
)}
</p>
</div>
<Button variant="outline" onClick={handleGeneratePassword}>
<Button
variant="outline"
onClick={handleOpenPasswordReset}
disabled={isSelf}
>
<RefreshCw className="mr-2 h-4 w-4" />
{t("ui.admin.users.detail.reset_password", "초기화 및 생성")}
{t("ui.admin.users.detail.reset_password", "초기화 및 설정")}
</Button>
</div>
{isPasswordResetOpen && !generatedPassword && (
<div className="p-4 border rounded-lg bg-destructive/5 space-y-4">
<p className="text-sm">
{isSelf && (
<div className="rounded-lg border px-4 py-3">
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.users.detail.reset_password_confirm",
"정말로 이 사용자의 비밀번호를 초기화하시겠습니까? 기존 비밀번호로는 즉시 로그인할 수 없게 됩니다.",
"msg.admin.users.detail.self_password_reset_blocked",
"본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요.",
)}
</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">
<Button
variant="ghost"
size="sm"
onClick={() => setIsPasswordResetOpen(false)}
onClick={handleClosePasswordReset}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={confirmGeneratePassword}
onClick={confirmPasswordReset}
disabled={resetPasswordMutation.isPending}
>
{resetPasswordMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t(
"ui.admin.users.detail.reset_password",
"초기화 및 생성",
"ui.admin.users.detail.reset_password_apply",
"비밀번호 적용",
)}
</Button>
</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="space-y-1">
<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 className="font-mono text-lg font-bold">
{generatedPassword}

View File

@@ -527,6 +527,22 @@ export async function updateUser(userId: string, payload: UserUpdateRequest) {
return data;
}
export type PasswordPolicyResponse = {
minLength?: number;
lowercase?: boolean;
uppercase?: boolean;
number?: boolean;
nonAlphanumeric?: boolean;
minCharacterTypes?: number;
};
export async function fetchPasswordPolicy() {
const { data } = await apiClient.get<PasswordPolicyResponse>(
"/v1/auth/password/policy",
);
return data;
}
export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`);
}

View File

@@ -288,6 +288,12 @@ name_required = "Name Required"
[msg.admin.users.detail.security]
password_hint = "Password Hint"
password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately."
password_manual_help = "Enter and apply a password with at least {{count}} characters."
password_manual_min_length = "Password must be at least {{count}} characters long."
password_manual_required = "Please enter a password."
reset_password_help = "Force-reset the user's password and apply either an auto-generated password or a manually entered one."
self_password_reset_blocked = "Please change your own password from the UserFront settings page."
[msg.admin.users.list]
delete_confirm = "Delete Confirm"
@@ -1094,6 +1100,16 @@ password = "Password"
password_placeholder = "Password Placeholder"
title = "Security Settings"
[ui.admin.users.detail]
manual_password = "New Password"
manual_password_placeholder = "Enter a new password"
password_mode_generated = "Auto Generate"
password_mode_manual = "Manual Input"
password_result_title = "Reset Password"
reset_password = "Reset & Set"
reset_password_apply = "Apply Password"
toggle_password_visibility = "Toggle password visibility"
[ui.admin.users.detail.tenants_section]
additional = "Additional Affiliated/Manageable Tenants"
primary = "Representative Affiliated Tenant"

View File

@@ -288,6 +288,12 @@ name_required = "이름은 필수입니다."
[msg.admin.users.detail.security]
password_hint = "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다."
password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다."
password_manual_help = "최소 {{count}}자 이상의 비밀번호를 직접 입력해 적용합니다."
password_manual_min_length = "비밀번호는 최소 {{count}}자 이상이어야 합니다."
password_manual_required = "비밀번호를 입력해 주세요."
reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다."
self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요."
[msg.admin.users.list]
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
@@ -1094,6 +1100,16 @@ password = "비밀번호 변경"
password_placeholder = "변경할 경우에만 입력"
title = "보안 설정"
[ui.admin.users.detail]
manual_password = "새 비밀번호"
manual_password_placeholder = "새 비밀번호를 입력하세요"
password_mode_generated = "자동 생성"
password_mode_manual = "수동 입력"
password_result_title = "Reset Password"
reset_password = "초기화 및 설정"
reset_password_apply = "비밀번호 적용"
toggle_password_visibility = "비밀번호 표시 전환"
[ui.admin.users.detail.tenants_section]
additional = "추가 소속/관리 테넌트"
primary = "대표 소속 테넌트"

View File

@@ -288,6 +288,12 @@ name_required = ""
[msg.admin.users.detail.security]
password_hint = ""
password_generated_help = ""
password_manual_help = ""
password_manual_min_length = ""
password_manual_required = ""
reset_password_help = ""
self_password_reset_blocked = ""
[msg.admin.users.list]
delete_confirm = ""
@@ -1094,6 +1100,16 @@ password = ""
password_placeholder = ""
title = ""
[ui.admin.users.detail]
manual_password = ""
manual_password_placeholder = ""
password_mode_generated = ""
password_mode_manual = ""
password_result_title = ""
reset_password = ""
reset_password_apply = ""
toggle_password_visibility = ""
[ui.admin.users.detail.tenants_section]
additional = ""
primary = ""

View File

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

View File

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

View File

@@ -2,7 +2,29 @@ package domain
import "testing"
func TestHydraClient_TrustedRPFlags(t *testing.T) {
func TestHydraClient_HeadlessLoginFlags(t *testing.T) {
t.Run("metadata-backed headless login client is supported", func(t *testing.T) {
client := HydraClient{
TokenEndpointAuthMethod: "none",
Metadata: map[string]any{
"headless_login_enabled": true,
"headless_token_endpoint_auth_method": "private_key_jwt",
"headless_jwks": map[string]any{
"keys": []map[string]any{{
"kty": "RSA",
}},
},
},
}
if !client.SupportsHeadlessLogin() {
t.Fatalf("expected metadata-backed headless login client")
}
if !client.IsHeadlessLoginEnabled() {
t.Fatalf("expected metadata-backed headless login enabled")
}
})
t.Run("inline jwks with private_key_jwt and headless enabled", func(t *testing.T) {
client := HydraClient{
TokenEndpointAuthMethod: "private_key_jwt",
@@ -16,15 +38,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
},
}
if !client.IsTrustedRP() {
t.Fatalf("expected trusted rp")
if !client.SupportsHeadlessLogin() {
t.Fatalf("expected headless login client")
}
if !client.IsHeadlessLoginEnabled() {
t.Fatalf("expected headless login enabled")
}
})
t.Run("jwks uri without private_key_jwt is not trusted", func(t *testing.T) {
t.Run("jwks uri without private_key_jwt does not support headless login", func(t *testing.T) {
client := HydraClient{
TokenEndpointAuthMethod: "none",
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
@@ -33,15 +55,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
},
}
if client.IsTrustedRP() {
t.Fatalf("expected untrusted rp")
if client.SupportsHeadlessLogin() {
t.Fatalf("expected headless login prerequisites to be missing")
}
if client.IsHeadlessLoginEnabled() {
t.Fatalf("expected headless login disabled when client is not trusted")
t.Fatalf("expected headless login disabled when prerequisites are missing")
}
})
t.Run("trusted rp without boolean metadata flag is not headless enabled", func(t *testing.T) {
t.Run("headless login client without boolean metadata flag is not enabled", func(t *testing.T) {
client := HydraClient{
TokenEndpointAuthMethod: "private_key_jwt",
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
@@ -50,8 +72,8 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
},
}
if !client.IsTrustedRP() {
t.Fatalf("expected trusted rp")
if !client.SupportsHeadlessLogin() {
t.Fatalf("expected headless login client")
}
if client.IsHeadlessLoginEnabled() {
t.Fatalf("expected headless login disabled for non-bool metadata")

View File

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

View File

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

View File

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

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

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -254,6 +255,65 @@ func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *test
}
}
func TestCompletePasswordReset_DuplicateTokenSubmitIsIdempotent(t *testing.T) {
const resetToken = "dup-token"
const loginID = "user@example.com"
const newPassword = "StrongPass1!"
redis := &testRedisRepo{
values: map[string]string{
prefixPwdResetToken + resetToken: loginID,
},
}
idp := &mockIdpProvider{
userExists: true,
err: nil,
}
h := &AuthHandler{
RedisService: redis,
IdpProvider: idp,
}
app := newResetFlowTestApp(h)
body, _ := json.Marshal(map[string]string{
"newPassword": newPassword,
})
url := fmt.Sprintf(
"/api/v1/auth/password/reset/complete?token=%s",
resetToken,
)
firstReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
firstReq.Header.Set("Content-Type", "application/json")
firstResp, err := app.Test(firstReq)
if err != nil {
t.Fatalf("first request failed: %v", err)
}
defer firstResp.Body.Close()
if firstResp.StatusCode != http.StatusOK {
t.Fatalf("expected first response to be 200, got %d", firstResp.StatusCode)
}
if idp.updateCallCount != 1 {
t.Fatalf("expected first request to update password once, got %d", idp.updateCallCount)
}
secondReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
secondReq.Header.Set("Content-Type", "application/json")
secondResp, err := app.Test(secondReq)
if err != nil {
t.Fatalf("second request failed: %v", err)
}
defer secondResp.Body.Close()
if secondResp.StatusCode != http.StatusOK {
t.Fatalf("expected duplicate response to be 200, got %d", secondResp.StatusCode)
}
if idp.updateCallCount != 1 {
t.Fatalf("expected duplicate request not to update password again, got %d", idp.updateCallCount)
}
}
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
const token = "tok-enc"
const loginID = "user+alias@example.com"
@@ -295,6 +355,102 @@ func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
}
}
func TestPasswordResetVerifyAlias_AcceptsShortVePath(t *testing.T) {
const token = "tok-ve"
const loginID = "user@example.com"
redis := &testRedisRepo{
values: map[string]string{
prefixPwdResetToken + token: loginID,
},
}
h := &AuthHandler{
RedisService: redis,
}
app := fiber.New()
app.Get("/api/v1/auth/password/reset/ve", h.VerifyPasswordResetPage)
app.Post("/api/v1/auth/password/reset/ve", h.ProcessPasswordResetToken)
getReq := httptest.NewRequest(
http.MethodGet,
"/api/v1/auth/password/reset/ve?token="+token,
nil,
)
getResp, err := app.Test(getReq)
if err != nil {
t.Fatalf("get request failed: %v", err)
}
defer getResp.Body.Close()
if getResp.StatusCode != http.StatusOK {
t.Fatalf("expected alias GET to return 200, got %d", getResp.StatusCode)
}
postReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/password/reset/ve?token="+token,
nil,
)
postResp, err := app.Test(postReq)
if err != nil {
t.Fatalf("post request failed: %v", err)
}
defer postResp.Body.Close()
if postResp.StatusCode != http.StatusFound {
t.Fatalf("expected alias POST to return 302, got %d", postResp.StatusCode)
}
}
func TestPasswordResetVerifyPathToken_AcceptsShortVPath(t *testing.T) {
const token = "tok-path"
const loginID = "user@example.com"
redis := &testRedisRepo{
values: map[string]string{
prefixPwdResetToken + token: loginID,
},
}
h := &AuthHandler{
RedisService: redis,
}
app := fiber.New()
app.Get("/api/v1/auth/password/reset/v/:token", h.VerifyPasswordResetPage)
app.Post("/api/v1/auth/password/reset/v/:token", h.ProcessPasswordResetToken)
getReq := httptest.NewRequest(
http.MethodGet,
"/api/v1/auth/password/reset/v/"+token,
nil,
)
getResp, err := app.Test(getReq)
if err != nil {
t.Fatalf("get request failed: %v", err)
}
defer getResp.Body.Close()
if getResp.StatusCode != http.StatusOK {
t.Fatalf("expected path-token GET to return 200, got %d", getResp.StatusCode)
}
postReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/password/reset/v/"+token,
nil,
)
postResp, err := app.Test(postReq)
if err != nil {
t.Fatalf("post request failed: %v", err)
}
defer postResp.Body.Close()
if postResp.StatusCode != http.StatusFound {
t.Fatalf("expected path-token POST to return 302, got %d", postResp.StatusCode)
}
}
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
h := &AuthHandler{}
app := newResetInitAppWithErrorCodeEnricher(h)
@@ -326,3 +482,40 @@ func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T)
t.Fatalf("expected code=bad_request, got %v", got["code"])
}
}
func TestInitiatePasswordReset_SmsContainsVerifyLink(t *testing.T) {
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
redis := &testRedisRepo{values: map[string]string{}}
smsSvc := &mockSmsService{}
h := &AuthHandler{
RedisService: redis,
IdpProvider: &mockIdpProvider{},
SmsService: smsSvc,
}
app := fiber.New()
app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset)
body, _ := json.Marshal(map[string]string{
"loginId": "01012345678",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if !strings.Contains(smsSvc.lastContent, "/api/v1/auth/password/reset/v/") {
t.Fatalf("expected SMS to contain short path verify link, got %q", smsSvc.lastContent)
}
if strings.Contains(smsSvc.lastContent, "/reset-password?token=") {
t.Fatalf("expected direct reset-password link to be removed, got %q", smsSvc.lastContent)
}
}

View File

@@ -21,6 +21,7 @@ type mockIdpProvider struct {
err error
initiateLinkErr error
updateCalled bool
updateCallCount int
updatedLoginID string
updatedPassword string
}
@@ -68,6 +69,7 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
m.updateCalled = true
m.updateCallCount++
m.updatedLoginID = loginID
m.updatedPassword = newPassword
return m.err

View File

@@ -891,6 +891,13 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
tokenAuthMethod = "client_secret_basic"
}
}
tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig(
clientType,
tokenAuthMethod,
valueOr(req.JwksUri, ""),
req.Jwks,
metadata,
)
clientReq := domain.HydraClient{
ClientID: clientID,
@@ -900,8 +907,8 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
ResponseTypes: responseTypes,
Scope: strings.Join(scopes, " "),
TokenEndpointAuthMethod: tokenAuthMethod,
JWKSUri: valueOr(req.JwksUri, ""),
JWKS: req.Jwks,
JWKSUri: jwksURI,
JWKS: jwks,
Metadata: metadata,
}
@@ -1044,6 +1051,23 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
}
metadata["status"] = status
}
resolvedClientType := currentSummary.Type
if clientType != "" {
resolvedClientType = clientType
}
resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod)
resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri)
resolvedJWKS := req.Jwks
if req.Jwks == nil {
resolvedJWKS = current.JWKS
}
resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig(
resolvedClientType,
resolvedTokenAuthMethod,
resolvedJWKSURI,
resolvedJWKS,
metadata,
)
updated := domain.HydraClient{
ClientID: current.ClientID,
@@ -1052,14 +1076,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
TokenEndpointAuthMethod: resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod),
JWKSUri: valueOr(req.JwksUri, current.JWKSUri),
JWKS: req.Jwks,
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
JWKSUri: resolvedJWKSURI,
JWKS: resolvedJWKS,
Metadata: metadata,
}
if req.Jwks == nil {
updated.JWKS = current.JWKS
}
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
return errorJSON(c, fiber.StatusForbidden, err.Error())
}
@@ -1676,6 +1697,70 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
}
}
func readMetadataStringValue(metadata map[string]interface{}, key string) string {
if metadata == nil {
return ""
}
raw, _ := metadata[key].(string)
return strings.TrimSpace(raw)
}
func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
if metadata == nil {
return false
}
value, _ := metadata[key].(bool)
return value
}
func normalizeHeadlessClientConfig(
clientType string,
tokenAuthMethod string,
jwksURI string,
jwks interface{},
metadata map[string]interface{},
) (string, string, interface{}, map[string]interface{}) {
if metadata == nil {
metadata = map[string]interface{}{}
}
headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled)
if clientType == "pkce" && headlessEnabled {
headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
if headlessTokenAuthMethod == "" && !strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "none") {
headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod)
}
if headlessTokenAuthMethod == "" {
headlessTokenAuthMethod = "private_key_jwt"
}
metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod
headlessJWKSURI := readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI)
if headlessJWKSURI == "" && strings.TrimSpace(jwksURI) != "" {
headlessJWKSURI = strings.TrimSpace(jwksURI)
}
if headlessJWKSURI != "" {
metadata[domain.MetadataHeadlessJWKSURI] = headlessJWKSURI
} else {
delete(metadata, domain.MetadataHeadlessJWKSURI)
}
if _, ok := metadata[domain.MetadataHeadlessJWKS]; !ok && jwks != nil {
metadata[domain.MetadataHeadlessJWKS] = jwks
}
if metadata[domain.MetadataHeadlessJWKS] == nil {
delete(metadata, domain.MetadataHeadlessJWKS)
}
return "none", "", nil, metadata
}
delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
delete(metadata, domain.MetadataHeadlessJWKSURI)
delete(metadata, domain.MetadataHeadlessJWKS)
return tokenAuthMethod, jwksURI, jwks, metadata
}
func defaultClientScopes() []string {
return []string{"openid", "profile", "email"}
}

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

View File

@@ -342,7 +342,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
}
}
// Fallback: Try syncing based on the tenant namespaces being updated
if !synced && h.TenantService != nil {
for k := range req.Metadata {
@@ -1249,10 +1249,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
finalLoginID := extractTraitString(traits, "id")
userEmail := extractTraitString(traits, "email")
userPhone := extractTraitString(traits, "phone")
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
userPhone := extractTraitString(traits, "phone_number")
if err := domain.ValidateLoginID(explicitLoginID, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
finalLoginID := resolvePasswordLoginID(traits)
state := normalizeKratosState(req.Status)
@@ -1336,7 +1337,10 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
if req.Password != nil && *req.Password != "" {
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
if h.OryProvider == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available")
}
if err := h.OryProvider.UpdateUserPassword(finalLoginID, *req.Password, nil); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
@@ -1618,6 +1622,16 @@ func extractTraitString(traits map[string]interface{}, key string) string {
return ""
}
func resolvePasswordLoginID(traits map[string]interface{}) string {
if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" {
return loginID
}
if email := strings.TrimSpace(extractTraitString(traits, "email")); email != "" {
return email
}
return strings.TrimSpace(extractTraitString(traits, "phone_number"))
}
// syncLoginID ensures that the 'id' trait (used as Kratos identifier) is in sync with the configured custom field.
func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) {
if loginIDField == "" {
@@ -1645,7 +1659,7 @@ func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantI
}
// 3. Check merged traits (which includes existing metadata)
// Important: Skip this if loginIDField is "id" because traits["id"] is the TARGET,
// Important: Skip this if loginIDField is "id" because traits["id"] is the TARGET,
// and we don't want to sync "id" to "id" if we already checked metadata.
if loginID == "" && loginIDField != "id" {
// Existing trait (flat)

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

View File

@@ -11,14 +11,20 @@ import (
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
type KratosIdentity struct {
ID string `json:"id"`
Traits map[string]interface{} `json:"traits"`
State string `json:"state,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
ID string `json:"id"`
SchemaID string `json:"schema_id,omitempty"`
Traits map[string]interface{} `json:"traits"`
State string `json:"state,omitempty"`
MetadataAdmin interface{} `json:"metadata_admin,omitempty"`
MetadataPublic interface{} `json:"metadata_public,omitempty"`
ExternalID string `json:"external_id,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type KratosAdminService interface {
@@ -172,20 +178,54 @@ func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID stri
}
func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
patchOps := []map[string]interface{}{
{
"op": "add",
"path": "/credentials/password/config/password",
"value": newPassword,
},
}
body, _ := json.Marshal(patchOps)
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
identity, err := s.GetIdentity(ctx, identityID)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json-patch+json")
if identity == nil {
return fmt.Errorf("kratos admin identity not found: %s", identityID)
}
hashedPassword, err := hashPasswordForKratosAdmin(newPassword)
if err != nil {
return err
}
payload := map[string]interface{}{
"schema_id": identity.SchemaID,
"traits": identity.Traits,
"state": identity.State,
"credentials": map[string]interface{}{
"password": map[string]interface{}{
"config": map[string]string{
"hashed_password": hashedPassword,
},
},
},
}
if payload["schema_id"] == "" {
payload["schema_id"] = "default"
}
if payload["state"] == "" {
payload["state"] = "active"
}
if identity.MetadataAdmin != nil {
payload["metadata_admin"] = identity.MetadataAdmin
}
if identity.MetadataPublic != nil {
payload["metadata_public"] = identity.MetadataPublic
}
if identity.ExternalID != "" {
payload["external_id"] = identity.ExternalID
}
body, _ := json.Marshal(payload)
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient().Do(req)
if err != nil {
@@ -199,6 +239,14 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
return nil
}
func hashPasswordForKratosAdmin(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)

View File

@@ -14,6 +14,8 @@ import (
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
@@ -711,20 +713,53 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
}
patchOps := []map[string]interface{}{
{
"op": "add",
"path": "/credentials/password/config/password",
"value": newPassword,
},
identity, err := o.getIdentity(identityID)
if err != nil {
return fmt.Errorf("ory provider: load identity failed: %w", err)
}
if identity == nil {
return fmt.Errorf("ory provider: identity payload missing for loginID=%s", loginID)
}
body, _ := json.Marshal(patchOps)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
hashedPassword, err := hashPasswordForKratos(newPassword)
if err != nil {
return fmt.Errorf("ory provider: hash password failed: %w", err)
}
payload := map[string]interface{}{
"schema_id": identity.SchemaID,
"traits": identity.Traits,
"state": identity.State,
"credentials": map[string]interface{}{
"password": map[string]interface{}{
"config": map[string]string{
"hashed_password": hashedPassword,
},
},
},
}
if payload["schema_id"] == "" {
payload["schema_id"] = "default"
}
if payload["state"] == "" {
payload["state"] = "active"
}
if identity.MetadataAdmin != nil {
payload["metadata_admin"] = identity.MetadataAdmin
}
if identity.MetadataPublic != nil {
payload["metadata_public"] = identity.MetadataPublic
}
if identity.ExternalID != "" {
payload["external_id"] = identity.ExternalID
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("ory provider: build request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json-patch+json")
req.Header.Set("Content-Type", "application/json")
resp, err := o.httpClient().Do(req)
if err != nil {
@@ -789,6 +824,41 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
return identities[0].ID, nil
}
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
if err != nil {
return nil, err
}
resp, err := o.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("ory provider: get identity failed status=%d body=%s", resp.StatusCode, string(respBody))
}
var identity KratosIdentity
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
return nil, err
}
return &identity, nil
}
func hashPasswordForKratos(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
func (o *OryProvider) httpClient() *http.Client {
if o.HTTPClient != nil {
return o.HTTPClient

View File

@@ -45,18 +45,38 @@ func TestUpdateUserPassword_Success(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
q := r.URL.Query()
if got := q.Get("credentials_identifier"); got != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
if r.URL.Path == "/admin/identities" {
q := r.URL.Query()
if got := q.Get("credentials_identifier"); got != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": identityID},
})
return
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": identityID},
if r.URL.Path != "/admin/identities/"+identityID {
t.Fatalf("unexpected identity lookup path: %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": identityID,
"schema_id": "default",
"state": "active",
"traits": map[string]interface{}{
"email": loginID,
},
})
return
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch:
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPut:
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), newPassword) {
t.Fatalf("payload missing new password, body=%s", string(body))
if !strings.Contains(string(body), "\"hashed_password\"") {
t.Fatalf("payload missing hashed_password, body=%s", string(body))
}
if strings.Contains(string(body), newPassword) {
t.Fatalf("payload must not contain plain password, body=%s", string(body))
}
if !strings.Contains(string(body), "\"schema_id\":\"default\"") {
t.Fatalf("payload missing schema_id, body=%s", string(body))
}
w.WriteHeader(http.StatusOK)
return
@@ -99,11 +119,25 @@ func TestUpdateUserPassword_ServerError(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "abc"},
})
return
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch:
if r.URL.Path == "/admin/identities" {
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "abc"},
})
return
}
if r.URL.Path == "/admin/identities/abc" {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"id": "abc",
"schema_id": "default",
"state": "active",
"traits": map[string]interface{}{
"email": "user@example.com",
},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPut:
http.Error(w, "boom", http.StatusInternalServerError)
return
default:

View File

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

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 : "";
}
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 {
try {
const url = new URL(value);
@@ -150,15 +161,42 @@ function ClientGeneralPage() {
setStatus(client.status);
setInitialStatus(client.status);
const metadata = client.metadata ?? {};
if (typeof metadata.description === "string")
setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
const headlessEnabled = !!metadata.headless_login_enabled;
setHeadlessLoginEnabled(headlessEnabled);
const savedAuthMethod =
client.tokenEndpointAuthMethod ||
(client.type === "pkce" ? "none" : "client_secret_basic");
if (isTokenEndpointAuthMethod(savedAuthMethod)) {
setTokenEndpointAuthMethod(savedAuthMethod);
const headlessAuthMethod = readMetadataString(
metadata,
"headless_token_endpoint_auth_method",
);
const selectedAuthMethod =
headlessEnabled && isTokenEndpointAuthMethod(headlessAuthMethod)
? headlessAuthMethod
: savedAuthMethod;
if (isTokenEndpointAuthMethod(selectedAuthMethod)) {
setTokenEndpointAuthMethod(selectedAuthMethod);
}
if (client.jwksUri) {
const headlessJwksUri = readMetadataString(metadata, "headless_jwks_uri");
const headlessJwks = readMetadataObject(metadata, "headless_jwks");
if (headlessJwksUri) {
setJwksUri(headlessJwksUri);
setJwksText("");
setJwksSource("uri");
} else if (headlessJwks) {
setJwksText(JSON.stringify(headlessJwks, null, 2));
setJwksUri("");
setJwksSource("inline");
} else if (client.jwksUri) {
setJwksUri(client.jwksUri);
setJwksText("");
setJwksSource("uri");
} else if (client.jwks) {
setJwksText(
@@ -166,18 +204,16 @@ function ClientGeneralPage() {
? client.jwks
: JSON.stringify(client.jwks, null, 2),
);
setJwksUri("");
setJwksSource("inline");
} else {
setJwksUri("");
setJwksText("");
setJwksSource("inline");
}
const metadata = client.metadata ?? {};
if (typeof metadata.description === "string")
setDescription(metadata.description);
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
setHeadlessLoginEnabled(!!metadata.headless_login_enabled);
// Fallbacks from metadata if top-level fields are empty
if (!client.tokenEndpointAuthMethod) {
if (!client.tokenEndpointAuthMethod && !headlessEnabled) {
const metaAuth = readMetadataString(
metadata,
"token_endpoint_auth_method",
@@ -187,7 +223,7 @@ function ClientGeneralPage() {
}
}
if (!client.jwksUri && !client.jwks) {
if (!client.jwksUri && !client.jwks && !headlessEnabled) {
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
if (metaJwksUri) {
setJwksUri(metaJwksUri);
@@ -342,11 +378,7 @@ function ClientGeneralPage() {
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
let finalJwks: ClientUpsertRequest["jwks"];
if (
tokenEndpointAuthMethod === "private_key_jwt" &&
jwksSource === "inline" &&
trimmedJwksText
) {
if (jwksSource === "inline" && trimmedJwksText) {
try {
finalJwks = JSON.parse(trimmedJwksText);
} catch (e) {
@@ -354,23 +386,48 @@ function ClientGeneralPage() {
}
}
const effectiveTokenEndpointAuthMethod =
clientType === "pkce" && headlessLoginEnabled
? "none"
: tokenEndpointAuthMethod;
const payload: ClientUpsertRequest = {
name,
type: clientType,
scopes: scopeNames,
tokenEndpointAuthMethod,
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
jwksUri:
tokenEndpointAuthMethod === "private_key_jwt" && jwksSource === "uri"
effectiveTokenEndpointAuthMethod === "private_key_jwt" &&
jwksSource === "uri"
? trimmedJwksUri
: undefined,
jwks: finalJwks,
jwks:
effectiveTokenEndpointAuthMethod === "private_key_jwt"
? finalJwks
: undefined,
metadata: {
description,
logo_url: logoUrl,
structured_scopes: scopes,
token_endpoint_auth_method: tokenEndpointAuthMethod,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
request_object_signing_alg: trimmedRequestObjectSigningAlg,
headless_login_enabled: headlessLoginEnabled,
headless_token_endpoint_auth_method:
clientType === "pkce" && headlessLoginEnabled
? tokenEndpointAuthMethod
: undefined,
headless_jwks_uri:
clientType === "pkce" &&
headlessLoginEnabled &&
jwksSource === "uri"
? trimmedJwksUri
: undefined,
headless_jwks:
clientType === "pkce" &&
headlessLoginEnabled &&
jwksSource === "inline"
? finalJwks
: undefined,
},
};
@@ -915,22 +972,22 @@ function ClientGeneralPage() {
<div className="space-y-0.5">
<Label
className="text-xs font-bold cursor-pointer"
htmlFor="trusted-rp-toggle"
htmlFor="headless-login-toggle"
>
{t(
"ui.dev.clients.general.security.trusted_rp_enable",
"Trusted RP (자체 로그인 UI 사용)",
"ui.dev.clients.general.security.headless_login_enable",
"Headless Login (자체 로그인 UI 사용)",
)}
</Label>
<p className="text-[10px] text-muted-foreground">
{t(
"ui.dev.clients.general.security.trusted_rp_enable_help",
"ui.dev.clients.general.security.headless_login_enable_help",
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
)}
</p>
</div>
<Switch
id="trusted-rp-toggle"
id="headless-login-toggle"
checked={headlessLoginEnabled}
onCheckedChange={handleHeadlessToggle}
/>
@@ -941,7 +998,7 @@ function ClientGeneralPage() {
</CardContent>
</Card>
{/* 4. Public Key Registration (Trusted RP) */}
{/* 4. Public Key Registration (Headless Login) */}
{clientType === "pkce" && headlessLoginEnabled && (
<Card className="glass-panel border-primary/20">
<CardHeader className="pb-3">
@@ -956,7 +1013,7 @@ function ClientGeneralPage() {
<CardDescription>
{t(
"msg.dev.clients.general.public_key.subtitle",
"Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.",
"Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다.",
)}
</CardDescription>
</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."
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
subtitle = "Select application type. Security level determines authentication method."
trusted_help = "Operate as a trusted RP using private_key_jwt and public key registration. Headless login is only available for this profile."
headless_login_help = "Operate as Headless Login using private_key_jwt and public key registration. Headless login is only available for this profile."
[msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
auth_method_none_help = "Use this for PKCE-based public clients."
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for trusted RP bootstrap and JAR verification."
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for Headless Login bootstrap and JAR verification."
guide_example = "Recommended example: https://rp.example.com/.well-known/jwks.json"
guide_intro = "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key."
guide_step_1 = "Generate a key pair on the RP server and keep the private key only in the RP backend."
@@ -406,7 +406,7 @@ jwks_inline_help = "Prefer the SSH-RSA public key format first. If you paste an
jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json"
request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login."
source_help = "Register the JWKS URI served by the RP so Baron can verify the public key."
subtitle = "Manage the public key and headless login settings required for trusted RP evaluation."
subtitle = "Manage the public key and headless login settings required for Headless Login evaluation."
[msg.dev.clients.general.public_key.validation]
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
@@ -1392,10 +1392,10 @@ delete = "Delete"
[ui.dev.clients.general.security]
private = "Server Side App"
pkce = "PKCE"
trusted = "Trusted RP"
headless_login = "Headless Login"
title = "Security Settings"
trusted_rp_enable = "Trusted RP (Custom Login UI)"
trusted_rp_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
headless_login_enable = "Headless Login (Custom Login UI)"
headless_login_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
[ui.dev.clients.general.public_key]
auth_method = "Token Endpoint Auth Method"

View File

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

View File

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

View File

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

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_success = "Update Success"
password_generated = "A secure password has been generated."
password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately."
password_manual_required = "Please enter a password."
reset_password_help = "Force-reset the user's password and apply either an auto-generated password or a manually entered one."
self_password_reset_blocked = "Please change your own password from the UserFront settings page."
[msg.admin.users.detail.form]
field_required = "Required."
@@ -1155,6 +1159,11 @@ back = "Back"
edit_title = "Edit Title"
title = "User Details"
generate_password = "Auto Generate"
password_mode_generated = "Auto Generate"
password_mode_manual = "Manual Entry"
password_result_title = "Reset Password"
reset_password_apply = "Apply Password"
toggle_password_visibility = "Toggle password visibility"
[ui.admin.users.detail.breadcrumb]
section = "Users"

View File

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

View File

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

View File

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

View File

@@ -6,12 +6,50 @@ type ProfileState = {
putBodies: Array<Record<string, unknown>>;
};
const PROFILE_DEPARTMENT_EDIT_X = 1170;
const PROFILE_DEPARTMENT_EDIT_Y = 680;
const PROFILE_DEPARTMENT_INPUT_X = 110;
const PROFILE_DEPARTMENT_INPUT_Y = 685;
const PROFILE_BLUR_X = 200;
const PROFILE_BLUR_Y = 260;
async function enableFlutterAccessibility(page: Page): Promise<void> {
const button = page.getByRole('button', { name: 'Enable accessibility' });
if (await button.count()) {
await button.click({ force: true });
await page.waitForTimeout(200);
}
}
type ProfileCoords = {
departmentEditX: number;
departmentEditY: number;
departmentInputX: number;
departmentInputY: number;
blurX: number;
blurY: number;
};
const desktopCoords: ProfileCoords = {
departmentEditX: 1170,
departmentEditY: 680,
departmentInputX: 110,
departmentInputY: 685,
blurX: 200,
blurY: 260,
};
const mobileCoords: ProfileCoords = {
departmentEditX: 350,
departmentEditY: 680,
departmentInputX: 110,
departmentInputY: 685,
blurX: 200,
blurY: 260,
};
function coordsFor(page: Page): ProfileCoords {
const viewport = page.viewportSize();
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
}
function isMobileProject(page: Page): boolean {
const viewport = page.viewportSize();
return (viewport?.width ?? 1280) <= 500;
}
async function seedTokenLogin(page: Page): Promise<void> {
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> {
if (isMobileProject(page)) {
await enableFlutterAccessibility(page);
await page
.getByRole('group', { name: '소속 QA' })
.getByRole('button', { name: '편집' })
.click({ force: true });
await page.waitForTimeout(200);
return;
}
const coords = coordsFor(page);
await page.locator('flt-glass-pane').click({
position: { x: PROFILE_DEPARTMENT_EDIT_X, y: PROFILE_DEPARTMENT_EDIT_Y },
position: { x: coords.departmentEditX, y: coords.departmentEditY },
force: true,
});
await page.waitForTimeout(200);
}
async function blurDepartmentEditor(page: Page): Promise<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({
position: { x: PROFILE_BLUR_X, y: PROFILE_BLUR_Y },
position: { x: coords.blurX, y: coords.blurY },
force: true,
});
await page.waitForTimeout(250);
}
async function submitDepartmentEditor(page: Page): Promise<void> {
if (isMobileProject(page)) {
await page.getByRole('textbox', { name: '소속' }).press('Enter');
await page.waitForTimeout(250);
return;
}
await page.keyboard.press('Enter');
await page.waitForTimeout(250);
}
async function fillDepartmentField(page: Page, value: string): Promise<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> {
await page.route('**/api/v1/**', async (route: Route) => {
const request = route.request();
@@ -176,7 +244,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
await fillDepartmentField(page, 'QA-Updated');
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1);
@@ -203,7 +271,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Repro');
await fillDepartmentField(page, 'QA-Repro');
await page.reload();
await expect(page).toHaveURL(/\/ko\/profile$/);
@@ -230,7 +298,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA');
await fillDepartmentField(page, 'QA');
await blurDepartmentEditor(page);
expect(state.putBodies).toHaveLength(0);
@@ -248,7 +316,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, '');
await fillDepartmentField(page, '');
await blurDepartmentEditor(page);
expect(state.putBodies).toHaveLength(0);
@@ -267,7 +335,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
await fillDepartmentField(page, 'QA-1');
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1);
@@ -276,7 +344,7 @@ test.describe('UserFront WASM profile department editing', () => {
await page.waitForTimeout(1200);
await openDepartmentEditor(page);
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
await fillDepartmentField(page, 'QA-2');
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(2);

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import 'package:logging/logging.dart';
import 'package:userfront/i18n.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
@@ -54,10 +55,80 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
bool _showCurrentPassword = false;
bool _showNewPassword = false;
bool _showConfirmPassword = false;
Map<String, dynamic>? _passwordPolicy;
bool _isPasswordPolicyLoading = false;
@override
void 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(
@@ -267,6 +338,58 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
);
return;
}
final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12;
final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0;
final hasLower = RegExp(r'[a-z]').hasMatch(newPassword);
final hasUpper = RegExp(r'[A-Z]').hasMatch(newPassword);
final hasNumber = RegExp(r'[0-9]').hasMatch(newPassword);
final hasSymbol = RegExp(r'[\W_]').hasMatch(newPassword);
int typeCount = 0;
if (hasLower) typeCount++;
if (hasUpper) typeCount++;
if (hasNumber) typeCount++;
if (hasSymbol) typeCount++;
if (newPassword.length < minLength) {
setState(
() => _passwordError = tr(
'msg.userfront.reset.error.min_length',
params: {'count': '$minLength'},
),
);
return;
}
if (minTypes > 0 && typeCount < minTypes) {
setState(
() => _passwordError = tr(
'msg.userfront.reset.error.min_types',
params: {'count': '$minTypes'},
),
);
return;
}
if ((_passwordPolicy?['lowercase'] ?? true) && !hasLower) {
setState(
() => _passwordError = tr('msg.userfront.reset.error.lowercase'),
);
return;
}
if ((_passwordPolicy?['uppercase'] ?? false) && !hasUpper) {
setState(
() => _passwordError = tr('msg.userfront.reset.error.uppercase'),
);
return;
}
if ((_passwordPolicy?['number'] ?? true) && !hasNumber) {
setState(() => _passwordError = tr('msg.userfront.reset.error.number'));
return;
}
if ((_passwordPolicy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
setState(() => _passwordError = tr('msg.userfront.reset.error.symbol'));
return;
}
if (newPassword != confirmPassword) {
setState(
() => _passwordError = tr('msg.userfront.profile.password.mismatch'),
@@ -645,6 +768,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}) {
final isEditing = _editingField == field;
final displayValue = value.isEmpty ? '-' : value;
final isCompact = MediaQuery.of(context).size.width < 640;
if (!isEditing) {
return ListTile(
@@ -661,57 +785,64 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final hasChanged = _hasFieldChanged(profile, field);
final inputField = TextField(
key: Key('profile-$field-input'),
controller: controller,
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _saveField(profile),
onChanged: (_) {
setState(() {
_fieldSaveError = null;
});
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: label,
errorText: _fieldSaveError,
),
);
final saveButton = ElevatedButton(
key: Key('profile-$field-save-button'),
onPressed: isUpdating || !hasChanged || _isSavingField
? null
: () => _saveField(profile),
child: _isSavingField
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(tr('ui.common.save')),
);
final cancelButton = OutlinedButton(
key: Key('profile-$field-cancel-button'),
onPressed: isUpdating || _isSavingField
? null
: () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
key: Key('profile-$field-input'),
controller: controller,
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _saveField(profile),
onChanged: (_) {
setState(() {
_fieldSaveError = null;
});
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: label,
errorText: _fieldSaveError,
),
),
),
const SizedBox(width: 12),
ElevatedButton(
key: Key('profile-$field-save-button'),
onPressed: isUpdating || !hasChanged || _isSavingField
? null
: () => _saveField(profile),
child: _isSavingField
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(tr('ui.common.save')),
),
const SizedBox(width: 8),
OutlinedButton(
key: Key('profile-$field-cancel-button'),
onPressed: isUpdating || _isSavingField
? null
: () => _cancelEditing(profile),
child: Text(tr('ui.common.cancel')),
),
],
),
if (isCompact) ...[
inputField,
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [saveButton, cancelButton]),
] else
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: inputField),
const SizedBox(width: 12),
saveButton,
const SizedBox(width: 8),
cancelButton,
],
),
],
);
}
@@ -853,6 +984,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
tr('msg.userfront.profile.password.subtitle'),
style: const TextStyle(color: Color(0xFF6B7280)),
),
const SizedBox(height: 8),
Text(
_buildPasswordPolicyDescription(),
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12),
),
const SizedBox(height: 16),
TextField(
controller: _currentPasswordController,