refs #709: replace portal query token with HttpOnly auth
This commit is contained in:
125
docs/design/709-hmm-portal-http-only-auth/README.md
Normal file
125
docs/design/709-hmm-portal-http-only-auth/README.md
Normal 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`에 기록한다.
|
||||
@@ -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로 들어오지 않는다.
|
||||
Reference in New Issue
Block a user