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 fedee65a..61ef792f 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -242,6 +242,7 @@ func main() { app := fiber.New(fiber.Config{ AppName: "Baron SSO Backend", DisableStartupMessage: true, // Clean logs + ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응) // Global Error Handler for Production Masking ErrorHandler: func(c *fiber.Ctx, err error) error { // Default status code @@ -459,6 +460,9 @@ func main() { auth.Post("/login/code/verify", authHandler.VerifyLoginCode) auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode) auth.Post("/password/login", authHandler.PasswordLogin) + auth.Get("/consent", authHandler.GetConsentRequest) + auth.Post("/consent/accept", authHandler.AcceptConsentRequest) + auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage) @@ -484,6 +488,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 cc82dfcc..a0cacfcd 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -97,3 +97,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 fa7b9cbf..2eacc8b3 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1266,8 +1266,9 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.Operation = "Auth.Password().SignIn" var req struct { - LoginID string `json:"loginId"` - Password string `json:"password"` + LoginID string `json:"loginId"` + Password string `json:"password"` + LoginChallenge string `json:"login_challenge,omitempty"` } if err := c.BodyParser(&req); err != nil { @@ -1314,6 +1315,21 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { setSessionIDLocal(c, authInfo.SessionToken) ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject)) + // --- OIDC 로그인 흐름 처리 --- + if req.LoginChallenge != "" { + slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge) + acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, authInfo.Subject) + if err != nil { + slog.Error("failed to accept hydra login request", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") + } + slog.Info("Hydra login request accepted", "redirectTo", acceptResp.RedirectTo) + return c.JSON(fiber.Map{ + "redirectTo": acceptResp.RedirectTo, + }) + } + // --- OIDC 로그인 흐름 처리 끝 --- + resp := fiber.Map{ "sessionJwt": authInfo.SessionToken.JWT, "status": "ok", @@ -2898,6 +2914,48 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { return c.JSON(linkedRpListResponse{Items: items}) } +func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { + challenge := c.Query("consent_challenge") + if challenge == "" { + return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required") + } + + consentRequest, err := h.Hydra.GetConsentRequest(c.Context(), challenge) + if err != nil { + slog.Error("failed to get hydra consent request", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") + } + + return c.JSON(consentRequest) +} + +func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { + var req struct { + ConsentChallenge string `json:"consent_challenge"` + } + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + if req.ConsentChallenge == "" { + return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required") + } + + consentRequest, err := h.Hydra.GetConsentRequest(c.Context(), req.ConsentChallenge) + if err != nil { + slog.Error("failed to get hydra consent request before accepting", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") + } + + acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest) + if err != nil { + slog.Error("failed to accept hydra consent request", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept consent request") + } + + return c.JSON(acceptResp) +} + + func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { token := h.getBearerToken(c) if token != "" { @@ -3997,6 +4055,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/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index 6d77cebf..103bc45f 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -36,6 +36,15 @@ type HydraClient struct { Metadata map[string]interface{} `json:"metadata,omitempty"` } +type HydraConsentRequest struct { + Challenge string `json:"challenge"` + RequestedScope []string `json:"requested_scope"` + RequestedAudience []string `json:"requested_access_token_audience"` + Skip bool `json:"skip"` + Subject string `json:"subject"` + Client HydraClient `json:"client"` +} + type HydraConsentSession struct { Subject string `json:"subject"` GrantedScope []string `json:"granted_scope"` @@ -347,3 +356,134 @@ func (s *HydraAdminService) buildURLWithParams(path string, params map[string]st u.RawQuery = q.Encode() return u.String(), nil } + +type AcceptLoginRequestResponse struct { + RedirectTo string `json:"redirectTo"` +} + +type AcceptConsentRequestResponse struct { + RedirectTo string `json:"redirectTo"` +} + +func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge string) (*HydraConsentRequest, error) { + params := map[string]string{ + "consent_challenge": challenge, + } + endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/consent", params) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("hydra admin: create request for get consent failed: %w", err) + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("hydra admin: get consent request failed: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hydra admin: get consent failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var consentReq HydraConsentRequest + if err := json.Unmarshal(body, &consentReq); err != nil { + return nil, fmt.Errorf("hydra admin: decode get consent response failed: %w", err) + } + + return &consentReq, nil +} + +func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest) (*AcceptConsentRequestResponse, error) { + params := map[string]string{ + "consent_challenge": challenge, + } + endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/consent/accept", params) + if err != nil { + return nil, err + } + + payload := map[string]interface{}{ + "grant_scope": grantInfo.RequestedScope, + "grant_audience": grantInfo.RequestedAudience, + "remember": true, + "remember_for": 3600, + } + body, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("hydra admin: create request for accept consent failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("hydra admin: accept consent request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hydra admin: accept consent failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + // Hydra 응답(redirect_to)을 읽어서 우리 응답(redirectTo)으로 변환 + var hydraResp struct { + RedirectTo string `json:"redirect_to"` + } + if err := json.Unmarshal(respBody, &hydraResp); err != nil { + return nil, fmt.Errorf("hydra admin: decode accept consent response failed: %w", err) + } + + return &AcceptConsentRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil +} + + +func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge string, subject string) (*AcceptLoginRequestResponse, error) { + params := map[string]string{ + "login_challenge": challenge, + } + endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/login/accept", params) + if err != nil { + return nil, err + } + + payload := map[string]interface{}{ + "subject": subject, + "remember": true, + "remember_for": 3600, + } + body, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("hydra admin: create request for accept login failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("hydra admin: accept login request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hydra admin: accept login failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + // Hydra 응답(redirect_to)을 읽어서 우리 응답(redirectTo)으로 변환 + var hydraResp struct { + RedirectTo string `json:"redirect_to"` + } + if err := json.Unmarshal(respBody, &hydraResp); err != nil { + return nil, fmt.Errorf("hydra admin: decode accept login response failed: %w", err) + } + + return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil +} 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..44ddfac3 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} @@ -107,7 +92,7 @@ services: container_name: ory_hydra environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 - - URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL:-http://localhost:5000/oidc} + - URLS_SELF_ISSUER=${USERFRONT_URL:-http://localhost:5000}/oidc - URLS_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login - URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} @@ -121,8 +106,6 @@ services: - ory-net - hydranet - - # --- Keto --- keto-migrate: image: oryd/keto:${KETO_VERSION:-v25.4.0} @@ -244,8 +227,8 @@ services: - hydranet volumes: - ory_postgres_data: - ory_clickhouse_data: + ory_postgres_data: + ory_clickhouse_data: networks: ory-net: diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 35d9448d..3063028b 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -1,6 +1,7 @@ import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react"; import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; +import { Toaster } from "../ui/toaster"; const navItems = [{ label: "Clients", to: "/clients", icon: ShieldHalf }]; @@ -105,6 +106,7 @@ function AppLayout() { + ); } diff --git a/devfront/src/components/ui/copy-button.tsx b/devfront/src/components/ui/copy-button.tsx new file mode 100644 index 00000000..83996231 --- /dev/null +++ b/devfront/src/components/ui/copy-button.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { Check, Copy } from "lucide-react"; +import { Button, type ButtonProps } from "./button"; +import { cn } from "../../lib/utils"; + +interface CopyButtonProps extends ButtonProps { + value: string; + onCopy?: () => void; +} + +export function CopyButton({ + value, + onCopy, + className, + variant = "secondary", + size = "icon", + ...props +}: CopyButtonProps) { + const [hasCopied, setHasCopied] = React.useState(false); + + React.useEffect(() => { + if (hasCopied) { + const timer = setTimeout(() => setHasCopied(false), 1500); + return () => clearTimeout(timer); + } + }, [hasCopied]); + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(value); + setHasCopied(true); + if (onCopy) onCopy(); + } catch (err) { + console.error("Failed to copy text: ", err); + } + }; + + return ( + + ); +} diff --git a/devfront/src/components/ui/toaster.tsx b/devfront/src/components/ui/toaster.tsx new file mode 100644 index 00000000..3f951781 --- /dev/null +++ b/devfront/src/components/ui/toaster.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; +import { useToastState } from "./use-toast"; +import { CheckCircle2, AlertCircle, Info, X } from "lucide-react"; +import { cn } from "../../lib/utils"; + +export function Toaster() { + const toasts = useToastState(); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((t) => ( +
+ {t.type === "success" && } + {t.type === "error" && } + {t.type === "info" && } +

{t.message}

+
+ ))} +
+ ); +} diff --git a/devfront/src/components/ui/use-toast.ts b/devfront/src/components/ui/use-toast.ts new file mode 100644 index 00000000..4c19c204 --- /dev/null +++ b/devfront/src/components/ui/use-toast.ts @@ -0,0 +1,42 @@ +import * as React from "react"; + +type ToastType = "success" | "error" | "info"; + +interface Toast { + id: string; + message: string; + type: ToastType; +} + +let subscribers: ((toasts: Toast[]) => void)[] = []; +let toasts: Toast[] = []; + +const notify = () => { + for (const sub of subscribers) { + sub(toasts); + } +}; + +export const toast = (message: string, type: ToastType = "success") => { + const id = Math.random().toString(36).substring(2, 9); + toasts = [...toasts, { id, message, type }]; + notify(); + + setTimeout(() => { + toasts = toasts.filter((t) => t.id !== id); + notify(); + }, 3000); +}; + +export const useToastState = () => { + const [state, setState] = React.useState(toasts); + + React.useEffect(() => { + subscribers.push(setState); + return () => { + subscribers = subscribers.filter((sub) => sub !== setState); + }; + }, []); + + return state; +}; \ No newline at end of file diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index dcecfbae..fe20da69 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -17,6 +17,8 @@ import { Textarea } from "../../components/ui/textarea"; import { Label } from "../../components/ui/label"; import { fetchClient, updateClient } from "../../lib/devApi"; import { cn } from "../../lib/utils"; +import { CopyButton } from "../../components/ui/copy-button"; +import { toast } from "../../components/ui/use-toast"; function ClientDetailsPage() { const params = useParams(); @@ -48,10 +50,10 @@ function ClientDetailsPage() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["client", clientId] }); - alert("Redirect URIs가 저장되었습니다."); + toast("Redirect URIs가 저장되었습니다."); }, onError: (err) => { - alert(`저장 실패: ${(err as Error).message}`); + toast(`저장 실패: ${(err as Error).message}`, "error"); }, }); @@ -145,9 +147,10 @@ function ClientDetailsPage() {

{data.client.id}

- + toast("Client ID가 복사되었습니다.")} + />
@@ -173,14 +176,11 @@ function ClientDetailsPage() { > {showSecret ? : } - + onCopy={() => toast("Client Secret이 복사되었습니다.")} + /> @@ -213,14 +213,11 @@ function ClientDetailsPage() { {endpoint.value} - + onCopy={() => toast(`${endpoint.label}가 복사되었습니다.`)} + /> ))} diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 14a0d3f6..a3ed28a7 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -42,6 +42,8 @@ import { updateClientStatus, } from "../../lib/devApi"; import { cn } from "../../lib/utils"; +import { CopyButton } from "../../components/ui/copy-button"; +import { toast } from "../../components/ui/use-toast"; function ClientsPage() { const navigate = useNavigate(); @@ -231,15 +233,13 @@ function ClientsPage() { {client.id} - + onCopy={() => toast("클라이언트 ID가 복사되었습니다.")} + /> diff --git a/docker-compose.yaml b/docker-compose.yaml index 981831c9..2b5e4ece 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -95,6 +95,7 @@ services: - APP_ENV=${APP_ENV} networks: - baron_net + - ory-net depends_on: backend: condition: service_healthy 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 921b8366..fd6bfb2d 100755 --- a/docker/ory/oathkeeper/rules.active.json +++ b/docker/ory/oathkeeper/rules.active.json @@ -83,6 +83,21 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] }, + { + "id": "hydra-well-known-oidc", + "description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)", + "match": { + "url": "<.*>://<.*>/oidc/.well-known/<.*>", + "methods": ["GET", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path_prefix": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, { "id": "hydra-oauth2", "description": "Hydra OAuth2 Endpoints", @@ -97,6 +112,21 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] }, + { + "id": "hydra-oauth2-oidc", + "description": "Hydra OAuth2 Endpoints (with /oidc prefix)", + "match": { + "url": "<.*>://<.*>/oidc/oauth2/<.*>", + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path_prefix": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, { "id": "hydra-userinfo", "description": "Hydra Userinfo", @@ -110,5 +140,20 @@ "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-userinfo-oidc", + "description": "Hydra Userinfo (with /oidc prefix)", + "match": { + "url": "<.*>://<.*>/oidc/userinfo", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path_prefix": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] } ] diff --git a/docker/ory/oathkeeper/rules.json b/docker/ory/oathkeeper/rules.json index 921b8366..fd6bfb2d 100755 --- a/docker/ory/oathkeeper/rules.json +++ b/docker/ory/oathkeeper/rules.json @@ -83,6 +83,21 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] }, + { + "id": "hydra-well-known-oidc", + "description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)", + "match": { + "url": "<.*>://<.*>/oidc/.well-known/<.*>", + "methods": ["GET", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path_prefix": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, { "id": "hydra-oauth2", "description": "Hydra OAuth2 Endpoints", @@ -97,6 +112,21 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] }, + { + "id": "hydra-oauth2-oidc", + "description": "Hydra OAuth2 Endpoints (with /oidc prefix)", + "match": { + "url": "<.*>://<.*>/oidc/oauth2/<.*>", + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path_prefix": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, { "id": "hydra-userinfo", "description": "Hydra Userinfo", @@ -110,5 +140,20 @@ "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-userinfo-oidc", + "description": "Hydra Userinfo (with /oidc prefix)", + "match": { + "url": "<.*>://<.*>/oidc/userinfo", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path_prefix": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] } ] diff --git a/docker/ory/oathkeeper/rules.stage.json b/docker/ory/oathkeeper/rules.stage.json index e65e9d51..4a0735da 100755 --- a/docker/ory/oathkeeper/rules.stage.json +++ b/docker/ory/oathkeeper/rules.stage.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": { @@ -83,11 +83,26 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] }, + { + "id": "hydra-well-known-oidc", + "description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)", + "match": { + "url": "<.*>://<.*>/oidc/.well-known/<.*>", + "methods": ["GET", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path_prefix": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, { "id": "hydra-oauth2", "description": "Hydra OAuth2 Endpoints", "match": { - "url": "<.*>://sso-test.hmac.kr/oauth2/<.*>", + "url": "<.*>://<.*>/oauth2/<.*>", "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] }, "upstream": { @@ -97,11 +112,26 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] }, + { + "id": "hydra-oauth2-oidc", + "description": "Hydra OAuth2 Endpoints (with /oidc prefix)", + "match": { + "url": "<.*>://<.*>/oidc/oauth2/<.*>", + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path_prefix": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, { "id": "hydra-userinfo", "description": "Hydra Userinfo", "match": { - "url": "<.*>://sso-test.hmac.kr/userinfo", + "url": "<.*>://<.*>/userinfo", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -110,5 +140,20 @@ "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-userinfo-oidc", + "description": "Hydra Userinfo (with /oidc prefix)", + "match": { + "url": "<.*>://<.*>/oidc/userinfo", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path_prefix": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "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/gateway/nginx.conf b/gateway/nginx.conf index 9b94fe8a..eeb6f234 100644 --- a/gateway/nginx.conf +++ b/gateway/nginx.conf @@ -21,6 +21,8 @@ log_format json_combined escape=json server { listen 5000; + client_header_buffer_size 16k; + large_client_header_buffers 4 64k; include /etc/nginx/mime.types; resolver 127.0.0.11 valid=10s ipv6=off; diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 8389815f..4d9886ec 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'http_client.dart'; +import 'dart:html' as html; class AuthProxyService { static String _envOrDefault(String key, String fallback) { @@ -196,23 +197,60 @@ class AuthProxyService { } } - static Future> loginWithPassword(String loginId, String password) async { + static Future> loginWithPassword(String loginId, String password, {String? loginChallenge}) async { final url = Uri.parse('$_baseUrl/api/v1/auth/password/login'); + final payload = { + 'loginId': loginId, + 'password': password, + if (loginChallenge != null && loginChallenge.isNotEmpty) 'login_challenge': loginChallenge, + }; + final response = await http.post( url, headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'loginId': loginId, - 'password': password, - }), + body: jsonEncode(payload), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['redirectTo'] != null && data['redirectTo'].isNotEmpty) { + html.window.location.href = data['redirectTo']; + } + return data; + } else { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['error'] ?? 'Failed to login'); + } + } + static Future> getConsentInfo(String consentChallenge) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/consent').replace(queryParameters: {'consent_challenge': consentChallenge}); + final response = await http.get( + url, + headers: {'Content-Type': 'application/json'}, ); if (response.statusCode == 200) { return jsonDecode(response.body); } else { final errorBody = jsonDecode(response.body); - throw Exception(errorBody['error'] ?? 'Failed to login'); + throw Exception(errorBody['error'] ?? 'Failed to get consent info'); + } + } + + static Future> acceptConsent(String consentChallenge) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept'); + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'consent_challenge': consentChallenge}), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['error'] ?? 'Failed to accept consent'); } } diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart new file mode 100644 index 00000000..a078e968 --- /dev/null +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -0,0 +1,123 @@ +import 'dart:html' as html; + +import 'package:flutter/material.dart'; +import 'package:userfront/core/services/auth_proxy_service.dart'; + +class ConsentScreen extends StatefulWidget { + final String consentChallenge; + + const ConsentScreen({super.key, required this.consentChallenge}); + + @override + State createState() => _ConsentScreenState(); +} + +class _ConsentScreenState extends State { + Map? _consentInfo; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _fetchConsentInfo(); + } + + Future _fetchConsentInfo() async { + try { + final info = await AuthProxyService.getConsentInfo(widget.consentChallenge); + setState(() { + _consentInfo = info; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = 'Failed to load consent information: $e'; + _isLoading = false; + }); + } + } + + Future _acceptConsent() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final result = + await AuthProxyService.acceptConsent(widget.consentChallenge); + if (result['redirectTo'] != null) { + html.window.location.href = result['redirectTo']; + } else { + setState(() { + _error = 'Consent accepted, but no redirect URL received.'; + _isLoading = false; + }); + } + } catch (e) { + setState(() { + _error = 'Failed to accept consent: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Grant Access')), + body: Center( + child: _isLoading + ? const CircularProgressIndicator() + : _error != null + ? Text(_error!, style: const TextStyle(color: Colors.red)) + : _consentInfo != null + ? Card( + elevation: 4, + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_consentInfo!['client']?['client_name'] ?? 'An application'} wants to access your account', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + const Text('This will allow the application to:'), + const SizedBox(height: 16), + if (_consentInfo!['requested_scope'] != null) + ...(_consentInfo!['requested_scope'] as List) + .map((scope) => ListTile( + leading: const Icon(Icons.check_circle_outline), + title: Text(scope.toString()), + )) + .toList(), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: () { + // TODO: Implement reject consent + html.window.alert('Consent rejected. You can close this window.'); + }, + child: const Text('Deny'), + ), + ElevatedButton( + onPressed: _acceptConsent, + child: const Text('Allow'), + ), + ], + ) + ], + ), + ), + ) + : const Text('No consent information available.'), + ), + ); + } +} 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/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 8bc03e11..7e73ac01 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -10,10 +10,13 @@ import '../../../core/services/auth_proxy_service.dart'; import '../../../core/services/auth_token_store.dart'; import '../../../core/notifiers/auth_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; +import 'dart:html' as html; class LoginScreen extends ConsumerStatefulWidget { final String? verificationToken; - const LoginScreen({super.key, this.verificationToken}); + final String? loginChallenge; + + const LoginScreen({super.key, this.verificationToken, this.loginChallenge}); @override ConsumerState createState() => _LoginScreenState(); @@ -26,6 +29,7 @@ class _LoginScreenState extends ConsumerState final TextEditingController _passwordLoginIdController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); String? _redirectUrl; + String? _loginChallenge; // QR Login Variables String? _qrImageBase64; @@ -58,14 +62,13 @@ class _LoginScreenState extends ConsumerState @override void initState() { super.initState(); - // 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크") _tabController = TabController(length: 3, vsync: this, initialIndex: 1); _tabController.addListener(_handleTabSelection); _drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv; - - // Check for tokens (Path Parameter or Legacy Query Parameter) + WidgetsBinding.instance.addPostFrameCallback((_) { final uri = Uri.base; + _loginChallenge = widget.loginChallenge ?? uri.queryParameters['login_challenge']; final loginIdParam = uri.queryParameters['loginId']; final codeParam = uri.queryParameters['code']; final pendingRefParam = uri.queryParameters['pendingRef']; @@ -190,7 +193,6 @@ class _LoginScreenState extends ConsumerState }); } - // JWT를 디코딩해 표시용 로그인 아이디 추출 String _getLoginIdFromJwt(String jwt) { try { final parts = jwt.split('.'); @@ -198,7 +200,6 @@ class _LoginScreenState extends ConsumerState final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); final data = json.decode(payload); - // 일반적으로 name/email/sub 필드를 사용 return data['name'] ?? data['email'] ?? data['sub'] ?? 'User'; } catch (e) { debugPrint("[JWT] Decode error: $e"); @@ -207,7 +208,6 @@ class _LoginScreenState extends ConsumerState } void _handleTabSelection() { - // QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작 if (_tabController.index == 2 && _qrPendingRef == null) { _startQrFlow(); } else if (_tabController.index != 2) { @@ -626,7 +626,6 @@ class _LoginScreenState extends ConsumerState super.dispose(); } - // 이메일/비밀번호 로그인 처리 Future _handlePasswordLogin() async { final input = _passwordLoginIdController.text.trim(); final password = _passwordController.text.trim(); @@ -637,14 +636,12 @@ class _LoginScreenState extends ConsumerState String loginId = input; if (!input.contains('@')) { - // Format phone number if it's not an email loginId = input.replaceAll(RegExp(r'[-\s]'), ''); if (loginId.startsWith('010')) { loginId = '+82${loginId.substring(1)}'; } } - // 로딩 인디케이터 표시 showDialog( context: context, barrierDismissible: false, @@ -652,15 +649,23 @@ class _LoginScreenState extends ConsumerState ); try { - final res = await AuthProxyService.loginWithPassword(loginId, password); + final res = await AuthProxyService.loginWithPassword(loginId, password, loginChallenge: _loginChallenge); final jwt = res['sessionJwt']; final provider = res['provider'] as String?; - if (jwt != null && mounted) { - Navigator.of(context).pop(); // 로딩 닫기 + final redirectTo = res['redirectTo'] as String?; + + if (mounted) Navigator.of(context).pop(); + + if (redirectTo != null && redirectTo.isNotEmpty) { + html.window.location.href = redirectTo; + return; + } + + if (jwt != null) { _onLoginSuccess(jwt, provider: provider); } } catch (e) { - if (mounted) Navigator.of(context).pop(); // 로딩 닫기 + if (mounted) Navigator.of(context).pop(); if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); } else { @@ -669,14 +674,12 @@ class _LoginScreenState extends ConsumerState } } - // 로그인 링크 전송 처리 Future _handleLinkLogin() async { final input = _linkIdController.text.trim(); if (input.isEmpty) return; String loginId = input; if (!input.contains('@')) { - // Format phone number if it's not an email loginId = input.replaceAll(RegExp(r'[-\s]'), ''); if (loginId.startsWith('010')) { loginId = '+82${loginId.substring(1)}'; @@ -685,7 +688,6 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Initiating Enchanted Link for: $loginId"); - // 링크 전송 전 사용자 존재 여부 체크 (백엔드에서 이미 처리하지만 에러 핸들링을 위해) try { await _startEnchantedFlow(loginId, isEmail: input.contains('@')); } catch (e) { @@ -707,7 +709,6 @@ class _LoginScreenState extends ConsumerState ); } - // 1. Init via Backend API final initResponse = await AuthProxyService.initEnchantedLink( loginId, codeOnly: codeOnly, @@ -727,13 +728,12 @@ class _LoginScreenState extends ConsumerState _lastLinkLoginId = loginId; _lastLinkIsEmail = isEmail; }); - Navigator.of(context).pop(); // Close Loading + Navigator.of(context).pop(); _showInfo(isEmail ? "입력하신 이메일로 로그인 링크를 보냈습니다." : "입력하신 번호로 로그인 링크를 보냈습니다."); - // 2. Poll Backend manually final initialInterval = (interval is int && interval > 0) ? Duration(seconds: interval) : const Duration(seconds: 2); @@ -761,7 +761,7 @@ class _LoginScreenState extends ConsumerState Future _pollForSession(String pendingRef, {Duration? initialInterval}) async { int attempts = 0; - const maxAttempts = 60; // 2 minutes + const maxAttempts = 60; var pollInterval = initialInterval ?? const Duration(seconds: 2); debugPrint("[Auth] Starting poll for ref: $pendingRef"); @@ -789,7 +789,7 @@ class _LoginScreenState extends ConsumerState } if (result['error'] == 'expired_token') { if (mounted) { - Navigator.of(context).pop(); // Close Polling Dialog + Navigator.of(context).pop(); _showError("Login timed out."); } return; @@ -820,7 +820,7 @@ class _LoginScreenState extends ConsumerState if (mounted) { debugPrint("[Auth] Polling timed out for ref: $pendingRef"); - Navigator.of(context).pop(); // Close Polling Dialog + Navigator.of(context).pop(); _showError("Login timed out."); } } @@ -879,19 +879,16 @@ class _LoginScreenState extends ConsumerState AuthTokenStore.setToken(token, provider: providerName); AuthTokenStore.clearPendingProvider(); - // 로그인 성공 직후 백엔드에서 전체 프로필 정보를 가져와 세션 업데이트 try { await ref.read(profileProvider.notifier).loadProfile(); } catch (e) { debugPrint("[Auth] Failed to pre-fetch profile: $e"); } - // 1. Handle Popup Flow if (WebAuthIntegration.isPopup()) { debugPrint("[Auth] Popup detected. Notifying opener and attempting to close."); WebAuthIntegration.sendLoginSuccess(token); } else { - // 2. Handle Redirect Flow if (_redirectUrl != null && _redirectUrl!.isNotEmpty) { debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl"); final target = "$_redirectUrl?token=$token"; @@ -900,7 +897,6 @@ class _LoginScreenState extends ConsumerState } } - // 3. Standalone mode / Fallback debugPrint("[Auth] Login success. Navigating to root."); AuthNotifier.instance.notify(); if (mounted) { @@ -908,7 +904,6 @@ class _LoginScreenState extends ConsumerState } } - // [New] 미등록 회원 안내 팝업 void _showUnregisteredDialog() { showDialog( context: context, @@ -1010,7 +1005,6 @@ class _LoginScreenState extends ConsumerState child: TabBarView( controller: _tabController, children: [ - // 1. 이메일/비밀번호 로그인 폼 Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( @@ -1047,7 +1041,6 @@ class _LoginScreenState extends ConsumerState ), ), - // 2. 로그인 링크 전송 -> 전송 후 코드 입력으로 전환 Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( @@ -1188,7 +1181,6 @@ class _LoginScreenState extends ConsumerState ), ), - // 3. QR 로그인 뷰 Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, 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..d83d7479 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'; @@ -19,6 +20,7 @@ import 'core/services/auth_token_store.dart'; import 'core/services/logger_service.dart'; import 'core/notifiers/auth_notifier.dart'; import 'package:logging/logging.dart'; +import 'features/auth/presentation/consent_screen.dart'; final _log = Logger('Main'); @@ -90,9 +92,29 @@ final _router = GoRouter( GoRoute( path: '/signin', builder: (context, state) { - _routerLogger.info("Navigating to /signin"); + final loginChallenge = state.uri.queryParameters['login_challenge']; + _routerLogger.info("Navigating to /signin with login_challenge: $loginChallenge"); + return LoginScreen(key: state.pageKey, loginChallenge: loginChallenge); + }, + ), + GoRoute( + path: '/login', + builder: (context, state) { + _routerLogger.info("Navigating to /login"); return LoginScreen(key: state.pageKey); - } + }, + ), + GoRoute( + path: '/consent', + builder: (BuildContext context, GoRouterState state) { + final consentChallenge = state.uri.queryParameters['consent_challenge']; + if (consentChallenge == null) { + _routerLogger.warning("Consent screen loaded without a challenge."); + return const Scaffold(body: Center(child: Text('Error: Consent challenge is missing.'))); + } + _routerLogger.info("Navigating to /consent with challenge."); + return ConsentScreen(consentChallenge: consentChallenge); + }, ), GoRoute( path: '/signup', @@ -101,6 +123,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 +145,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 +167,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 +184,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 +246,19 @@ 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' || + path == '/consent'; // Consent page is public _routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn"); @@ -197,7 +269,12 @@ final _router = GoRouter( // If not logged in and trying to access a protected page, redirect to /signin if (!isLoggedIn) { - _routerLogger.info("Not logged in, redirecting to /signin"); + _routerLogger.info("Not logged in, redirecting to /signin"); + // Preserve OIDC challenge if present + final loginChallenge = state.uri.queryParameters['login_challenge']; + if (loginChallenge != null) { + return '/signin?login_challenge=$loginChallenge'; + } return '/signin'; }