Files

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가 덮어쓴 내부 사용자 헤더만 사용한다.
  • 로그아웃은 쿠키를 만료시키고 로그인 화면으로 돌아간다.
  • 로그인·로그아웃 이후 주소에 토큰이나 자격 증명이 남지 않는다.

구성

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=<old-token> 요청이 인증되지 않고 로그인 화면으로 이동한다.
  • 로그인 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에 기록한다.