DOCS/journey/troubleshooting/251117_admin_dashboard_jwt_verification_separation_issue.md

9.8 KiB

Admin Dashboard JWT 검증 분리 구현 - 리다이렉트 루프 문제

날짜: 2025-11-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 리다이렉트 비활성화
@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 앱 설정:

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 추가
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에서는 리다이렉트하지 않도록 체크 추가
// 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 테스트 결과

# frontend-base 직접 접근
$ curl http://localhost:8000/admin
<!DOCTYPE html>...  # ✅ 정상

# 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)
  • 로그를 통해 정확한 리다이렉트 발생 지점 파악