forked from baron/baron-sso
Merge branch 'dev' into feat/id_login
This commit is contained in:
@@ -515,7 +515,7 @@ jobs:
|
|||||||
adminfront-tests:
|
adminfront-tests:
|
||||||
needs: lint
|
needs: lint
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
||||||
runs-on: playwright
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -594,7 +594,7 @@ jobs:
|
|||||||
devfront-tests:
|
devfront-tests:
|
||||||
needs: lint
|
needs: lint
|
||||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
|
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
|
||||||
runs-on: playwright
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -62,7 +62,7 @@ up-front-dev: up-infra up-ory up-backend
|
|||||||
@echo "Dev stack is up (infra + ory + backend)."
|
@echo "Dev stack is up (infra + ory + backend)."
|
||||||
|
|
||||||
# --- 종료 (Down) ---
|
# --- 종료 (Down) ---
|
||||||
down-all:
|
down:
|
||||||
@echo "Stopping ALL stacks (infra + ory + app)..."
|
@echo "Stopping ALL stacks (infra + ory + app)..."
|
||||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
|
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
|
||||||
|
|
||||||
|
|||||||
140
README.md
140
README.md
@@ -2,13 +2,27 @@
|
|||||||
|
|
||||||
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
|
||||||
|
|
||||||
## 버그 대응 대원칙 (필수)
|
## 📂 프로젝트 구조 (Project Structure)
|
||||||
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
|
|
||||||
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
|
|
||||||
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
|
|
||||||
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
|
|
||||||
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
|
|
||||||
|
|
||||||
|
```
|
||||||
|
baron_sso/
|
||||||
|
├── backend/ # Go Fiber 애플리케이션
|
||||||
|
│ ├── cmd/server/ # 진입점 (Entry point)
|
||||||
|
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── userfront/ # Flutter 애플리케이션
|
||||||
|
│ ├── src/ # UI 및 로직
|
||||||
|
│ └── pubspec.yaml
|
||||||
|
├── adminfront/ # React 기반 관리
|
||||||
|
│ ├── src/ # UI 및 로직
|
||||||
|
│ └── pubspec.yaml
|
||||||
|
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
|
||||||
|
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||||
|
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
|
||||||
|
├── docker-compose.yaml # 앱 서비스 (Front, Back)
|
||||||
|
├── .env.sample # 환경 설정 템플릿
|
||||||
|
└── README.md # 본 파일
|
||||||
|
```
|
||||||
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
|
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
|
||||||
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
|
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
|
||||||
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
|
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
|
||||||
@@ -20,6 +34,7 @@
|
|||||||
* AdminFront: 사용자 관리 등 Admin 기능
|
* AdminFront: 사용자 관리 등 Admin 기능
|
||||||
* DevFront: RP 관리 등 개발자 기능
|
* DevFront: RP 관리 등 개발자 기능
|
||||||
|
|
||||||
|
|
||||||
## 🏗 아키텍처 (Architecture)
|
## 🏗 아키텍처 (Architecture)
|
||||||
|
|
||||||
### 0. Ory Stack
|
### 0. Ory Stack
|
||||||
@@ -88,6 +103,85 @@ flowchart
|
|||||||
2.1 향후 App Push 등 2차 인증 강화수단 검토 필요
|
2.1 향후 App Push 등 2차 인증 강화수단 검토 필요
|
||||||
3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환
|
3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환
|
||||||
|
|
||||||
|
### 5. Headless Login ID/Password Flow
|
||||||
|
- 목적: headless login을 허용한 클라이언트가 자체 로그인 화면에서 `ID/password`를 수집하되, Baron Backend가 OIDC 로그인 흐름만 계속 진행하고 RP에는 `sessionJwt`를 직접 넘기지 않습니다.
|
||||||
|
- 대상 엔드포인트: `POST /api/v1/auth/headless/password/login`
|
||||||
|
- 관련 구현:
|
||||||
|
- `backend/internal/handler/auth_handler.go`
|
||||||
|
- `backend/internal/domain/hydra_models.go`
|
||||||
|
- `backend/internal/handler/auth_handler_login_test.go`
|
||||||
|
|
||||||
|
#### 호출 순서
|
||||||
|
1. RP 브라우저가 Hydra Public의 `/oauth2/auth`를 호출해 OIDC 인증을 시작합니다.
|
||||||
|
2. Hydra가 로그인 단계로 넘긴 `login_challenge`를 RP가 확보합니다.
|
||||||
|
3. RP backend가 자기 private key로 `client_assertion` JWT를 서명합니다.
|
||||||
|
4. RP backend가 Baron Backend의 `POST /api/v1/auth/headless/password/login`에 `client_id`, `client_assertion`, `login_challenge`, `loginId`, `password`를 전송합니다.
|
||||||
|
5. Baron Backend가 Hydra login request와 RP 설정을 검증한 뒤 Kratos sign-in 및 Hydra login accept를 수행합니다.
|
||||||
|
6. 성공 시 Baron Backend는 `redirectTo`만 반환하고, RP 브라우저는 그 URL로 이동해 OIDC 흐름을 이어갑니다.
|
||||||
|
|
||||||
|
#### 요청 바디
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "headless-login-client",
|
||||||
|
"client_assertion": "<signed-jwt>",
|
||||||
|
"login_challenge": "<hydra-login-challenge>",
|
||||||
|
"loginId": "employee001",
|
||||||
|
"password": "secret"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 성공 응답
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"provider": "ory",
|
||||||
|
"redirectTo": "https://rp.example.com/callback?code=..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### RP / Hydra 선행 조건
|
||||||
|
- Hydra login request의 `client.client_id`와 요청 바디의 `client_id`가 반드시 같아야 합니다.
|
||||||
|
- client가 headless login 선행 조건을 만족해야 합니다.
|
||||||
|
- `headless_token_endpoint_auth_method == "private_key_jwt"` 또는 top-level `token_endpoint_auth_method == "private_key_jwt"`
|
||||||
|
- `headless_jwks_uri` 또는 `headless_jwks`가 존재해야 합니다.
|
||||||
|
- `headless_login_enabled == true`가 필요합니다.
|
||||||
|
- `metadata.status == "inactive"`인 client는 차단됩니다.
|
||||||
|
|
||||||
|
#### `client_assertion` 규칙
|
||||||
|
- 구현상 `client_assertion`은 현재 필수입니다.
|
||||||
|
- JWT claim의 `iss`와 `sub`는 모두 `client_id`와 같아야 합니다.
|
||||||
|
- `exp`는 현재 시각 이후여야 합니다.
|
||||||
|
- `nbf`, `iat`가 있으면 미래 시각이면 안 됩니다.
|
||||||
|
- `aud`는 다음 둘 중 하나와 일치해야 합니다.
|
||||||
|
- `https://<backend-origin>/api/v1/auth/headless/password/login`
|
||||||
|
- `/api/v1/auth/headless/password/login`
|
||||||
|
- 서명 검증용 public key는 `headless_jwks_uri` 또는 `headless_jwks`에서 읽습니다.
|
||||||
|
|
||||||
|
#### 일반 로그인과의 차이
|
||||||
|
- `POST /api/v1/auth/password/login`
|
||||||
|
- UserFront 기본 비밀번호 로그인용입니다.
|
||||||
|
- `login_challenge`가 없으면 `sessionJwt`를 반환합니다.
|
||||||
|
- `login_challenge`가 있으면 Hydra accept 후 `redirectTo`를 반환합니다.
|
||||||
|
- `POST /api/v1/auth/headless/password/login`
|
||||||
|
- headless login 허용 클라이언트 전용입니다.
|
||||||
|
- `client_assertion` 검증이 추가됩니다.
|
||||||
|
- 항상 `sessionJwt` 없이 `redirectTo`만 반환합니다.
|
||||||
|
|
||||||
|
#### 실패 패턴 요약
|
||||||
|
- `400 bad_request`
|
||||||
|
- 필수 필드 누락
|
||||||
|
- `client_assertion` 누락
|
||||||
|
- `401 invalid_client_assertion`
|
||||||
|
- JWKS 조회 실패
|
||||||
|
- 서명 불일치
|
||||||
|
- `aud`/`iss`/`sub`/`exp` 검증 실패
|
||||||
|
- `403 forbidden`
|
||||||
|
- `client_id` 불일치
|
||||||
|
- `headless_login_enabled` 미설정
|
||||||
|
- inactive client
|
||||||
|
- `401 password_or_email_mismatch`
|
||||||
|
- 사용자 인증 실패
|
||||||
|
|
||||||
|
|
||||||
### 전체 연결 구조도
|
### 전체 연결 구조도
|
||||||
|
|
||||||
@@ -416,34 +510,12 @@ cd devfront
|
|||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📂 프로젝트 구조 (Project Structure)
|
|
||||||
|
|
||||||
```
|
## 버그 대응 대원칙 (필수)
|
||||||
baron_sso/
|
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
|
||||||
├── backend/ # Go Fiber 애플리케이션
|
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
|
||||||
│ ├── cmd/server/ # 진입점 (Entry point)
|
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
|
||||||
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
|
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
|
||||||
│ └── Dockerfile
|
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.
|
||||||
├── userfront/ # Flutter 애플리케이션
|
|
||||||
│ ├── src/ # UI 및 로직
|
|
||||||
│ └── pubspec.yaml
|
|
||||||
├── adminfront/ # React 기반 관리
|
|
||||||
│ ├── src/ # UI 및 로직
|
|
||||||
│ └── pubspec.yaml
|
|
||||||
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
|
|
||||||
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
|
|
||||||
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
|
|
||||||
├── docker-compose.yaml # 앱 서비스 (Front, Back)
|
|
||||||
├── .env.sample # 환경 설정 템플릿
|
|
||||||
└── README.md # 본 파일
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 상태 및 로드맵 (Status & Roadmap)
|
|
||||||
- [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료)
|
|
||||||
- [x] **Phase 2**: Backend Audit API 구현 (일부 완료)
|
|
||||||
- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정)
|
|
||||||
- [ ] **Phase 4**: adminfront 기능 추가 (예정)
|
|
||||||
- [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정)
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Building2,
|
Building2,
|
||||||
Copy,
|
Copy,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
Key,
|
Key,
|
||||||
Loader2,
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -32,16 +34,24 @@ import {
|
|||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Label } from "../../components/ui/label";
|
import { Label } from "../../components/ui/label";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "../../components/ui/tabs";
|
||||||
import { toast } from "../../components/ui/use-toast";
|
import { toast } from "../../components/ui/use-toast";
|
||||||
import {
|
import {
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
type UserUpdateRequest,
|
type UserUpdateRequest,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
fetchPasswordPolicy,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchTenants,
|
fetchTenants,
|
||||||
fetchUser,
|
fetchUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
|
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { generateSecurePassword } from "../../lib/utils";
|
import { generateSecurePassword } from "../../lib/utils";
|
||||||
|
|
||||||
@@ -58,6 +68,116 @@ type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
|||||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PasswordResetMode = "generated" | "manual";
|
||||||
|
|
||||||
|
const PASSWORD_RESET_MIN_LENGTH = 12;
|
||||||
|
|
||||||
|
function buildPasswordPolicyDescription(policy?: PasswordPolicyResponse) {
|
||||||
|
const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH;
|
||||||
|
const minTypes = policy?.minCharacterTypes ?? 0;
|
||||||
|
const requiresLower = policy?.lowercase ?? true;
|
||||||
|
const requiresUpper = policy?.uppercase ?? false;
|
||||||
|
const requiresNumber = policy?.number ?? true;
|
||||||
|
const requiresSymbol = policy?.nonAlphanumeric ?? true;
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
t("msg.userfront.signup.policy.min_length", "최소 {{count}}자 이상", {
|
||||||
|
count: String(minLength),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (minTypes > 0) {
|
||||||
|
parts.push(
|
||||||
|
t(
|
||||||
|
"msg.userfront.signup.policy.min_types",
|
||||||
|
"영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상",
|
||||||
|
{ count: String(minTypes) },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (requiresLower) {
|
||||||
|
parts.push(t("msg.userfront.signup.policy.lowercase", "소문자"));
|
||||||
|
}
|
||||||
|
if (requiresUpper) {
|
||||||
|
parts.push(t("msg.userfront.signup.policy.uppercase", "대문자"));
|
||||||
|
}
|
||||||
|
if (requiresNumber) {
|
||||||
|
parts.push(t("msg.userfront.signup.policy.number", "숫자"));
|
||||||
|
}
|
||||||
|
if (requiresSymbol) {
|
||||||
|
parts.push(t("msg.userfront.signup.policy.symbol", "특수문자"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManualPassword(
|
||||||
|
password: string,
|
||||||
|
policy?: PasswordPolicyResponse,
|
||||||
|
) {
|
||||||
|
if (password.trim().length === 0) {
|
||||||
|
return t(
|
||||||
|
"msg.admin.users.detail.password_manual_required",
|
||||||
|
"비밀번호를 입력해 주세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH;
|
||||||
|
if (password.length < minLength) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.min_length",
|
||||||
|
"비밀번호는 최소 {{count}}자 이상이어야 합니다.",
|
||||||
|
{ count: String(minLength) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLower = /[a-z]/.test(password);
|
||||||
|
const hasUpper = /[A-Z]/.test(password);
|
||||||
|
const hasNumber = /[0-9]/.test(password);
|
||||||
|
const hasSymbol = /[\W_]/.test(password);
|
||||||
|
let typeCount = 0;
|
||||||
|
if (hasLower) typeCount++;
|
||||||
|
if (hasUpper) typeCount++;
|
||||||
|
if (hasNumber) typeCount++;
|
||||||
|
if (hasSymbol) typeCount++;
|
||||||
|
|
||||||
|
const minTypes = policy?.minCharacterTypes ?? 0;
|
||||||
|
if (minTypes > 0 && typeCount < minTypes) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.min_types",
|
||||||
|
"비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.",
|
||||||
|
{ count: String(minTypes) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((policy?.lowercase ?? true) && !hasLower) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.lowercase",
|
||||||
|
"최소 1개 이상의 소문자를 포함해야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((policy?.uppercase ?? false) && !hasUpper) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.uppercase",
|
||||||
|
"최소 1개 이상의 대문자를 포함해야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((policy?.number ?? true) && !hasNumber) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.number",
|
||||||
|
"최소 1개 이상의 숫자를 포함해야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((policy?.nonAlphanumeric ?? true) && !hasSymbol) {
|
||||||
|
return t(
|
||||||
|
"msg.userfront.reset.error.symbol",
|
||||||
|
"최소 1개 이상의 특수문자를 포함해야 합니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function TenantMetadataFields({
|
function TenantMetadataFields({
|
||||||
tenant,
|
tenant,
|
||||||
schema,
|
schema,
|
||||||
@@ -166,6 +286,15 @@ function UserDetailPage() {
|
|||||||
const [generatedPassword, setGeneratedPassword] = React.useState<
|
const [generatedPassword, setGeneratedPassword] = React.useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [passwordResetMode, setPasswordResetMode] =
|
||||||
|
React.useState<PasswordResetMode>("generated");
|
||||||
|
const [manualPassword, setManualPassword] = React.useState("");
|
||||||
|
const [manualPasswordConfirm, setManualPasswordConfirm] = React.useState("");
|
||||||
|
const [isManualPasswordVisible, setIsManualPasswordVisible] =
|
||||||
|
React.useState(false);
|
||||||
|
const [passwordResetError, setPasswordResetError] = React.useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
@@ -187,6 +316,12 @@ function UserDetailPage() {
|
|||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchTenants(100, 0),
|
||||||
});
|
});
|
||||||
const tenants = tenantsData?.items ?? [];
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
const { data: passwordPolicy, isLoading: isPasswordPolicyLoading } = useQuery(
|
||||||
|
{
|
||||||
|
queryKey: ["password-policy"],
|
||||||
|
queryFn: fetchPasswordPolicy,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -211,11 +346,13 @@ function UserDetailPage() {
|
|||||||
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||||
|
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||||
|
|
||||||
const resetPasswordMutation = useMutation({
|
const resetPasswordMutation = useMutation({
|
||||||
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
|
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
|
||||||
onSuccess: (_, newPass) => {
|
onSuccess: (_, newPass) => {
|
||||||
setGeneratedPassword(newPass);
|
setGeneratedPassword(newPass);
|
||||||
|
setPasswordResetError(null);
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
"msg.admin.users.detail.password_generated",
|
"msg.admin.users.detail.password_generated",
|
||||||
@@ -224,20 +361,67 @@ function UserDetailPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
toast.error(
|
const message =
|
||||||
err.response?.data?.error ||
|
err.response?.data?.error ||
|
||||||
t("msg.admin.users.detail.update_error", "수정에 실패했습니다."),
|
t("msg.admin.users.detail.update_error", "수정에 실패했습니다.");
|
||||||
);
|
setPasswordResetError(message);
|
||||||
|
toast.error(message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleGeneratePassword = () => {
|
const handleOpenPasswordReset = () => {
|
||||||
|
if (isSelf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsPasswordResetOpen(true);
|
setIsPasswordResetOpen(true);
|
||||||
setGeneratedPassword(null);
|
setGeneratedPassword(null);
|
||||||
|
setPasswordResetMode("generated");
|
||||||
|
setManualPassword("");
|
||||||
|
setManualPasswordConfirm("");
|
||||||
|
setIsManualPasswordVisible(false);
|
||||||
|
setPasswordResetError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmGeneratePassword = () => {
|
const handleClosePasswordReset = () => {
|
||||||
const newPass = generateSecurePassword();
|
setIsPasswordResetOpen(false);
|
||||||
|
setGeneratedPassword(null);
|
||||||
|
setPasswordResetMode("generated");
|
||||||
|
setManualPassword("");
|
||||||
|
setManualPasswordConfirm("");
|
||||||
|
setIsManualPasswordVisible(false);
|
||||||
|
setPasswordResetError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPasswordReset = () => {
|
||||||
|
if (isSelf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPass = manualPassword;
|
||||||
|
|
||||||
|
if (passwordResetMode === "manual") {
|
||||||
|
const validationError = validateManualPassword(
|
||||||
|
manualPassword,
|
||||||
|
passwordPolicy,
|
||||||
|
);
|
||||||
|
if (validationError) {
|
||||||
|
setPasswordResetError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (manualPassword !== manualPasswordConfirm) {
|
||||||
|
setPasswordResetError(
|
||||||
|
t(
|
||||||
|
"msg.userfront.reset.error.mismatch",
|
||||||
|
"비밀번호가 일치하지 않습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newPass = generateSecurePassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordResetError(null);
|
||||||
resetPasswordMutation.mutate(newPass);
|
resetPasswordMutation.mutate(newPass);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -717,7 +901,7 @@ function UserDetailPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/20">
|
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
@@ -726,44 +910,205 @@ function UserDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
사용자의 비밀번호를 강제로 재설정하고 새 비밀번호를
|
{t(
|
||||||
생성합니다.
|
"msg.admin.users.detail.reset_password_help",
|
||||||
|
"사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={handleGeneratePassword}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleOpenPasswordReset}
|
||||||
|
disabled={isSelf}
|
||||||
|
>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
{t("ui.admin.users.detail.reset_password", "초기화 및 생성")}
|
{t("ui.admin.users.detail.reset_password", "초기화 및 설정")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPasswordResetOpen && !generatedPassword && (
|
{isSelf && (
|
||||||
<div className="p-4 border rounded-lg bg-destructive/5 space-y-4">
|
<div className="rounded-lg border px-4 py-3">
|
||||||
<p className="text-sm">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.users.detail.reset_password_confirm",
|
"msg.admin.users.detail.self_password_reset_blocked",
|
||||||
"정말로 이 사용자의 비밀번호를 초기화하시겠습니까? 기존 비밀번호로는 즉시 로그인할 수 없게 됩니다.",
|
"본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPasswordResetOpen && !generatedPassword && !isSelf && (
|
||||||
|
<div className="space-y-4 rounded-lg border px-4 py-4">
|
||||||
|
<Tabs
|
||||||
|
value={passwordResetMode}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPasswordResetMode(value as PasswordResetMode);
|
||||||
|
setPasswordResetError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2 rounded-lg bg-muted p-1">
|
||||||
|
<TabsTrigger
|
||||||
|
value="generated"
|
||||||
|
className="bg-slate-200 font-normal text-foreground data-[state=inactive]:bg-slate-200 data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.password_mode_generated",
|
||||||
|
"자동 생성",
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="manual"
|
||||||
|
className="bg-slate-200 font-normal text-foreground data-[state=inactive]:bg-slate-200 data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.detail.password_mode_manual",
|
||||||
|
"수동 입력",
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="generated" className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.users.detail.password_generated_help",
|
||||||
|
"보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="manual" className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isPasswordPolicyLoading
|
||||||
|
? t(
|
||||||
|
"msg.userfront.signup.policy.loading",
|
||||||
|
"비밀번호 정책을 불러오는 중입니다...",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"msg.userfront.signup.policy.summary",
|
||||||
|
"보안 정책: {{rules}}",
|
||||||
|
{
|
||||||
|
rules:
|
||||||
|
buildPasswordPolicyDescription(
|
||||||
|
passwordPolicy,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
id="manualPassword"
|
||||||
|
type={isManualPasswordVisible ? "text" : "password"}
|
||||||
|
value={manualPassword}
|
||||||
|
placeholder=" "
|
||||||
|
className="peer pr-12 pt-5"
|
||||||
|
onChange={(event) => {
|
||||||
|
setManualPassword(event.target.value);
|
||||||
|
if (passwordResetError) {
|
||||||
|
setPasswordResetError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="manualPassword"
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 bg-background px-1 text-sm text-muted-foreground transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:translate-y-[-50%] peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-0 peer-[&:not(:placeholder-shown)]:translate-y-[-50%] peer-[&:not(:placeholder-shown)]:text-xs"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.userfront.reset.new_password",
|
||||||
|
"새 비밀번호",
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
|
||||||
|
onClick={() =>
|
||||||
|
setIsManualPasswordVisible((prev) => !prev)
|
||||||
|
}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.admin.users.detail.toggle_password_visibility",
|
||||||
|
"비밀번호 표시 전환",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isManualPasswordVisible ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
id="manualPasswordConfirm"
|
||||||
|
type={isManualPasswordVisible ? "text" : "password"}
|
||||||
|
value={manualPasswordConfirm}
|
||||||
|
placeholder=" "
|
||||||
|
className="peer pr-12 pt-5"
|
||||||
|
onChange={(event) => {
|
||||||
|
setManualPasswordConfirm(event.target.value);
|
||||||
|
if (passwordResetError) {
|
||||||
|
setPasswordResetError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="manualPasswordConfirm"
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 bg-background px-1 text-sm text-muted-foreground transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:translate-y-[-50%] peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-0 peer-[&:not(:placeholder-shown)]:translate-y-[-50%] peer-[&:not(:placeholder-shown)]:text-xs"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"ui.userfront.reset.confirm_password",
|
||||||
|
"새 비밀번호 확인",
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
|
||||||
|
onClick={() =>
|
||||||
|
setIsManualPasswordVisible((prev) => !prev)
|
||||||
|
}
|
||||||
|
aria-label={t(
|
||||||
|
"ui.admin.users.detail.toggle_password_visibility",
|
||||||
|
"비밀번호 표시 전환",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isManualPasswordVisible ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
{passwordResetError && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{passwordResetError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsPasswordResetOpen(false)}
|
onClick={handleClosePasswordReset}
|
||||||
>
|
>
|
||||||
{t("ui.common.cancel", "취소")}
|
{t("ui.common.cancel", "취소")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={confirmGeneratePassword}
|
onClick={confirmPasswordReset}
|
||||||
disabled={resetPasswordMutation.isPending}
|
disabled={resetPasswordMutation.isPending}
|
||||||
>
|
>
|
||||||
{resetPasswordMutation.isPending && (
|
{resetPasswordMutation.isPending && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{t(
|
{t(
|
||||||
"ui.admin.users.detail.reset_password",
|
"ui.admin.users.detail.reset_password_apply",
|
||||||
"초기화 및 생성",
|
"비밀번호 적용",
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -774,7 +1119,10 @@ function UserDetailPage() {
|
|||||||
<div className="p-4 border border-dashed rounded-lg bg-yellow-500/5 flex flex-wrap items-center justify-between gap-4">
|
<div className="p-4 border border-dashed rounded-lg bg-yellow-500/5 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-bold text-yellow-600 uppercase tracking-wider">
|
<p className="text-xs font-bold text-yellow-600 uppercase tracking-wider">
|
||||||
Generated Password
|
{t(
|
||||||
|
"ui.admin.users.detail.password_result_title",
|
||||||
|
"Reset Password",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-lg font-bold">
|
<p className="font-mono text-lg font-bold">
|
||||||
{generatedPassword}
|
{generatedPassword}
|
||||||
|
|||||||
@@ -527,6 +527,22 @@ export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PasswordPolicyResponse = {
|
||||||
|
minLength?: number;
|
||||||
|
lowercase?: boolean;
|
||||||
|
uppercase?: boolean;
|
||||||
|
number?: boolean;
|
||||||
|
nonAlphanumeric?: boolean;
|
||||||
|
minCharacterTypes?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchPasswordPolicy() {
|
||||||
|
const { data } = await apiClient.get<PasswordPolicyResponse>(
|
||||||
|
"/v1/auth/password/policy",
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteUser(userId: string) {
|
export async function deleteUser(userId: string) {
|
||||||
await apiClient.delete(`/v1/admin/users/${userId}`);
|
await apiClient.delete(`/v1/admin/users/${userId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,12 @@ name_required = "Name Required"
|
|||||||
|
|
||||||
[msg.admin.users.detail.security]
|
[msg.admin.users.detail.security]
|
||||||
password_hint = "Password Hint"
|
password_hint = "Password Hint"
|
||||||
|
password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately."
|
||||||
|
password_manual_help = "Enter and apply a password with at least {{count}} characters."
|
||||||
|
password_manual_min_length = "Password must be at least {{count}} characters long."
|
||||||
|
password_manual_required = "Please enter a password."
|
||||||
|
reset_password_help = "Force-reset the user's password and apply either an auto-generated password or a manually entered one."
|
||||||
|
self_password_reset_blocked = "Please change your own password from the UserFront settings page."
|
||||||
|
|
||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = "Delete Confirm"
|
delete_confirm = "Delete Confirm"
|
||||||
@@ -1094,6 +1100,16 @@ password = "Password"
|
|||||||
password_placeholder = "Password Placeholder"
|
password_placeholder = "Password Placeholder"
|
||||||
title = "Security Settings"
|
title = "Security Settings"
|
||||||
|
|
||||||
|
[ui.admin.users.detail]
|
||||||
|
manual_password = "New Password"
|
||||||
|
manual_password_placeholder = "Enter a new password"
|
||||||
|
password_mode_generated = "Auto Generate"
|
||||||
|
password_mode_manual = "Manual Input"
|
||||||
|
password_result_title = "Reset Password"
|
||||||
|
reset_password = "Reset & Set"
|
||||||
|
reset_password_apply = "Apply Password"
|
||||||
|
toggle_password_visibility = "Toggle password visibility"
|
||||||
|
|
||||||
[ui.admin.users.detail.tenants_section]
|
[ui.admin.users.detail.tenants_section]
|
||||||
additional = "Additional Affiliated/Manageable Tenants"
|
additional = "Additional Affiliated/Manageable Tenants"
|
||||||
primary = "Representative Affiliated Tenant"
|
primary = "Representative Affiliated Tenant"
|
||||||
|
|||||||
@@ -288,6 +288,12 @@ name_required = "이름은 필수입니다."
|
|||||||
|
|
||||||
[msg.admin.users.detail.security]
|
[msg.admin.users.detail.security]
|
||||||
password_hint = "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다."
|
password_hint = "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다."
|
||||||
|
password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다."
|
||||||
|
password_manual_help = "최소 {{count}}자 이상의 비밀번호를 직접 입력해 적용합니다."
|
||||||
|
password_manual_min_length = "비밀번호는 최소 {{count}}자 이상이어야 합니다."
|
||||||
|
password_manual_required = "비밀번호를 입력해 주세요."
|
||||||
|
reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다."
|
||||||
|
self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요."
|
||||||
|
|
||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
||||||
@@ -1094,6 +1100,16 @@ password = "비밀번호 변경"
|
|||||||
password_placeholder = "변경할 경우에만 입력"
|
password_placeholder = "변경할 경우에만 입력"
|
||||||
title = "보안 설정"
|
title = "보안 설정"
|
||||||
|
|
||||||
|
[ui.admin.users.detail]
|
||||||
|
manual_password = "새 비밀번호"
|
||||||
|
manual_password_placeholder = "새 비밀번호를 입력하세요"
|
||||||
|
password_mode_generated = "자동 생성"
|
||||||
|
password_mode_manual = "수동 입력"
|
||||||
|
password_result_title = "Reset Password"
|
||||||
|
reset_password = "초기화 및 설정"
|
||||||
|
reset_password_apply = "비밀번호 적용"
|
||||||
|
toggle_password_visibility = "비밀번호 표시 전환"
|
||||||
|
|
||||||
[ui.admin.users.detail.tenants_section]
|
[ui.admin.users.detail.tenants_section]
|
||||||
additional = "추가 소속/관리 테넌트"
|
additional = "추가 소속/관리 테넌트"
|
||||||
primary = "대표 소속 테넌트"
|
primary = "대표 소속 테넌트"
|
||||||
|
|||||||
@@ -288,6 +288,12 @@ name_required = ""
|
|||||||
|
|
||||||
[msg.admin.users.detail.security]
|
[msg.admin.users.detail.security]
|
||||||
password_hint = ""
|
password_hint = ""
|
||||||
|
password_generated_help = ""
|
||||||
|
password_manual_help = ""
|
||||||
|
password_manual_min_length = ""
|
||||||
|
password_manual_required = ""
|
||||||
|
reset_password_help = ""
|
||||||
|
self_password_reset_blocked = ""
|
||||||
|
|
||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = ""
|
delete_confirm = ""
|
||||||
@@ -1094,6 +1100,16 @@ password = ""
|
|||||||
password_placeholder = ""
|
password_placeholder = ""
|
||||||
title = ""
|
title = ""
|
||||||
|
|
||||||
|
[ui.admin.users.detail]
|
||||||
|
manual_password = ""
|
||||||
|
manual_password_placeholder = ""
|
||||||
|
password_mode_generated = ""
|
||||||
|
password_mode_manual = ""
|
||||||
|
password_result_title = ""
|
||||||
|
reset_password = ""
|
||||||
|
reset_password_apply = ""
|
||||||
|
toggle_password_visibility = ""
|
||||||
|
|
||||||
[ui.admin.users.detail.tenants_section]
|
[ui.admin.users.detail.tenants_section]
|
||||||
additional = ""
|
additional = ""
|
||||||
primary = ""
|
primary = ""
|
||||||
|
|||||||
@@ -539,8 +539,12 @@ func main() {
|
|||||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||||
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||||
|
auth.Get("/password/reset/v/:token", authHandler.VerifyPasswordResetPage)
|
||||||
|
auth.Get("/password/reset/ve", authHandler.VerifyPasswordResetPage)
|
||||||
// [Added] Use POST for actual verification triggered by the user
|
// [Added] Use POST for actual verification triggered by the user
|
||||||
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
||||||
|
auth.Post("/password/reset/v/:token", authHandler.ProcessPasswordResetToken)
|
||||||
|
auth.Post("/password/reset/ve", authHandler.ProcessPasswordResetToken)
|
||||||
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
||||||
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
||||||
auth.Post("/sms", authHandler.SendSms)
|
auth.Post("/sms", authHandler.SendSms)
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MetadataHeadlessLoginEnabled = "headless_login_enabled"
|
||||||
|
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
||||||
|
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
||||||
|
MetadataHeadlessJWKS = "headless_jwks"
|
||||||
|
)
|
||||||
|
|
||||||
type HydraClient struct {
|
type HydraClient struct {
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
@@ -17,22 +27,53 @@ type HydraClient struct {
|
|||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HydraClient) IsTrustedRP() bool {
|
func (c *HydraClient) SupportsHeadlessLogin() bool {
|
||||||
// A Trusted RP must have a public key registered (URI or Inline)
|
// A headless login client must have a public key registered (URI or Inline)
|
||||||
// and use private_key_jwt for token endpoint authentication.
|
// and use private_key_jwt for token endpoint authentication.
|
||||||
hasPublicKey := c.JWKSUri != "" || c.JWKS != nil
|
hasPublicKey := c.HeadlessJWKSURI() != "" || c.HeadlessJWKS() != nil
|
||||||
isPrivateKeyJwt := c.TokenEndpointAuthMethod == "private_key_jwt"
|
isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt"
|
||||||
return hasPublicKey && isPrivateKeyJwt
|
return hasPublicKey && isPrivateKeyJwt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *HydraClient) HeadlessTokenEndpointAuthMethod() string {
|
||||||
|
if c.Metadata != nil {
|
||||||
|
if raw, ok := c.Metadata[MetadataHeadlessTokenEndpointAuthMethod].(string); ok {
|
||||||
|
if value := strings.TrimSpace(raw); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.TokenEndpointAuthMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HydraClient) HeadlessJWKSURI() string {
|
||||||
|
if c.Metadata != nil {
|
||||||
|
if raw, ok := c.Metadata[MetadataHeadlessJWKSURI].(string); ok {
|
||||||
|
if value := strings.TrimSpace(raw); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(c.JWKSUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HydraClient) HeadlessJWKS() interface{} {
|
||||||
|
if c.Metadata != nil {
|
||||||
|
if value, ok := c.Metadata[MetadataHeadlessJWKS]; ok && value != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.JWKS
|
||||||
|
}
|
||||||
|
|
||||||
func (c *HydraClient) IsHeadlessLoginEnabled() bool {
|
func (c *HydraClient) IsHeadlessLoginEnabled() bool {
|
||||||
if !c.IsTrustedRP() {
|
if !c.SupportsHeadlessLogin() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if c.Metadata == nil {
|
if c.Metadata == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val, ok := c.Metadata["headless_login_enabled"]
|
val, ok := c.Metadata[MetadataHeadlessLoginEnabled]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,29 @@ package domain
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestHydraClient_TrustedRPFlags(t *testing.T) {
|
func TestHydraClient_HeadlessLoginFlags(t *testing.T) {
|
||||||
|
t.Run("metadata-backed headless login client is supported", func(t *testing.T) {
|
||||||
|
client := HydraClient{
|
||||||
|
TokenEndpointAuthMethod: "none",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"headless_login_enabled": true,
|
||||||
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
|
"headless_jwks": map[string]any{
|
||||||
|
"keys": []map[string]any{{
|
||||||
|
"kty": "RSA",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !client.SupportsHeadlessLogin() {
|
||||||
|
t.Fatalf("expected metadata-backed headless login client")
|
||||||
|
}
|
||||||
|
if !client.IsHeadlessLoginEnabled() {
|
||||||
|
t.Fatalf("expected metadata-backed headless login enabled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("inline jwks with private_key_jwt and headless enabled", func(t *testing.T) {
|
t.Run("inline jwks with private_key_jwt and headless enabled", func(t *testing.T) {
|
||||||
client := HydraClient{
|
client := HydraClient{
|
||||||
TokenEndpointAuthMethod: "private_key_jwt",
|
TokenEndpointAuthMethod: "private_key_jwt",
|
||||||
@@ -16,15 +38,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if !client.IsTrustedRP() {
|
if !client.SupportsHeadlessLogin() {
|
||||||
t.Fatalf("expected trusted rp")
|
t.Fatalf("expected headless login client")
|
||||||
}
|
}
|
||||||
if !client.IsHeadlessLoginEnabled() {
|
if !client.IsHeadlessLoginEnabled() {
|
||||||
t.Fatalf("expected headless login enabled")
|
t.Fatalf("expected headless login enabled")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("jwks uri without private_key_jwt is not trusted", func(t *testing.T) {
|
t.Run("jwks uri without private_key_jwt does not support headless login", func(t *testing.T) {
|
||||||
client := HydraClient{
|
client := HydraClient{
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
||||||
@@ -33,15 +55,15 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if client.IsTrustedRP() {
|
if client.SupportsHeadlessLogin() {
|
||||||
t.Fatalf("expected untrusted rp")
|
t.Fatalf("expected headless login prerequisites to be missing")
|
||||||
}
|
}
|
||||||
if client.IsHeadlessLoginEnabled() {
|
if client.IsHeadlessLoginEnabled() {
|
||||||
t.Fatalf("expected headless login disabled when client is not trusted")
|
t.Fatalf("expected headless login disabled when prerequisites are missing")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("trusted rp without boolean metadata flag is not headless enabled", func(t *testing.T) {
|
t.Run("headless login client without boolean metadata flag is not enabled", func(t *testing.T) {
|
||||||
client := HydraClient{
|
client := HydraClient{
|
||||||
TokenEndpointAuthMethod: "private_key_jwt",
|
TokenEndpointAuthMethod: "private_key_jwt",
|
||||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
||||||
@@ -50,8 +72,8 @@ func TestHydraClient_TrustedRPFlags(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if !client.IsTrustedRP() {
|
if !client.SupportsHeadlessLogin() {
|
||||||
t.Fatalf("expected trusted rp")
|
t.Fatalf("expected headless login client")
|
||||||
}
|
}
|
||||||
if client.IsHeadlessLoginEnabled() {
|
if client.IsHeadlessLoginEnabled() {
|
||||||
t.Fatalf("expected headless login disabled for non-bool metadata")
|
t.Fatalf("expected headless login disabled for non-bool metadata")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type NaverSmsRequest struct {
|
|||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
CountryCode string `json:"countryCode"`
|
CountryCode string `json:"countryCode"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Messages []SmsMessage `json:"messages"`
|
Messages []SmsMessage `json:"messages"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,19 +66,21 @@ const (
|
|||||||
loginFlowLink = "link"
|
loginFlowLink = "link"
|
||||||
|
|
||||||
// Durations
|
// Durations
|
||||||
defaultExpiration = 5 * time.Minute
|
defaultExpiration = 5 * time.Minute
|
||||||
signupStateExpiration = 10 * time.Minute
|
signupStateExpiration = 10 * time.Minute
|
||||||
signupBlockDuration = 10 * time.Minute
|
signupBlockDuration = 10 * time.Minute
|
||||||
maxSignupFailures = 5
|
maxSignupFailures = 5
|
||||||
emailCodeTTL = 5 * time.Minute
|
emailCodeTTL = 5 * time.Minute
|
||||||
smsCodeTTL = 3 * time.Minute
|
smsCodeTTL = 3 * time.Minute
|
||||||
prefixPwdResetToken = "pwdreset_token:"
|
prefixPwdResetToken = "pwdreset_token:"
|
||||||
pwdResetExpiration = 15 * time.Minute
|
prefixPwdResetUsed = "pwdreset_used:"
|
||||||
minPollInterval = 2 * time.Second
|
pwdResetExpiration = 15 * time.Minute
|
||||||
loginCodeExpiration = 10 * time.Minute
|
pwdResetUsedExpiration = 2 * time.Minute
|
||||||
linkResendCooldown = 60 * time.Second
|
minPollInterval = 2 * time.Second
|
||||||
prefixDrySend = "dry_send:"
|
loginCodeExpiration = 10 * time.Minute
|
||||||
headlessJWKSFetchTTL = 5 * time.Second
|
linkResendCooldown = 60 * time.Second
|
||||||
|
prefixDrySend = "dry_send:"
|
||||||
|
headlessJWKSFetchTTL = 5 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
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) {
|
func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient) (*jose.JSONWebKeySet, error) {
|
||||||
var raw []byte
|
var raw []byte
|
||||||
switch {
|
switch {
|
||||||
case client.JWKS != nil:
|
case client.HeadlessJWKS() != nil:
|
||||||
data, err := json.Marshal(client.JWKS)
|
data, err := json.Marshal(client.HeadlessJWKS())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encode jwks: %w", err)
|
return nil, fmt.Errorf("failed to encode jwks: %w", err)
|
||||||
}
|
}
|
||||||
raw = data
|
raw = data
|
||||||
case strings.TrimSpace(client.JWKSUri) != "":
|
case client.HeadlessJWKSURI() != "":
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(client.JWKSUri), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.HeadlessJWKSURI(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to build jwks request: %w", err)
|
return nil, fmt.Errorf("failed to build jwks request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1768,7 +1770,7 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC
|
|||||||
}
|
}
|
||||||
raw = body
|
raw = body
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("trusted rp public key is not configured")
|
return nil, fmt.Errorf("headless login public key is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
var keySet jose.JSONWebKeySet
|
var keySet jose.JSONWebKeySet
|
||||||
@@ -1776,7 +1778,7 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC
|
|||||||
return nil, fmt.Errorf("failed to decode jwks: %w", err)
|
return nil, fmt.Errorf("failed to decode jwks: %w", err)
|
||||||
}
|
}
|
||||||
if len(keySet.Keys) == 0 {
|
if len(keySet.Keys) == 0 {
|
||||||
return nil, fmt.Errorf("trusted rp jwks has no keys")
|
return nil, fmt.Errorf("headless login jwks has no keys")
|
||||||
}
|
}
|
||||||
return &keySet, nil
|
return &keySet, nil
|
||||||
}
|
}
|
||||||
@@ -2410,9 +2412,9 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userfrontURL := h.resolveUserfrontURL(c)
|
userfrontURL := h.resolveUserfrontURL(c)
|
||||||
// [Changed] Point to Backend API for verification (which then redirects to Frontend)
|
// 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다.
|
||||||
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL)
|
// 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다.
|
||||||
ale.RedirectTo = redirectURL
|
verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL)
|
||||||
|
|
||||||
// 내부 토큰 발급 + 우리 채널로 전송
|
// 내부 토큰 발급 + 우리 채널로 전송
|
||||||
resetToken := GenerateSecureToken(32)
|
resetToken := GenerateSecureToken(32)
|
||||||
@@ -2432,7 +2434,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token")
|
return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token")
|
||||||
}
|
}
|
||||||
|
|
||||||
resetLink := fmt.Sprintf("%s/reset-password?token=%s", userfrontURL, resetToken)
|
resetLink := fmt.Sprintf("%s/%s", verifyBaseURL, resetToken)
|
||||||
ale.RedirectTo = resetLink
|
ale.RedirectTo = resetLink
|
||||||
ale.Operation = "SendPasswordReset"
|
ale.Operation = "SendPasswordReset"
|
||||||
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
||||||
@@ -2498,6 +2500,9 @@ func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error {
|
|||||||
if token == "" {
|
if token == "" {
|
||||||
token = c.Query("t")
|
token = c.Query("t")
|
||||||
}
|
}
|
||||||
|
if token == "" {
|
||||||
|
token = c.Params("token")
|
||||||
|
}
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
||||||
@@ -2551,6 +2556,9 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
|||||||
token = c.Query("t")
|
token = c.Query("t")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if token == "" {
|
||||||
|
token = c.Params("token")
|
||||||
|
}
|
||||||
ale.Token = token
|
ale.Token = token
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -2625,6 +2633,14 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
|||||||
if resetToken != "" {
|
if resetToken != "" {
|
||||||
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
|
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
|
||||||
if err != nil || strings.TrimSpace(val) == "" {
|
if err != nil || strings.TrimSpace(val) == "" {
|
||||||
|
if usedLoginID, usedErr := h.RedisService.Get(prefixPwdResetUsed + resetToken); usedErr == nil && strings.TrimSpace(usedLoginID) != "" {
|
||||||
|
ale.Status = fiber.StatusOK
|
||||||
|
ale.LatencyMs = time.Since(startTime)
|
||||||
|
ale.Token = resetToken
|
||||||
|
ale.LoginIDs["loginId"] = strings.TrimSpace(usedLoginID)
|
||||||
|
ale.Log(slog.LevelInfo, "Duplicate reset completion ignored after successful use")
|
||||||
|
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
||||||
|
}
|
||||||
ale.Status = fiber.StatusUnauthorized
|
ale.Status = fiber.StatusUnauthorized
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.ProviderError = "Invalid or expired reset token"
|
ale.ProviderError = "Invalid or expired reset token"
|
||||||
@@ -2694,6 +2710,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
|||||||
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
|
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
|
||||||
if resetToken != "" {
|
if resetToken != "" {
|
||||||
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
|
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
|
||||||
|
_ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration)
|
||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Mock services
|
// Mock services
|
||||||
type mockEmailService struct{}
|
type mockEmailService struct {
|
||||||
|
lastTo string
|
||||||
|
lastSubject string
|
||||||
|
lastBody string
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockEmailService) SendEmail(to, subject, body string) error { return nil }
|
func (m *mockEmailService) SendEmail(to, subject, body string) error {
|
||||||
|
m.lastTo = to
|
||||||
|
m.lastSubject = subject
|
||||||
|
m.lastBody = body
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockSmsService struct{}
|
type mockSmsService struct {
|
||||||
|
lastTo string
|
||||||
|
lastContent string
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockSmsService) SendSms(to, content string) error { return nil }
|
func (m *mockSmsService) SendSms(to, content string) error {
|
||||||
|
m.lastTo = to
|
||||||
|
m.lastContent = content
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
|
func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
@@ -156,7 +172,7 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
|
|||||||
assert.Equal(t, "expired_token", got["code"])
|
assert.Equal(t, "expired_token", got["code"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
|
func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||||
|
|
||||||
@@ -170,12 +186,13 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
|
|||||||
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||||
Challenge: "challenge-123",
|
Challenge: "challenge-123",
|
||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "trusted-rp",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "private_key_jwt",
|
TokenEndpointAuthMethod: "none",
|
||||||
JWKS: jwks,
|
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]interface{}{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
|
"headless_jwks": jwks,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -198,8 +215,8 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) {
|
|||||||
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
"client_id": "trusted-rp",
|
"client_id": "headless-login-client",
|
||||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"),
|
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
|
||||||
"loginId": "010-1234-5678",
|
"loginId": "010-1234-5678",
|
||||||
"login_challenge": "challenge-123",
|
"login_challenge": "challenge-123",
|
||||||
})
|
})
|
||||||
@@ -231,12 +248,13 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
_ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||||
Challenge: "challenge-123",
|
Challenge: "challenge-123",
|
||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "trusted-rp",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "private_key_jwt",
|
TokenEndpointAuthMethod: "none",
|
||||||
JWKS: jwks,
|
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]interface{}{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
|
"headless_jwks": jwks,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -266,8 +284,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
t.Setenv("USERFRONT_URL", "http://userfront.test")
|
||||||
|
|
||||||
initBody, _ := json.Marshal(map[string]string{
|
initBody, _ := json.Marshal(map[string]string{
|
||||||
"client_id": "trusted-rp",
|
"client_id": "headless-login-client",
|
||||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/init"),
|
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"),
|
||||||
"loginId": "010-1234-5678",
|
"loginId": "010-1234-5678",
|
||||||
"login_challenge": "challenge-123",
|
"login_challenge": "challenge-123",
|
||||||
})
|
})
|
||||||
@@ -300,8 +318,8 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
pollBody, _ := json.Marshal(map[string]string{
|
pollBody, _ := json.Marshal(map[string]string{
|
||||||
"client_id": "trusted-rp",
|
"client_id": "headless-login-client",
|
||||||
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "trusted-rp", "http://example.com/api/v1/auth/headless/link/poll"),
|
"client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"),
|
||||||
"pendingRef": pendingRef,
|
"pendingRef": pendingRef,
|
||||||
})
|
})
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody))
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody))
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
|
func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||||
mockIdp := new(MockIdentityProvider)
|
mockIdp := new(MockIdentityProvider)
|
||||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||||
@@ -305,12 +305,13 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
|
|||||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||||
Challenge: "challenge-123",
|
Challenge: "challenge-123",
|
||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "trusted-rp",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "private_key_jwt",
|
TokenEndpointAuthMethod: "none",
|
||||||
JWKSUri: jwksServer.URL + "/.well-known/jwks.json",
|
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]interface{}{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
|
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -338,11 +339,11 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) {
|
|||||||
clientAssertion := mustHeadlessClientAssertion(
|
clientAssertion := mustHeadlessClientAssertion(
|
||||||
t,
|
t,
|
||||||
privateKey,
|
privateKey,
|
||||||
"trusted-rp",
|
"headless-login-client",
|
||||||
"http://example.com/api/v1/auth/headless/password/login",
|
"http://example.com/api/v1/auth/headless/password/login",
|
||||||
)
|
)
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
"client_id": "trusted-rp",
|
"client_id": "headless-login-client",
|
||||||
"client_assertion": clientAssertion,
|
"client_assertion": clientAssertion,
|
||||||
"loginId": "employee001",
|
"loginId": "employee001",
|
||||||
"password": "password",
|
"password": "password",
|
||||||
@@ -389,7 +390,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
|||||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||||
Challenge: "challenge-123",
|
Challenge: "challenge-123",
|
||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "trusted-rp",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "private_key_jwt",
|
TokenEndpointAuthMethod: "private_key_jwt",
|
||||||
JWKS: map[string]any{
|
JWKS: map[string]any{
|
||||||
"keys": []map[string]any{},
|
"keys": []map[string]any{},
|
||||||
@@ -420,7 +421,7 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
|||||||
app := newHeadlessPasswordLoginTestApp(h)
|
app := newHeadlessPasswordLoginTestApp(h)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
"client_id": "trusted-rp",
|
"client_id": "headless-login-client",
|
||||||
"loginId": "employee001",
|
"loginId": "employee001",
|
||||||
"password": "password",
|
"password": "password",
|
||||||
"login_challenge": "challenge-123",
|
"login_challenge": "challenge-123",
|
||||||
@@ -459,7 +460,7 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
|||||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||||
Challenge: "challenge-123",
|
Challenge: "challenge-123",
|
||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "trusted-rp",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "private_key_jwt",
|
TokenEndpointAuthMethod: "private_key_jwt",
|
||||||
JWKS: jwks,
|
JWKS: jwks,
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]interface{}{
|
||||||
@@ -490,11 +491,11 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
|||||||
clientAssertion := mustHeadlessClientAssertion(
|
clientAssertion := mustHeadlessClientAssertion(
|
||||||
t,
|
t,
|
||||||
invalidKey,
|
invalidKey,
|
||||||
"trusted-rp",
|
"headless-login-client",
|
||||||
"http://example.com/api/v1/auth/headless/password/login",
|
"http://example.com/api/v1/auth/headless/password/login",
|
||||||
)
|
)
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
"client_id": "trusted-rp",
|
"client_id": "headless-login-client",
|
||||||
"client_assertion": clientAssertion,
|
"client_assertion": clientAssertion,
|
||||||
"loginId": "employee001",
|
"loginId": "employee001",
|
||||||
"password": "password",
|
"password": "password",
|
||||||
@@ -523,11 +524,12 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
|
|||||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||||
Challenge: "challenge-123",
|
Challenge: "challenge-123",
|
||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "trusted-rp",
|
ClientID: "headless-login-client",
|
||||||
TokenEndpointAuthMethod: "private_key_jwt",
|
TokenEndpointAuthMethod: "none",
|
||||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]interface{}{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
|
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||||
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -547,7 +549,7 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
|
|||||||
app := newHeadlessPasswordLoginTestApp(h)
|
app := newHeadlessPasswordLoginTestApp(h)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
"client_id": "trusted-rp",
|
"client_id": "headless-login-client",
|
||||||
"loginId": "employee001",
|
"loginId": "employee001",
|
||||||
"password": "password",
|
"password": "password",
|
||||||
"login_challenge": "challenge-123",
|
"login_challenge": "challenge-123",
|
||||||
@@ -576,11 +578,12 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) {
|
|||||||
Challenge: "challenge-123",
|
Challenge: "challenge-123",
|
||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "other-rp",
|
ClientID: "other-rp",
|
||||||
TokenEndpointAuthMethod: "private_key_jwt",
|
TokenEndpointAuthMethod: "none",
|
||||||
JWKSUri: "https://rp.example.com/.well-known/jwks.json",
|
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]interface{}{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
|
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -600,7 +603,7 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) {
|
|||||||
app := newHeadlessPasswordLoginTestApp(h)
|
app := newHeadlessPasswordLoginTestApp(h)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
"client_id": "trusted-rp",
|
"client_id": "headless-login-client",
|
||||||
"loginId": "employee001",
|
"loginId": "employee001",
|
||||||
"password": "password",
|
"password": "password",
|
||||||
"login_challenge": "challenge-123",
|
"login_challenge": "challenge-123",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -254,6 +255,65 @@ func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompletePasswordReset_DuplicateTokenSubmitIsIdempotent(t *testing.T) {
|
||||||
|
const resetToken = "dup-token"
|
||||||
|
const loginID = "user@example.com"
|
||||||
|
const newPassword = "StrongPass1!"
|
||||||
|
|
||||||
|
redis := &testRedisRepo{
|
||||||
|
values: map[string]string{
|
||||||
|
prefixPwdResetToken + resetToken: loginID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
idp := &mockIdpProvider{
|
||||||
|
userExists: true,
|
||||||
|
err: nil,
|
||||||
|
}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
IdpProvider: idp,
|
||||||
|
}
|
||||||
|
app := newResetFlowTestApp(h)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"newPassword": newPassword,
|
||||||
|
})
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"/api/v1/auth/password/reset/complete?token=%s",
|
||||||
|
resetToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
firstReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
firstReq.Header.Set("Content-Type", "application/json")
|
||||||
|
firstResp, err := app.Test(firstReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer firstResp.Body.Close()
|
||||||
|
|
||||||
|
if firstResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected first response to be 200, got %d", firstResp.StatusCode)
|
||||||
|
}
|
||||||
|
if idp.updateCallCount != 1 {
|
||||||
|
t.Fatalf("expected first request to update password once, got %d", idp.updateCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
secondReq.Header.Set("Content-Type", "application/json")
|
||||||
|
secondResp, err := app.Test(secondReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer secondResp.Body.Close()
|
||||||
|
|
||||||
|
if secondResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected duplicate response to be 200, got %d", secondResp.StatusCode)
|
||||||
|
}
|
||||||
|
if idp.updateCallCount != 1 {
|
||||||
|
t.Fatalf("expected duplicate request not to update password again, got %d", idp.updateCallCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
||||||
const token = "tok-enc"
|
const token = "tok-enc"
|
||||||
const loginID = "user+alias@example.com"
|
const loginID = "user+alias@example.com"
|
||||||
@@ -295,6 +355,102 @@ func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetVerifyAlias_AcceptsShortVePath(t *testing.T) {
|
||||||
|
const token = "tok-ve"
|
||||||
|
const loginID = "user@example.com"
|
||||||
|
|
||||||
|
redis := &testRedisRepo{
|
||||||
|
values: map[string]string{
|
||||||
|
prefixPwdResetToken + token: loginID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/auth/password/reset/ve", h.VerifyPasswordResetPage)
|
||||||
|
app.Post("/api/v1/auth/password/reset/ve", h.ProcessPasswordResetToken)
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/auth/password/reset/ve?token="+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
getResp, err := app.Test(getReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
|
||||||
|
if getResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected alias GET to return 200, got %d", getResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
postReq := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/auth/password/reset/ve?token="+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
postResp, err := app.Test(postReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer postResp.Body.Close()
|
||||||
|
|
||||||
|
if postResp.StatusCode != http.StatusFound {
|
||||||
|
t.Fatalf("expected alias POST to return 302, got %d", postResp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordResetVerifyPathToken_AcceptsShortVPath(t *testing.T) {
|
||||||
|
const token = "tok-path"
|
||||||
|
const loginID = "user@example.com"
|
||||||
|
|
||||||
|
redis := &testRedisRepo{
|
||||||
|
values: map[string]string{
|
||||||
|
prefixPwdResetToken + token: loginID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/auth/password/reset/v/:token", h.VerifyPasswordResetPage)
|
||||||
|
app.Post("/api/v1/auth/password/reset/v/:token", h.ProcessPasswordResetToken)
|
||||||
|
|
||||||
|
getReq := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/auth/password/reset/v/"+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
getResp, err := app.Test(getReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer getResp.Body.Close()
|
||||||
|
|
||||||
|
if getResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected path-token GET to return 200, got %d", getResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
postReq := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/auth/password/reset/v/"+token,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
postResp, err := app.Test(postReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer postResp.Body.Close()
|
||||||
|
|
||||||
|
if postResp.StatusCode != http.StatusFound {
|
||||||
|
t.Fatalf("expected path-token POST to return 302, got %d", postResp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
|
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
|
||||||
h := &AuthHandler{}
|
h := &AuthHandler{}
|
||||||
app := newResetInitAppWithErrorCodeEnricher(h)
|
app := newResetInitAppWithErrorCodeEnricher(h)
|
||||||
@@ -326,3 +482,40 @@ func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T)
|
|||||||
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInitiatePasswordReset_SmsContainsVerifyLink(t *testing.T) {
|
||||||
|
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
|
||||||
|
|
||||||
|
redis := &testRedisRepo{values: map[string]string{}}
|
||||||
|
smsSvc := &mockSmsService{}
|
||||||
|
h := &AuthHandler{
|
||||||
|
RedisService: redis,
|
||||||
|
IdpProvider: &mockIdpProvider{},
|
||||||
|
SmsService: smsSvc,
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"loginId": "01012345678",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(smsSvc.lastContent, "/api/v1/auth/password/reset/v/") {
|
||||||
|
t.Fatalf("expected SMS to contain short path verify link, got %q", smsSvc.lastContent)
|
||||||
|
}
|
||||||
|
if strings.Contains(smsSvc.lastContent, "/reset-password?token=") {
|
||||||
|
t.Fatalf("expected direct reset-password link to be removed, got %q", smsSvc.lastContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type mockIdpProvider struct {
|
|||||||
err error
|
err error
|
||||||
initiateLinkErr error
|
initiateLinkErr error
|
||||||
updateCalled bool
|
updateCalled bool
|
||||||
|
updateCallCount int
|
||||||
updatedLoginID string
|
updatedLoginID string
|
||||||
updatedPassword string
|
updatedPassword string
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,7 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn
|
|||||||
|
|
||||||
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||||
m.updateCalled = true
|
m.updateCalled = true
|
||||||
|
m.updateCallCount++
|
||||||
m.updatedLoginID = loginID
|
m.updatedLoginID = loginID
|
||||||
m.updatedPassword = newPassword
|
m.updatedPassword = newPassword
|
||||||
return m.err
|
return m.err
|
||||||
|
|||||||
@@ -891,6 +891,13 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
tokenAuthMethod = "client_secret_basic"
|
tokenAuthMethod = "client_secret_basic"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig(
|
||||||
|
clientType,
|
||||||
|
tokenAuthMethod,
|
||||||
|
valueOr(req.JwksUri, ""),
|
||||||
|
req.Jwks,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
clientReq := domain.HydraClient{
|
clientReq := domain.HydraClient{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
@@ -900,8 +907,8 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
ResponseTypes: responseTypes,
|
ResponseTypes: responseTypes,
|
||||||
Scope: strings.Join(scopes, " "),
|
Scope: strings.Join(scopes, " "),
|
||||||
TokenEndpointAuthMethod: tokenAuthMethod,
|
TokenEndpointAuthMethod: tokenAuthMethod,
|
||||||
JWKSUri: valueOr(req.JwksUri, ""),
|
JWKSUri: jwksURI,
|
||||||
JWKS: req.Jwks,
|
JWKS: jwks,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,6 +1051,23 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
metadata["status"] = status
|
metadata["status"] = status
|
||||||
}
|
}
|
||||||
|
resolvedClientType := currentSummary.Type
|
||||||
|
if clientType != "" {
|
||||||
|
resolvedClientType = clientType
|
||||||
|
}
|
||||||
|
resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod)
|
||||||
|
resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri)
|
||||||
|
resolvedJWKS := req.Jwks
|
||||||
|
if req.Jwks == nil {
|
||||||
|
resolvedJWKS = current.JWKS
|
||||||
|
}
|
||||||
|
resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig(
|
||||||
|
resolvedClientType,
|
||||||
|
resolvedTokenAuthMethod,
|
||||||
|
resolvedJWKSURI,
|
||||||
|
resolvedJWKS,
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
|
||||||
updated := domain.HydraClient{
|
updated := domain.HydraClient{
|
||||||
ClientID: current.ClientID,
|
ClientID: current.ClientID,
|
||||||
@@ -1052,14 +1076,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
|
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
|
||||||
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
|
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
|
||||||
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
|
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
|
||||||
TokenEndpointAuthMethod: resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod),
|
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
|
||||||
JWKSUri: valueOr(req.JwksUri, current.JWKSUri),
|
JWKSUri: resolvedJWKSURI,
|
||||||
JWKS: req.Jwks,
|
JWKS: resolvedJWKS,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
if req.Jwks == nil {
|
|
||||||
updated.JWKS = current.JWKS
|
|
||||||
}
|
|
||||||
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
||||||
return errorJSON(c, fiber.StatusForbidden, err.Error())
|
return errorJSON(c, fiber.StatusForbidden, err.Error())
|
||||||
}
|
}
|
||||||
@@ -1676,6 +1697,70 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readMetadataStringValue(metadata map[string]interface{}, key string) string {
|
||||||
|
if metadata == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, _ := metadata[key].(string)
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
|
||||||
|
if metadata == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
value, _ := metadata[key].(bool)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHeadlessClientConfig(
|
||||||
|
clientType string,
|
||||||
|
tokenAuthMethod string,
|
||||||
|
jwksURI string,
|
||||||
|
jwks interface{},
|
||||||
|
metadata map[string]interface{},
|
||||||
|
) (string, string, interface{}, map[string]interface{}) {
|
||||||
|
if metadata == nil {
|
||||||
|
metadata = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled)
|
||||||
|
if clientType == "pkce" && headlessEnabled {
|
||||||
|
headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
|
||||||
|
if headlessTokenAuthMethod == "" && !strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "none") {
|
||||||
|
headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod)
|
||||||
|
}
|
||||||
|
if headlessTokenAuthMethod == "" {
|
||||||
|
headlessTokenAuthMethod = "private_key_jwt"
|
||||||
|
}
|
||||||
|
metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod
|
||||||
|
|
||||||
|
headlessJWKSURI := readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI)
|
||||||
|
if headlessJWKSURI == "" && strings.TrimSpace(jwksURI) != "" {
|
||||||
|
headlessJWKSURI = strings.TrimSpace(jwksURI)
|
||||||
|
}
|
||||||
|
if headlessJWKSURI != "" {
|
||||||
|
metadata[domain.MetadataHeadlessJWKSURI] = headlessJWKSURI
|
||||||
|
} else {
|
||||||
|
delete(metadata, domain.MetadataHeadlessJWKSURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := metadata[domain.MetadataHeadlessJWKS]; !ok && jwks != nil {
|
||||||
|
metadata[domain.MetadataHeadlessJWKS] = jwks
|
||||||
|
}
|
||||||
|
if metadata[domain.MetadataHeadlessJWKS] == nil {
|
||||||
|
delete(metadata, domain.MetadataHeadlessJWKS)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "none", "", nil, metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
|
||||||
|
delete(metadata, domain.MetadataHeadlessJWKSURI)
|
||||||
|
delete(metadata, domain.MetadataHeadlessJWKS)
|
||||||
|
return tokenAuthMethod, jwksURI, jwks, metadata
|
||||||
|
}
|
||||||
|
|
||||||
func defaultClientScopes() []string {
|
func defaultClientScopes() []string {
|
||||||
return []string{"openid", "profile", "email"}
|
return []string{"openid", "profile", "email"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -611,7 +611,7 @@ func TestDevHandler_NoAuditNoAction(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
|
func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||||
var captured domain.HydraClient
|
var captured domain.HydraClient
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
@@ -653,7 +653,7 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
|
|||||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"name": "Trusted RP App",
|
"name": "Headless Login App",
|
||||||
"type": "pkce",
|
"type": "pkce",
|
||||||
"redirectUris": []string{"https://rp.example.com/callback"},
|
"redirectUris": []string{"https://rp.example.com/callback"},
|
||||||
"scopes": []string{"openid", "profile"},
|
"scopes": []string{"openid", "profile"},
|
||||||
@@ -676,22 +676,23 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) {
|
|||||||
|
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod)
|
assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
|
||||||
assert.NotNil(t, captured.JWKS)
|
assert.Nil(t, captured.JWKS)
|
||||||
assert.True(t, captured.IsTrustedRP())
|
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
|
||||||
|
assert.NotNil(t, captured.Metadata["headless_jwks"])
|
||||||
assert.True(t, captured.IsHeadlessLoginEnabled())
|
assert.True(t, captured.IsHeadlessLoginEnabled())
|
||||||
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
||||||
assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"])
|
assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
|
func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||||
var captured domain.HydraClient
|
var captured domain.HydraClient
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-trusted" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-trusted",
|
"client_id": "client-headless-login",
|
||||||
"client_name": "Trusted Before",
|
"client_name": "Headless Login Before",
|
||||||
"redirect_uris": []string{"https://before.example.com/callback"},
|
"redirect_uris": []string{"https://before.example.com/callback"},
|
||||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||||
"response_types": []string{"code"},
|
"response_types": []string{"code"},
|
||||||
@@ -702,7 +703,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-trusted" {
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" {
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
err = json.Unmarshal(body, &captured)
|
err = json.Unmarshal(body, &captured)
|
||||||
@@ -740,7 +741,7 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
|
|||||||
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
app.Put("/api/v1/dev/clients/:id", h.UpdateClient)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"name": "Trusted After",
|
"name": "Headless Login After",
|
||||||
"type": "pkce",
|
"type": "pkce",
|
||||||
"tokenEndpointAuthMethod": "private_key_jwt",
|
"tokenEndpointAuthMethod": "private_key_jwt",
|
||||||
"jwksUri": "https://rp.example.com/.well-known/jwks.json",
|
"jwksUri": "https://rp.example.com/.well-known/jwks.json",
|
||||||
@@ -749,14 +750,15 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) {
|
|||||||
"request_object_signing_alg": "RS256",
|
"request_object_signing_alg": "RS256",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-trusted", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, _ := app.Test(req, -1)
|
resp, _ := app.Test(req, -1)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod)
|
assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
|
||||||
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri)
|
assert.Equal(t, "", captured.JWKSUri)
|
||||||
assert.True(t, captured.IsTrustedRP())
|
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
|
||||||
|
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
|
||||||
assert.True(t, captured.IsHeadlessLoginEnabled())
|
assert.True(t, captured.IsHeadlessLoginEnabled())
|
||||||
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Try syncing based on the tenant namespaces being updated
|
// Fallback: Try syncing based on the tenant namespaces being updated
|
||||||
if !synced && h.TenantService != nil {
|
if !synced && h.TenantService != nil {
|
||||||
for k := range req.Metadata {
|
for k := range req.Metadata {
|
||||||
@@ -1249,10 +1249,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
finalLoginID := extractTraitString(traits, "id")
|
finalLoginID := extractTraitString(traits, "id")
|
||||||
userEmail := extractTraitString(traits, "email")
|
userEmail := extractTraitString(traits, "email")
|
||||||
userPhone := extractTraitString(traits, "phone")
|
userPhone := extractTraitString(traits, "phone_number")
|
||||||
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
|
if err := domain.ValidateLoginID(explicitLoginID, userEmail, userPhone); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
finalLoginID := resolvePasswordLoginID(traits)
|
||||||
|
|
||||||
state := normalizeKratosState(req.Status)
|
state := normalizeKratosState(req.Status)
|
||||||
|
|
||||||
@@ -1336,7 +1337,10 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Password != nil && *req.Password != "" {
|
if req.Password != nil && *req.Password != "" {
|
||||||
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
|
if h.OryProvider == nil {
|
||||||
|
return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available")
|
||||||
|
}
|
||||||
|
if err := h.OryProvider.UpdateUserPassword(finalLoginID, *req.Password, nil); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1618,6 +1622,16 @@ func extractTraitString(traits map[string]interface{}, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolvePasswordLoginID(traits map[string]interface{}) string {
|
||||||
|
if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" {
|
||||||
|
return loginID
|
||||||
|
}
|
||||||
|
if email := strings.TrimSpace(extractTraitString(traits, "email")); email != "" {
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(extractTraitString(traits, "phone_number"))
|
||||||
|
}
|
||||||
|
|
||||||
// syncLoginID ensures that the 'id' trait (used as Kratos identifier) is in sync with the configured custom field.
|
// syncLoginID ensures that the 'id' trait (used as Kratos identifier) is in sync with the configured custom field.
|
||||||
func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) {
|
func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) {
|
||||||
if loginIDField == "" {
|
if loginIDField == "" {
|
||||||
@@ -1645,7 +1659,7 @@ func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check merged traits (which includes existing metadata)
|
// 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.
|
// and we don't want to sync "id" to "id" if we already checked metadata.
|
||||||
if loginID == "" && loginIDField != "id" {
|
if loginID == "" && loginIDField != "id" {
|
||||||
// Existing trait (flat)
|
// Existing trait (flat)
|
||||||
|
|||||||
@@ -488,6 +488,117 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockOry := new(MockOryProvider)
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
OryProvider: mockOry,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||||
|
return h.UpdateUser(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
userID := "u-1"
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"id": "dyddus1210",
|
||||||
|
"email": "dyddus1210@gmail.com",
|
||||||
|
"companyCode": "test-tenant",
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||||
|
ID: "t-1",
|
||||||
|
Slug: "test-tenant",
|
||||||
|
}, nil)
|
||||||
|
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||||
|
|
||||||
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||||
|
return traits["id"] == "dyddus1210"
|
||||||
|
}), "").Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"id": "dyddus1210",
|
||||||
|
"email": "dyddus1210@gmail.com",
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockOry.On("UpdateUserPassword", "dyddus1210", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"password": "asdfzxcv1234!",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
mockOry.AssertExpectations(t)
|
||||||
|
mockKratos.AssertNotCalled(t, "UpdateIdentityPassword", mock.Anything, mock.Anything, mock.Anything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserHandler_UpdateUser_PasswordFallsBackToEmail(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
mockKratos := new(MockKratosAdmin)
|
||||||
|
mockOry := new(MockOryProvider)
|
||||||
|
mockTenant := new(MockTenantServiceForUser)
|
||||||
|
h := &UserHandler{
|
||||||
|
KratosAdmin: mockKratos,
|
||||||
|
OryProvider: mockOry,
|
||||||
|
TenantService: mockTenant,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||||
|
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||||
|
return h.UpdateUser(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
userID := "u-2"
|
||||||
|
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"email": "dyddus1210@gmail.com",
|
||||||
|
"companyCode": "test-tenant",
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||||
|
ID: "t-1",
|
||||||
|
Slug: "test-tenant",
|
||||||
|
}, nil)
|
||||||
|
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||||
|
|
||||||
|
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||||
|
return traits["email"] == "dyddus1210@gmail.com"
|
||||||
|
}), "").Return(&service.KratosIdentity{
|
||||||
|
ID: userID,
|
||||||
|
Traits: map[string]interface{}{
|
||||||
|
"email": "dyddus1210@gmail.com",
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
mockOry.On("UpdateUserPassword", "dyddus1210@gmail.com", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"password": "asdfzxcv1234!",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, _ := app.Test(req)
|
||||||
|
assert.Equal(t, 200, resp.StatusCode)
|
||||||
|
mockOry.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
||||||
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
|
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
|
|||||||
@@ -425,16 +425,16 @@ func TestRotateClientSecret_PersistsForLaterDetailFetch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
func TestUpdateClient_HeadlessLoginSecretPersistsForLaterDetailFetch(t *testing.T) {
|
||||||
getCount := 0
|
getCount := 0
|
||||||
|
|
||||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-trusted" {
|
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
||||||
getCount++
|
getCount++
|
||||||
if getCount == 1 {
|
if getCount == 1 {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-trusted",
|
"client_id": "client-headless-login",
|
||||||
"client_name": "Trusted Before",
|
"client_name": "Headless Login Before",
|
||||||
"redirect_uris": []string{"https://before.example.com/callback"},
|
"redirect_uris": []string{"https://before.example.com/callback"},
|
||||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||||
"response_types": []string{"code"},
|
"response_types": []string{"code"},
|
||||||
@@ -447,14 +447,14 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-trusted",
|
"client_id": "client-headless-login",
|
||||||
"client_name": "Trusted After",
|
"client_name": "Headless Login After",
|
||||||
"redirect_uris": []string{"https://trusted.example.com/callback"},
|
"redirect_uris": []string{"https://headless.example.com/callback"},
|
||||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||||
"response_types": []string{"code"},
|
"response_types": []string{"code"},
|
||||||
"scope": "openid profile",
|
"scope": "openid profile",
|
||||||
"token_endpoint_auth_method": "private_key_jwt",
|
"token_endpoint_auth_method": "private_key_jwt",
|
||||||
"jwks_uri": "https://trusted.example.com/jwks.json",
|
"jwks_uri": "https://headless.example.com/jwks.json",
|
||||||
"metadata": map[string]any{
|
"metadata": map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
@@ -463,17 +463,17 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
|||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-trusted" {
|
if r.Method == http.MethodPut && r.URL.Path == "/clients/client-headless-login" {
|
||||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||||
"client_id": "client-trusted",
|
"client_id": "client-headless-login",
|
||||||
"client_name": "Trusted After",
|
"client_name": "Headless Login After",
|
||||||
"client_secret": "trusted-secret",
|
"client_secret": "headless-secret",
|
||||||
"redirect_uris": []string{"https://trusted.example.com/callback"},
|
"redirect_uris": []string{"https://headless.example.com/callback"},
|
||||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||||
"response_types": []string{"code"},
|
"response_types": []string{"code"},
|
||||||
"scope": "openid profile",
|
"scope": "openid profile",
|
||||||
"token_endpoint_auth_method": "private_key_jwt",
|
"token_endpoint_auth_method": "private_key_jwt",
|
||||||
"jwks_uri": "https://trusted.example.com/jwks.json",
|
"jwks_uri": "https://headless.example.com/jwks.json",
|
||||||
"metadata": map[string]any{
|
"metadata": map[string]any{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
@@ -507,16 +507,16 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
|||||||
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
app.Get("/api/v1/dev/clients/:id", h.GetClient)
|
||||||
|
|
||||||
updateBody, _ := json.Marshal(map[string]any{
|
updateBody, _ := json.Marshal(map[string]any{
|
||||||
"name": "Trusted After",
|
"name": "Headless Login After",
|
||||||
"redirectUris": []string{"https://trusted.example.com/callback"},
|
"redirectUris": []string{"https://headless.example.com/callback"},
|
||||||
"tokenEndpointAuthMethod": "private_key_jwt",
|
"tokenEndpointAuthMethod": "private_key_jwt",
|
||||||
"jwksUri": "https://trusted.example.com/jwks.json",
|
"jwksUri": "https://headless.example.com/jwks.json",
|
||||||
"metadata": map[string]any{
|
"metadata": map[string]any{
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"request_object_signing_alg": "RS256",
|
"request_object_signing_alg": "RS256",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-trusted", bytes.NewReader(updateBody))
|
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-headless-login", bytes.NewReader(updateBody))
|
||||||
updateReq.Header.Set("Content-Type", "application/json")
|
updateReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
updateResp, err := app.Test(updateReq, -1)
|
updateResp, err := app.Test(updateReq, -1)
|
||||||
@@ -527,20 +527,20 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
|||||||
t.Fatalf("expected update 200, got %d", updateResp.StatusCode)
|
t.Fatalf("expected update 200, got %d", updateResp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-trusted")
|
storedSecret, _ := secretRepo.GetByID(context.Background(), "client-headless-login")
|
||||||
if storedSecret != "trusted-secret" {
|
if storedSecret != "headless-secret" {
|
||||||
t.Fatalf("expected postgres secret trusted-secret, got %q", storedSecret)
|
t.Fatalf("expected postgres secret headless-secret, got %q", storedSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
redisSecret, err := redisRepo.Get("client_secret:client-trusted")
|
redisSecret, err := redisRepo.Get("client_secret:client-headless-login")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected redis secret, got error: %v", err)
|
t.Fatalf("expected redis secret, got error: %v", err)
|
||||||
}
|
}
|
||||||
if redisSecret != "trusted-secret" {
|
if redisSecret != "headless-secret" {
|
||||||
t.Fatalf("expected redis secret trusted-secret, got %q", redisSecret)
|
t.Fatalf("expected redis secret headless-secret, got %q", redisSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-trusted", nil)
|
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-headless-login", nil)
|
||||||
getResp, err := app.Test(getReq, -1)
|
getResp, err := app.Test(getReq, -1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get request failed: %v", err)
|
t.Fatalf("get request failed: %v", err)
|
||||||
@@ -557,7 +557,7 @@ func TestUpdateClient_TrustedRPSecretPersistsForLaterDetailFetch(t *testing.T) {
|
|||||||
if err := json.NewDecoder(getResp.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(getResp.Body).Decode(&payload); err != nil {
|
||||||
t.Fatalf("decode response: %v", err)
|
t.Fatalf("decode response: %v", err)
|
||||||
}
|
}
|
||||||
if payload.Client.ClientSecret != "trusted-secret" {
|
if payload.Client.ClientSecret != "headless-secret" {
|
||||||
t.Fatalf("expected detail secret trusted-secret, got %q", payload.Client.ClientSecret)
|
t.Fatalf("expected detail secret headless-secret, got %q", payload.Client.ClientSecret)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,14 +11,20 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type KratosIdentity struct {
|
type KratosIdentity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Traits map[string]interface{} `json:"traits"`
|
SchemaID string `json:"schema_id,omitempty"`
|
||||||
State string `json:"state,omitempty"`
|
Traits map[string]interface{} `json:"traits"`
|
||||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
State string `json:"state,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at,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 {
|
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 {
|
func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||||
patchOps := []map[string]interface{}{
|
identity, err := s.GetIdentity(ctx, identityID)
|
||||||
{
|
|
||||||
"op": "add",
|
|
||||||
"path": "/credentials/password/config/password",
|
|
||||||
"value": newPassword,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(patchOps)
|
|
||||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
if identity == nil {
|
||||||
|
return fmt.Errorf("kratos admin identity not found: %s", identityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := hashPasswordForKratosAdmin(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"schema_id": identity.SchemaID,
|
||||||
|
"traits": identity.Traits,
|
||||||
|
"state": identity.State,
|
||||||
|
"credentials": map[string]interface{}{
|
||||||
|
"password": map[string]interface{}{
|
||||||
|
"config": map[string]string{
|
||||||
|
"hashed_password": hashedPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if payload["schema_id"] == "" {
|
||||||
|
payload["schema_id"] = "default"
|
||||||
|
}
|
||||||
|
if payload["state"] == "" {
|
||||||
|
payload["state"] = "active"
|
||||||
|
}
|
||||||
|
if identity.MetadataAdmin != nil {
|
||||||
|
payload["metadata_admin"] = identity.MetadataAdmin
|
||||||
|
}
|
||||||
|
if identity.MetadataPublic != nil {
|
||||||
|
payload["metadata_public"] = identity.MetadataPublic
|
||||||
|
}
|
||||||
|
if identity.ExternalID != "" {
|
||||||
|
payload["external_id"] = identity.ExternalID
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := s.httpClient().Do(req)
|
resp, err := s.httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -199,6 +239,14 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hashPasswordForKratosAdmin(password string) (string, error) {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashed), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
|
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
|
||||||
@@ -711,20 +713,53 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
|
|||||||
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
||||||
}
|
}
|
||||||
|
|
||||||
patchOps := []map[string]interface{}{
|
identity, err := o.getIdentity(identityID)
|
||||||
{
|
if err != nil {
|
||||||
"op": "add",
|
return fmt.Errorf("ory provider: load identity failed: %w", err)
|
||||||
"path": "/credentials/password/config/password",
|
}
|
||||||
"value": newPassword,
|
if identity == nil {
|
||||||
},
|
return fmt.Errorf("ory provider: identity payload missing for loginID=%s", loginID)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := json.Marshal(patchOps)
|
hashedPassword, err := hashPasswordForKratos(newPassword)
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
if err != nil {
|
||||||
|
return fmt.Errorf("ory provider: hash password failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"schema_id": identity.SchemaID,
|
||||||
|
"traits": identity.Traits,
|
||||||
|
"state": identity.State,
|
||||||
|
"credentials": map[string]interface{}{
|
||||||
|
"password": map[string]interface{}{
|
||||||
|
"config": map[string]string{
|
||||||
|
"hashed_password": hashedPassword,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if payload["schema_id"] == "" {
|
||||||
|
payload["schema_id"] = "default"
|
||||||
|
}
|
||||||
|
if payload["state"] == "" {
|
||||||
|
payload["state"] = "active"
|
||||||
|
}
|
||||||
|
if identity.MetadataAdmin != nil {
|
||||||
|
payload["metadata_admin"] = identity.MetadataAdmin
|
||||||
|
}
|
||||||
|
if identity.MetadataPublic != nil {
|
||||||
|
payload["metadata_public"] = identity.MetadataPublic
|
||||||
|
}
|
||||||
|
if identity.ExternalID != "" {
|
||||||
|
payload["external_id"] = identity.ExternalID
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ory provider: build request failed: %w", err)
|
return fmt.Errorf("ory provider: build request failed: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := o.httpClient().Do(req)
|
resp, err := o.httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -789,6 +824,41 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
|||||||
return identities[0].ID, nil
|
return identities[0].ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := o.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return nil, fmt.Errorf("ory provider: get identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity KratosIdentity
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPasswordForKratos(password string) (string, error) {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashed), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OryProvider) httpClient() *http.Client {
|
func (o *OryProvider) httpClient() *http.Client {
|
||||||
if o.HTTPClient != nil {
|
if o.HTTPClient != nil {
|
||||||
return o.HTTPClient
|
return o.HTTPClient
|
||||||
|
|||||||
@@ -45,18 +45,38 @@ func TestUpdateUserPassword_Success(t *testing.T) {
|
|||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
||||||
q := r.URL.Query()
|
if r.URL.Path == "/admin/identities" {
|
||||||
if got := q.Get("credentials_identifier"); got != loginID {
|
q := r.URL.Query()
|
||||||
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
|
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{
|
if r.URL.Path != "/admin/identities/"+identityID {
|
||||||
{"id": 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
|
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)
|
body, _ := io.ReadAll(r.Body)
|
||||||
if !strings.Contains(string(body), newPassword) {
|
if !strings.Contains(string(body), "\"hashed_password\"") {
|
||||||
t.Fatalf("payload missing new password, body=%s", string(body))
|
t.Fatalf("payload missing hashed_password, body=%s", string(body))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), newPassword) {
|
||||||
|
t.Fatalf("payload must not contain plain password, body=%s", string(body))
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "\"schema_id\":\"default\"") {
|
||||||
|
t.Fatalf("payload missing schema_id, body=%s", string(body))
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
@@ -99,11 +119,25 @@ func TestUpdateUserPassword_ServerError(t *testing.T) {
|
|||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
||||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
if r.URL.Path == "/admin/identities" {
|
||||||
{"id": "abc"},
|
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||||
})
|
{"id": "abc"},
|
||||||
return
|
})
|
||||||
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch:
|
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)
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const naverSMSMaxBytes = 90
|
||||||
|
|
||||||
type SmsServiceImpl struct {
|
type SmsServiceImpl struct {
|
||||||
accessKey string
|
accessKey string
|
||||||
secretKey string
|
secretKey string
|
||||||
@@ -46,17 +48,11 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
|||||||
// Naver SENS API requires phone number without '+'
|
// Naver SENS API requires phone number without '+'
|
||||||
sanitizedTo := strings.Replace(to, "+", "", 1)
|
sanitizedTo := strings.Replace(to, "+", "", 1)
|
||||||
|
|
||||||
reqBody := domain.NaverSmsRequest{
|
reqBody := buildNaverSmsRequest(s.senderPhone, sanitizedTo, content)
|
||||||
Type: "SMS",
|
if reqBody.Type == "LMS" {
|
||||||
ContentType: "COMM",
|
slog.Info("[SmsService] Upgrading message type to LMS due to content length",
|
||||||
CountryCode: "82",
|
"bytes", len([]byte(content)),
|
||||||
From: s.senderPhone,
|
)
|
||||||
Content: content,
|
|
||||||
Messages: []domain.SmsMessage{
|
|
||||||
{
|
|
||||||
To: sanitizedTo,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBody, err := json.Marshal(reqBody)
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
@@ -100,6 +96,29 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildNaverSmsRequest(senderPhone, sanitizedTo, content string) domain.NaverSmsRequest {
|
||||||
|
requestType := "SMS"
|
||||||
|
subject := ""
|
||||||
|
if len([]byte(content)) > naverSMSMaxBytes {
|
||||||
|
requestType = "LMS"
|
||||||
|
subject = "[Baron 로그인]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.NaverSmsRequest{
|
||||||
|
Type: requestType,
|
||||||
|
ContentType: "COMM",
|
||||||
|
CountryCode: "82",
|
||||||
|
From: senderPhone,
|
||||||
|
Subject: subject,
|
||||||
|
Content: content,
|
||||||
|
Messages: []domain.SmsMessage{
|
||||||
|
{
|
||||||
|
To: sanitizedTo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
|
func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
|
||||||
space := " "
|
space := " "
|
||||||
newLine := "\n"
|
newLine := "\n"
|
||||||
|
|||||||
26
backend/internal/service/sms_service_test.go
Normal file
26
backend/internal/service/sms_service_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,17 @@ function readMetadataString(
|
|||||||
return typeof value === "string" ? value : "";
|
return typeof value === "string" ? value : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readMetadataObject(
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const value = metadata[key];
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
function isValidUrl(value: string): boolean {
|
function isValidUrl(value: string): boolean {
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
@@ -150,15 +161,42 @@ function ClientGeneralPage() {
|
|||||||
setStatus(client.status);
|
setStatus(client.status);
|
||||||
setInitialStatus(client.status);
|
setInitialStatus(client.status);
|
||||||
|
|
||||||
|
const metadata = client.metadata ?? {};
|
||||||
|
if (typeof metadata.description === "string")
|
||||||
|
setDescription(metadata.description);
|
||||||
|
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
||||||
|
|
||||||
|
const headlessEnabled = !!metadata.headless_login_enabled;
|
||||||
|
setHeadlessLoginEnabled(headlessEnabled);
|
||||||
|
|
||||||
const savedAuthMethod =
|
const savedAuthMethod =
|
||||||
client.tokenEndpointAuthMethod ||
|
client.tokenEndpointAuthMethod ||
|
||||||
(client.type === "pkce" ? "none" : "client_secret_basic");
|
(client.type === "pkce" ? "none" : "client_secret_basic");
|
||||||
if (isTokenEndpointAuthMethod(savedAuthMethod)) {
|
const headlessAuthMethod = readMetadataString(
|
||||||
setTokenEndpointAuthMethod(savedAuthMethod);
|
metadata,
|
||||||
|
"headless_token_endpoint_auth_method",
|
||||||
|
);
|
||||||
|
const selectedAuthMethod =
|
||||||
|
headlessEnabled && isTokenEndpointAuthMethod(headlessAuthMethod)
|
||||||
|
? headlessAuthMethod
|
||||||
|
: savedAuthMethod;
|
||||||
|
if (isTokenEndpointAuthMethod(selectedAuthMethod)) {
|
||||||
|
setTokenEndpointAuthMethod(selectedAuthMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.jwksUri) {
|
const headlessJwksUri = readMetadataString(metadata, "headless_jwks_uri");
|
||||||
|
const headlessJwks = readMetadataObject(metadata, "headless_jwks");
|
||||||
|
if (headlessJwksUri) {
|
||||||
|
setJwksUri(headlessJwksUri);
|
||||||
|
setJwksText("");
|
||||||
|
setJwksSource("uri");
|
||||||
|
} else if (headlessJwks) {
|
||||||
|
setJwksText(JSON.stringify(headlessJwks, null, 2));
|
||||||
|
setJwksUri("");
|
||||||
|
setJwksSource("inline");
|
||||||
|
} else if (client.jwksUri) {
|
||||||
setJwksUri(client.jwksUri);
|
setJwksUri(client.jwksUri);
|
||||||
|
setJwksText("");
|
||||||
setJwksSource("uri");
|
setJwksSource("uri");
|
||||||
} else if (client.jwks) {
|
} else if (client.jwks) {
|
||||||
setJwksText(
|
setJwksText(
|
||||||
@@ -166,18 +204,16 @@ function ClientGeneralPage() {
|
|||||||
? client.jwks
|
? client.jwks
|
||||||
: JSON.stringify(client.jwks, null, 2),
|
: JSON.stringify(client.jwks, null, 2),
|
||||||
);
|
);
|
||||||
|
setJwksUri("");
|
||||||
|
setJwksSource("inline");
|
||||||
|
} else {
|
||||||
|
setJwksUri("");
|
||||||
|
setJwksText("");
|
||||||
setJwksSource("inline");
|
setJwksSource("inline");
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = client.metadata ?? {};
|
|
||||||
if (typeof metadata.description === "string")
|
|
||||||
setDescription(metadata.description);
|
|
||||||
if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url);
|
|
||||||
|
|
||||||
setHeadlessLoginEnabled(!!metadata.headless_login_enabled);
|
|
||||||
|
|
||||||
// Fallbacks from metadata if top-level fields are empty
|
// Fallbacks from metadata if top-level fields are empty
|
||||||
if (!client.tokenEndpointAuthMethod) {
|
if (!client.tokenEndpointAuthMethod && !headlessEnabled) {
|
||||||
const metaAuth = readMetadataString(
|
const metaAuth = readMetadataString(
|
||||||
metadata,
|
metadata,
|
||||||
"token_endpoint_auth_method",
|
"token_endpoint_auth_method",
|
||||||
@@ -187,7 +223,7 @@ function ClientGeneralPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client.jwksUri && !client.jwks) {
|
if (!client.jwksUri && !client.jwks && !headlessEnabled) {
|
||||||
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
|
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
|
||||||
if (metaJwksUri) {
|
if (metaJwksUri) {
|
||||||
setJwksUri(metaJwksUri);
|
setJwksUri(metaJwksUri);
|
||||||
@@ -342,11 +378,7 @@ function ClientGeneralPage() {
|
|||||||
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
||||||
|
|
||||||
let finalJwks: ClientUpsertRequest["jwks"];
|
let finalJwks: ClientUpsertRequest["jwks"];
|
||||||
if (
|
if (jwksSource === "inline" && trimmedJwksText) {
|
||||||
tokenEndpointAuthMethod === "private_key_jwt" &&
|
|
||||||
jwksSource === "inline" &&
|
|
||||||
trimmedJwksText
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
finalJwks = JSON.parse(trimmedJwksText);
|
finalJwks = JSON.parse(trimmedJwksText);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -354,23 +386,48 @@ function ClientGeneralPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveTokenEndpointAuthMethod =
|
||||||
|
clientType === "pkce" && headlessLoginEnabled
|
||||||
|
? "none"
|
||||||
|
: tokenEndpointAuthMethod;
|
||||||
|
|
||||||
const payload: ClientUpsertRequest = {
|
const payload: ClientUpsertRequest = {
|
||||||
name,
|
name,
|
||||||
type: clientType,
|
type: clientType,
|
||||||
scopes: scopeNames,
|
scopes: scopeNames,
|
||||||
tokenEndpointAuthMethod,
|
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
|
||||||
jwksUri:
|
jwksUri:
|
||||||
tokenEndpointAuthMethod === "private_key_jwt" && jwksSource === "uri"
|
effectiveTokenEndpointAuthMethod === "private_key_jwt" &&
|
||||||
|
jwksSource === "uri"
|
||||||
? trimmedJwksUri
|
? trimmedJwksUri
|
||||||
: undefined,
|
: undefined,
|
||||||
jwks: finalJwks,
|
jwks:
|
||||||
|
effectiveTokenEndpointAuthMethod === "private_key_jwt"
|
||||||
|
? finalJwks
|
||||||
|
: undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
description,
|
description,
|
||||||
logo_url: logoUrl,
|
logo_url: logoUrl,
|
||||||
structured_scopes: scopes,
|
structured_scopes: scopes,
|
||||||
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||||||
request_object_signing_alg: trimmedRequestObjectSigningAlg,
|
request_object_signing_alg: trimmedRequestObjectSigningAlg,
|
||||||
headless_login_enabled: headlessLoginEnabled,
|
headless_login_enabled: headlessLoginEnabled,
|
||||||
|
headless_token_endpoint_auth_method:
|
||||||
|
clientType === "pkce" && headlessLoginEnabled
|
||||||
|
? tokenEndpointAuthMethod
|
||||||
|
: undefined,
|
||||||
|
headless_jwks_uri:
|
||||||
|
clientType === "pkce" &&
|
||||||
|
headlessLoginEnabled &&
|
||||||
|
jwksSource === "uri"
|
||||||
|
? trimmedJwksUri
|
||||||
|
: undefined,
|
||||||
|
headless_jwks:
|
||||||
|
clientType === "pkce" &&
|
||||||
|
headlessLoginEnabled &&
|
||||||
|
jwksSource === "inline"
|
||||||
|
? finalJwks
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -915,22 +972,22 @@ function ClientGeneralPage() {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label
|
<Label
|
||||||
className="text-xs font-bold cursor-pointer"
|
className="text-xs font-bold cursor-pointer"
|
||||||
htmlFor="trusted-rp-toggle"
|
htmlFor="headless-login-toggle"
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
"ui.dev.clients.general.security.trusted_rp_enable",
|
"ui.dev.clients.general.security.headless_login_enable",
|
||||||
"Trusted RP (자체 로그인 UI 사용)",
|
"Headless Login (자체 로그인 UI 사용)",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"ui.dev.clients.general.security.trusted_rp_enable_help",
|
"ui.dev.clients.general.security.headless_login_enable_help",
|
||||||
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
|
"Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="trusted-rp-toggle"
|
id="headless-login-toggle"
|
||||||
checked={headlessLoginEnabled}
|
checked={headlessLoginEnabled}
|
||||||
onCheckedChange={handleHeadlessToggle}
|
onCheckedChange={handleHeadlessToggle}
|
||||||
/>
|
/>
|
||||||
@@ -941,7 +998,7 @@ function ClientGeneralPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 4. Public Key Registration (Trusted RP) */}
|
{/* 4. Public Key Registration (Headless Login) */}
|
||||||
{clientType === "pkce" && headlessLoginEnabled && (
|
{clientType === "pkce" && headlessLoginEnabled && (
|
||||||
<Card className="glass-panel border-primary/20">
|
<Card className="glass-panel border-primary/20">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -956,7 +1013,7 @@ function ClientGeneralPage() {
|
|||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t(
|
{t(
|
||||||
"msg.dev.clients.general.public_key.subtitle",
|
"msg.dev.clients.general.public_key.subtitle",
|
||||||
"Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.",
|
"Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다.",
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -390,12 +390,12 @@ subtitle = "Define the permission scopes this application can request."
|
|||||||
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||||
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
||||||
subtitle = "Select application type. Security level determines authentication method."
|
subtitle = "Select application type. Security level determines authentication method."
|
||||||
trusted_help = "Operate as a trusted RP using private_key_jwt and public key registration. Headless login is only available for this profile."
|
headless_login_help = "Operate as Headless Login using private_key_jwt and public key registration. Headless login is only available for this profile."
|
||||||
|
|
||||||
[msg.dev.clients.general.public_key]
|
[msg.dev.clients.general.public_key]
|
||||||
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
|
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
|
||||||
auth_method_none_help = "Use this for PKCE-based public clients."
|
auth_method_none_help = "Use this for PKCE-based public clients."
|
||||||
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for trusted RP bootstrap and JAR verification."
|
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for Headless Login bootstrap and JAR verification."
|
||||||
guide_example = "Recommended example: https://rp.example.com/.well-known/jwks.json"
|
guide_example = "Recommended example: https://rp.example.com/.well-known/jwks.json"
|
||||||
guide_intro = "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key."
|
guide_intro = "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key."
|
||||||
guide_step_1 = "Generate a key pair on the RP server and keep the private key only in the RP backend."
|
guide_step_1 = "Generate a key pair on the RP server and keep the private key only in the RP backend."
|
||||||
@@ -406,7 +406,7 @@ jwks_inline_help = "Prefer the SSH-RSA public key format first. If you paste an
|
|||||||
jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json"
|
jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json"
|
||||||
request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login."
|
request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login."
|
||||||
source_help = "Register the JWKS URI served by the RP so Baron can verify the public key."
|
source_help = "Register the JWKS URI served by the RP so Baron can verify the public key."
|
||||||
subtitle = "Manage the public key and headless login settings required for trusted RP evaluation."
|
subtitle = "Manage the public key and headless login settings required for Headless Login evaluation."
|
||||||
|
|
||||||
[msg.dev.clients.general.public_key.validation]
|
[msg.dev.clients.general.public_key.validation]
|
||||||
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
|
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
|
||||||
@@ -1392,10 +1392,10 @@ delete = "Delete"
|
|||||||
[ui.dev.clients.general.security]
|
[ui.dev.clients.general.security]
|
||||||
private = "Server Side App"
|
private = "Server Side App"
|
||||||
pkce = "PKCE"
|
pkce = "PKCE"
|
||||||
trusted = "Trusted RP"
|
headless_login = "Headless Login"
|
||||||
title = "Security Settings"
|
title = "Security Settings"
|
||||||
trusted_rp_enable = "Trusted RP (Custom Login UI)"
|
headless_login_enable = "Headless Login (Custom Login UI)"
|
||||||
trusted_rp_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
|
headless_login_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
|
||||||
|
|
||||||
[ui.dev.clients.general.public_key]
|
[ui.dev.clients.general.public_key]
|
||||||
auth_method = "Token Endpoint Auth Method"
|
auth_method = "Token Endpoint Auth Method"
|
||||||
|
|||||||
@@ -390,12 +390,12 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
|||||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||||
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||||
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
||||||
trusted_help = "private_key_jwt와 공개키 등록을 사용해 trusted RP로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다."
|
headless_login_help = "private_key_jwt와 공개키 등록을 사용해 Headless Login으로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다."
|
||||||
|
|
||||||
[msg.dev.clients.general.public_key]
|
[msg.dev.clients.general.public_key]
|
||||||
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
|
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
|
||||||
auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다."
|
auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다."
|
||||||
auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다."
|
auth_method_private_key_jwt_help = "Headless Login bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다."
|
||||||
guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json"
|
guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json"
|
||||||
guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다."
|
guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다."
|
||||||
guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다."
|
guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다."
|
||||||
@@ -406,7 +406,7 @@ jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa
|
|||||||
jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
|
jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
|
||||||
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
|
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
|
||||||
source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
|
source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
|
||||||
subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다."
|
subtitle = "Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다."
|
||||||
|
|
||||||
[msg.dev.clients.general.public_key.validation]
|
[msg.dev.clients.general.public_key.validation]
|
||||||
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
|
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
|
||||||
@@ -1393,8 +1393,8 @@ delete = "삭제"
|
|||||||
private = "Server side App"
|
private = "Server side App"
|
||||||
pkce = "PKCE"
|
pkce = "PKCE"
|
||||||
title = "보안 설정"
|
title = "보안 설정"
|
||||||
trusted_rp_enable = "Trusted RP (자체 로그인 UI 사용)"
|
headless_login_enable = "Headless Login (자체 로그인 UI 사용)"
|
||||||
trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
|
headless_login_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
|
||||||
|
|
||||||
|
|
||||||
[ui.dev.clients.general.public_key]
|
[ui.dev.clients.general.public_key]
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ subtitle = ""
|
|||||||
private_help = ""
|
private_help = ""
|
||||||
pkce_help = ""
|
pkce_help = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
trusted_help = ""
|
headless_login_help = ""
|
||||||
|
|
||||||
[msg.dev.clients.general.public_key]
|
[msg.dev.clients.general.public_key]
|
||||||
auth_method_client_secret_basic_help = ""
|
auth_method_client_secret_basic_help = ""
|
||||||
@@ -1392,8 +1392,8 @@ delete = ""
|
|||||||
private = ""
|
private = ""
|
||||||
pkce = ""
|
pkce = ""
|
||||||
title = ""
|
title = ""
|
||||||
trusted_rp_enable = ""
|
headless_login_enable = ""
|
||||||
trusted_rp_enable_help = ""
|
headless_login_enable_help = ""
|
||||||
|
|
||||||
[ui.dev.clients.general.public_key]
|
[ui.dev.clients.general.public_key]
|
||||||
auth_method = ""
|
auth_method = ""
|
||||||
|
|||||||
@@ -123,23 +123,23 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
|
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pkce trusted rp with inline ssh-rsa key should persist mapped payload", async ({
|
test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const state = {
|
const state = {
|
||||||
clients: [
|
clients: [
|
||||||
makeClient("client-trusted", { name: "Trusted App", type: "pkce" }),
|
makeClient("client-headless-login", { name: "Headless Login App", type: "pkce" }),
|
||||||
],
|
],
|
||||||
consents: [] as Consent[],
|
consents: [] as Consent[],
|
||||||
auditLogsByCursor: undefined,
|
auditLogsByCursor: undefined,
|
||||||
};
|
};
|
||||||
await installDevApiMock(page, state);
|
await installDevApiMock(page, state);
|
||||||
|
|
||||||
await page.goto("/clients/client-trusted/settings");
|
await page.goto("/clients/client-headless-login/settings");
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole("switch", {
|
.getByRole("switch", {
|
||||||
name: /Trusted RP \(자체 로그인 UI 사용\)|Trusted RP \(Custom Login UI\)/i,
|
name: /Headless Login \(자체 로그인 UI 사용\)|Headless Login \(Custom Login UI\)/i,
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
@@ -158,15 +158,20 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => state.clients[0]?.tokenEndpointAuthMethod)
|
.poll(() => state.clients[0]?.tokenEndpointAuthMethod)
|
||||||
.toBe("private_key_jwt");
|
.toBe("none");
|
||||||
await expect
|
await expect
|
||||||
.poll(() => state.clients[0]?.metadata?.headless_login_enabled)
|
.poll(() => state.clients[0]?.metadata?.headless_login_enabled)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => state.clients[0]?.metadata?.headless_token_endpoint_auth_method,
|
||||||
|
)
|
||||||
|
.toBe("private_key_jwt");
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
state.clients[0]?.jwks as {
|
state.clients[0]?.metadata?.headless_jwks as {
|
||||||
keys?: Array<{ kty?: string; alg?: string }>;
|
keys?: Array<{ kty?: string; alg?: string }>;
|
||||||
}
|
}
|
||||||
)?.keys?.[0]?.kty,
|
)?.keys?.[0]?.kty,
|
||||||
@@ -176,7 +181,7 @@ test.describe("DevFront clients lifecycle", () => {
|
|||||||
.poll(
|
.poll(
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
state.clients[0]?.jwks as {
|
state.clients[0]?.metadata?.headless_jwks as {
|
||||||
keys?: Array<{ kty?: string; alg?: string }>;
|
keys?: Array<{ kty?: string; alg?: string }>;
|
||||||
}
|
}
|
||||||
)?.keys?.[0]?.alg,
|
)?.keys?.[0]?.alg,
|
||||||
|
|||||||
@@ -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`에 테스트 결과와 남은 후속 범위(전화번호 링크 승인형 분리)를 코멘트로 남깁니다.
|
||||||
@@ -313,6 +313,10 @@ not_found = "Not Found"
|
|||||||
update_error = "Failed to User Edit."
|
update_error = "Failed to User Edit."
|
||||||
update_success = "Update Success"
|
update_success = "Update Success"
|
||||||
password_generated = "A secure password has been generated."
|
password_generated = "A secure password has been generated."
|
||||||
|
password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately."
|
||||||
|
password_manual_required = "Please enter a password."
|
||||||
|
reset_password_help = "Force-reset the user's password and apply either an auto-generated password or a manually entered one."
|
||||||
|
self_password_reset_blocked = "Please change your own password from the UserFront settings page."
|
||||||
|
|
||||||
[msg.admin.users.detail.form]
|
[msg.admin.users.detail.form]
|
||||||
field_required = "Required."
|
field_required = "Required."
|
||||||
@@ -1155,6 +1159,11 @@ back = "Back"
|
|||||||
edit_title = "Edit Title"
|
edit_title = "Edit Title"
|
||||||
title = "User Details"
|
title = "User Details"
|
||||||
generate_password = "Auto Generate"
|
generate_password = "Auto Generate"
|
||||||
|
password_mode_generated = "Auto Generate"
|
||||||
|
password_mode_manual = "Manual Entry"
|
||||||
|
password_result_title = "Reset Password"
|
||||||
|
reset_password_apply = "Apply Password"
|
||||||
|
toggle_password_visibility = "Toggle password visibility"
|
||||||
|
|
||||||
[ui.admin.users.detail.breadcrumb]
|
[ui.admin.users.detail.breadcrumb]
|
||||||
section = "Users"
|
section = "Users"
|
||||||
|
|||||||
@@ -685,6 +685,10 @@ not_found = "사용자를 찾을 수 없습니다."
|
|||||||
update_error = "사용자 수정에 실패했습니다."
|
update_error = "사용자 수정에 실패했습니다."
|
||||||
update_success = "사용자 정보가 수정되었습니다."
|
update_success = "사용자 정보가 수정되었습니다."
|
||||||
password_generated = "안전한 비밀번호가 생성되었습니다."
|
password_generated = "안전한 비밀번호가 생성되었습니다."
|
||||||
|
password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다."
|
||||||
|
password_manual_required = "비밀번호를 입력해 주세요."
|
||||||
|
reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다."
|
||||||
|
self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요."
|
||||||
|
|
||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
||||||
@@ -1145,6 +1149,11 @@ back = "목록으로 돌아가기"
|
|||||||
edit_title = "정보 수정"
|
edit_title = "정보 수정"
|
||||||
title = "사용자 상세"
|
title = "사용자 상세"
|
||||||
generate_password = "자동 생성"
|
generate_password = "자동 생성"
|
||||||
|
password_mode_generated = "자동 생성"
|
||||||
|
password_mode_manual = "수동 입력"
|
||||||
|
password_result_title = "Reset Password"
|
||||||
|
reset_password_apply = "비밀번호 적용"
|
||||||
|
toggle_password_visibility = "비밀번호 표시 전환"
|
||||||
|
|
||||||
[ui.admin.users.list]
|
[ui.admin.users.list]
|
||||||
add = "사용자 추가"
|
add = "사용자 추가"
|
||||||
|
|||||||
@@ -685,6 +685,10 @@ not_found = ""
|
|||||||
update_error = ""
|
update_error = ""
|
||||||
update_success = ""
|
update_success = ""
|
||||||
password_generated = ""
|
password_generated = ""
|
||||||
|
password_generated_help = ""
|
||||||
|
password_manual_required = ""
|
||||||
|
reset_password_help = ""
|
||||||
|
self_password_reset_blocked = ""
|
||||||
|
|
||||||
[msg.admin.users.list]
|
[msg.admin.users.list]
|
||||||
delete_confirm = ""
|
delete_confirm = ""
|
||||||
@@ -1145,6 +1149,11 @@ back = ""
|
|||||||
edit_title = ""
|
edit_title = ""
|
||||||
title = ""
|
title = ""
|
||||||
generate_password = ""
|
generate_password = ""
|
||||||
|
password_mode_generated = ""
|
||||||
|
password_mode_manual = ""
|
||||||
|
password_result_title = ""
|
||||||
|
reset_password_apply = ""
|
||||||
|
toggle_password_visibility = ""
|
||||||
|
|
||||||
[ui.admin.users.list]
|
[ui.admin.users.list]
|
||||||
add = ""
|
add = ""
|
||||||
|
|||||||
@@ -7,32 +7,94 @@ type RequestCapture = {
|
|||||||
clientLogs: string[];
|
clientLogs: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIGNIN_PASSWORD_TAB_X = 522;
|
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||||
const SIGNIN_TAB_Y = 158;
|
await page.waitForTimeout(300);
|
||||||
const SIGNIN_LOGIN_ID_X = 640;
|
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||||
const SIGNIN_LOGIN_ID_Y = 245;
|
if (await button.count()) {
|
||||||
const SIGNIN_PASSWORD_X = 640;
|
await button.click({ force: true });
|
||||||
const SIGNIN_PASSWORD_Y = 311;
|
const placeholder = page.locator('flt-semantics-placeholder');
|
||||||
const SIGNIN_SUBMIT_X = 640;
|
if (await placeholder.count()) {
|
||||||
const SIGNIN_SUBMIT_Y = 381;
|
await placeholder.first().click({ force: true });
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const RESET_NEW_PASSWORD_X = 640;
|
type ScreenCoords = {
|
||||||
const RESET_NEW_PASSWORD_Y = 382;
|
signinPasswordTabX: number;
|
||||||
const RESET_CONFIRM_PASSWORD_X = 640;
|
signinTabY: number;
|
||||||
const RESET_CONFIRM_PASSWORD_Y = 464;
|
signinLoginIdX: number;
|
||||||
const RESET_SUBMIT_X = 640;
|
signinLoginIdY: number;
|
||||||
const RESET_SUBMIT_Y = 534;
|
signinPasswordX: number;
|
||||||
|
signinPasswordY: number;
|
||||||
|
signinSubmitX: number;
|
||||||
|
signinSubmitY: number;
|
||||||
|
resetNewPasswordX: number;
|
||||||
|
resetNewPasswordY: number;
|
||||||
|
resetConfirmPasswordX: number;
|
||||||
|
resetConfirmPasswordY: number;
|
||||||
|
resetSubmitX: number;
|
||||||
|
resetSubmitY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktopCoords: ScreenCoords = {
|
||||||
|
signinPasswordTabX: 522,
|
||||||
|
signinTabY: 158,
|
||||||
|
signinLoginIdX: 640,
|
||||||
|
signinLoginIdY: 245,
|
||||||
|
signinPasswordX: 640,
|
||||||
|
signinPasswordY: 311,
|
||||||
|
signinSubmitX: 640,
|
||||||
|
signinSubmitY: 381,
|
||||||
|
resetNewPasswordX: 640,
|
||||||
|
resetNewPasswordY: 382,
|
||||||
|
resetConfirmPasswordX: 640,
|
||||||
|
resetConfirmPasswordY: 464,
|
||||||
|
resetSubmitX: 640,
|
||||||
|
resetSubmitY: 534,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mobileCoords: ScreenCoords = {
|
||||||
|
signinPasswordTabX: 90,
|
||||||
|
signinTabY: 158,
|
||||||
|
signinLoginIdX: 206,
|
||||||
|
signinLoginIdY: 268,
|
||||||
|
signinPasswordX: 206,
|
||||||
|
signinPasswordY: 334,
|
||||||
|
signinSubmitX: 206,
|
||||||
|
signinSubmitY: 399,
|
||||||
|
resetNewPasswordX: 206,
|
||||||
|
resetNewPasswordY: 382,
|
||||||
|
resetConfirmPasswordX: 206,
|
||||||
|
resetConfirmPasswordY: 464,
|
||||||
|
resetSubmitX: 206,
|
||||||
|
resetSubmitY: 534,
|
||||||
|
};
|
||||||
|
|
||||||
|
function coordsFor(page: Page): ScreenCoords {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMobileProject(page: Page): boolean {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
return (viewport?.width ?? 1280) <= 500;
|
||||||
|
}
|
||||||
|
|
||||||
async function clickPasswordTab(page: Page): Promise<void> {
|
async function clickPasswordTab(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
await page.waitForTimeout(900);
|
await page.waitForTimeout(900);
|
||||||
const pane = page.locator('flt-glass-pane');
|
const pane = page.locator('flt-glass-pane');
|
||||||
await pane.click({
|
await pane.click({
|
||||||
position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y },
|
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(120);
|
await page.waitForTimeout(120);
|
||||||
await pane.click({
|
await pane.click({
|
||||||
position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y },
|
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
@@ -47,6 +109,68 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise<
|
|||||||
await page.keyboard.type(value);
|
await page.keyboard.type(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fillPasswordLoginForm(
|
||||||
|
page: Page,
|
||||||
|
loginId: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await enableFlutterAccessibility(page);
|
||||||
|
const inputs = page.getByRole('textbox');
|
||||||
|
await inputs.nth(0).fill(loginId);
|
||||||
|
await inputs.nth(1).fill(password);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
|
await fillAt(page, coords.signinLoginIdX, coords.signinLoginIdY, loginId);
|
||||||
|
await fillAt(page, coords.signinPasswordX, coords.signinPasswordY, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPasswordLogin(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await page.getByRole('button', { name: '로그인' }).click({ force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
|
await page.locator('flt-glass-pane').click({
|
||||||
|
position: { x: coords.signinSubmitX, y: coords.signinSubmitY },
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillResetPasswordForm(page: Page, password: string): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await enableFlutterAccessibility(page);
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: /^새 비밀번호$/ })
|
||||||
|
.fill(password);
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: /^새 비밀번호 확인$/ })
|
||||||
|
.fill(password);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
|
await fillAt(page, coords.resetNewPasswordX, coords.resetNewPasswordY, password);
|
||||||
|
await fillAt(
|
||||||
|
page,
|
||||||
|
coords.resetConfirmPasswordX,
|
||||||
|
coords.resetConfirmPasswordY,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitResetPassword(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
|
await page.locator('flt-glass-pane').click({
|
||||||
|
position: { x: coords.resetSubmitX, y: coords.resetSubmitY },
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void> {
|
async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void> {
|
||||||
await page.route('**/api/v1/**', async (route: Route) => {
|
await page.route('**/api/v1/**', async (route: Route) => {
|
||||||
const requestUrl = new URL(route.request().url());
|
const requestUrl = new URL(route.request().url());
|
||||||
@@ -187,17 +311,17 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void>
|
|||||||
test.describe('UserFront WASM password login and reset', () => {
|
test.describe('UserFront WASM password login and reset', () => {
|
||||||
test.skip(({ isMobile }) => isMobile, 'Desktop only (hardcoded coordinates)');
|
test.skip(({ isMobile }) => isMobile, 'Desktop only (hardcoded coordinates)');
|
||||||
test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => {
|
test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => {
|
||||||
|
test.skip(
|
||||||
|
isMobileProject(page),
|
||||||
|
'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.',
|
||||||
|
);
|
||||||
const capture: RequestCapture = { clientLogs: [] };
|
const capture: RequestCapture = { clientLogs: [] };
|
||||||
await mockAuthApis(page, capture);
|
await mockAuthApis(page, capture);
|
||||||
|
|
||||||
await page.goto('/ko/signin');
|
await page.goto('/ko/signin');
|
||||||
await clickPasswordTab(page);
|
await clickPasswordTab(page);
|
||||||
await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com');
|
await fillPasswordLoginForm(page, 'e2e@example.com', 'ValidPass1!');
|
||||||
await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'ValidPass1!');
|
await submitPasswordLogin(page);
|
||||||
await page.locator('flt-glass-pane').click({
|
|
||||||
position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y },
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||||
|
|
||||||
@@ -211,17 +335,17 @@ test.describe('UserFront WASM password login and reset', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => {
|
test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => {
|
||||||
|
test.skip(
|
||||||
|
isMobileProject(page),
|
||||||
|
'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.',
|
||||||
|
);
|
||||||
const capture: RequestCapture = { clientLogs: [] };
|
const capture: RequestCapture = { clientLogs: [] };
|
||||||
await mockAuthApis(page, capture);
|
await mockAuthApis(page, capture);
|
||||||
|
|
||||||
await page.goto('/ko/signin');
|
await page.goto('/ko/signin');
|
||||||
await clickPasswordTab(page);
|
await clickPasswordTab(page);
|
||||||
await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com');
|
await fillPasswordLoginForm(page, 'e2e@example.com', 'WrongPass1!');
|
||||||
await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'WrongPass1!');
|
await submitPasswordLogin(page);
|
||||||
await page.locator('flt-glass-pane').click({
|
|
||||||
position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y },
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||||
await expect
|
await expect
|
||||||
@@ -247,17 +371,8 @@ test.describe('UserFront WASM password login and reset', () => {
|
|||||||
await page.goto('/ko/reset-password?token=reset-token-e2e');
|
await page.goto('/ko/reset-password?token=reset-token-e2e');
|
||||||
await policyLoaded;
|
await policyLoaded;
|
||||||
await page.waitForTimeout(900);
|
await page.waitForTimeout(900);
|
||||||
await fillAt(page, RESET_NEW_PASSWORD_X, RESET_NEW_PASSWORD_Y, 'ValidPass1!A');
|
await fillResetPasswordForm(page, 'ValidPass1!A');
|
||||||
await fillAt(
|
await submitResetPassword(page);
|
||||||
page,
|
|
||||||
RESET_CONFIRM_PASSWORD_X,
|
|
||||||
RESET_CONFIRM_PASSWORD_Y,
|
|
||||||
'ValidPass1!A',
|
|
||||||
);
|
|
||||||
await page.locator('flt-glass-pane').click({
|
|
||||||
position: { x: RESET_SUBMIT_X, y: RESET_SUBMIT_Y },
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
|
|||||||
@@ -6,12 +6,50 @@ type ProfileState = {
|
|||||||
putBodies: Array<Record<string, unknown>>;
|
putBodies: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROFILE_DEPARTMENT_EDIT_X = 1170;
|
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||||
const PROFILE_DEPARTMENT_EDIT_Y = 680;
|
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||||
const PROFILE_DEPARTMENT_INPUT_X = 110;
|
if (await button.count()) {
|
||||||
const PROFILE_DEPARTMENT_INPUT_Y = 685;
|
await button.click({ force: true });
|
||||||
const PROFILE_BLUR_X = 200;
|
await page.waitForTimeout(200);
|
||||||
const PROFILE_BLUR_Y = 260;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileCoords = {
|
||||||
|
departmentEditX: number;
|
||||||
|
departmentEditY: number;
|
||||||
|
departmentInputX: number;
|
||||||
|
departmentInputY: number;
|
||||||
|
blurX: number;
|
||||||
|
blurY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktopCoords: ProfileCoords = {
|
||||||
|
departmentEditX: 1170,
|
||||||
|
departmentEditY: 680,
|
||||||
|
departmentInputX: 110,
|
||||||
|
departmentInputY: 685,
|
||||||
|
blurX: 200,
|
||||||
|
blurY: 260,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mobileCoords: ProfileCoords = {
|
||||||
|
departmentEditX: 350,
|
||||||
|
departmentEditY: 680,
|
||||||
|
departmentInputX: 110,
|
||||||
|
departmentInputY: 685,
|
||||||
|
blurX: 200,
|
||||||
|
blurY: 260,
|
||||||
|
};
|
||||||
|
|
||||||
|
function coordsFor(page: Page): ProfileCoords {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMobileProject(page: Page): boolean {
|
||||||
|
const viewport = page.viewportSize();
|
||||||
|
return (viewport?.width ?? 1280) <= 500;
|
||||||
|
}
|
||||||
|
|
||||||
async function seedTokenLogin(page: Page): Promise<void> {
|
async function seedTokenLogin(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -32,26 +70,56 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openDepartmentEditor(page: Page): Promise<void> {
|
async function openDepartmentEditor(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await enableFlutterAccessibility(page);
|
||||||
|
await page
|
||||||
|
.getByRole('group', { name: '소속 QA' })
|
||||||
|
.getByRole('button', { name: '편집' })
|
||||||
|
.click({ force: true });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
await page.locator('flt-glass-pane').click({
|
await page.locator('flt-glass-pane').click({
|
||||||
position: { x: PROFILE_DEPARTMENT_EDIT_X, y: PROFILE_DEPARTMENT_EDIT_Y },
|
position: { x: coords.departmentEditX, y: coords.departmentEditY },
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function blurDepartmentEditor(page: Page): Promise<void> {
|
async function blurDepartmentEditor(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await page.getByRole('textbox', { name: '소속' }).blur();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
await page.locator('flt-glass-pane').click({
|
await page.locator('flt-glass-pane').click({
|
||||||
position: { x: PROFILE_BLUR_X, y: PROFILE_BLUR_Y },
|
position: { x: coords.blurX, y: coords.blurY },
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitDepartmentEditor(page: Page): Promise<void> {
|
async function submitDepartmentEditor(page: Page): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await page.getByRole('textbox', { name: '소속' }).press('Enter');
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fillDepartmentField(page: Page, value: string): Promise<void> {
|
||||||
|
if (isMobileProject(page)) {
|
||||||
|
await page.getByRole('textbox', { name: '소속' }).fill(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const coords = coordsFor(page);
|
||||||
|
await fillAt(page, coords.departmentInputX, coords.departmentInputY, value);
|
||||||
|
}
|
||||||
|
|
||||||
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
||||||
await page.route('**/api/v1/**', async (route: Route) => {
|
await page.route('**/api/v1/**', async (route: Route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
@@ -176,7 +244,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await waitForInitialProfileLoad(state);
|
await waitForInitialProfileLoad(state);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
|
await fillDepartmentField(page, 'QA-Updated');
|
||||||
await submitDepartmentEditor(page);
|
await submitDepartmentEditor(page);
|
||||||
|
|
||||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||||
@@ -203,7 +271,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await waitForInitialProfileLoad(state);
|
await waitForInitialProfileLoad(state);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Repro');
|
await fillDepartmentField(page, 'QA-Repro');
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||||
@@ -230,7 +298,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await waitForInitialProfileLoad(state);
|
await waitForInitialProfileLoad(state);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA');
|
await fillDepartmentField(page, 'QA');
|
||||||
await blurDepartmentEditor(page);
|
await blurDepartmentEditor(page);
|
||||||
|
|
||||||
expect(state.putBodies).toHaveLength(0);
|
expect(state.putBodies).toHaveLength(0);
|
||||||
@@ -248,7 +316,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await waitForInitialProfileLoad(state);
|
await waitForInitialProfileLoad(state);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, '');
|
await fillDepartmentField(page, '');
|
||||||
await blurDepartmentEditor(page);
|
await blurDepartmentEditor(page);
|
||||||
|
|
||||||
expect(state.putBodies).toHaveLength(0);
|
expect(state.putBodies).toHaveLength(0);
|
||||||
@@ -267,7 +335,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await waitForInitialProfileLoad(state);
|
await waitForInitialProfileLoad(state);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
|
await fillDepartmentField(page, 'QA-1');
|
||||||
await submitDepartmentEditor(page);
|
await submitDepartmentEditor(page);
|
||||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||||
|
|
||||||
@@ -276,7 +344,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
|||||||
await page.waitForTimeout(1200);
|
await page.waitForTimeout(1200);
|
||||||
|
|
||||||
await openDepartmentEditor(page);
|
await openDepartmentEditor(page);
|
||||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
|
await fillDepartmentField(page, 'QA-2');
|
||||||
await submitDepartmentEditor(page);
|
await submitDepartmentEditor(page);
|
||||||
await expect.poll(() => state.putBodies.length).toBe(2);
|
await expect.poll(() => state.putBodies.length).toBe(2);
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
_tabController = TabController(length: 3, vsync: this, initialIndex: 0);
|
||||||
_tabController.addListener(_handleTabSelection);
|
_tabController.addListener(_handleTabSelection);
|
||||||
_drySendEnabled =
|
_drySendEnabled =
|
||||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handlePasswordReset() async {
|
Future<void> _handlePasswordReset() async {
|
||||||
|
if (_isLoading) return;
|
||||||
if (_formKey.currentState?.validate() != true) return;
|
if (_formKey.currentState?.validate() != true) return;
|
||||||
if ((_loginId == null || _loginId!.isEmpty) &&
|
if ((_loginId == null || _loginId!.isEmpty) &&
|
||||||
(_token == null || _token!.isEmpty)) {
|
(_token == null || _token!.isEmpty)) {
|
||||||
@@ -76,6 +77,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
bool isSuccess = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.completePasswordReset(
|
await AuthProxyService.completePasswordReset(
|
||||||
@@ -84,6 +86,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
newPassword: _passwordController.text,
|
newPassword: _passwordController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
isSuccess = true;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ToastService.success(tr('msg.userfront.reset.success'));
|
ToastService.success(tr('msg.userfront.reset.success'));
|
||||||
context.go(buildLocalizedSigninPath(Uri.base));
|
context.go(buildLocalizedSigninPath(Uri.base));
|
||||||
@@ -98,7 +101,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted && !isSuccess) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:logging/logging.dart';
|
|||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
import '../../../../core/notifiers/auth_notifier.dart';
|
import '../../../../core/notifiers/auth_notifier.dart';
|
||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
|
import '../../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
import '../../../../core/ui/layout_breakpoints.dart';
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
import '../../../../core/ui/toast_service.dart';
|
import '../../../../core/ui/toast_service.dart';
|
||||||
@@ -54,10 +55,80 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
bool _showCurrentPassword = false;
|
bool _showCurrentPassword = false;
|
||||||
bool _showNewPassword = false;
|
bool _showNewPassword = false;
|
||||||
bool _showConfirmPassword = false;
|
bool _showConfirmPassword = false;
|
||||||
|
Map<String, dynamic>? _passwordPolicy;
|
||||||
|
bool _isPasswordPolicyLoading = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadPasswordPolicy();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPasswordPolicy() async {
|
||||||
|
setState(() {
|
||||||
|
_isPasswordPolicyLoading = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final policy = await AuthProxyService.fetchPasswordPolicy();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_passwordPolicy = policy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 정책 조회 실패 시 기본 검증 규칙 사용
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isPasswordPolicyLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildPasswordPolicyDescription() {
|
||||||
|
if (_isPasswordPolicyLoading) {
|
||||||
|
return tr('msg.userfront.signup.policy.loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12;
|
||||||
|
final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0;
|
||||||
|
final requiresLower = _passwordPolicy?['lowercase'] ?? true;
|
||||||
|
final requiresUpper = _passwordPolicy?['uppercase'] ?? false;
|
||||||
|
final requiresNumber = _passwordPolicy?['number'] ?? true;
|
||||||
|
final requiresSymbol = _passwordPolicy?['nonAlphanumeric'] ?? true;
|
||||||
|
|
||||||
|
final parts = <String>[
|
||||||
|
tr(
|
||||||
|
'msg.userfront.signup.policy.min_length',
|
||||||
|
params: {'count': '$minLength'},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (minTypes > 0) {
|
||||||
|
parts.add(
|
||||||
|
tr(
|
||||||
|
'msg.userfront.signup.policy.min_types',
|
||||||
|
params: {'count': '$minTypes'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (requiresLower) {
|
||||||
|
parts.add(tr('msg.userfront.signup.policy.lowercase'));
|
||||||
|
}
|
||||||
|
if (requiresUpper) {
|
||||||
|
parts.add(tr('msg.userfront.signup.policy.uppercase'));
|
||||||
|
}
|
||||||
|
if (requiresNumber) {
|
||||||
|
parts.add(tr('msg.userfront.signup.policy.number'));
|
||||||
|
}
|
||||||
|
if (requiresSymbol) {
|
||||||
|
parts.add(tr('msg.userfront.signup.policy.symbol'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tr(
|
||||||
|
'msg.userfront.signup.policy.summary',
|
||||||
|
params: {'rules': parts.join(", ")},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _debugLog(
|
void _debugLog(
|
||||||
@@ -267,6 +338,58 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12;
|
||||||
|
final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0;
|
||||||
|
final hasLower = RegExp(r'[a-z]').hasMatch(newPassword);
|
||||||
|
final hasUpper = RegExp(r'[A-Z]').hasMatch(newPassword);
|
||||||
|
final hasNumber = RegExp(r'[0-9]').hasMatch(newPassword);
|
||||||
|
final hasSymbol = RegExp(r'[\W_]').hasMatch(newPassword);
|
||||||
|
int typeCount = 0;
|
||||||
|
if (hasLower) typeCount++;
|
||||||
|
if (hasUpper) typeCount++;
|
||||||
|
if (hasNumber) typeCount++;
|
||||||
|
if (hasSymbol) typeCount++;
|
||||||
|
|
||||||
|
if (newPassword.length < minLength) {
|
||||||
|
setState(
|
||||||
|
() => _passwordError = tr(
|
||||||
|
'msg.userfront.reset.error.min_length',
|
||||||
|
params: {'count': '$minLength'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (minTypes > 0 && typeCount < minTypes) {
|
||||||
|
setState(
|
||||||
|
() => _passwordError = tr(
|
||||||
|
'msg.userfront.reset.error.min_types',
|
||||||
|
params: {'count': '$minTypes'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_passwordPolicy?['lowercase'] ?? true) && !hasLower) {
|
||||||
|
setState(
|
||||||
|
() => _passwordError = tr('msg.userfront.reset.error.lowercase'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_passwordPolicy?['uppercase'] ?? false) && !hasUpper) {
|
||||||
|
setState(
|
||||||
|
() => _passwordError = tr('msg.userfront.reset.error.uppercase'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_passwordPolicy?['number'] ?? true) && !hasNumber) {
|
||||||
|
setState(() => _passwordError = tr('msg.userfront.reset.error.number'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((_passwordPolicy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
|
||||||
|
setState(() => _passwordError = tr('msg.userfront.reset.error.symbol'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (newPassword != confirmPassword) {
|
if (newPassword != confirmPassword) {
|
||||||
setState(
|
setState(
|
||||||
() => _passwordError = tr('msg.userfront.profile.password.mismatch'),
|
() => _passwordError = tr('msg.userfront.profile.password.mismatch'),
|
||||||
@@ -645,6 +768,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
}) {
|
}) {
|
||||||
final isEditing = _editingField == field;
|
final isEditing = _editingField == field;
|
||||||
final displayValue = value.isEmpty ? '-' : value;
|
final displayValue = value.isEmpty ? '-' : value;
|
||||||
|
final isCompact = MediaQuery.of(context).size.width < 640;
|
||||||
|
|
||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -661,57 +785,64 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
|
|
||||||
final hasChanged = _hasFieldChanged(profile, field);
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
if (isCompact) ...[
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
inputField,
|
||||||
children: [
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Wrap(spacing: 8, runSpacing: 8, children: [saveButton, cancelButton]),
|
||||||
child: TextField(
|
] else
|
||||||
key: Key('profile-$field-input'),
|
Row(
|
||||||
controller: controller,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
children: [
|
||||||
textInputAction: TextInputAction.done,
|
Expanded(child: inputField),
|
||||||
onSubmitted: (_) => _saveField(profile),
|
const SizedBox(width: 12),
|
||||||
onChanged: (_) {
|
saveButton,
|
||||||
setState(() {
|
const SizedBox(width: 8),
|
||||||
_fieldSaveError = null;
|
cancelButton,
|
||||||
});
|
],
|
||||||
},
|
),
|
||||||
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')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -853,6 +984,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
tr('msg.userfront.profile.password.subtitle'),
|
tr('msg.userfront.profile.password.subtitle'),
|
||||||
style: const TextStyle(color: Color(0xFF6B7280)),
|
style: const TextStyle(color: Color(0xFF6B7280)),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_buildPasswordPolicyDescription(),
|
||||||
|
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _currentPasswordController,
|
controller: _currentPasswordController,
|
||||||
|
|||||||
Reference in New Issue
Block a user