refs #709: replace portal query token with HttpOnly auth

This commit is contained in:
devmrko
2026-07-23 14:40:47 +09:00
parent da80041bcb
commit ce4b418603
8 changed files with 1079 additions and 134 deletions

View File

@@ -0,0 +1,125 @@
# 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=<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`에 기록한다.

View File

@@ -0,0 +1,80 @@
# HMM 포털 HttpOnly 쿠키 인증 적용·검증 보고서
- 일자: 2026-07-23
- Redmine: #709
- 브랜치: `hmm-backoffice`
- 서비스: `https://hmm.cloud-handson.com`
## 수정 결과
Streamlit의 `poc4_remember` query-token 생성·복원 코드를 삭제했다. Nginx가 모든 포털
HTTP·WebSocket 요청에 `auth_request`를 수행하고, localhost 인증 서비스가 검증한 사용자와
만료 시각만 Streamlit에 전달한다.
| 구성 | 결과 |
|---|---|
| 인증 서비스 | `hmm-portal-auth.service` / active |
| 인증 서비스 bind | `127.0.0.1:8621` |
| 포털 | `poc4-streamlit.service` / active |
| 공개 경계 | Nginx `auth_request` |
| session cookie | `__Host-HMM_PORTAL_SESSION` |
| cookie 속성 | `Secure; HttpOnly; SameSite=Lax; Path=/` |
| 로그인 CSRF | 10분 일회용 double-submit cookie |
| URL token | 제거 |
| 이전 서명키 | 운영 환경과 환경 백업에서 제거 |
## 자동 테스트
```text
python3 -m unittest discover -s tests -p 'test_*.py' -v
Ran 21 tests
OK (skipped=1)
```
검증 항목:
- PBKDF2 비밀번호 비교
- session token 발급·검증·만료·조작 거부
- persistent/session/logout cookie 속성
- login rate limit
- 실제 HTTP login → auth check → tamper reject → logout 흐름
- login 성공 redirect가 `/`이고 token·remember query가 없는지 확인
## 운영 HTTP 검증
| 요청 | 결과 |
|---|---|
| 쿠키 없이 `/` | 302 → `/auth/login` |
| `/?poc4_remember=retired-token` | 302 → `/auth/login`, query 전달 안 됨 |
| `/auth/login` | 200, `Cache-Control: no-store` |
| 조작 session cookie | 401 |
| 유효 session cookie | Streamlit 200 |
| `/auth/logout` | session cookie `Max-Age=0`, 로그인 화면 이동 |
로그인 페이지 응답에는 `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy:
no-referrer`, 제한된 CSP가 포함된다.
## 실제 브라우저 검증
Playwright에서 다음을 확인했다.
- 미인증 query-token URL은 `/auth/login`으로 이동하고 최종 URL에서 query가 사라짐
- 로그인 화면의 사용자 ID, 비밀번호, 로그인 유지 UI 표시
- 인증 후 최종 URL은 `/`, query 없음
- `__Host-HMM_PORTAL_SESSION`: HttpOnly=true, Secure=true, SameSite=Lax, Path=/
- `document.cookie`에 portal session cookie가 없음
- 아키텍처·시나리오·감사로그·보안관리 탭 필수 내용 표시
- `https://hmm-mcp.cloud-handson.com/mcp` 설정 표시
- 로그아웃 후 session cookie 없음
- page error 0, console error 0
캡처와 기계 판독 보고서는 운영 검증 작업 디렉터리
`/private/tmp/hmm-cookie-auth-audit/`에 생성했다.
## 보안 정리
- 노출된 과거 query-token은 새 인증 경로에서 사용되지 않으며 기존 HMAC 서명키도 회전했다.
- 회전 전 환경 백업 두 개에서는 이전·중간 서명키 줄을 제거했다.
- 현재 secret은 `/opt/hmm-poc4/.env`에만 있고 파일 권한은 `opc:opc 0600`이다.
- 토큰, 비밀번호, cookie 값은 Git·Redmine·보고서·서비스 로그에 기록하지 않았다.
- Nginx access log에는 앞으로 인증 token이 URL로 들어오지 않는다.