# HMM 포털 URL 토큰 제거와 HttpOnly 쿠키 인증 설계 (#709) > 상태: 구현·배포·검증 완료 > 대상: `https://hmm.cloud-handson.com` > 브랜치: `hmm-backoffice` ## 문제 현재 Streamlit 로그인 유지 기능은 서명된 토큰을 `poc4_remember` query parameter에 저장한다. 토큰이 암호화된 비밀번호는 아니더라도 유효 기간 동안 인증 수단으로 작동하므로 다음 위치에 남을 수 있다. - 브라우저 주소와 방문 기록 - Nginx·상위 프록시 access log - 사용자가 복사한 링크와 화면 캡처 - 외부 링크 이동 시 Referer 인증 수단은 URL에 포함하지 않는다. 기존 query-token 코드를 삭제하고 기존 서명 secret을 회전해 과거 URL을 즉시 무효화한다. ## 목표 - 로그인은 `POST /auth/login`으로만 처리한다. - 인증 상태는 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/` 쿠키에만 둔다. - Nginx가 모든 Streamlit HTTP·WebSocket 요청 전에 쿠키를 검증한다. - Streamlit은 외부 요청 헤더가 아니라 Nginx가 덮어쓴 내부 사용자 헤더만 사용한다. - 로그아웃은 쿠키를 만료시키고 로그인 화면으로 돌아간다. - 로그인·로그아웃 이후 주소에 토큰이나 자격 증명이 남지 않는다. ## 구성 ```text Browser ├─ GET /auth/login ────────────────┐ ├─ POST /auth/login (ID/password) │ └─ Cookie: __Host-HMM_PORTAL_SESSION ▼ Nginx :443 ├─ /auth/* ───────────────► auth_gateway.py :8621 └─ /* + auth_request ──────► /auth/check ├─ 204 + X-Auth-User ─► Streamlit :8622 └─ 401 ───────────────► /auth/login ``` 인증 서비스와 Streamlit은 모두 `127.0.0.1`에만 바인딩한다. 외부에서 인증 사용자 헤더를 보내더라도 Nginx가 `auth_request` 결과로 값을 덮어쓴다. ## 쿠키 | 항목 | 값 | |---|---| | 이름 | `__Host-HMM_PORTAL_SESSION` | | 속성 | `Secure; HttpOnly; SameSite=Lax; Path=/` | | 기본 로그인 | 브라우저 세션 쿠키, 서버 토큰 만료 12시간 | | 로그인 유지 | `Max-Age=604800`, 서버 토큰 만료 7일 | | 형식 | version, user, issued-at, expiry, nonce를 담은 base64url payload + HMAC-SHA256 | | 서명키 | `POC4_LOGIN_COOKIE_SECRET`, Git·로그 미기록 | `__Host-` 접두사는 `Secure`, `Path=/`, Domain 미지정 조건을 강제해 하위 도메인의 쿠키 주입 범위를 줄인다. ## 로그인 보호 - PBKDF2 비밀번호 해시는 기존 `POC4_LOGIN_PASSWORD_PBKDF2`를 사용한다. - 로그인 GET에서 10분 유효한 일회용 CSRF 쿠키와 hidden 값을 발급한다. - 로그인 POST는 CSRF 두 값을 상수 시간 비교한 뒤 자격 증명을 확인한다. - 오류 메시지는 사용자 존재 여부와 비밀번호 실패를 구분하지 않는다. - 요청 body와 필드 길이를 제한한다. - 실패 횟수는 IP별 짧은 시간 창에서 제한한다. - 인증 응답에는 `Cache-Control: no-store`와 보안 헤더를 설정한다. - 서비스 로그에는 query string, 쿠키, 비밀번호, 토큰을 기록하지 않는다. ## Streamlit 변경 - `poc4_remember` 상수, 생성, 복원, query 정리 코드를 삭제한다. - Streamlit 내부 로그인 유지 로직을 삭제한다. - `st.context.headers["X-HMM-Authenticated-User"]`가 설정된 경우에만 포털 세션을 활성화한다. - 기대 사용자와 프록시 사용자 값은 상수 시간 비교한다. - 로그아웃 UI는 `/auth/logout`으로 이동해 쿠키를 만료시킨다. - 신뢰 헤더가 없으면 자격 증명 폼 대신 인증 게이트웨이 설정 오류만 표시한다. ## 배포 1. 인증 서비스 소스와 systemd unit을 `/opt/hmm-poc4`에 배포한다. 2. 새 `POC4_LOGIN_COOKIE_SECRET`을 root 소유 환경 파일에 추가하고 기존 `POC4_LOGIN_REMEMBER_SECRET`은 제거한다. 3. 인증 서비스를 `127.0.0.1:8621`에서 시작한다. 4. Nginx 설정에 `/auth/*`, 내부 `/auth/check`, `auth_request`를 적용한다. 5. Streamlit 소스를 배포하고 서비스를 재시작한다. 6. `nginx -t`, 서비스 상태, 로그인·쿠키·WebSocket·로그아웃을 검증한다. ## 완료 검증 - 기존 `?poc4_remember=` 요청이 인증되지 않고 로그인 화면으로 이동한다. - 로그인 POST 응답의 `Location`은 `/`이고 URL에 토큰이 없다. - 세션 쿠키에 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`가 모두 있다. - 조작·만료 쿠키는 `/auth/check`에서 401이다. - 인증 쿠키가 없으면 Streamlit asset·WebSocket을 포함한 보호 경로를 사용할 수 없다. - 로그인 후 포털 주요 탭, MCP 설정, 사용자 전환이 정상 동작한다. - 로그아웃 후 쿠키가 만료되고 보호 경로가 다시 로그인 화면으로 이동한다. ## 롤백 변경 전 Nginx 설정, Streamlit 소스, 환경 파일을 타임스탬프 백업한다. 장애 시 이 세 파일을 복구하고 인증 서비스를 중지한다. 롤백을 해도 query-token 구현은 재활성화하지 않으며, 임시로 포털 접근을 차단하는 쪽을 우선한다. ## 배포 검증 결과 2026-07-23 운영 배포에서 다음을 확인했다. - `hmm-portal-auth.service`, `poc4-streamlit.service`, `nginx` 모두 `active` - 기존 query-token 서명키 제거·회전, 환경 백업의 이전 서명키도 제거 - `/` 미인증 요청: `/auth/login`으로 이동 - `/?poc4_remember=retired-token`: 인증되지 않고 `/auth/login`으로 이동하며 query 제거 - 로그인 페이지: URL token 없음, CSRF cookie는 `Secure; HttpOnly; SameSite=Strict` - 포털 session cookie: `Secure; HttpOnly; SameSite=Lax; Path=/` - 브라우저 `document.cookie`에서 session cookie를 읽을 수 없음 - 인증 후 URL: `https://hmm.cloud-handson.com/`, query 없음 - 아키텍처·시나리오·감사로그·보안관리 탭 및 MCP endpoint 설정 표시 정상 - 로그아웃 후 session cookie 제거와 로그인 화면 복귀 확인 - Python 단위·HTTP 통합 테스트 21건 통과, 선택적 Streamlit runtime 테스트 1건 skip - 브라우저 page error 0건, console error 0건 상세 증거는 `docs/reports/2026-07-23-hmm-portal-cookie-auth-verification.md`에 기록한다.