From 974bfe7b9a53ac77b2ec4caafe27abc629b6e5c9 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 10:02:53 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Hydra=20=EB=8F=99=EC=9D=98=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=EA=B8=B0=EA=B0=84=20=EC=97=B0=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/hydra_admin_service.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index bebae1ed..5c909572 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -509,7 +509,7 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge "grant_scope": grantInfo.RequestedScope, "grant_audience": grantInfo.RequestedAudience, "remember": true, - "remember_for": 3600, + "remember_for": 2592000, } if len(sessionClaims) > 0 { payload["session"] = map[string]any{ @@ -559,7 +559,7 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st payload := map[string]interface{}{ "subject": subject, "remember": true, - "remember_for": 3600, + "remember_for": 2592000, } body, _ := json.Marshal(payload) From 3627d70ad976bd98b9cd47c1e3f6e77facc1e148 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 10:04:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EC=9E=AC=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=20=EB=8F=99=EC=9D=98=20=ED=99=94=EB=A9=B4=20=EC=83=9D?= =?UTF-8?q?=EB=9E=B5=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 28 +++++++++++++++++++ .../auth/presentation/consent_screen.dart | 6 ++++ 2 files changed, 34 insertions(+) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 7b5d4595..1294bea5 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3390,6 +3390,34 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") } + // [DEBUG] Hydra 응답 상세 로깅 + slog.Info("GetConsentRequest Debug", + "challenge", challenge, + "skip", consentRequest.Skip, + "subject", consentRequest.Subject, + "client_id", consentRequest.Client.ClientID, + "scopes", consentRequest.RequestedScope, + ) + + // Hydra가 이전에 동의한 이력이 있어 skip을 권장하는 경우, 즉시 승인 처리 + if consentRequest.Skip { + identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject) + if err != nil || identity == nil { + slog.Error("failed to load identity for skip consent", "error", err, "subject", consentRequest.Subject) + // 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행 + } else { + sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope) + acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) + if err != nil { + slog.Error("failed to auto-accept hydra consent request", "error", err) + // 자동 승인 실패 시 일반 흐름으로 진행 + } else { + slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID) + return c.JSON(acceptResp) + } + } + } + // Hydra 응답을 기본으로 하되, 메타데이터에서 커스텀 스코프 설명을 추출하여 추가 response := fiber.Map{ "challenge": consentRequest.Challenge, diff --git a/userfront/lib/features/auth/presentation/consent_screen.dart b/userfront/lib/features/auth/presentation/consent_screen.dart index 93c7a761..7e5c7a2c 100644 --- a/userfront/lib/features/auth/presentation/consent_screen.dart +++ b/userfront/lib/features/auth/presentation/consent_screen.dart @@ -49,6 +49,12 @@ class _ConsentScreenState extends State { try { final info = await AuthProxyService.getConsentInfo(widget.consentChallenge); + // [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동 + if (info['redirectTo'] != null) { + webWindow.redirectTo(info['redirectTo']); + return; + } + // 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용 if (info['scope_details'] != null) { final details = info['scope_details'] as Map; From f4c38755ff3d3e83975c1039d6e53f1202e723c6 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 5 Feb 2026 10:05:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Consent=20=EB=B0=98=EB=B3=B5=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/consent_loop_fix_report.md | 117 ++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/consent_loop_fix_report.md diff --git a/docs/consent_loop_fix_report.md b/docs/consent_loop_fix_report.md new file mode 100644 index 00000000..82f45c49 --- /dev/null +++ b/docs/consent_loop_fix_report.md @@ -0,0 +1,117 @@ +# Consent 반복 노출 문제 해결 보고서 + +## 1. 개요 +Gitea 등 RP(Relying Party) 로그인 시, 사용자가 이미 권한에 동의했음에도 불구하고 재로그인할 때마다 Consent(권한 동의) 화면이 반복적으로 노출되는 문제를 해결했습니다. +이 문서는 해당 문제를 해결하기 위해 수정된 파일, 함수, 그리고 변경된 동작 흐름을 기술합니다. + +## 2. 수정된 파일 및 함수 + +### A. 백엔드 서비스 계층 +* **파일**: `backend/internal/service/hydra_admin_service.go` +* **함수**: `AcceptConsentRequest`, `AcceptLoginRequest` +* **변경 내용**: Hydra에 동의 및 로그인 정보를 저장할 때 유효 기간(`remember_for`)을 연장. + +### B. 백엔드 핸들러 계층 +* **파일**: `backend/internal/handler/auth_handler.go` +* **함수**: `GetConsentRequest` +* **변경 내용**: Hydra로부터 받은 동의 요청 정보에 `skip: true` 플래그가 있는 경우, 화면 데이터를 반환하는 대신 **자동 승인 프로세스**를 수행하도록 로직 추가. + +### C. 프론트엔드 (UserFront) +* **파일**: `userfront/lib/features/auth/presentation/consent_screen.dart` +* **함수**: `_fetchConsentInfo` +* **변경 내용**: 백엔드 API 응답에 `redirectTo` 필드가 포함된 경우, UI 렌더링을 건너뛰고 **즉시 해당 URL로 리다이렉트**하도록 수정. + +--- + +## 3. 상세 수정 로직 및 동작 흐름 + +### 3.1. 동의 기억 기간 연장 (Backend Service) +기존에는 동의 정보가 1시간(`3600`) 동안만 유지되어, 1시간 후 재로그인 시 다시 동의 화면이 나타났습니다. 이를 30일(`2592000`)로 늘려 사용자의 편의성을 높였습니다. + +```go +// backend/internal/service/hydra_admin_service.go + +func (s *HydraAdminService) AcceptConsentRequest(...) { + // ... + payload := map[string]interface{}{ + // ... + "remember": true, + "remember_for": 2592000, // 수정 전: 3600 (1시간) -> 수정 후: 30일 + } + // ... +} +``` + +### 3.2. 자동 승인(Skip) 로직 구현 (Backend Handler) +Hydra는 사용자가 이전에 동의한 기록이 유효하다면 `skip: true` 플래그를 보냅니다. 백엔드는 이 신호를 감지하여 사용자 개입 없이 동의 절차를 완료해야 합니다. + +**[수정된 흐름]** +1. `GetConsentRequest` 호출 시 Hydra로부터 `consentRequest` 정보를 받아옴. +2. `consentRequest.Skip`이 `true`인지 확인. +3. **True인 경우 (자동 승인):** + * Kratos에서 사용자 신원(`Identity`) 조회. + * 사용자 특성(`Traits`)을 기반으로 OIDC 클레임(`sessionClaims`) 생성. + * `Hydra.AcceptConsentRequest`를 호출하여 승인 처리. + * Hydra가 반환한 리다이렉트 URL(`redirectTo`)을 프론트엔드에 JSON으로 응답. +4. **False인 경우 (일반 진행):** + * 기존 로직대로 Consent 화면에 필요한 정보(클라이언트 이름, 스코프 목록 등)를 반환. + +```go +// backend/internal/handler/auth_handler.go + +func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { + // ... Hydra 조회 ... + + // [추가된 로직] Skip 플래그 확인 및 자동 승인 + if consentRequest.Skip { + // 1. 사용자 정보 조회 + identity, _ := h.KratosAdmin.GetIdentity(...) + // 2. 클레임 생성 + sessionClaims := buildOidcClaimsFromTraits(...) + // 3. Hydra 승인 요청 + acceptResp, _ := h.Hydra.AcceptConsentRequest(..., sessionClaims) + + // 4. 리다이렉트 URL 반환 (화면 생략) + return c.JSON(acceptResp) + } + + // ... 기존 화면 정보 반환 로직 ... +} +``` + +### 3.3. 즉시 리다이렉트 처리 (Frontend) +프론트엔드는 백엔드의 응답을 확인하여, 화면을 그릴지 아니면 바로 다른 페이지로 이동할지 결정합니다. + +**[수정된 흐름]** +1. `ConsentScreen` 진입 시 `_fetchConsentInfo` 실행. +2. 백엔드 API(`GET /consent`) 호출. +3. 응답 데이터(`info`)에 `redirectTo` 필드가 있는지 확인. +4. **존재하는 경우:** `webWindow.redirectTo`를 통해 즉시 이동. (UI 렌더링 중단) +5. **없는 경우:** 받은 정보를 바탕으로 권한 동의 UI(체크박스, 버튼 등) 렌더링. + +```dart +// userfront/lib/features/auth/presentation/consent_screen.dart + +Future _fetchConsentInfo() async { + final info = await AuthProxyService.getConsentInfo(...); + + // [추가된 로직] 리다이렉트 URL 존재 시 즉시 이동 + if (info['redirectTo'] != null) { + webWindow.redirectTo(info['redirectTo']); + return; + } + + // ... UI 렌더링 준비 ... +} +``` + +## 4. 최종 동작 시나리오 + +1. **최초 로그인**: + * Hydra `skip: false` -> 백엔드가 화면 정보 반환 -> 프론트엔드가 Consent UI 노출 -> 사용자 동의 -> 백엔드가 `remember_for: 30일`로 승인 처리. +2. **재로그인 (30일 이내)**: + * Hydra `skip: true` 반환. + * 백엔드 `GetConsentRequest`가 이를 감지하고 내부적으로 `AcceptConsentRequest` 수행. + * 백엔드가 프론트엔드에 `{ "redirectTo": "https://gitea..." }` 응답. + * 프론트엔드는 화면을 그리지 않고 즉시 Gitea로 이동. + * **결과**: 사용자는 동의 화면을 보지 않고 로그인 완료.