From 39296ca52287bd1e66122ae8ec7de1928ad31679 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Mon, 2 Feb 2026 16:22:23 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 5 +- README.md | 2 +- backend/cmd/server/main.go | 1 + backend/docs/openapi.yaml | 26 +++ backend/internal/domain/auth_models.go | 6 + backend/internal/handler/auth_handler.go | 66 +++++++ backend/internal/utils/masking.go | 1 + compose.ory.yaml | 51 ++---- docker/ory/kratos/kratos.yml | 17 +- docker/ory/oathkeeper/rules.active.json | 24 +-- docs/auth-flow.md | 11 +- docs/compose-ory.md | 25 +-- docs/ory-usage.md | 12 +- .../auth/presentation/error_screen.dart | 117 ++++++++++++ .../data/repositories/profile_repository.dart | 31 ++++ .../presentation/pages/profile_page.dart | 166 ++++++++++++++++++ userfront/lib/main.dart | 59 ++++++- 17 files changed, 531 insertions(+), 89 deletions(-) create mode 100644 userfront/lib/features/auth/presentation/error_screen.dart diff --git a/.env.sample b/.env.sample index 63b4c206..5233fbac 100644 --- a/.env.sample +++ b/.env.sample @@ -101,9 +101,8 @@ KRATOS_ADMIN_URL=http://kratos:4434 # 브라우저가 접근할 Kratos Public/UI 외부 URL # Oathkeeper가 /auth 경로를 Kratos Public API로 라우팅합니다. KRATOS_BROWSER_URL=${OATHKEEPER_PUBLIC_URL}/auth -# Kratos UI는 별도 서브도메인이 없으면 UserFront가 렌더링하거나 /kratos-ui 등으로 라우팅 필요 -# 현재는 예시로 로컬 포트 유지 (프로덕션에선 UserFront에 통합됨) -KRATOS_UI_URL=http://localhost:4455 +# Kratos UI는 UserFront가 렌더링합니다. +KRATOS_UI_URL=http://localhost:5000 HYDRA_ADMIN_URL=http://hydra:4445 # Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다. diff --git a/README.md b/README.md index 776b2d11..66ca662f 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ docker compose -f docker-compose.yaml up -d - **ClickHouse**: http://localhost:8123 - **Kratos Public**: http://localhost:4433 - **Hydra Public**: http://localhost:4444 -- **Kratos UI**: http://localhost:4455 +- **Kratos UI (UserFront)**: http://localhost:5000 ### MCP 서버 (Hydra/Kratos/Keto) MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다. diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 7bcaa495..0b1c84fa 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -484,6 +484,7 @@ func main() { user := api.Group("/user") user.Get("/me", authHandler.GetMe) user.Put("/me", authHandler.UpdateMe) + user.Post("/me/password", authHandler.ChangeMyPassword) user.Post("/me/send-code", authHandler.SendUpdateCode) user.Post("/me/verify-code", authHandler.VerifyUpdateCode) user.Get("/rp/linked", authHandler.ListLinkedRps) diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index f1ecda2e..76df7b00 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -452,6 +452,24 @@ paths: schema: $ref: "#/components/schemas/MessageResponse" + /api/v1/user/me/password: + post: + tags: [User] + summary: 비밀번호 변경 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserPasswordChangeRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/MessageResponse" + /api/v1/user/me/verify-code: post: tags: [User] @@ -1127,6 +1145,14 @@ components: companyCode: type: string + UserPasswordChangeRequest: + type: object + properties: + currentPassword: + type: string + newPassword: + type: string + UserProfileSendCodeRequest: type: object properties: diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index ff025ddb..7f267ff1 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -96,3 +96,9 @@ type PasswordResetCompleteRequest struct { LoginID string `json:"loginId"` NewPassword string `json:"newPassword"` } + +// PasswordChangeRequest는 로그인 상태에서 비밀번호 변경 요청을 표현합니다. +type PasswordChangeRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index aab70eeb..197233d7 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3970,6 +3970,72 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { }) } +// ChangeMyPassword - 로그인 상태에서 현재 비밀번호를 확인한 뒤 변경합니다. +func (h *AuthHandler) ChangeMyPassword(c *fiber.Ctx) error { + var req domain.PasswordChangeRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + currentPassword := strings.TrimSpace(req.CurrentPassword) + newPassword := strings.TrimSpace(req.NewPassword) + if currentPassword == "" || newPassword == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Current password and new password are required"}) + } + + policy := h.resolvePasswordPolicy() + if err := validatePasswordWithPolicy(policy, newPassword); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + + loginID := "" + token := h.getBearerToken(c) + if token != "" && looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + resolved, err := h.resolveDescopeLoginID(c.Context(), userToken) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Failed to resolve login ID"}) + } + loginID = resolved + } + } + + if loginID == "" && token != "" { + if resolved, err := h.resolveKratosLoginID(token); err == nil { + loginID = resolved + } + } + + if loginID == "" { + cookie := c.Get("Cookie") + if cookie == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) + } + _, traits, err := h.getKratosIdentityWithCookie(cookie) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + loginID = pickLoginIDFromTraits(traits) + if loginID == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Login ID not found"}) + } + if !strings.Contains(loginID, "@") { + loginID = normalizePhoneForLoginID(loginID) + } + } + + if _, err := h.IdpProvider.SignIn(loginID, currentPassword); err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Current password is invalid"}) + } + + if err := h.IdpProvider.UpdateUserPassword(loginID, newPassword, nil); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"}) + } + + return c.JSON(fiber.Map{"message": "Password updated"}) +} + // SendUpdateCode - Sends OTP for phone number change func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { token := h.getBearerToken(c) diff --git a/backend/internal/utils/masking.go b/backend/internal/utils/masking.go index ab83f5ca..eae78644 100644 --- a/backend/internal/utils/masking.go +++ b/backend/internal/utils/masking.go @@ -7,6 +7,7 @@ import ( var sensitiveKeys = map[string]struct{}{ "password": {}, + "currentpassword": {}, "newpassword": {}, "oldpassword": {}, "token": {}, diff --git a/compose.ory.yaml b/compose.ory.yaml index 2b97f4c9..3c90d7a7 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -28,15 +28,15 @@ services: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433} - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} - - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:4455} - - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:4455}","${USERFRONT_URL:-http://localhost:5000}"] - - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/error - - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/settings - - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/recovery - - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/verification - - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/login - - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/registration - - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:4455}/login + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"] + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login volumes: - ./docker/ory/kratos:/etc/config/kratos command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes @@ -56,15 +56,15 @@ services: - COOKIE_SECRET=${COOKIE_SECRET:-localcookie123} - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433} - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} - - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:4455} - - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:4455}","${USERFRONT_URL:-http://localhost:5000}"] - - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/error - - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/settings - - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/recovery - - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/verification - - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/login - - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:4455}/registration - - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:4455}/login + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"] + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login volumes: - ./docker/ory/kratos:/etc/config/kratos command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier @@ -75,21 +75,6 @@ services: - ory-net - kratosnet - kratos-ui: - image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0} - container_name: ory_kratos_ui - environment: - - KRATOS_PUBLIC_URL=${KRATOS_PUBLIC_URL:-http://kratos:4433/} - - KRATOS_BROWSER_URL=${KRATOS_BROWSER_URL:-http://localhost:${KRATOS_PUBLIC_PORT:-4433}} - - KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434/} - - NODE_ENV=development - - PORT=${KRATOS_UI_PORT:-4455} - - COOKIE_SECRET=${COOKIE_SECRET} - - CSRF_COOKIE_NAME=${CSRF_COOKIE_NAME} - - CSRF_COOKIE_SECRET=${CSRF_COOKIE_SECRET} - networks: - - ory-net - # --- Hydra --- hydra-migrate: image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml index 277dc1d0..474febf3 100644 --- a/docker/ory/kratos/kratos.yml +++ b/docker/ory/kratos/kratos.yml @@ -11,9 +11,8 @@ serve: base_url: http://localhost:4434/ selfservice: - default_browser_return_url: http://localhost:4455/ + default_browser_return_url: http://localhost:5000/ allowed_return_urls: - - http://localhost:4455 - http://localhost:5000 - https://sss.hmac.kr - https://sss.hmac.kr/ @@ -33,24 +32,24 @@ selfservice: flows: error: - ui_url: http://localhost:4455/error + ui_url: http://localhost:5000/error settings: - ui_url: http://localhost:4455/settings + ui_url: http://localhost:5000/error?error=settings_disabled privileged_session_max_age: 15m recovery: - ui_url: http://localhost:4455/recovery + ui_url: http://localhost:5000/recovery use: code verification: - ui_url: http://localhost:4455/verification + ui_url: http://localhost:5000/verification use: code logout: after: - default_browser_return_url: http://localhost:4455/login + default_browser_return_url: http://localhost:5000/login login: - ui_url: http://localhost:4455/login + ui_url: http://localhost:5000/login lifespan: 10m registration: - ui_url: http://localhost:4455/registration + ui_url: http://localhost:5000/registration lifespan: 10m log: diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json index e65e9d51..921b8366 100755 --- a/docker/ory/oathkeeper/rules.active.json +++ b/docker/ory/oathkeeper/rules.active.json @@ -1,9 +1,9 @@ [ { "id": "public-health", - "description": "공개 헬스체크 (STAGE 도메인)", + "description": "공개 헬스체크", "match": { - "url": "<.*>://sso-test.hmac.kr/health", + "url": "<.*>://<.*>/health", "methods": ["GET"] }, "upstream": { @@ -15,9 +15,9 @@ }, { "id": "public-preflight", - "description": "CORS preflight (STAGE 도메인)", + "description": "CORS preflight", "match": { - "url": "<.*>://sso-test.hmac.kr/api/v1/<.*>", + "url": "<.*>://<.*>/api/v1/<.*>", "methods": ["OPTIONS"] }, "upstream": { @@ -29,9 +29,9 @@ }, { "id": "public-auth", - "description": "인증/회원가입 등 공개 엔드포인트 (STAGE 도메인)", + "description": "인증/회원가입 등 공개 엔드포인트", "match": { - "url": "<.*>://sso-test.hmac.kr/api/v1/auth/<.*>", + "url": "<.*>://<.*>/api/v1/auth/<.*>", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -45,7 +45,7 @@ "id": "backend-command", "description": "Command 요청은 Backend로 전달 (Audit 강제)", "match": { - "url": "<.*>://sso-test.hmac.kr/api/v1/<.*>", + "url": "<.*>://<.*>/api/v1/<.*>", "methods": ["POST", "PUT", "PATCH", "DELETE"] }, "upstream": { @@ -59,7 +59,7 @@ "id": "backend-query", "description": "Backend Query (admin/dev 포함)", "match": { - "url": "<.*>://sso-test.hmac.kr/api/v1/<.*>", + "url": "<.*>://<.*>/api/v1/<.*>", "methods": ["GET"] }, "upstream": { @@ -73,7 +73,7 @@ "id": "hydra-well-known", "description": "Hydra OIDC Discovery & JWKS", "match": { - "url": "<.*>://sso-test.hmac.kr/.well-known/<.*>", + "url": "<.*>://<.*>/.well-known/<.*>", "methods": ["GET", "OPTIONS"] }, "upstream": { @@ -87,7 +87,7 @@ "id": "hydra-oauth2", "description": "Hydra OAuth2 Endpoints", "match": { - "url": "<.*>://sso-test.hmac.kr/oauth2/<.*>", + "url": "<.*>://<.*>/oauth2/<.*>", "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] }, "upstream": { @@ -101,7 +101,7 @@ "id": "hydra-userinfo", "description": "Hydra Userinfo", "match": { - "url": "<.*>://sso-test.hmac.kr/userinfo", + "url": "<.*>://<.*>/userinfo", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -111,4 +111,4 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] } -] \ No newline at end of file +] diff --git a/docs/auth-flow.md b/docs/auth-flow.md index d5bab1de..76d9dad5 100644 --- a/docs/auth-flow.md +++ b/docs/auth-flow.md @@ -107,12 +107,7 @@ ## 6) UserFront 주의사항 - `sessionJwt`가 **JWT 형식이 아닐 수 있음** (Kratos session token은 opaque 가능) -- 현재 UserFront는 Descope SDK 기반 세션 처리 로직이 포함되어 있어, Ory 사용 시 이 부분은 분리/대체가 필요함 +- 현재 UserFront는 Descope SDK 기반 세션 처리 로직을 제거했으니 남아 있다면 제거 대상임. 즉시 사용자에게 알리고 이슈로 생성 바람. ---- - -## 7) 다음 액션 제안 - -1. **Kratos 세션 쿠키 전달 방식(A) 구현** -2. Enchanted/Magic Link의 Ory 대응(로그인 코드/링크 방식) 설계 -3. SMS 코드/QR 플로우의 Kratos 세션 교환 정책 확정 +참고. +- AdminFront, DevFront는 JWT 방식으로 RP중 하나인 것 처럼 동작시키는 설계. 따라서 JWT에 대한 대응도 내부적으로는 완벽히 진행해야 함. diff --git a/docs/compose-ory.md b/docs/compose-ory.md index 57d98ad8..e7277d46 100644 --- a/docs/compose-ory.md +++ b/docs/compose-ory.md @@ -24,19 +24,12 @@ - Kratos Admin API를 대신 호출해주는 “자동화/툴링(LLM 연동 포함) 브리지” - 사람/브라우저가 직접 쓰는 서비스라기보다, 내부 도구가 붙어서 identity 관리 작업을 자동화할 때 사용 -### 5) `kratos-ui` - -- Kratos의 로그인/회원가입 등 Self-service 화면을 제공하는 UI 서버 -- Kratos public/admin URL을 환경변수로 받아서 UI가 Kratos와 통신함 - ---- - -### 6) `hydra-migrate` +### 5) `hydra-migrate` - Hydra DB 스키마 마이그레이션을 수행하는 1회성 컨테이너 - Postgres가 healthy가 된 뒤 실행되고, 성공해야 Hydra가 뜸 -### 7) `hydra` +### 6) `hydra` - **OAuth2 / OIDC Provider**: authorization code 발급, access/refresh token 발급 등 - 포트 @@ -45,7 +38,7 @@ - `URLS_SELF_ISSUER`, `URLS_LOGIN`, `URLS_CONSENT`로 “로그인/동의 화면을 어디서 처리할지”를 외부(backend)로 위임 -### 8) `hydra-mcp-server` (지금은 profiles 제거되어 항상 뜸) +### 7) `hydra-mcp-server` (지금은 profiles 제거되어 항상 뜸) - Hydra Admin/Public API를 대신 호출해주는 “자동화/툴링(LLM 연동 포함) 브리지” - 주 용도는 OAuth 클라이언트 생성/수정/조회 자동화, 테스트 환경 세팅, 운영 자동화 등 @@ -53,12 +46,12 @@ --- -### 9) `keto-migrate` +### 8) `keto-migrate` - Keto(권한/관계 기반 접근제어) DB 마이그레이션 수행 1회성 컨테이너 - Postgres가 healthy가 된 뒤 실행되고, 성공해야 Keto가 뜸 -### 10) `keto` +### 9) `keto` - **권한/정책(관계 튜플) 기반 접근제어** 담당(Ory Keto) - 포트 @@ -69,7 +62,7 @@ --- -### 11) `oathkeeper` +### 10) `oathkeeper` - **Reverse proxy + Access rule enforcement**(인증/인가 게이트웨이) - 일반적으로 앞단에서 요청을 받아서 “인증 여부 확인 후” 백엔드로 프록시 @@ -79,12 +72,12 @@ --- -### 12) `ory_stack_check` +### 11) `ory_stack_check` - 알파인에서 curl로 Kratos/Hydra/Keto의 `/health/ready`를 폴링해서 “스택 준비 완료”를 확인하는 헬퍼 - 준비가 끝나야 다음 단계(init-rp)가 안전하게 실행됨 -### 13) `init-rp` +### 12) `init-rp` - Hydra Admin API로 **OAuth 클라이언트(Relying Party)를 자동 등록**하는 1회성 컨테이너 - 여기서는 `adminfront`, `devfront` 클라이언트를 만들어 둠 @@ -148,7 +141,7 @@ curl -i http://localhost:4456/health/ready ### 화면이 떠야 하는 것 (UI) ``` -http://localhost:4455/... : Kratos UI (이미 OK) +http://localhost:5000/... : Kratos UI(UserFront) (이미 OK) http://localhost:5000, http://localhost:5174 : 프론트들 (이미 OK) ``` diff --git a/docs/ory-usage.md b/docs/ory-usage.md index fa3367ec..7f381071 100644 --- a/docs/ory-usage.md +++ b/docs/ory-usage.md @@ -6,7 +6,7 @@ - **Kratos**: Identity/Session 관리(SoT) - **Hydra**: OAuth2/OIDC 토큰 엔진 - **Keto**: 권한/정책 -- **Kratos UI**: Self-service UI (login/registration 등) +- **Kratos UI**: UserFront가 self-service UI 역할 (login/registration 등) ## 2) 실행 방법 ```bash @@ -28,18 +28,18 @@ Ory 구성은 **컨테이너 내부 통신 URL**과 **브라우저 접근 URL** ### 브라우저 접근용 URL(외부 도메인/프록시) - `KRATOS_BROWSER_URL` : Kratos Public의 외부 URL -- `KRATOS_UI_URL` : Kratos UI의 외부 URL +- `KRATOS_UI_URL` : UserFront의 외부 URL (Kratos UI 역할) 예시(로컬): ```env KRATOS_BROWSER_URL=http://localhost:4433 -KRATOS_UI_URL=http://localhost:4455 +KRATOS_UI_URL=http://localhost:5000 ``` 예시(리버스 프록시/도메인): ```env KRATOS_BROWSER_URL=https://sso.example.com -KRATOS_UI_URL=https://sso-ui.example.com +KRATOS_UI_URL=https://sso.example.com ``` ### 포트 노출 정책 @@ -49,7 +49,7 @@ KRATOS_UI_URL=https://sso-ui.example.com - 브라우저/Frontend는 Backend API를 통해서만 IDP 기능을 호출 ## 4) Kratos Self-service UI 리다이렉트 설정 -Kratos는 self-service UI URL을 설정값으로 사용합니다. 브라우저에서 접근 가능한 URL이어야 정상 동작합니다. +Kratos는 self-service UI URL을 설정값으로 사용합니다. **UserFront의 브라우저 접근 URL**이어야 정상 동작합니다. - `KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL` - `KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS` @@ -58,7 +58,7 @@ Kratos는 self-service UI URL을 설정값으로 사용합니다. 브라우저 compose에서 기본적으로 다음과 같이 오버라이드합니다: - `KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login` - `KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration` -- `KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/settings` +- `KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled` (임시 비활성) - `KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery` - `KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification` diff --git a/userfront/lib/features/auth/presentation/error_screen.dart b/userfront/lib/features/auth/presentation/error_screen.dart new file mode 100644 index 00000000..bf19ba79 --- /dev/null +++ b/userfront/lib/features/auth/presentation/error_screen.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ErrorScreen extends StatelessWidget { + final String? errorId; + final String? errorCode; + final String? description; + + const ErrorScreen({ + super.key, + this.errorId, + this.errorCode, + this.description, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final errorType = (errorCode == null || errorCode!.isEmpty) + ? 'unknown_error' + : errorCode!; + final title = errorCode == null || errorCode!.isEmpty + ? '인증 과정에서 오류가 발생했습니다' + : '오류: $errorCode'; + final detail = description?.isNotEmpty == true + ? description! + : '요청을 처리하는 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.'; + + return Scaffold( + backgroundColor: const Color(0xFFF7F8FA), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 24), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Color(0xFFE5E7EB)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(28, 28, 28, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: const Color(0xFF111827), + ), + ), + const SizedBox(height: 12), + Text( + detail, + style: theme.textTheme.bodyMedium?.copyWith( + color: const Color(0xFF4B5563), + height: 1.5, + ), + ), + const SizedBox(height: 12), + Text( + '오류 종류: $errorType', + style: theme.textTheme.bodySmall?.copyWith( + color: const Color(0xFF6B7280), + ), + ), + if (errorId != null && errorId!.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + '오류 ID: $errorId', + style: theme.textTheme.bodySmall?.copyWith( + color: const Color(0xFF6B7280), + ), + ), + ], + const SizedBox(height: 20), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ElevatedButton( + onPressed: () => context.go('/login'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF111827), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text('로그인으로 이동'), + ), + OutlinedButton( + onPressed: () => context.go('/'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF111827), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + side: const BorderSide(color: Color(0xFFCBD5F5)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text('홈으로 이동'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/userfront/lib/features/profile/data/repositories/profile_repository.dart b/userfront/lib/features/profile/data/repositories/profile_repository.dart index 4b3ed55c..58edf995 100644 --- a/userfront/lib/features/profile/data/repositories/profile_repository.dart +++ b/userfront/lib/features/profile/data/repositories/profile_repository.dart @@ -102,6 +102,37 @@ class ProfileRepository { } } + Future changePassword({ + required String currentPassword, + required String newPassword, + }) async { + final token = await _getToken(); + final useCookie = AuthTokenStore.usesCookie(); + if (token == null && !useCookie) throw Exception('No active session'); + + final url = Uri.parse('$_baseUrl/api/v1/user/me/password'); + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.post( + url, + headers: headers, + body: jsonEncode({ + 'currentPassword': currentPassword, + 'newPassword': newPassword, + }), + ); + client.close(); + + if (response.statusCode != 200) { + throw Exception('Failed to change password: ${response.body}'); + } + } + Future verifyUpdateCode(String phone, String code) async { final token = await _getToken(); final useCookie = AuthTokenStore.usesCookie(); diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index ea467f4a..19ab68df 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -26,6 +26,9 @@ class _ProfilePageState extends ConsumerState { TextEditingController? _phoneController; TextEditingController? _departmentController; TextEditingController? _codeController; + TextEditingController? _currentPasswordController; + TextEditingController? _newPasswordController; + TextEditingController? _confirmPasswordController; final FocusNode _nameFocus = FocusNode(); final FocusNode _departmentFocus = FocusNode(); final FocusNode _phoneFocus = FocusNode(); @@ -42,6 +45,13 @@ class _ProfilePageState extends ConsumerState { bool _isCodeSent = false; bool _isVerifying = false; + bool _isPasswordSaving = false; + String? _passwordError; + String? _passwordSuccess; + bool _showCurrentPassword = false; + bool _showNewPassword = false; + bool _showConfirmPassword = false; + @override void initState() { super.initState(); @@ -97,6 +107,9 @@ class _ProfilePageState extends ConsumerState { _phoneController?.dispose(); _departmentController?.dispose(); _codeController?.dispose(); + _currentPasswordController?.dispose(); + _newPasswordController?.dispose(); + _confirmPasswordController?.dispose(); _nameFocus.dispose(); _departmentFocus.dispose(); _phoneFocus.dispose(); @@ -113,6 +126,9 @@ class _ProfilePageState extends ConsumerState { _nameController ??= TextEditingController(text: profile.name); _departmentController ??= TextEditingController(text: profile.department); _codeController ??= TextEditingController(); + _currentPasswordController ??= TextEditingController(); + _newPasswordController ??= TextEditingController(); + _confirmPasswordController ??= TextEditingController(); if (_phoneController == null) { _phoneController = TextEditingController(text: profile.phone); @@ -256,6 +272,54 @@ class _ProfilePageState extends ConsumerState { } } + Future _changePassword() async { + if (_isPasswordSaving) return; + final currentPassword = _currentPasswordController?.text.trim() ?? ''; + final newPassword = _newPasswordController?.text.trim() ?? ''; + final confirmPassword = _confirmPasswordController?.text.trim() ?? ''; + + if (currentPassword.isEmpty) { + setState(() => _passwordError = '현재 비밀번호를 입력해 주세요.'); + return; + } + if (newPassword.isEmpty) { + setState(() => _passwordError = '새 비밀번호를 입력해 주세요.'); + return; + } + if (newPassword != confirmPassword) { + setState(() => _passwordError = '새 비밀번호가 일치하지 않습니다.'); + return; + } + + setState(() { + _passwordError = null; + _passwordSuccess = null; + _isPasswordSaving = true; + }); + + try { + await ref.read(profileRepositoryProvider).changePassword( + currentPassword: currentPassword, + newPassword: newPassword, + ); + _currentPasswordController?.clear(); + _newPasswordController?.clear(); + _confirmPasswordController?.clear(); + setState(() { + _passwordSuccess = '비밀번호가 변경되었습니다.'; + }); + } catch (e) { + final message = e.toString().replaceFirst('Exception: ', ''); + setState(() { + _passwordError = '비밀번호 변경 실패: $message'; + }); + } finally { + if (mounted) { + setState(() => _isPasswordSaving = false); + } + } + } + void _autoSaveIfEditing(UserProfile profile, String field) { if (_editingField != field) return; if (_isVerifying) return; @@ -693,6 +757,104 @@ class _ProfilePageState extends ConsumerState { ); } + Widget _buildPasswordSection() { + return _buildCard( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '비밀번호 변경', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + const Text( + '현재 비밀번호 확인 후 새 비밀번호로 변경합니다.', + style: TextStyle(color: Color(0xFF6B7280)), + ), + const SizedBox(height: 16), + TextField( + controller: _currentPasswordController, + obscureText: !_showCurrentPassword, + decoration: InputDecoration( + labelText: '현재 비밀번호', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon(_showCurrentPassword ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() { + _showCurrentPassword = !_showCurrentPassword; + }), + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _newPasswordController, + obscureText: !_showNewPassword, + decoration: InputDecoration( + labelText: '새 비밀번호', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon(_showNewPassword ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() { + _showNewPassword = !_showNewPassword; + }), + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _confirmPasswordController, + obscureText: !_showConfirmPassword, + decoration: InputDecoration( + labelText: '새 비밀번호 확인', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon(_showConfirmPassword ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() { + _showConfirmPassword = !_showConfirmPassword; + }), + ), + ), + ), + if (_passwordError != null) ...[ + const SizedBox(height: 12), + Text( + _passwordError!, + style: const TextStyle(color: Colors.red), + ), + ], + if (_passwordSuccess != null) ...[ + const SizedBox(height: 12), + Text( + _passwordSuccess!, + style: const TextStyle(color: Colors.green), + ), + ], + const SizedBox(height: 16), + Row( + children: [ + ElevatedButton( + onPressed: _isPasswordSaving ? null : _changePassword, + child: _isPasswordSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('비밀번호 변경'), + ), + const SizedBox(width: 12), + TextButton( + onPressed: () => context.go('/recovery'), + child: const Text('비밀번호를 잊으셨나요?'), + ), + ], + ), + ], + ), + ); + } + Widget _buildContent(UserProfile profile, bool isUpdating) { return RefreshIndicator( onRefresh: () => ref.read(profileProvider.notifier).loadProfile(), @@ -754,6 +916,10 @@ class _ProfilePageState extends ConsumerState { ], ), ), + const SizedBox(height: 28), + _buildSectionTitle('보안', '비밀번호를 안전하게 관리합니다.'), + const SizedBox(height: 12), + _buildPasswordSection(), if (isUpdating || _isVerifying) ...[ const SizedBox(height: 24), const Center(child: CircularProgressIndicator()), diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 71d0f98d..c4f9ce7b 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -11,6 +11,7 @@ import 'features/auth/presentation/approve_qr_screen.dart'; import 'features/auth/presentation/qr_scan_screen.dart'; import 'features/auth/presentation/forgot_password_screen.dart'; import 'features/auth/presentation/reset_password_screen.dart'; +import 'features/auth/presentation/error_screen.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; import 'features/admin/presentation/user_management_screen.dart'; import 'features/profile/presentation/pages/profile_page.dart'; @@ -94,6 +95,13 @@ final _router = GoRouter( return LoginScreen(key: state.pageKey); } ), + GoRoute( + path: '/login', + builder: (context, state) { + _routerLogger.info("Navigating to /login"); + return LoginScreen(key: state.pageKey); + }, + ), GoRoute( path: '/signup', builder: (context, state) { @@ -101,6 +109,13 @@ final _router = GoRouter( return const SignupScreen(); }, ), + GoRoute( + path: '/registration', + builder: (context, state) { + _routerLogger.info("Navigating to /registration"); + return const SignupScreen(); + }, + ), GoRoute( path: '/verify', builder: (context, state) { @@ -116,6 +131,13 @@ final _router = GoRouter( return LoginScreen(key: state.pageKey, verificationToken: token); }, ), + GoRoute( + path: '/verification', + builder: (context, state) { + _routerLogger.info("Navigating to /verification"); + return LoginScreen(key: state.pageKey); + }, + ), GoRoute( path: '/l/:shortCode', builder: (context, state) { @@ -131,6 +153,13 @@ final _router = GoRouter( return const ForgotPasswordScreen(); }, ), + GoRoute( + path: '/recovery', + builder: (context, state) { + _routerLogger.info("Navigating to /recovery"); + return const ForgotPasswordScreen(); + }, + ), GoRoute( // Supports both /reset-password and /reset-password?token=... path: '/reset-password', @@ -141,6 +170,28 @@ final _router = GoRouter( return const ResetPasswordScreen(); }, ), + GoRoute( + path: '/error', + builder: (context, state) { + _routerLogger.info("Navigating to /error"); + final params = state.uri.queryParameters; + return ErrorScreen( + errorId: params['id'], + errorCode: params['error'], + description: params['error_description'] ?? params['message'], + ); + }, + ), + GoRoute( + path: '/settings', + builder: (context, state) { + _routerLogger.info("Navigating to /settings (disabled)"); + return const ErrorScreen( + errorCode: 'settings_disabled', + description: '현재 계정 설정 화면은 준비 중입니다.', + ); + }, + ), GoRoute( path: '/approve', builder: (context, state) { @@ -181,12 +232,18 @@ final _router = GoRouter( // Public paths that don't require login final isPublicPath = path == '/signin' || path == '/signup' || + path == '/login' || + path == '/registration' || path == '/verify' || + path == '/verification' || path.startsWith('/verify/') || path == '/approve' || path.startsWith('/ql/') || path == '/forgot-password' || - path == '/reset-password'; + path == '/recovery' || + path == '/reset-password' || + path == '/error' || + path == '/settings'; _routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");