docs: Admin Dashboard JWT 검증 분리 구현 - 리다이렉트 루프 문제 문서화
This commit is contained in:
parent
06030d536f
commit
b260e384ed
@ -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
|
||||||
|
<!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)
|
||||||
|
- 로그를 통해 정확한 리다이렉트 발생 지점 파악
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user