From b260e384ed13863e912471cc8ddcbfdb3f2208d6 Mon Sep 17 00:00:00 2001 From: happybell80 Date: Mon, 17 Nov 2025 15:29:04 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20Admin=20Dashboard=20JWT=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=B6=84=EB=A6=AC=20=EA=B5=AC=ED=98=84=20-=20?= =?UTF-8?q?=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20=EB=A3=A8?= =?UTF-8?q?=ED=94=84=20=EB=AC=B8=EC=A0=9C=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...board_jwt_verification_separation_issue.md | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 journey/troubleshooting/250117_admin_dashboard_jwt_verification_separation_issue.md diff --git a/journey/troubleshooting/250117_admin_dashboard_jwt_verification_separation_issue.md b/journey/troubleshooting/250117_admin_dashboard_jwt_verification_separation_issue.md new file mode 100644 index 0000000..0945e42 --- /dev/null +++ b/journey/troubleshooting/250117_admin_dashboard_jwt_verification_separation_issue.md @@ -0,0 +1,280 @@ +# Admin Dashboard JWT 검증 분리 구현 - 리다이렉트 루프 문제 + +**날짜**: 2025-01-17 +**작업자**: Claude (51123 서버 관리자) +**관련 서버**: 51123 +**관련 서비스**: robeing-gateway, frontend-base, nginx +**상태**: ⚠️ 진행 중 (리다이렉트 루프 문제 미해결) + +--- + +## 배경 + +관리자 대시보드(`/admin`) 접근 시 JWT 검증 문제를 해결하기 위해 HTML과 API를 분리하려고 시도했으나, 리다이렉트 루프 문제가 발생하여 현재 진행 중입니다. + +### 문제 상황 +- 브라우저가 `/admin` HTML 페이지를 요청 +- Gateway가 모든 요청에 JWT 검증 요구 → 401 반환 +- HTML 페이지 자체를 받지 못해서 JavaScript 실행 불가 + +### 해결 시도 +1. Gateway에서 HTML/정적 파일은 JWT 검증 없이, API만 JWT 검증하도록 수정 +2. Frontend JavaScript에서 localStorage JWT 확인 후 없으면 리다이렉트하도록 수정 +3. **하지만 리다이렉트 루프 발생** (ERR_TOO_MANY_REDIRECTS) + +--- + +## 구현 내역 + +### 1. Gateway 라우팅 수정 + +**파일**: `/home/admin/robeing-gateway/app/main.py` + +**변경 사항**: +- `/admin`, `/admin/`, `/admin/{path:path}` 모두 처리하도록 라우트 추가 +- 조건부 JWT 검증: `path.startswith("api/")`인 경우만 JWT 검증 +- `redirect_slashes=False` 추가하여 trailing slash 리다이렉트 비활성화 + +```python +@app.api_route("/admin", methods=["GET", "POST", "PUT", "DELETE"]) +@app.api_route("/admin/", methods=["GET", "POST", "PUT", "DELETE"]) +@app.api_route("/admin/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) +async def admin_proxy( + path: str = "", + request: Request, + authorization: Optional[str] = Header(None) +): + """ + Admin dashboard - proxy to frontend-base (51123:8000) + + - HTML/정적 파일: JWT 검증 없이 프록시 (JavaScript에서 JWT 체크) + - API 요청 (/admin/api/*): JWT 검증 필요 + """ + frontend_base_url = "http://localhost:8000" + target_url = f"{frontend_base_url}/admin/{path}" if path else f"{frontend_base_url}/admin" + + # API 요청인지 확인 (robeing-monitor API) + is_api_request = path.startswith("api/") if path else False + + # API 요청은 JWT 검증 필요 + if is_api_request: + try: + user_uuid = get_verified_user(authorization) + logger.info(f"Admin API request from user {user_uuid} to {target_url}") + except HTTPException as e: + logger.warning(f"Admin API request without valid JWT to {target_url}") + raise e + else: + # HTML/정적 파일은 JWT 검증 없이 프록시 + logger.info(f"Admin static/HTML request to {target_url}") + + # Forward request + # 리다이렉트를 따라가지 않고 직접 처리 (루프 방지) + async with httpx.AsyncClient(follow_redirects=False) as client: + response = await client.request( + method=request.method, + url=target_url, + headers=dict(request.headers), + content=await request.body() + ) + + # 307/308 리다이렉트인 경우 최종 URL로 직접 요청 + if response.status_code in (307, 308) and 'location' in response.headers: + redirect_url = response.headers['location'] + # 상대 경로인 경우 절대 경로로 변환 + if redirect_url.startswith('/'): + redirect_url = f"{frontend_base_url}{redirect_url}" + elif not redirect_url.startswith('http'): + redirect_url = f"{frontend_base_url}/{redirect_url}" + + # 리다이렉트 URL로 직접 요청 + final_response = await client.request( + method=request.method, + url=redirect_url, + headers=dict(request.headers), + content=await request.body() + ) + return Response( + content=final_response.content, + status_code=final_response.status_code, + headers=dict(final_response.headers) + ) + + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers) + ) +``` + +**FastAPI 앱 설정**: +```python +app = FastAPI( + title=env_setting.APP_TITLE, + description=env_setting.APP_DESCRIPTION, + version=env_setting.APP_VER, + lifespan=lifespan, + redirect_slashes=False # trailing slash 리다이렉트 비활성화 +) +``` + +### 2. Frontend-base 수정 + +**파일**: `/home/admin/frontend-base/backend/main.py` + +**변경 사항**: +- `/admin`과 `/admin/` 모두 같은 핸들러로 처리 +- `redirect_slashes=False` 추가 + +```python +app = FastAPI( + title="로빙 중앙 대시보드 API", + description="로빙 AI 컨테이너들을 관리하는 중앙 FastAPI 서버", + version="1.0.0", + lifespan=lifespan, + redirect_slashes=False # trailing slash 리다이렉트 비활성화 +) + +# 관리자 페이지 정적 파일 서빙 +@app.get("/admin") +@app.get("/admin/") +async def serve_admin_page(): + """관리자 대시보드 페이지""" + return FileResponse("admin-ui/index.html") +``` + +### 3. Frontend JavaScript 수정 + +**파일**: `/home/admin/frontend-base/admin-ui/index.html` + +**변경 사항**: +- localStorage에서 JWT 확인 (`auth_token` 또는 `token`) +- JWT 없으면 `auth.ro-being.com/login`으로 리다이렉트 +- auth.ro-being.com에서는 리다이렉트하지 않도록 체크 추가 + +```javascript +// JWT token check (Gateway 인증) +const jwtToken = localStorage.getItem('auth_token') || localStorage.getItem('token'); +let authToken = localStorage.getItem('adminToken'); // frontend-base 자체 인증 (fallback) + +// JWT가 없으면 로그인 페이지로 리다이렉트 +// 단, 이미 auth.ro-being.com이면 리다이렉트하지 않음 (무한 루프 방지) +if (!jwtToken && !window.location.hostname.includes('auth.ro-being.com')) { + // 현재 URL 저장 (로그인 후 돌아오기 위해) + const currentUrl = window.location.href; + localStorage.setItem('admin_redirect_url', currentUrl); + + // auth.ro-being.com으로 리다이렉트 + window.location.href = 'https://auth.ro-being.com/login?redirect=' + encodeURIComponent(currentUrl); + return; // throw 대신 return 사용 +} +``` + +--- + +## 발생한 문제 + +### 리다이렉트 루프 (ERR_TOO_MANY_REDIRECTS) + +**증상**: +- 브라우저에서 `https://ro-being.com/admin` 접근 시 리다이렉트 루프 발생 +- `curl http://localhost:8100/admin` 시 307 Temporary Redirect 반환 +- Location 헤더: `http://localhost:8100/admin/` + +**원인 분석**: +1. Gateway가 `/admin` 요청을 받음 +2. frontend-base로 프록시 → frontend-base가 307 리다이렉트 반환 (`/admin/`) +3. Gateway가 리다이렉트를 따라가려고 시도 +4. 다시 `/admin/` 요청 → 루프 발생 + +**시도한 해결책**: +1. ✅ `redirect_slashes=False` 설정 (Gateway, frontend-base 모두) +2. ✅ `/admin`과 `/admin/` 모두 명시적으로 라우트 추가 +3. ✅ `follow_redirects=False`로 설정하고 수동으로 리다이렉트 처리 +4. ❌ 여전히 리다이렉트 루프 발생 + +**현재 상태**: +- frontend-base 직접 접근 (`http://localhost:8000/admin`): ✅ 200 OK, HTML 반환 +- Gateway 접근 (`http://localhost:8100/admin`): ❌ 307 리다이렉트 반환 +- 브라우저 접근 (`https://ro-being.com/admin`): ❌ ERR_TOO_MANY_REDIRECTS + +--- + +## 로그 분석 + +### Gateway 로그 +``` +2025-11-17 15:26:41,573 - httpx - INFO - HTTP Request: GET http://localhost:8000/admin "HTTP/1.1 307 Temporary Redirect" +2025-11-17 15:26:41,579 - app.main - INFO - Admin static/HTML request to http://localhost:8000/admin +2025-11-17 15:26:41,591 - httpx - INFO - HTTP Request: GET http://localhost:8000/admin "HTTP/1.1 307 Temporary Redirect" +``` + +frontend-base가 여전히 307 리다이렉트를 반환하고 있습니다. + +### curl 테스트 결과 +```bash +# frontend-base 직접 접근 +$ curl http://localhost:8000/admin +... # ✅ 정상 + +# Gateway 접근 +$ curl http://localhost:8100/admin +# 307 Temporary Redirect +# Location: http://localhost:8100/admin/ +``` + +--- + +## 다음 단계 (미해결) + +### 가능한 해결 방안 + +1. **Starlette/FastAPI의 redirect_slashes 동작 확인** + - `redirect_slashes=False`가 제대로 작동하는지 확인 + - Starlette의 내부 리다이렉트 로직 확인 필요 + +2. **nginx 설정 확인** + - nginx가 리다이렉트를 생성하는지 확인 + - `proxy_redirect` 설정 확인 + +3. **대안: nginx에서 직접 frontend-base로 프록시** + - Gateway를 거치지 않고 nginx에서 직접 frontend-base로 프록시 + - API 요청만 Gateway 경유 + +4. **대안: 별도 엔드포인트 사용** + - `/admin`은 nginx에서 직접 frontend-base로 + - `/admin-api/*`는 Gateway 경유하여 JWT 검증 + +--- + +## 관련 파일 + +- Gateway: `/home/admin/robeing-gateway/app/main.py` +- Frontend-base: `/home/admin/frontend-base/backend/main.py` +- Frontend HTML: `/home/admin/frontend-base/admin-ui/index.html` +- Nginx: `/home/admin/nginx-infra/server-nginx-default` +- 테스트: `/home/admin/frontend-base/tests/test_admin_api.py` + +--- + +## 참고 문서 + +- 이전 구현: `/home/admin/DOCS/journey/troubleshooting/251117_admin_dashboard_routing_implementation.md` +- Gateway 아키텍처: `/home/admin/DOCS/book/300_architecture/gateway_proxy_patterns.md` + +--- + +## 교훈 + +### FastAPI/Starlette의 리다이렉트 동작 +- `redirect_slashes=False` 설정만으로는 부족할 수 있음 +- 라우트 정의 순서와 방식이 리다이렉트에 영향을 줄 수 있음 + +### 프록시 환경에서의 리다이렉트 처리 +- 프록시 서버가 리다이렉트를 따라갈 때 Location 헤더 해석이 중요 +- 상대 경로 vs 절대 경로 처리 주의 필요 + +### 디버깅 접근 +- 각 레이어별로 직접 테스트 (frontend-base → Gateway → nginx) +- 로그를 통해 정확한 리다이렉트 발생 지점 파악 +