diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0b1c84fa..63d8a4c7 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) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 197233d7..bd123dd6 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", @@ -2897,6 +2913,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 != "" { 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/compose.ory.yaml b/compose.ory.yaml index 3c90d7a7..44ddfac3 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -92,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} @@ -106,8 +106,6 @@ services: - ory-net - hydranet - - # --- Keto --- keto-migrate: image: oryd/keto:${KETO_VERSION:-v25.4.0} @@ -229,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/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/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/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/main.dart b/userfront/lib/main.dart index c4f9ce7b..d83d7479 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -20,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'); @@ -91,9 +92,10 @@ final _router = GoRouter( GoRoute( path: '/signin', builder: (context, state) { - _routerLogger.info("Navigating to /signin"); - return LoginScreen(key: state.pageKey); - } + 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', @@ -102,6 +104,18 @@ final _router = GoRouter( 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', builder: (context, state) { @@ -243,7 +257,8 @@ final _router = GoRouter( path == '/recovery' || path == '/reset-password' || path == '/error' || - path == '/settings'; + path == '/settings' || + path == '/consent'; // Consent page is public _routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn"); @@ -254,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'; }