diff --git a/.env.sample b/.env.sample index 463b0415..bd8f46b3 100644 --- a/.env.sample +++ b/.env.sample @@ -92,6 +92,8 @@ KETO_READ_URL=http://keto:4466 KETO_WRITE_URL=http://keto:4467 # KETO_READ_PORT=4466 # Internal only # KETO_WRITE_PORT=4467 # Internal only +KETO_READ_URL=http://keto:4466 +KETO_WRITE_URL=http://keto:4467 # Kratos Selfservice UI upstreams (override for deployments) ORY_SDK_URL=http://kratos:4433 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4e8fabf7..b5dd9fc9 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -489,6 +489,7 @@ func main() { // Auth Proxy Routes auth := api.Group("/auth") + auth.All("/oidc/*", authHandler.ProxyOidc) auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) @@ -553,7 +554,7 @@ func main() { KetoService: ketoService, }) requireAdmin := middleware.RequireRole(middleware.RBACConfig{ - AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin}, + AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin}, AuthHandler: authHandler, KetoService: ketoService, }) diff --git a/backend/internal/bootstrap/kratos_seed.go b/backend/internal/bootstrap/kratos_seed.go index 7ddb4a4c..b1205cb5 100644 --- a/backend/internal/bootstrap/kratos_seed.go +++ b/backend/internal/bootstrap/kratos_seed.go @@ -34,6 +34,7 @@ func SeedAdminIdentity(idp domain.IdentityProvider) error { "affiliationType": "internal", "companyCode": "", "grade": "admin", + "role": domain.RoleSuperAdmin, }, } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 5903c80a..66ea4ebb 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1526,7 +1526,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { loginID := strings.TrimSpace(req.LoginID) ale.LoginIDs["loginId"] = req.LoginID // 원문 ale.LoginIDs["loginId_normalized"] = loginID - ale.NewPassword = req.Password // For test only, logging password (sensitive) + // ale.NewPassword = req.Password // For test only, logging password (sensitive) ale.Log(slog.LevelInfo, "Attempting to login") @@ -1568,22 +1568,25 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { // --- OIDC 로그인 흐름 처리 --- if req.LoginChallenge != "" { - slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge) + slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge, "subject", subject) // Check if the client is active loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge) - if err == nil && loginReq != nil && loginReq.Client.Metadata != nil { - if status, ok := loginReq.Client.Metadata["status"].(string); ok { - if strings.ToLower(status) == "inactive" { - slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID) - return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") + if err == nil && loginReq != nil { + slog.Info("OIDC Client Info", "client_id", loginReq.Client.ClientID, "name", loginReq.Client.ClientName) + if loginReq.Client.Metadata != nil { + if status, ok := loginReq.Client.Metadata["status"].(string); ok { + if strings.ToLower(status) == "inactive" { + slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID) + return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.") + } } } } acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject) if err != nil { - slog.Error("failed to accept hydra login request", "error", err) + slog.Error("failed to accept hydra login request", "error", err, "challenge", req.LoginChallenge) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") } slog.Info("Hydra login request accepted", "redirectTo", acceptResp.RedirectTo) @@ -1595,6 +1598,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { resp := fiber.Map{ "sessionToken": authInfo.SessionToken.JWT, + "sessionJwt": authInfo.SessionToken.JWT, // Frontend compatibility "status": "ok", "provider": h.IdpProvider.Name(), } @@ -2480,7 +2484,56 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string { return phone } -// GetMe - Returns current user's profile with enriched data from local DB +// ProxyOidc - 프론트엔드의 OIDC 요청을 내부 Hydra 서비스로 프록시합니다. +func (h *AuthHandler) ProxyOidc(c *fiber.Ctx) error { + path := c.Params("*") + // [Strict] Always use internal Docker network address for proxying to avoid external loops + targetURL := "http://hydra:4444" + + // 프록시 URL 구성 + u, err := url.Parse(targetURL) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "invalid hydra public url") + } + u.Path = strings.TrimRight(u.Path, "/") + "/" + path + u.RawQuery = string(c.Request().URI().QueryString()) + + slog.Debug("Proxying OIDC request", "from", c.Path(), "to", u.String()) + + // 요청 준비 + req, err := http.NewRequestWithContext(c.Context(), c.Method(), u.String(), bytes.NewReader(c.Body())) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to create proxy request") + } + + // 헤더 복사 + c.Request().Header.VisitAll(func(key, value []byte) { + k := string(key) + if k != "Host" && k != "Connection" { + req.Header.Add(k, string(value)) + } + }) + + // 요청 실행 (Hydra 내부 HttpClient 사용) + resp, err := h.Hydra.HttpClient().Do(req) + if err != nil { + return fiber.NewError(fiber.StatusServiceUnavailable, "hydra public api unavailable") + } + defer resp.Body.Close() + + // 응답 헤더 복사 + for k, values := range resp.Header { + for _, v := range values { + c.Set(k, v) + } + } + + // 상태 코드 및 바디 설정 + c.Status(resp.StatusCode) + _, err = io.Copy(c.Response().BodyWriter(), resp.Body) + return err +} + func (h *AuthHandler) GetMe(c *fiber.Ctx) error { profile, err := h.resolveCurrentProfile(c) if err != nil { @@ -4797,26 +4850,40 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string req.Header.Set("X-Session-Token", sessionToken) resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return "", nil, fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) + if err == nil { + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + var result struct { + Identity struct { + ID string `json:"id"` + Traits map[string]interface{} `json:"traits"` + } `json:"identity"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err == nil { + return result.Identity.ID, result.Identity.Traits, nil + } + } } - var result struct { - Identity struct { - ID string `json:"id"` - Traits map[string]interface{} `json:"traits"` - } `json:"identity"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", nil, err + // 2. Kratos 실패 시 Hydra Introspection 시도 (OIDC Access Token 대응) + if h.Hydra != nil { + slog.Debug("[Auth] Kratos whoami failed, trying Hydra introspection", "token_prefix", sessionToken[:min(len(sessionToken), 10)]) + introspection, err := h.Hydra.IntrospectToken(context.Background(), sessionToken) + if err == nil && introspection["active"] == true { + subject, _ := introspection["sub"].(string) + if subject != "" { + // Hydra는 Traits를 직접 주지 않으므로, Kratos Admin API로 상세 정보를 가져옴 + identity, err := h.KratosAdmin.GetIdentity(context.Background(), subject) + if err == nil && identity != nil { + return identity.ID, identity.Traits, nil + } + // Identity 정보가 없더라도 최소한 Subject는 반환 + return subject, map[string]interface{}{}, nil + } + } } - return result.Identity.ID, result.Identity.Traits, nil + return "", nil, fmt.Errorf("invalid session or token") } func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) { diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go index 67afe9da..a7f08f5a 100644 --- a/backend/internal/middleware/rbac.go +++ b/backend/internal/middleware/rbac.go @@ -3,6 +3,7 @@ package middleware import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" + "fmt" "log/slog" "github.com/gofiber/fiber/v2" @@ -25,7 +26,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. return func(c *fiber.Ctx) error { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_keto)"}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증에 실패했습니다. (rbac_keto)"}) } // Store profile in locals for further use in handlers @@ -43,7 +44,7 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. } if objectID == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "권한 검증을 위한 대상 ID가 누락되었습니다."}) } // Set tenant_id for audit logging if namespace is Tenant @@ -55,7 +56,9 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) if err != nil || !allowed { slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation) - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"}) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": fmt.Sprintf("접근 권한이 없습니다. 현재 '%s' 권한으로는 요청하신 리소스에 대한 상세 권한(Keto)이 부족합니다. 관리자에게 문의하세요.", profile.Role), + }) } return c.Next() @@ -73,7 +76,7 @@ func RequireRole(config RBACConfig) fiber.Handler { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "error": "unauthorized (trace:rbac_role): " + err.Error(), + "error": "인증 정보 조회에 실패했습니다: " + err.Error(), }) } @@ -102,7 +105,7 @@ func RequireRole(config RBACConfig) fiber.Handler { "path", c.Path(), ) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "error": "forbidden: insufficient permissions", + "error": fmt.Sprintf("접근 권한이 없습니다. 현재 '%s' 권한으로는 이 기능을 사용할 수 없습니다. 관리자에게 문의하여 'rp_admin' 이상의 권한을 확보하세요.", profile.Role), }) } @@ -123,7 +126,7 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_match)"}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "인증에 실패했습니다. (rbac_match)"}) } // Store profile in locals for further use in handlers @@ -143,12 +146,12 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler { if profile.TenantID == nil || *profile.TenantID != targetTenantID { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "error": "forbidden: you do not have access to this tenant", + "error": fmt.Sprintf("해당 테넌트에 대한 접근 권한이 없습니다. 사용자님의 '%s' 권한은 소속된 테넌트의 리소스만 관리할 수 있습니다.", profile.Role), }) } return c.Next() } - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden"}) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "요청하신 리소스에 접근할 수 없습니다."}) } } diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index ef1e0911..ce9c97bc 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -47,7 +47,7 @@ func (s *HydraAdminService) ListClients(ctx context.Context, limit, offset int) return nil, err } - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, err } @@ -75,7 +75,7 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*do return nil, err } - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, err } @@ -114,7 +114,7 @@ func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, sta } req.Header.Set("Content-Type", "application/json-patch+json") - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, err } @@ -145,7 +145,7 @@ func (s *HydraAdminService) CreateClient(ctx context.Context, client domain.Hydr } req.Header.Set("Content-Type", "application/json") - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, err } @@ -174,7 +174,7 @@ func (s *HydraAdminService) UpdateClient(ctx context.Context, clientID string, c } req.Header.Set("Content-Type", "application/json") - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, err } @@ -202,7 +202,7 @@ func (s *HydraAdminService) DeleteClient(ctx context.Context, clientID string) e return err } - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return err } @@ -235,7 +235,7 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl return nil, err } - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, err } @@ -276,7 +276,7 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, return err } - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return err } @@ -289,7 +289,7 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, return nil } -func (s *HydraAdminService) httpClient() *http.Client { +func (s *HydraAdminService) HttpClient() *http.Client { if s.HTTPClient != nil { return s.HTTPClient } @@ -367,7 +367,7 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str return nil, fmt.Errorf("hydra admin: create request for get consent failed: %w", err) } - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: get consent request failed: %w", err) } @@ -407,7 +407,7 @@ func (s *HydraAdminService) RejectConsentRequest(ctx context.Context, challenge } req.Header.Set("Content-Type", "application/json") - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: reject consent request failed: %w", err) } @@ -449,7 +449,7 @@ func (s *HydraAdminService) RejectLoginRequest(ctx context.Context, challenge, e } req.Header.Set("Content-Type", "application/json") - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: reject login request failed: %w", err) } @@ -484,7 +484,7 @@ func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge strin return nil, fmt.Errorf("hydra admin: create request for get login failed: %w", err) } - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: get login request failed: %w", err) } @@ -532,7 +532,7 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge } req.Header.Set("Content-Type", "application/json") - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: accept consent request failed: %w", err) } @@ -576,7 +576,7 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st } req.Header.Set("Content-Type", "application/json") - resp, err := s.httpClient().Do(req) + resp, err := s.HttpClient().Do(req) if err != nil { return nil, fmt.Errorf("hydra admin: accept login request failed: %w", err) } @@ -597,3 +597,34 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil } + +func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (map[string]interface{}, error) { + endpoint := fmt.Sprintf("%s/admin/oauth2/introspect", strings.TrimRight(s.AdminURL, "/")) + + data := url.Values{} + data.Set("token", token) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.HttpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("hydra admin: introspect failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/devfront/package-lock.json b/devfront/package-lock.json index 460860c8..54d01aab 100644 --- a/devfront/package-lock.json +++ b/devfront/package-lock.json @@ -18,9 +18,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "oidc-client-ts": "^3.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", + "react-oidc-context": "^3.3.0", "react-router-dom": "^6.28.2", "tailwind-merge": "^3.4.0", "zod": "^3.24.1" @@ -2332,6 +2334,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -2774,6 +2785,18 @@ "node": ">= 6" } }, + "node_modules/oidc-client-ts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", + "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3095,6 +3118,19 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-oidc-context": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz", + "integrity": "sha512-302T/ma4AOVAxrHdYctDSKXjCq9KNHT564XEO2yOPxRfxEP58xa4nz+GQinNl8x7CnEXECSM5JEjQJk3Cr5BvA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "oidc-client-ts": "^3.1.0", + "react": ">=16.14.0" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/devfront/package.json b/devfront/package.json index cf724b45..a8c53649 100644 --- a/devfront/package.json +++ b/devfront/package.json @@ -22,9 +22,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.563.0", + "oidc-client-ts": "^3.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", + "react-oidc-context": "^3.3.0", "react-router-dom": "^6.28.2", "tailwind-merge": "^3.4.0", "zod": "^3.24.1" diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx index 0bbba406..7da4c279 100644 --- a/devfront/src/app/routes.tsx +++ b/devfront/src/app/routes.tsx @@ -1,5 +1,8 @@ import { Navigate, createBrowserRouter } from "react-router-dom"; import AppLayout from "../components/layout/AppLayout"; +import AuthCallbackPage from "../features/auth/AuthCallbackPage"; +import AuthGuard from "../features/auth/AuthGuard"; +import LoginPage from "../features/auth/LoginPage"; import ClientConsentsPage from "../features/clients/ClientConsentsPage"; import ClientDetailsPage from "../features/clients/ClientDetailsPage"; import ClientGeneralPage from "../features/clients/ClientGeneralPage"; @@ -7,16 +10,29 @@ import ClientsPage from "../features/clients/ClientsPage"; export const router = createBrowserRouter( [ + { + path: "/login", + element: , + }, + { + path: "/callback", + element: , + }, { path: "/", - element: , + element: , children: [ - { index: true, element: }, - { path: "clients", element: }, - { path: "clients/new", element: }, - { path: "clients/:id", element: }, - { path: "clients/:id/consents", element: }, - { path: "clients/:id/settings", element: }, + { + element: , + children: [ + { index: true, element: }, + { path: "clients", element: }, + { path: "clients/new", element: }, + { path: "clients/:id", element: }, + { path: "clients/:id/consents", element: }, + { path: "clients/:id/settings", element: }, + ], + }, ], }, ], diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index c3a2d809..fbe92580 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -1,5 +1,6 @@ -import { BadgeCheck, Moon, ShieldHalf, Sun } from "lucide-react"; +import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react"; import { useEffect, useState } from "react"; +import { useAuth } from "react-oidc-context"; import { NavLink, Outlet } from "react-router-dom"; import { t } from "../../lib/i18n"; import { Toaster } from "../ui/toaster"; @@ -14,10 +15,22 @@ const navItems = [ ]; function AppLayout() { - const [theme, setTheme] = useState<"light" | "dark">(() => { - const stored = window.localStorage.getItem("admin_theme"); - return stored === "dark" ? "dark" : "light"; - }); + const auth = useAuth(); + + // OIDC ID Token에서 프로필 정보 추출 + // auth.user?.profile에는 OIDC 표준 클레임(sub, email, name 등)이 포함됨 + const profile = auth.user?.profile ? { + id: auth.user.profile.sub, + email: auth.user.profile.email, + name: (auth.user.profile.name as string) || (auth.user.profile.preferred_username as string) || auth.user.profile.email, + tenantId: auth.user.profile.tenant_id as string, + tenant: auth.user.profile.tenant as any, + } : null; + + const [theme, setTheme] = useState<"light" | "dark">(() => { + const stored = window.localStorage.getItem("admin_theme"); + return stored === "dark" ? "dark" : "light"; + }); useEffect(() => { const root = document.documentElement; @@ -30,9 +43,20 @@ function AppLayout() { window.localStorage.setItem("admin_theme", theme); }, [theme]); - const toggleTheme = () => { - setTheme((prev) => (prev === "light" ? "dark" : "light")); - }; + const toggleTheme = () => { + setTheme((prev) => (prev === "light" ? "dark" : "light")); + }; + + const handleLogout = () => { + auth.signoutRedirect(); + }; + + // Set initial tenant ID if profile is loaded and no tenant is selected + useEffect(() => { + if (profile?.tenantId && !window.localStorage.getItem("dev_tenant_id")) { + window.localStorage.setItem("dev_tenant_id", profile.tenantId); + } + }, [profile]); return (
@@ -61,6 +85,11 @@ function AppLayout() { {t("ui.dev.env_badge", "Env: dev")} + {profile?.tenant && ( + + Tenant: {profile.tenant.name} + + )}
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => ( @@ -116,6 +145,16 @@ function AppLayout() { ? t("ui.common.theme_light", "Light") : t("ui.common.theme_dark", "Dark")} + {auth.isAuthenticated && ( + + )}
diff --git a/devfront/src/components/ui/toaster.tsx b/devfront/src/components/ui/toaster.tsx index 864901c9..0c2640c3 100644 --- a/devfront/src/components/ui/toaster.tsx +++ b/devfront/src/components/ui/toaster.tsx @@ -1,4 +1,5 @@ -import { AlertCircle, CheckCircle2, Info } from "lucide-react"; +import { CheckCircle2, AlertCircle, Info } from "lucide-react"; + import { cn } from "../../lib/utils"; import { useToastState } from "./use-toast"; diff --git a/devfront/src/features/auth/AuthCallbackPage.tsx b/devfront/src/features/auth/AuthCallbackPage.tsx new file mode 100644 index 00000000..638a7924 --- /dev/null +++ b/devfront/src/features/auth/AuthCallbackPage.tsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useAuth } from "react-oidc-context"; +import { useNavigate } from "react-router-dom"; + +export default function AuthCallbackPage() { + const auth = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (auth.isAuthenticated) { + navigate("/", { replace: true }); + } else if (auth.error) { + console.error("Auth Error:", auth.error); + navigate("/login", { replace: true }); + } + }, [auth.isAuthenticated, auth.error, navigate]); + + return
Loading Auth...
; +} diff --git a/devfront/src/features/auth/AuthGuard.tsx b/devfront/src/features/auth/AuthGuard.tsx new file mode 100644 index 00000000..50abe838 --- /dev/null +++ b/devfront/src/features/auth/AuthGuard.tsx @@ -0,0 +1,20 @@ +import { useAuth } from "react-oidc-context"; +import { Navigate, Outlet } from "react-router-dom"; + +export default function AuthGuard() { + const auth = useAuth(); + + if (auth.isLoading) { + return
Loading...
; + } + + if (auth.error) { + return
Auth Error: {auth.error.message}
; + } + + if (!auth.isAuthenticated) { + return ; + } + + return ; +} diff --git a/devfront/src/features/auth/LoginPage.tsx b/devfront/src/features/auth/LoginPage.tsx new file mode 100644 index 00000000..53579bd0 --- /dev/null +++ b/devfront/src/features/auth/LoginPage.tsx @@ -0,0 +1,87 @@ +import { ShieldHalf, LogIn, ExternalLink } from "lucide-react"; +import { useAuth } from "react-oidc-context"; +import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../components/ui/card"; + +function LoginPage() { + const auth = useAuth(); + + const handleSSOLogin = () => { + // OIDC client-side authentication flow started here + auth.signinRedirect(); + }; + + return ( +
+
+
+
+ +
+
+

Baron SSO

+

+ Developer Control Plane +

+
+
+ + + + + + 개발자 포털 로그인 + + + Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다. + + + + + +

+ 개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
+ 민감한 작업 시 재인증을 요구할 수 있습니다. +

+
+
+ +
+
+
+
+
+ +

+ 인증 정보가 없거나 로그인이 되지 않는 경우
+ 시스템 관리자에게 문의하세요. +

+
+
+ ); +} + +export default LoginPage; diff --git a/devfront/src/features/auth/authApi.ts b/devfront/src/features/auth/authApi.ts new file mode 100644 index 00000000..9fa27955 --- /dev/null +++ b/devfront/src/features/auth/authApi.ts @@ -0,0 +1,22 @@ +import apiClient from "../../lib/apiClient"; + +export interface Tenant { + id: string; + name: string; + slug: string; +} + +export interface UserProfile { + id: string; + email: string; + name: string; + role: string; + companyCode?: string; + tenantId?: string; + tenant?: Tenant; +} + +export async function fetchMe() { + const { data } = await apiClient.get("/user/me"); + return data; +} diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index a4ae4b3a..39197e21 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -211,14 +211,10 @@ function ClientsPage() { variant={ item.tone === "up" ? "success" - : item.tone === "down" - ? "warning" - : "muted" + : "muted" } className={cn( "px-2", - item.tone === "down" && - "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-200", item.tone === "stable" && "bg-muted/40 text-foreground", )} > diff --git a/devfront/src/lib/apiClient.ts b/devfront/src/lib/apiClient.ts index b362ba79..fd7cf54f 100644 --- a/devfront/src/lib/apiClient.ts +++ b/devfront/src/lib/apiClient.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { userManager } from "./auth"; const apiClient = axios.create({ baseURL: @@ -7,15 +8,15 @@ const apiClient = axios.create({ "/api/v1", }); -apiClient.interceptors.request.use((config) => { - // TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다. - const sessionToken = window.localStorage.getItem("admin_session"); - if (sessionToken) { - config.headers.Authorization = `Bearer ${sessionToken}`; +apiClient.interceptors.request.use(async (config) => { + // OIDC Access Token 주입 + const user = await userManager.getUser(); + if (user?.access_token) { + config.headers.Authorization = `Bearer ${user.access_token}`; } // TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다. - const tenantId = window.localStorage.getItem("admin_tenant"); + const tenantId = window.localStorage.getItem("dev_tenant_id"); // 키 이름을 좀 더 명확하게 변경 고려 if (tenantId) { config.headers["X-Tenant-ID"] = tenantId; } @@ -26,7 +27,13 @@ apiClient.interceptors.request.use((config) => { apiClient.interceptors.response.use( (response) => response, (error) => { - // TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다. + if (error.response?.status === 401) { + // 401 발생 시 로그인 페이지로 리다이렉트 + const isAuthPath = window.location.pathname.startsWith("/callback"); + if (!isAuthPath) { + userManager.signinRedirect(); + } + } return Promise.reject(error); }, ); diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts new file mode 100644 index 00000000..6ce22a23 --- /dev/null +++ b/devfront/src/lib/auth.ts @@ -0,0 +1,20 @@ +import { UserManager, WebStorageStateStore } from "oidc-client-ts"; +import type { AuthProviderProps } from "react-oidc-context"; + +export const oidcConfig: AuthProviderProps = { + authority: import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL + client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront-client", + redirect_uri: `${window.location.origin}/callback`, + response_type: "code", + scope: "openid offline_access profile email", // offline_access for refresh token + post_logout_redirect_uri: window.location.origin, + userStore: new WebStorageStateStore({ store: window.localStorage }), + automaticSilentRenew: true, +}; + +export const userManager = new UserManager({ + ...oidcConfig, + authority: oidcConfig.authority || "", + client_id: oidcConfig.client_id || "", + redirect_uri: oidcConfig.redirect_uri || "", +}); diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index 8e47dd83..0aa35ff0 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -9,6 +9,7 @@ export type ClientSummary = { type: ClientType; status: ClientStatus; createdAt?: string; + clientSecret?: string; redirectUris: string[]; scopes: string[]; }; diff --git a/devfront/src/main.tsx b/devfront/src/main.tsx index e311b5b9..b718dd50 100644 --- a/devfront/src/main.tsx +++ b/devfront/src/main.tsx @@ -1,11 +1,13 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { AuthProvider } from "react-oidc-context"; import { RouterProvider } from "react-router-dom"; import { queryClient } from "./app/queryClient"; import { router } from "./app/routes"; import { Toaster } from "./components/ui/toaster"; import "./index.css"; +import { oidcConfig } from "./lib/auth"; const rootElement = document.getElementById("root"); @@ -15,9 +17,11 @@ if (!rootElement) { createRoot(rootElement).render( - - - - + + + + + + , ); diff --git a/devfront/vite.config.ts b/devfront/vite.config.ts index 4b6f15a4..460b97bb 100644 --- a/devfront/vite.config.ts +++ b/devfront/vite.config.ts @@ -13,4 +13,7 @@ export default defineConfig({ }, }, }, + esbuild: { + // drop: process.env.APP_ENV === "production" ? ["console", "debugger"] : [], + }, }); diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml index d79d447d..9f9fb077 100644 --- a/docker/ory/kratos/kratos.yml +++ b/docker/ory/kratos/kratos.yml @@ -10,6 +10,12 @@ serve: admin: base_url: http://localhost:4434/ +session: + cookie: + domain: hmac.kr + same_site: Lax + path: / + selfservice: default_browser_return_url: http://localhost:5000/ allowed_return_urls: diff --git a/docs/devfront_auth_flow_explanation.md b/docs/devfront_auth_flow_explanation.md new file mode 100644 index 00000000..9b9a17fa --- /dev/null +++ b/docs/devfront_auth_flow_explanation.md @@ -0,0 +1,91 @@ +# DevFront OIDC 인증 흐름 및 무한 루프 해결 보고 + +이 문서는 `devfront` 개발자 포털의 OIDC 인증 구현 방식, 무한 루프 문제의 원인 및 해결 방법, 그리고 클라이언트 자동 등록 원리에 대해 설명합니다. + +## 1. DevFront 로그인 동작 플로우 (OIDC Authorization Code Flow) + +### 시퀀스 다이어그램 +```mermaid +sequenceDiagram + actor User + participant DF as DevFront (RP) + participant HY as Ory Hydra (OP) + participant UF as UserFront (Login UI) + participant KR as Ory Kratos (Identity) + + User->>DF: 로그인 버튼 클릭 + DF->>HY: 인증 요청 (/oauth2/auth) + HY->>UF: 로그인 UI 리다이렉트 + UF->>User: 로그인 페이지 표시 + User->>UF: 자격 증명 입력 (Email/PW) + UF->>KR: 인증 수행 + KR-->>UF: 인증 성공 (Session 생성) + UF->>HY: 로그인 승인 요청 + HY->>User: 권한 동의(Consent) 화면 표시 + User->>HY: '허용' 클릭 + HY-->>DF: 인증 코드와 함께 리다이렉트 (/callback?code=...) + DF->>HY: 토큰 교환 요청 (Code -> ID/Access Token) + HY-->>DF: 토큰 발급 + Note over DF: [FIX] 백엔드 /api/me 호출 대신
ID Token에서 프로필 정보 직접 추출 + DF->>User: 대시보드 및 프로필 표시 +``` + +### 단계별 설명 + +1. **인증 요청 (Login Request)**: + * 사용자가 `devfront` (localhost:5174)의 로그인 버튼을 클릭합니다. + * `react-oidc-context` 라이브러리가 Hydra의 `/oauth2/auth` 엔드포인트로 사용자를 리다이렉트합니다. + * 이때 클라이언트 ID(`devfront`), 리다이렉트 URI, Scope 등을 파라미터로 전달합니다. + +2. **사용자 인증 (Authentication)**: + * Hydra는 현재 세션이 없음을 확인하고, 설정된 로그인 UI(`userfront`)로 사용자를 보냅니다. + * 사용자는 `userfront`에서 아이디/비밀번호를 입력하여 Kratos를 통해 인증을 마칩니다. + +3. **권한 동의 (Consent)**: + * 인증이 완료되면 Hydra는 사용자에게 `devfront` 앱이 요청한 권한(openid, profile, email 등)을 허용할지 묻는 Consent 화면을 띄웁니다. + * 사용자가 '허용'을 누르면 Hydra는 `devfront`가 신뢰할 수 있는 앱임을 기록합니다. + +4. **인증 코드 전달 및 토큰 교환 (Callback)**: + * Hydra는 사용자를 `devfront`의 콜백 페이지(`http://localhost:5174/callback?code=...`)로 보냅니다. + * `devfront`는 이 코드를 Hydra의 토큰 엔드포인트로 보내 **ID Token**과 **Access Token**을 발급받습니다. + +5. **사용자 정보 로드 (Profile Recovery)**: + * `devfront`는 발급받은 **ID Token**의 Payload를 디코딩하여 사용자 이름, 이메일 등의 프로필 정보를 즉시 화면에 렌더링합니다. + +--- + +## 2. 클라이언트 자동 등록의 원리 + +사용자가 직접 `devfront`를 클라이언트로 등록하지 않았음에도 로그인이 가능한 이유는 인프라 설정 파일인 `compose.ory.yaml`에 정의된 **`init-rp` 컨테이너** 덕분입니다. + +### `init-rp` 서비스의 역할 +* Ory 스택(Kratos, Hydra, Keto)이 모두 정상 가동된 직후 실행됩니다. +* Hydra Admin API를 호출하여 서비스 운영에 필수적인 기본 클라이언트들을 자동으로 생성합니다. + +### 자동 등록된 `devfront` 명세 +```bash +hydra clients create + --endpoint http://hydra:4445 + --id devfront + --grant-types authorization_code,refresh_token + --response-types code + --scope openid,offline_access,profile,email + --token-endpoint-auth-method none \ # Public Client (PKCE 사용) + --callbacks http://localhost:5174/callback; +``` +이 설정으로 인해 `devfront`라는 ID의 클라이언트가 미리 존재하게 되며, `localhost:5174`로의 리다이렉션이 안전하게 허용됩니다. + +--- + +## 3. 무한 루프 문제 해결 분석 + +### 발생 원인 (Problem) +* `devfront`가 로그인 성공 후 사용자 정보를 가져오기 위해 백엔드 API인 `/api/v1/user/me`를 호출했습니다. +* 이 API는 백엔드 세션 쿠키를 기반으로 동작하도록 설계되어 있었습니다. +* 하지만 브라우저 보안 정책(SameSite/Cross-Domain)으로 인해 `localhost`에서 보낸 요청에는 `sso-test.hmac.kr` 도메인의 쿠키가 포함되지 않았습니다. +* **결과**: 백엔드는 401 Unauthorized를 반환 -> `devfront`는 401을 받으면 다시 로그인을 시도하도록 구현되어 있어 무한 루프가 발생했습니다. + +### 해결 방법 (Solution) +* **쿠키 의존성 제거**: `AppLayout.tsx`에서 백엔드 호출(`fetchMe`)을 삭제했습니다. +* **ID Token 직접 활용**: 이미 OIDC 인증 성공 시점에 전달받은 **ID Token(`auth.user.profile`)**에 필요한 모든 사용자 정보가 들어있으므로, 이를 직접 사용하도록 수정했습니다. +* **표준 RP 구조 확립**: 이제 `devfront`는 백엔드의 세션 쿠키를 전혀 사용하지 않으며, API 요청 시에도 `Authorization: Bearer ` 헤더를 사용하여 정당한 권한을 증명합니다. diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 7b35ccfa..d273509e 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1193,7 +1193,7 @@ class _DashboardScreenState extends ConsumerState { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: statusColor.withValues(alpha: 31), + color: statusColor, borderRadius: BorderRadius.circular(999), ), child: Text( @@ -1203,9 +1203,9 @@ class _DashboardScreenState extends ConsumerState { 'ui.userfront.dashboard.status.revoked', fallback: '해지됨', ), - style: TextStyle( + style: const TextStyle( fontSize: 11, - color: statusColor, + color: Colors.white, fontWeight: FontWeight.w600, ), ),