Compare commits

..

2 Commits

Author SHA1 Message Date
devmrko
1917df09a2 refs #712: authenticate backoffice MCP user tokens 2026-07-23 14:45:18 +09:00
devmrko
ce4b418603 refs #709: replace portal query token with HttpOnly auth 2026-07-23 14:40:47 +09:00
26 changed files with 1604 additions and 171 deletions

View File

@@ -75,7 +75,7 @@ HMM_KNOWLEDGE_DOCUMENTS ──< HMM_KNOWLEDGE_CHUNKS ──< HMM_KNOWLEDGE_TAGS
## HMM MCP 및 시스템 설정 ## HMM MCP 및 시스템 설정
- 운영 MCP 주소는 환경변수 `BACKOFFICE_HMM_MCP_PUBLIC_URL`로 관리하며 기본값은 - 운영 MCP 주소는 환경변수 `BACKOFFICE_HMM_MCP_PUBLIC_URL`로 관리하며 기본값은
`https://hmm-mcp.cloud-handson.com/mcp`이다. 시스템 설정과 MCP 연동 화면은 이 값을 표시하므로 사용자 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`이다. 시스템 설정과 MCP 연동 화면은 이 값을 표시하므로
도메인 변경 시 화면 소스를 수정하지 않는다. 도메인 변경 시 화면 소스를 수정하지 않는다.
- HMM MCP와 백오피스 호환 `/mcp`는 동일하게 `resolve_hr_term`, `search_hr_data`, - HMM MCP와 백오피스 호환 `/mcp`는 동일하게 `resolve_hr_term`, `search_hr_data`,
`search_hr_policy`를 제공한다. 각각 `HMM_HR_TERM_RESOLVER`, `search_hr_policy`를 제공한다. 각각 `HMM_HR_TERM_RESOLVER`,

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,78 @@
# HMM 백오피스 MCP 사용자 Bearer 인증 설계 (#712)
> 상태: 구현·배포·검증 완료
> 대상: `https://hmm-backoffice.cloud-handson.com/mcp`
> 브랜치: `hmm-backoffice`
## 목적
HMM 백오피스에서 발급한 `vpd_live_*` 사용자 토큰을 MCP의 실제 인증 수단으로 사용한다.
현재 별도 호스트의 `https://hmm-mcp.cloud-handson.com/mcp`는 서버 공용 게이트웨이 토큰만
허용하므로 사용자 토큰을 보내면 Nginx에서 HTTP 401을 반환한다. 사용자별 HMM 데모는
백오피스 MCP 주소를 사용해야 한다.
## 확인된 현행 결함
- 백오피스의 `/mcp``initialize`, `tools/list`, `tools/call`을 제공한다.
- Controller가 `Authorization: Bearer` 값을 추출하지만 Service의
`ignoredAuthorization` 인자로 전달해 검증하지 않는다.
- Tool 실행은 토큰 사용자 컨텍스트를 설정하지 않고 백오피스 JDBC 계정으로 바로
`DBMS_CLOUD_AI_AGENT.RUN_TOOL`을 호출한다.
- 따라서 주소를 올바르게 사용해도 토큰이 인증과 행 접근 문맥에 연결되지 않는다.
## 변경 설계
```text
Private Agent Factory
→ POST https://hmm-backoffice.cloud-handson.com/mcp
→ Authorization: Bearer <vpd_live 사용자 토큰>
→ HMM_ACCESS_BEARER_TOKENS SHA-256/만료/회수/재직 검증
→ 동일 JDBC connection에서 HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER
→ DBMS_CLOUD_AI_AGENT.RUN_TOOL
→ finally HMM_ACCESS_CTX_PKG.CLEAR_USER
```
1. 모든 MCP method는 활성 사용자 Bearer를 요구한다. 누락·오류·만료·회수 토큰은 HTTP 401로
fail-closed한다.
2. 원문 토큰은 로그, 응답, DB, Git에 기록하지 않는다. DB에는 기존 SHA-256 해시만 사용한다.
3. `tools/call`은 토큰 검증과 Tool 실행을 같은 요청에서 수행한다.
4. Tool 실행 connection에는 컨텍스트를 설정하고 성공·실패와 무관하게 `finally`에서 지운다.
5. `initialize``tools/list`도 토큰을 검증해 discovery만으로 인증을 우회할 수 없게 한다.
## Agent Factory 설정
| 항목 | 값 |
|---|---|
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` |
| Authentication mode | `Bearer Token` |
| Bearer token | 백오피스에서 발급한 토큰 원문만 입력 (`Bearer ` 접두어 제외) |
별도 서버 `https://hmm-mcp.cloud-handson.com/mcp`에는 사용자 토큰을 사용하지 않는다. 그 주소는
운영 `HMM_MCP_BEARER_TOKEN`을 사용하는 호환 게이트웨이다.
## 완료 기준
- 활성 사용자 토큰으로 `initialize`, `tools/list`, 세 Tool 호출이 성공한다.
- 누락·무효·회수 토큰은 HTTP 401이다.
- Tool 호출 전후 DB context 설정·정리가 자동 테스트로 검증된다.
- 운영 배포 후 외부 HTTPS에서 discovery와 대표 Tool 호출을 검증한다.
- 토큰 원문이나 해시는 테스트 출력과 문서에 남지 않는다.
## 배포 검증 결과
2026-07-23 운영 배포에서 다음을 확인했다.
- 자동 테스트 107건 통과
- 운영 JAR과 검증 빌드 SHA-256 일치
- 무토큰 `initialize`, `tools/list`: HTTP 401
- 활성 E1002 임시 사용자 토큰:
- `initialize`: HTTP 200
- `tools/list`: HTTP 200
- `resolve_hr_term`: HTTP 200, 정상 MCP result
- 발견 도구: `resolve_hr_term`, `search_hr_data`, `search_hr_policy`
- 같은 토큰을 회수한 직후 `tools/list`: HTTP 401
- 검증 중 발급한 임시 토큰과 이전 실패 시 남은 임시 토큰을 모두 회수
- 백오피스 시스템 설정과 MCP 서비스 화면의 Agent Factory 주소를
`https://hmm-backoffice.cloud-handson.com/mcp`로 변경
상세 증거는 `docs/reports/2026-07-23-hmm-backoffice-mcp-bearer-verification.md`에 기록한다.

View File

@@ -0,0 +1,60 @@
# HMM 백오피스 MCP 사용자 Bearer 검증 보고서
- 일자: 2026-07-23
- Redmine: #712
- 브랜치: `hmm-backoffice`
- 서비스: `https://hmm-backoffice.cloud-handson.com/mcp`
## 원인
백오피스에서 발급한 `vpd_live_*` 직원 토큰을 별도 호환 서버인
`https://hmm-mcp.cloud-handson.com/mcp`에 보냈다. 이 서버의 Nginx는 운영 공용
`HMM_MCP_BEARER_TOKEN` 한 개만 비교하므로, 직원 토큰에서 `Bearer ` 문자열을 제거해도 HTTP 401이
정상이다.
직원 토큰용 주소는 `https://hmm-backoffice.cloud-handson.com/mcp`다. 조사 시 이 경로는 MCP 도구를
제공했지만 Authorization 값을 실제로 검증하지 않고 버리는 결함도 확인됐다.
## 수정
- 모든 MCP method에서 `HMM_ACCESS_BEARER_TOKENS`의 SHA-256 해시, 만료, 회수, 직원 재직 상태 검증
- 누락·무효·만료·회수 토큰은 HTTP 401
- Tool 호출 connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER` 실행
- 같은 connection에서 `DBMS_CLOUD_AI_AGENT.RUN_TOOL` 실행
- 성공·실패와 무관하게 `finally`에서 `HMM_ACCESS_CTX_PKG.CLEAR_USER`
- Agent Factory 표시 주소를 백오피스 MCP 주소로 수정
- 공용 gateway와 직원 VPD endpoint의 주소·토큰 조합을 운영 문서에서 분리
토큰 원문은 Controller에서 `Bearer ` 접두어만 제거해 전달하며 로그·응답·DB에 기록하지 않는다.
DB에는 기존 SHA-256 해시와 식별용 prefix만 남는다.
## 검증
| 항목 | 결과 |
|---|---|
| Maven 자동 테스트 | 107건 통과 |
| 같은 JDBC connection의 context 설정·Tool 실행·context 정리 | 단위 테스트 통과 |
| 운영 JAR SHA-256 | 로컬 검증 빌드와 일치 |
| 서비스 | `vpd-backoffice.service` active |
| 로그인 상태 | HTTP 200 |
| 무토큰 MCP | HTTP 401 |
| 활성 E1002 임시 토큰 initialize | HTTP 200 |
| 활성 E1002 임시 토큰 tools/list | HTTP 200 |
| Tool discovery | HMM Tool 3개 |
| `resolve_hr_term` Tool 호출 | HTTP 200 / MCP result 성공 |
| 회수 후 같은 토큰 | HTTP 401 |
검증용 토큰은 백오피스의 정상 발급 흐름으로 만들고 원문을 출력하지 않았다. 검증 완료 시 즉시
회수했으며, 이전 실패 과정에서 남은 같은 용도의 임시 토큰 한 건도 함께 회수했다.
## Agent Factory 최종 입력
| 항목 | 값 |
|---|---|
| Server name | `hmm-backoffice-mcp` |
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` |
| Authentication mode | `Bearer Token` |
| Bearer token | 백오피스 발급 토큰 원문만 입력 |
Token 입력란에 `Bearer ` 접두어를 직접 쓰지 않는다. Agent Factory가 HTTP
`Authorization: Bearer <token>` 헤더를 조립한다. OAuth URL과 client ID/secret은 사용하지 않는다.

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로 들어오지 않는다.

View File

@@ -0,0 +1,140 @@
# HMM MCP endpoint와 토큰 설정
## 인증 수단 구분
HMM 포털 로그인과 MCP 호출은 서로 다른 인증 수단을 사용한다.
| 인증 수단 | 사용 위치 | 전달 방식 | MCP 호출 사용 여부 |
|---|---|---|---|
| `__Host-HMM_PORTAL_SESSION` | `hmm.cloud-handson.com` 포털 로그인 | 브라우저 `Secure; HttpOnly` 쿠키 | 사용 금지 |
| `HMM_MCP_BEARER_TOKEN` | 별도 호환 MCP 서버 접근 | HTTP `Authorization: Bearer` | 포털의 공용 gateway에 사용 |
| HMM 직원별 VPD 토큰 | 백오피스 MCP 사용자 인증·DB context | HTTP `Authorization: Bearer` | Agent Factory 사용자별 연동에 사용 |
포털 쿠키를 복사해 MCP Bearer Token으로 사용하면 안 된다. 브라우저 JavaScript에서도 포털 쿠키를
읽을 수 없도록 `HttpOnly`로 설정한다.
## MCP endpoint 구분
두 주소는 같은 토큰을 받지 않는다.
| 항목 | 값 |
|---|---|
| 사용자별 VPD MCP | `https://hmm-backoffice.cloud-handson.com/mcp` |
| 사용자별 인증 | 백오피스에서 발급한 `vpd_live_*` 토큰 원문 |
| 공용 호환 MCP | `https://hmm-mcp.cloud-handson.com/mcp` |
| 공용 인증 | 운영 `HMM_MCP_BEARER_TOKEN` |
| Transport | Streamable HTTP POST |
허용 도구는 다음 세 개다.
| 도구 | 주요 인자 | 용도 |
|---|---|---|
| `search_hr_data` | `query` | 조직, 직원, 휴가 잔여·신청, 근태 조회 |
| `resolve_hr_term` | `term` | 휴가·근태 표현을 표준 용어와 코드로 변환 |
| `search_hr_policy` | `query` | HR 규정 PDF 지식 검색 |
## 포털의 현재 공용 토큰 조합
`config/vpd_token_presets.json`의 현재 조합은 다음과 같다.
| 데모 사용자 | 역할 | `mcp_token_env` |
|---|---|---|
| E1001 Kim Minseo | HR Team Manager | `HMM_MCP_BEARER_TOKEN` |
| E1002 Lee Jiwon | HR Operations Specialist | `HMM_MCP_BEARER_TOKEN` |
| E1003 Park Dohyun | People Analytics Analyst | `HMM_MCP_BEARER_TOKEN` |
| E1005 Han Seojun | Recruiting Specialist | `HMM_MCP_BEARER_TOKEN` |
| E1007 Kang Minho | HR Coordinator | `HMM_MCP_BEARER_TOKEN` |
현재는 모든 preset이 같은 서버 관리 토큰을 쓴다. preset을 바꾸면 질문에 포함되는 데모 사용자
문맥은 바뀌지만, Bearer Token 자체는 바뀌지 않는다. 따라서 이 조합만으로는 사용자별 VPD
보안 경계를 만들지 못한다.
## 포털 설정
`config/mcp_servers.json`에는 토큰 원문 대신 환경변수 이름만 기록한다.
```json
{
"id": "hmm_hr_mcp",
"endpoint_url": "https://hmm-mcp.cloud-handson.com/mcp",
"auth_token_env": "HMM_MCP_BEARER_TOKEN",
"tool_allowlist": [
"search_hr_data",
"resolve_hr_term",
"search_hr_policy"
]
}
```
운영 서버 `/opt/hmm-poc4/.env`에 실제 값이 있어야 한다.
```dotenv
HMM_MCP_BEARER_TOKEN=<MCP 서버에 등록된 동일한 임의 토큰>
POC3_MCP_TIMEOUT_SECONDS=45
```
토큰 원문은 JSON, Git, 대화 기록, 화면 상세에 저장하지 않는다. 환경 파일은 운영 계정만 읽을
수 있게 제한한다.
## 사용자 VPD MCP 직접 호출 예시
```bash
export HMM_USER_BEARER_TOKEN='<백오피스 발급 화면에서 한 번 표시된 원문>'
curl --fail-with-body \
-H "Authorization: Bearer ${HMM_USER_BEARER_TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
--data '{
"jsonrpc": "2.0",
"id": "tools-list-1",
"method": "tools/list",
"params": {}
}' \
https://hmm-backoffice.cloud-handson.com/mcp
unset HMM_USER_BEARER_TOKEN
```
MCP 서버가 `initialize`와 session ID를 요구하면 다음 순서를 사용한다.
1. `initialize`
2. 응답의 `Mcp-Session-Id` 보관
3. `notifications/initialized`
4. 같은 session header와 Bearer Token으로 `tools/list`
5. 같은 session header와 Bearer Token으로 `tools/call`
## Oracle AI Database Private Agent Factory 설정
| 입력 항목 | 값 |
|---|---|
| Server name | `hmm-backoffice-mcp` |
| Server URL | `https://hmm-backoffice.cloud-handson.com/mcp` |
| Authentication mode | Bearer Token |
| Token | 백오피스에서 해당 직원에게 발급한 토큰 원문. `Bearer ` 문자열은 붙이지 않음 |
| Allowed tools | 위 세 도구만 선택 |
| Timeout | 45초부터 시작 |
이 endpoint는 OAuth authorization endpoint가 아니다. OAuth client ID, client secret,
authorization URL, token URL은 입력하지 않는다.
## 사용자별 VPD 적용 구조
MCP의 표준 `Authorization` header는 하나이므로 “공통 gateway token + 직원 VPD token” 두 개를
같은 header에 조합하지 않는다. 다음 구조가 권장된다.
1. 백오피스에서 E1001, E1002 등 직원별 opaque token을 각각 발급한다.
2. MCP 서버는 그 직원 token 자체를 Bearer Token으로 검증한다.
3. 검증된 같은 token으로 DB 연결에서
`ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(:token)`을 호출한다.
4. 같은 DB 세션에서 `HMM_LEAVE_BALANCES`, `HMM_LEAVE_REQUESTS`를 조회한다.
5. `finally`에서 context를 초기화하고 connection pool에 반환한다.
6. 서버 관리용 공통 토큰과 직원별 토큰을 동시에 요구해야 한다면 OAuth/API Gateway에서
application identity와 user subject를 하나의 검증 가능한 access token으로 합친다.
이 흐름은 2026-07-23 백오피스 MCP에 적용됐다. 모든 MCP method가 토큰 해시·만료·회수·재직
상태를 확인하며, Tool 호출은 같은 JDBC connection에서 `HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER`
후 실행하고 `finally`에서 context를 지운다. 무토큰·무효·회수 토큰은 HTTP 401이다.
포털의 `HMM_MCP_BEARER_TOKEN` 공용 preset은 별도 호환 gateway를 사용하는 기존 UI 라우팅이다.
Agent Factory의 사용자별 권한 검증에는 반드시 백오피스 MCP 주소와 직원 토큰을 사용한다.

View File

@@ -10,8 +10,6 @@ with the Bearer token entered on the screen.
from __future__ import annotations from __future__ import annotations
import base64
import binascii
import hashlib import hashlib
import hmac import hmac
import html import html
@@ -91,9 +89,8 @@ DEFAULT_VPD_USER_ID = "E1001"
VPD_OPERATIONS_URL = "https://hmm-backoffice.cloud-handson.com/" VPD_OPERATIONS_URL = "https://hmm-backoffice.cloud-handson.com/"
PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated" PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated"
PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user"
PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed" PORTAL_AUTH_PROXY_USER_HEADER = "X-HMM-Authenticated-User"
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember" PORTAL_AUTH_PROXY_EXPIRY_HEADER = "X-HMM-Auth-Expires"
PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
AUDIT_DB_ENV_FILE = Path( AUDIT_DB_ENV_FILE = Path(
os.environ.get("POC4_AUDIT_DB_ENV_FILE", str(ENV_FILE)) os.environ.get("POC4_AUDIT_DB_ENV_FILE", str(ENV_FILE))
).expanduser() ).expanduser()
@@ -1914,150 +1911,48 @@ def _portal_auth_value(name: str) -> str:
return (os.environ.get(name) or _dotenv_value(name)).strip() return (os.environ.get(name) or _dotenv_value(name)).strip()
def _portal_password_matches(password: str, encoded_password: str) -> bool: def _proxy_auth_headers() -> tuple[str, int]:
try: try:
scheme, iterations_text, salt_hex, expected_hex = encoded_password.split("$", 3) headers = st.context.headers
iterations = int(iterations_text) username = str(headers.get(PORTAL_AUTH_PROXY_USER_HEADER) or "").strip()
salt = bytes.fromhex(salt_hex) expires_at = int(
expected = bytes.fromhex(expected_hex) str(headers.get(PORTAL_AUTH_PROXY_EXPIRY_HEADER) or "0").strip()
except (TypeError, ValueError):
return False
if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000:
return False
candidate = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
) )
return hmac.compare_digest(candidate, expected) except (AttributeError, TypeError, ValueError):
return "", 0
return username, expires_at
def _portal_auth_configured() -> bool: def _restore_portal_proxy_session() -> None:
return bool( username, expires_at = _proxy_auth_headers()
_portal_auth_value("POC4_LOGIN_USER") expected_username = _portal_auth_value("POC4_LOGIN_USER")
and _portal_auth_value("POC4_LOGIN_PASSWORD_PBKDF2") authenticated = bool(
username
and expected_username
and expires_at > int(datetime.now(timezone.utc).timestamp())
and hmac.compare_digest(username, expected_username)
) )
if not authenticated:
st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None)
def _portal_credentials_are_valid(username: str, password: str) -> bool: st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
expected_username = _portal_auth_value("POC4_LOGIN_USER")
encoded_password = _portal_auth_value("POC4_LOGIN_PASSWORD_PBKDF2")
username_matches = hmac.compare_digest(username.strip(), expected_username)
password_matches = _portal_password_matches(password, encoded_password)
return username_matches and password_matches
def _portal_remember_secret() -> str:
return _portal_auth_value("POC4_LOGIN_REMEMBER_SECRET")
def _portal_remember_token(username: str) -> str:
secret = _portal_remember_secret()
if not secret:
return ""
payload = {
"v": 1,
"u": username.strip(),
"e": int(datetime.now(timezone.utc).timestamp()) + PORTAL_REMEMBER_MAX_AGE_SECONDS,
}
encoded = base64.urlsafe_b64encode(
json.dumps(payload, separators=(",", ":")).encode("utf-8")
).decode("ascii").rstrip("=")
signature = hmac.new(
secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256
).hexdigest()
return f"{encoded}.{signature}"
def _restore_portal_remembered_session() -> None:
if st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
return
token = st.query_params.get(PORTAL_REMEMBER_TOKEN_PARAM, "")
if not isinstance(token, str) or not token or len(token) > 2048:
return
secret = _portal_remember_secret()
expected_username = _portal_auth_value("POC4_LOGIN_USER")
try:
encoded, supplied_signature = token.split(".", 1)
expected_signature = hmac.new(
secret.encode("utf-8"), encoded.encode("ascii"), hashlib.sha256
).hexdigest()
padded = encoded + "=" * (-len(encoded) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
expires_at = int(payload["e"])
username = str(payload["u"])
except (binascii.Error, KeyError, TypeError, ValueError, UnicodeDecodeError):
return
if not secret or not hmac.compare_digest(supplied_signature, expected_signature):
return
if expires_at < int(datetime.now(timezone.utc).timestamp()):
return
if not hmac.compare_digest(username, expected_username):
return return
st.session_state[PORTAL_AUTHENTICATED_KEY] = True st.session_state[PORTAL_AUTHENTICATED_KEY] = True
st.session_state[PORTAL_AUTH_USER_KEY] = username st.session_state[PORTAL_AUTH_USER_KEY] = username
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False
def _clear_portal_remembered_session() -> None:
if PORTAL_REMEMBER_TOKEN_PARAM in st.query_params:
del st.query_params[PORTAL_REMEMBER_TOKEN_PARAM]
def _render_portal_login(profile: AppProfile) -> None: def _render_portal_login(profile: AppProfile) -> None:
with st.container(key="console_login_container"): with st.container(key="console_login_container"):
render_login_brand(st, profile) render_login_brand(st, profile)
if not _portal_auth_configured(): st.error(
st.info("데모 계정 설정 중입니다. 운영 담당자에게 계정 발급을 요청해 주세요.") "인증 게이트웨이의 사용자 확인 정보가 없습니다. "
return "공식 포털 주소로 다시 접속해 주세요."
with st.form("poc4_portal_login_form", clear_on_submit=True):
username = st.text_input(
"사용자 ID",
max_chars=80,
placeholder="사용자 ID를 입력하세요.",
) )
password = st.text_input(
"비밀번호",
type="password",
max_chars=200,
placeholder="비밀번호를 입력하세요.",
)
remember_login = st.checkbox(
"로그인 유지 (7일)",
disabled=not bool(_portal_remember_secret()),
help="이 브라우저에서 7일 동안 로그인 상태를 유지합니다.",
)
submitted = st.form_submit_button("로그인", use_container_width=True)
if submitted:
if _portal_credentials_are_valid(username, password):
st.session_state[PORTAL_AUTHENTICATED_KEY] = True
st.session_state[PORTAL_AUTH_USER_KEY] = username.strip()
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False
if remember_login:
token = _portal_remember_token(username)
if token:
st.query_params[PORTAL_REMEMBER_TOKEN_PARAM] = token
else:
_clear_portal_remembered_session()
st.rerun()
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = True
if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False):
st.error("사용자 ID 또는 비밀번호를 확인해 주세요.")
st.markdown( st.markdown(
f'<p class="console-muted">{html.escape(profile.login_footer)}</p>', f'<p class="console-muted">{html.escape(profile.login_footer)}</p>',
unsafe_allow_html=True, unsafe_allow_html=True,
) )
def _logout_portal() -> None:
_clear_portal_remembered_session()
st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None)
st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None)
st.rerun()
def _render_app_header(profile: AppProfile) -> None: def _render_app_header(profile: AppProfile) -> None:
render_console_header(st, profile) render_console_header(st, profile)
@@ -6750,7 +6645,7 @@ def main() -> None:
page_title=profile.page_title, page_icon=profile.page_icon, layout="wide" page_title=profile.page_title, page_icon=profile.page_icon, layout="wide"
) )
_apply_console_theme(profile) _apply_console_theme(profile)
_restore_portal_remembered_session() _restore_portal_proxy_session()
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False): if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
_render_portal_login(profile) _render_portal_login(profile)
return return
@@ -6822,8 +6717,11 @@ def main() -> None:
st.caption( st.caption(
f"포털 사용자 · {st.session_state.get(PORTAL_AUTH_USER_KEY, '')}" f"포털 사용자 · {st.session_state.get(PORTAL_AUTH_USER_KEY, '')}"
) )
if st.button("로그아웃", use_container_width=True): st.markdown(
_logout_portal() '<a class="console-logout-button" href="/auth/logout" '
'target="_self">로그아웃</a>',
unsafe_allow_html=True,
)
st.divider() st.divider()
st.markdown('<div class="kb-panel-title">AI 사용자 설정</div>', unsafe_allow_html=True) st.markdown('<div class="kb-panel-title">AI 사용자 설정</div>', unsafe_allow_html=True)
selected_query_model_profile = st.selectbox( selected_query_model_profile = st.selectbox(

View File

@@ -0,0 +1,41 @@
location = /auth/check {
internal;
proxy_pass http://127.0.0.1:8621/auth/check;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Real-IP $remote_addr;
}
location /auth/ {
proxy_pass http://127.0.0.1:8621;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location @hmm_portal_login {
return 302 /auth/login;
}
location / {
auth_request /auth/check;
error_page 401 = @hmm_portal_login;
auth_request_set $hmm_auth_user $upstream_http_x_auth_user;
auth_request_set $hmm_auth_expires $upstream_http_x_auth_expires;
proxy_pass http://127.0.0.1:8622;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-HMM-Authenticated-User $hmm_auth_user;
proxy_set_header X-HMM-Auth-Expires $hmm_auth_expires;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300;
proxy_send_timeout 300;
}

View File

@@ -0,0 +1,29 @@
[Unit]
Description=HMM Portal HttpOnly Cookie Authentication
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=opc
Group=opc
WorkingDirectory=/opt/hmm-poc4
Environment=PYTHONUNBUFFERED=1
EnvironmentFile=/opt/hmm-poc4/.env
ExecStart=/opt/hmm-poc4/.venv/bin/python -m src.agent_console.auth_gateway
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_INET6
UMask=0077
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,549 @@
"""Small localhost authentication service for the HMM Streamlit portal.
Nginx owns the public security boundary. This module validates the existing
PBKDF2 login, issues a signed HttpOnly cookie, and answers Nginx auth_request
subrequests. Authentication values are never accepted from a URL.
"""
from __future__ import annotations
import base64
import binascii
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import datetime, timezone
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import hashlib
import hmac
import html
import json
import logging
import os
import secrets
import threading
import time
from typing import Deque
from urllib.parse import parse_qs, urlsplit
LOG = logging.getLogger("hmm_portal_auth")
SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION"
CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF"
SESSION_TOKEN_VERSION = 2
MAX_REQUEST_BYTES = 8_192
MAX_FIELD_CHARS = 200
@dataclass(frozen=True)
class AuthConfig:
username: str
password_pbkdf2: str
cookie_secret: str
bind_address: str = "127.0.0.1"
port: int = 8621
session_seconds: int = 12 * 60 * 60
remember_seconds: int = 7 * 24 * 60 * 60
product_name: str = "HMM AI 업무 에이전트"
login_title: str = "HMM AI 업무 에이전트"
login_description: str = "사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다."
login_footer: str = "승인된 사용자만 접속할 수 있습니다."
primary_color: str = "#004b87"
@classmethod
def from_environment(cls) -> "AuthConfig":
config = cls(
username=os.environ.get("POC4_LOGIN_USER", "").strip(),
password_pbkdf2=os.environ.get(
"POC4_LOGIN_PASSWORD_PBKDF2", ""
).strip(),
cookie_secret=os.environ.get(
"POC4_LOGIN_COOKIE_SECRET", ""
).strip(),
bind_address=os.environ.get(
"PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1"
).strip(),
port=int(os.environ.get("PORTAL_AUTH_PORT", "8621")),
session_seconds=int(
os.environ.get("PORTAL_AUTH_SESSION_SECONDS", str(12 * 60 * 60))
),
remember_seconds=int(
os.environ.get(
"PORTAL_AUTH_REMEMBER_SECONDS", str(7 * 24 * 60 * 60)
)
),
product_name=os.environ.get(
"AGENT_CONSOLE_NAME", "HMM AI 업무 에이전트"
).strip(),
login_title=os.environ.get(
"AGENT_CONSOLE_LOGIN_TITLE", "HMM AI 업무 에이전트"
).strip(),
login_description=os.environ.get(
"AGENT_CONSOLE_LOGIN_DESCRIPTION",
"사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다.",
).strip(),
login_footer=os.environ.get(
"AGENT_CONSOLE_LOGIN_FOOTER",
"승인된 사용자만 접속할 수 있습니다.",
).strip(),
primary_color=os.environ.get(
"AGENT_CONSOLE_PRIMARY_COLOR", "#004b87"
).strip(),
)
config.validate()
return config
def validate(self) -> None:
if not self.username or not self.password_pbkdf2:
raise ValueError("POC4 portal login credentials are not configured")
if len(self.cookie_secret.encode("utf-8")) < 32:
raise ValueError("POC4_LOGIN_COOKIE_SECRET must be at least 32 bytes")
if self.bind_address not in {"127.0.0.1", "::1"}:
raise ValueError("Portal authentication service must bind to loopback")
if not 1 <= self.port <= 65535:
raise ValueError("PORTAL_AUTH_PORT is invalid")
if not 300 <= self.session_seconds <= 24 * 60 * 60:
raise ValueError("PORTAL_AUTH_SESSION_SECONDS is outside the safe range")
if not self.session_seconds <= self.remember_seconds <= 30 * 24 * 60 * 60:
raise ValueError("PORTAL_AUTH_REMEMBER_SECONDS is outside the safe range")
@dataclass(frozen=True)
class AuthenticatedSession:
username: str
expires_at: int
class SessionTokenCodec:
def __init__(self, secret: str):
self._secret = secret.encode("utf-8")
def issue(self, username: str, lifetime_seconds: int, now: int | None = None) -> str:
issued_at = int(time.time()) if now is None else now
payload = {
"v": SESSION_TOKEN_VERSION,
"u": username,
"i": issued_at,
"e": issued_at + lifetime_seconds,
"n": secrets.token_urlsafe(18),
}
encoded = _base64url_encode(
json.dumps(payload, separators=(",", ":")).encode("utf-8")
)
signature = hmac.new(
self._secret, encoded.encode("ascii"), hashlib.sha256
).hexdigest()
return f"{encoded}.{signature}"
def verify(self, token: str, expected_username: str, now: int | None = None) -> AuthenticatedSession | None:
if not token or len(token) > 2048:
return None
current_time = int(time.time()) if now is None else now
try:
encoded, supplied_signature = token.split(".", 1)
expected_signature = hmac.new(
self._secret, encoded.encode("ascii"), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(supplied_signature, expected_signature):
return None
payload = json.loads(_base64url_decode(encoded).decode("utf-8"))
version = int(payload["v"])
username = str(payload["u"])
issued_at = int(payload["i"])
expires_at = int(payload["e"])
except (
binascii.Error,
KeyError,
TypeError,
ValueError,
UnicodeDecodeError,
json.JSONDecodeError,
):
return None
if version != SESSION_TOKEN_VERSION:
return None
if issued_at > current_time + 30 or expires_at <= current_time:
return None
if expires_at - issued_at > 30 * 24 * 60 * 60:
return None
if not hmac.compare_digest(username, expected_username):
return None
return AuthenticatedSession(username=username, expires_at=expires_at)
class LoginAttemptLimiter:
def __init__(self, maximum_failures: int = 5, window_seconds: int = 300):
self._maximum_failures = maximum_failures
self._window_seconds = window_seconds
self._failures: dict[str, Deque[float]] = defaultdict(deque)
self._lock = threading.Lock()
def blocked(self, key: str, now: float | None = None) -> bool:
current_time = time.monotonic() if now is None else now
with self._lock:
failures = self._failures[key]
self._prune(failures, current_time)
return len(failures) >= self._maximum_failures
def record_failure(self, key: str, now: float | None = None) -> None:
current_time = time.monotonic() if now is None else now
with self._lock:
failures = self._failures[key]
self._prune(failures, current_time)
failures.append(current_time)
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
def _prune(self, failures: Deque[float], now: float) -> None:
cutoff = now - self._window_seconds
while failures and failures[0] < cutoff:
failures.popleft()
def password_matches(password: str, encoded_password: str) -> bool:
try:
scheme, iterations_text, salt_hex, expected_hex = encoded_password.split(
"$", 3
)
iterations = int(iterations_text)
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(expected_hex)
except (TypeError, ValueError):
return False
if scheme != "pbkdf2_sha256" or not 100_000 <= iterations <= 2_000_000:
return False
candidate = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, iterations
)
return hmac.compare_digest(candidate, expected)
def session_cookie_header(token: str, max_age: int | None) -> str:
attributes = [
f"{SESSION_COOKIE_NAME}={token}",
"Path=/",
"Secure",
"HttpOnly",
"SameSite=Lax",
]
if max_age is not None:
attributes.append(f"Max-Age={max_age}")
return "; ".join(attributes)
def clear_session_cookie_header() -> str:
return (
f"{SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; "
"Secure; HttpOnly; SameSite=Lax"
)
def csrf_cookie_header(value: str, max_age: int = 600) -> str:
return (
f"{CSRF_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; "
"Secure; HttpOnly; SameSite=Strict"
)
def clear_csrf_cookie_header() -> str:
return (
f"{CSRF_COOKIE_NAME}=; Path=/; Max-Age=0; "
"Secure; HttpOnly; SameSite=Strict"
)
def _base64url_encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def _base64url_decode(value: str) -> bytes:
padded = value + "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(padded)
def _cookie_value(cookie_header: str, name: str) -> str:
try:
cookies = SimpleCookie()
cookies.load(cookie_header)
morsel = cookies.get(name)
return morsel.value if morsel is not None else ""
except (KeyError, TypeError):
return ""
def _login_page(config: AuthConfig, csrf_value: str, error: str = "") -> bytes:
error_html = (
f'<div class="error" role="alert">{html.escape(error)}</div>'
if error
else ""
)
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(config.product_name)}</title>
<style>
:root {{ --primary:{html.escape(config.primary_color)}; --text:#17232d;
--muted:#60717f; --border:#d9e0e5; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#fff; color:var(--text);
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
main {{ width:min(420px,calc(100% - 40px)); margin:12vh auto 0; }}
.wordmark {{ color:var(--primary); font-size:1.25rem; font-weight:800;
letter-spacing:.08em; }}
h1 {{ margin:16px 0 8px; font-size:1.75rem; }}
.description,.footer {{ color:var(--muted); line-height:1.55; }}
form {{ margin-top:28px; }}
label {{ display:block; margin:0 0 18px; font-weight:700; }}
input[type="text"],input[type="password"] {{ width:100%; margin-top:8px;
padding:12px 13px; border:1px solid var(--border); border-radius:5px;
font:inherit; color:var(--text); background:#fff; }}
.remember {{ display:flex; align-items:center; gap:8px; font-weight:500; }}
.remember input {{ width:17px; height:17px; }}
button {{ width:100%; padding:12px; border:1px solid var(--primary);
border-radius:5px; background:var(--primary); color:#fff; font:inherit;
font-weight:800; cursor:pointer; }}
.error {{ margin:18px 0 0; padding:11px 12px; border:1px solid #d99898;
border-radius:5px; color:#8a2222; background:#fff7f7; }}
.footer {{ margin-top:22px; font-size:.9rem; }}
</style>
</head>
<body>
<main>
<div class="wordmark">HMM</div>
<h1>{html.escape(config.login_title)}</h1>
<p class="description">{html.escape(config.login_description)}</p>
{error_html}
<form action="/auth/login" method="post" autocomplete="on">
<input type="hidden" name="csrf" value="{html.escape(csrf_value)}">
<label>사용자 ID
<input name="username" type="text" maxlength="80" autocomplete="username"
required autofocus>
</label>
<label>비밀번호
<input name="password" type="password" maxlength="200"
autocomplete="current-password" required>
</label>
<label class="remember">
<input name="remember" type="checkbox" value="yes"> 로그인 유지 (7일)
</label>
<button type="submit">로그인</button>
</form>
<p class="footer">{html.escape(config.login_footer)}</p>
</main>
</body>
</html>""".encode("utf-8")
def build_handler(config: AuthConfig) -> type[BaseHTTPRequestHandler]:
codec = SessionTokenCodec(config.cookie_secret)
limiter = LoginAttemptLimiter()
class PortalAuthHandler(BaseHTTPRequestHandler):
server_version = "HMMPortalAuth/1.0"
sys_version = ""
def do_HEAD(self) -> None:
self._route(send_body=False)
def do_GET(self) -> None:
self._route(send_body=True)
def do_POST(self) -> None:
path = urlsplit(self.path).path
if path == "/auth/login":
self._login()
elif path == "/auth/logout":
self._logout()
else:
self._send_text(HTTPStatus.NOT_FOUND, "Not found")
def _route(self, send_body: bool) -> None:
path = urlsplit(self.path).path
if path == "/auth/check":
self._check()
elif path == "/auth/login":
self._show_login(send_body=send_body)
elif path == "/auth/logout":
self._logout()
elif path == "/auth/healthz":
self._send_text(HTTPStatus.OK, "ok", send_body=send_body)
else:
self._send_text(HTTPStatus.NOT_FOUND, "Not found", send_body=send_body)
def _check(self) -> None:
session = self._session()
if session is None:
self._send_empty(HTTPStatus.UNAUTHORIZED)
return
self.send_response(HTTPStatus.NO_CONTENT)
self._security_headers()
self.send_header("X-Auth-User", session.username)
self.send_header("X-Auth-Expires", str(session.expires_at))
self.end_headers()
def _show_login(self, send_body: bool = True, error: str = "") -> None:
if self._session() is not None and not error:
self._redirect("/")
return
csrf_value = secrets.token_urlsafe(32)
body = _login_page(config, csrf_value, error)
self.send_response(HTTPStatus.OK)
self._security_headers()
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Set-Cookie", csrf_cookie_header(csrf_value))
self.end_headers()
if send_body:
self.wfile.write(body)
def _login(self) -> None:
client_key = self._client_key()
if limiter.blocked(client_key):
self._show_login(error="로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해 주세요.")
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
content_length = 0
if not 1 <= content_length <= MAX_REQUEST_BYTES:
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
return
raw_body = self.rfile.read(content_length)
try:
form = parse_qs(
raw_body.decode("utf-8"),
keep_blank_values=True,
strict_parsing=False,
max_num_fields=8,
)
except (UnicodeDecodeError, ValueError):
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
return
username = _form_value(form, "username")
password = _form_value(form, "password")
csrf_form = _form_value(form, "csrf")
csrf_cookie = _cookie_value(
self.headers.get("Cookie", ""), CSRF_COOKIE_NAME
)
if (
not csrf_form
or not csrf_cookie
or not hmac.compare_digest(csrf_form, csrf_cookie)
):
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
return
valid_credentials = (
len(username) <= 80
and len(password) <= MAX_FIELD_CHARS
and hmac.compare_digest(username.strip(), config.username)
and password_matches(password, config.password_pbkdf2)
)
if not valid_credentials:
limiter.record_failure(client_key)
self._show_login(error="사용자 ID 또는 비밀번호를 확인해 주세요.")
return
limiter.reset(client_key)
remember = _form_value(form, "remember") == "yes"
lifetime = (
config.remember_seconds if remember else config.session_seconds
)
token = codec.issue(config.username, lifetime)
self.send_response(HTTPStatus.SEE_OTHER)
self._security_headers()
self.send_header("Location", "/")
self.send_header(
"Set-Cookie",
session_cookie_header(token, lifetime if remember else None),
)
self.send_header("Set-Cookie", clear_csrf_cookie_header())
self.end_headers()
def _logout(self) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self._security_headers()
self.send_header("Location", "/auth/login")
self.send_header("Set-Cookie", clear_session_cookie_header())
self.send_header("Set-Cookie", clear_csrf_cookie_header())
self.end_headers()
def _session(self) -> AuthenticatedSession | None:
token = _cookie_value(
self.headers.get("Cookie", ""), SESSION_COOKIE_NAME
)
return codec.verify(token, config.username)
def _client_key(self) -> str:
forwarded = self.headers.get("X-Real-IP", "").strip()
return forwarded or self.client_address[0]
def _redirect(self, location: str) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self._security_headers()
self.send_header("Location", location)
self.end_headers()
def _send_empty(self, status: HTTPStatus) -> None:
self.send_response(status)
self._security_headers()
self.send_header("Content-Length", "0")
self.end_headers()
def _send_text(
self,
status: HTTPStatus,
message: str,
send_body: bool = True,
) -> None:
body = message.encode("utf-8")
self.send_response(status)
self._security_headers()
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if send_body:
self.wfile.write(body)
def _security_headers(self) -> None:
self.send_header("Cache-Control", "no-store")
self.send_header("Pragma", "no-cache")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; "
"form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
)
def log_message(self, _format: str, *args: object) -> None:
# Do not log query strings, cookies, form bodies, or tokens.
LOG.info("%s %s", self.command, urlsplit(self.path).path)
return PortalAuthHandler
def _form_value(form: dict[str, list[str]], name: str) -> str:
values = form.get(name)
return values[0] if values else ""
def main() -> None:
logging.basicConfig(
level=os.environ.get("PORTAL_AUTH_LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
config = AuthConfig.from_environment()
server = ThreadingHTTPServer(
(config.bind_address, config.port), build_handler(config)
)
LOG.info("HMM portal authentication service listening on loopback port %s", config.port)
server.serve_forever()
if __name__ == "__main__":
main()

View File

@@ -94,6 +94,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
.st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }} .st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }}
.console-login {{ text-align:left; }} .console-login {{ text-align:left; }}
.console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }} .console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }}
a.console-logout-button {{ display:block; width:100%; padding:.55rem .8rem;
margin:.25rem 0 .75rem; background:#fff; color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important;
border:1px solid var(--console-border); border-radius:4px;
text-align:center; text-decoration:none; font-weight:700; }}
a.console-logout-button:hover {{ background:#f6f8fa; }}
@media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }} @media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }}
</style> </style>
""", """,

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import hashlib
import http.client
import os
from pathlib import Path
import sys
import threading
import unittest
from urllib.parse import urlencode
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.agent_console.auth_gateway import ( # noqa: E402
AuthConfig,
LoginAttemptLimiter,
SESSION_COOKIE_NAME,
SessionTokenCodec,
build_handler,
clear_session_cookie_header,
password_matches,
session_cookie_header,
)
class AuthGatewayTest(unittest.TestCase):
def setUp(self) -> None:
self.secret = "s" * 48
self.codec = SessionTokenCodec(self.secret)
def test_session_token_round_trip_and_tamper_rejection(self) -> None:
token = self.codec.issue("demo-admin", 3600, now=1_000)
session = self.codec.verify(token, "demo-admin", now=1_001)
self.assertIsNotNone(session)
self.assertEqual("demo-admin", session.username)
self.assertEqual(4_600, session.expires_at)
self.assertIsNone(self.codec.verify(token + "x", "demo-admin", now=1_001))
self.assertIsNone(self.codec.verify(token, "other-user", now=1_001))
def test_expired_session_token_is_rejected(self) -> None:
token = self.codec.issue("demo-admin", 300, now=1_000)
self.assertIsNone(self.codec.verify(token, "demo-admin", now=1_300))
def test_remember_cookie_has_required_security_attributes(self) -> None:
header = session_cookie_header("signed-value", 604_800)
self.assertIn(f"{SESSION_COOKIE_NAME}=signed-value", header)
self.assertIn("Path=/", header)
self.assertIn("Secure", header)
self.assertIn("HttpOnly", header)
self.assertIn("SameSite=Lax", header)
self.assertIn("Max-Age=604800", header)
self.assertNotIn("Domain=", header)
def test_session_cookie_omits_persistent_max_age(self) -> None:
header = session_cookie_header("signed-value", None)
self.assertNotIn("Max-Age", header)
self.assertIn("HttpOnly", header)
def test_logout_cookie_expires_immediately(self) -> None:
header = clear_session_cookie_header()
self.assertIn("Max-Age=0", header)
self.assertIn("Secure", header)
self.assertIn("HttpOnly", header)
def test_pbkdf2_password_verification(self) -> None:
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
expected = hashlib.pbkdf2_hmac(
"sha256", b"correct-password", salt, 200_000
).hex()
encoded = f"pbkdf2_sha256$200000${salt.hex()}${expected}"
self.assertTrue(password_matches("correct-password", encoded))
self.assertFalse(password_matches("wrong-password", encoded))
def test_rate_limiter_blocks_only_after_threshold(self) -> None:
limiter = LoginAttemptLimiter(maximum_failures=2, window_seconds=10)
limiter.record_failure("client", now=1)
self.assertFalse(limiter.blocked("client", now=2))
limiter.record_failure("client", now=3)
self.assertTrue(limiter.blocked("client", now=4))
self.assertFalse(limiter.blocked("client", now=20))
def test_environment_config_requires_new_cookie_secret(self) -> None:
previous = dict(os.environ)
try:
os.environ["POC4_LOGIN_USER"] = "demo-admin"
os.environ["POC4_LOGIN_PASSWORD_PBKDF2"] = "encoded"
os.environ.pop("POC4_LOGIN_COOKIE_SECRET", None)
with self.assertRaisesRegex(ValueError, "COOKIE_SECRET"):
AuthConfig.from_environment()
finally:
os.environ.clear()
os.environ.update(previous)
def test_http_login_check_and_logout_flow_never_uses_url_token(self) -> None:
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
expected = hashlib.pbkdf2_hmac(
"sha256", b"correct-password", salt, 200_000
).hex()
config = AuthConfig(
username="demo-admin",
password_pbkdf2=(
f"pbkdf2_sha256$200000${salt.hex()}${expected}"
),
cookie_secret=self.secret,
port=8621,
)
from http.server import ThreadingHTTPServer
server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(config))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
connection = http.client.HTTPConnection(
"127.0.0.1", server.server_address[1], timeout=3
)
try:
connection.request("GET", "/auth/login")
login_page = connection.getresponse()
body = login_page.read().decode("utf-8")
self.assertEqual(200, login_page.status)
csrf_header = next(
value
for name, value in login_page.getheaders()
if name.lower() == "set-cookie"
and value.startswith("__Host-HMM_LOGIN_CSRF=")
)
csrf_value = csrf_header.split("=", 1)[1].split(";", 1)[0]
self.assertIn(
f'name="csrf" value="{csrf_value}"',
body,
)
payload = urlencode(
{
"csrf": csrf_value,
"username": "demo-admin",
"password": "correct-password",
"remember": "yes",
}
)
connection.request(
"POST",
"/auth/login",
body=payload,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": f"__Host-HMM_LOGIN_CSRF={csrf_value}",
},
)
logged_in = connection.getresponse()
logged_in.read()
self.assertEqual(303, logged_in.status)
self.assertEqual("/", logged_in.getheader("Location"))
self.assertNotRegex(logged_in.getheader("Location"), r"token|remember")
session_header = next(
value
for name, value in logged_in.getheaders()
if name.lower() == "set-cookie"
and value.startswith(f"{SESSION_COOKIE_NAME}=")
)
session_value = session_header.split("=", 1)[1].split(";", 1)[0]
self.assertIn("Secure", session_header)
self.assertIn("HttpOnly", session_header)
self.assertIn("SameSite=Lax", session_header)
connection.request(
"GET",
"/auth/check",
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
)
check = connection.getresponse()
check.read()
self.assertEqual(204, check.status)
self.assertEqual("demo-admin", check.getheader("X-Auth-User"))
connection.request(
"GET",
"/auth/check",
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}x"},
)
tampered = connection.getresponse()
tampered.read()
self.assertEqual(401, tampered.status)
connection.request(
"GET",
"/auth/logout",
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
)
logout = connection.getresponse()
logout.read()
self.assertEqual(303, logout.status)
self.assertEqual("/auth/login", logout.getheader("Location"))
self.assertTrue(
any(
name.lower() == "set-cookie" and "Max-Age=0" in value
for name, value in logout.getheaders()
)
)
finally:
connection.close()
server.shutdown()
server.server_close()
thread.join(timeout=3)
if __name__ == "__main__":
unittest.main()

View File

@@ -7,5 +7,5 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
@FunctionalInterface @FunctionalInterface
public interface HmmAiAgentToolRunner { public interface HmmAiAgentToolRunner {
JsonNode run(String toolName, ObjectNode input); JsonNode run(String toolName, ObjectNode input, String bearerToken);
} }

View File

@@ -0,0 +1,8 @@
package com.cloudhandson.vpdbackoffice.service;
/** Validates an HMM backoffice bearer token without retaining its plaintext value. */
@FunctionalInterface
public interface HmmMcpBearerAuthenticator {
HmmMcpPrincipal authenticate(String bearerToken);
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.service;
/** Authenticated HMM employee identity resolved from a one-way bearer-token hash. */
public record HmmMcpPrincipal(
long employeeId,
String employeeCode,
Long teamId
) {
}

View File

@@ -3,7 +3,11 @@ package com.cloudhandson.vpdbackoffice.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.TextNode; import com.fasterxml.jackson.databind.node.TextNode;
import java.sql.CallableStatement;
import java.sql.Clob; import java.sql.Clob;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -20,16 +24,45 @@ public class JdbcHmmAiAgentToolRunner implements HmmAiAgentToolRunner {
} }
@Override @Override
public JsonNode run(String toolName, com.fasterxml.jackson.databind.node.ObjectNode input) { public JsonNode run(
String toolName,
com.fasterxml.jackson.databind.node.ObjectNode input,
String bearerToken
) {
try { try {
String request = objectMapper.writeValueAsString(input); String request = objectMapper.writeValueAsString(input);
String response = jdbcTemplate.queryForObject(""" String response = jdbcTemplate.execute((ConnectionCallback<String>) connection -> {
boolean contextSet = false;
try {
try (CallableStatement statement = connection.prepareCall(
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;")) {
statement.setString(1, bearerToken);
statement.execute();
contextSet = true;
}
try (PreparedStatement statement = connection.prepareStatement("""
SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?)) SELECT DBMS_CLOUD_AI_AGENT.RUN_TOOL(?, TO_CLOB(?))
FROM dual FROM dual
""", (resultSet, rowNum) -> { """)) {
statement.setString(1, toolName);
statement.setString(2, request);
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
return "";
}
Clob clob = resultSet.getClob(1); Clob clob = resultSet.getClob(1);
return clob == null ? "" : clob.getSubString(1, (int) clob.length()); return clob == null ? "" : clob.getSubString(1, (int) clob.length());
}, toolName, request); }
}
} finally {
if (contextSet) {
try (CallableStatement statement = connection.prepareCall(
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;")) {
statement.execute();
}
}
}
});
if (response == null || response.isBlank()) { if (response == null || response.isBlank()) {
return objectMapper.createObjectNode(); return objectMapper.createObjectNode();
} }

View File

@@ -0,0 +1,45 @@
package com.cloudhandson.vpdbackoffice.service;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/** Resolves active HMM employee tokens using only their SHA-256 hashes in ADB. */
@Service
public class JdbcHmmMcpBearerAuthenticator implements HmmMcpBearerAuthenticator {
private static final int MAX_TOKEN_LENGTH = 4096;
private final JdbcTemplate jdbcTemplate;
public JdbcHmmMcpBearerAuthenticator(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public HmmMcpPrincipal authenticate(String bearerToken) {
if (bearerToken == null || bearerToken.isBlank() || bearerToken.length() > MAX_TOKEN_LENGTH) {
throw new McpUnauthorizedException();
}
try {
return jdbcTemplate.queryForObject("""
SELECT employee.employee_id,
employee.employee_code,
employee.team_id
FROM hmm_access_bearer_tokens token
JOIN hmm_hr_employees employee
ON employee.employee_id = token.employee_id
WHERE token.key_hash = STANDARD_HASH(?, 'SHA256')
AND token.revoked_at IS NULL
AND token.expires_at > CAST(SYSTIMESTAMP AS TIMESTAMP)
AND employee.employment_status = 'ACTIVE'
""", (resultSet, rowNum) -> new HmmMcpPrincipal(
resultSet.getLong("employee_id"),
resultSet.getString("employee_code"),
resultSet.getObject("team_id", Long.class)
), bearerToken);
} catch (EmptyResultDataAccessException exception) {
throw new McpUnauthorizedException();
}
}
}

View File

@@ -37,10 +37,16 @@ public class McpSseService {
); );
private final HmmAiAgentToolRunner agentToolRunner; private final HmmAiAgentToolRunner agentToolRunner;
private final HmmMcpBearerAuthenticator bearerAuthenticator;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public McpSseService(HmmAiAgentToolRunner agentToolRunner, ObjectMapper objectMapper) { public McpSseService(
HmmAiAgentToolRunner agentToolRunner,
HmmMcpBearerAuthenticator bearerAuthenticator,
ObjectMapper objectMapper
) {
this.agentToolRunner = agentToolRunner; this.agentToolRunner = agentToolRunner;
this.bearerAuthenticator = bearerAuthenticator;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -48,11 +54,9 @@ public class McpSseService {
return handle(contextPath, request, ""); return handle(contextPath, request, "");
} }
/** /** Validates the user bearer before serving discovery or executing a tool. */
* Backoffice authentication protects this compatibility endpoint. The public public ObjectNode handle(String contextPath, JsonNode request, String bearerToken) {
* HMM MCP endpoint performs its own fixed Bearer-token validation in Nginx. bearerAuthenticator.authenticate(bearerToken);
*/
public ObjectNode handle(String contextPath, JsonNode request, String ignoredAuthorization) {
ObjectNode response = objectMapper.createObjectNode(); ObjectNode response = objectMapper.createObjectNode();
response.put("jsonrpc", "2.0"); response.put("jsonrpc", "2.0");
if (request != null && request.has("id")) { if (request != null && request.has("id")) {
@@ -66,7 +70,7 @@ public class McpSseService {
case "initialize" -> initializeResult(contextPath); case "initialize" -> initializeResult(contextPath);
case "notifications/initialized" -> objectMapper.createObjectNode(); case "notifications/initialized" -> objectMapper.createObjectNode();
case "tools/list" -> toolsListResult(); case "tools/list" -> toolsListResult();
case "tools/call" -> toolsCallResult(parameters); case "tools/call" -> toolsCallResult(parameters, bearerToken);
default -> throw new AppException("지원하지 않는 MCP method입니다: " + method); default -> throw new AppException("지원하지 않는 MCP method입니다: " + method);
}); });
} catch (Exception exception) { } catch (Exception exception) {
@@ -128,7 +132,7 @@ public class McpSseService {
return item; return item;
} }
private ObjectNode toolsCallResult(JsonNode params) { private ObjectNode toolsCallResult(JsonNode params, String bearerToken) {
String requestedName = params.path("name").asText(""); String requestedName = params.path("name").asText("");
ToolSpec tool = HMM_TOOLS.stream() ToolSpec tool = HMM_TOOLS.stream()
.filter(candidate -> candidate.name().equals(requestedName)) .filter(candidate -> candidate.name().equals(requestedName))
@@ -140,7 +144,7 @@ public class McpSseService {
} }
ObjectNode input = objectMapper.createObjectNode(); ObjectNode input = objectMapper.createObjectNode();
input.put(tool.agentParameterName(), argument); input.put(tool.agentParameterName(), argument);
JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input); JsonNode toolResponse = agentToolRunner.run(tool.agentToolName(), input, bearerToken);
ObjectNode payload = objectMapper.createObjectNode(); ObjectNode payload = objectMapper.createObjectNode();
payload.put("toolName", tool.name()); payload.put("toolName", tool.name());

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.vpdbackoffice.service;
/** Deliberately generic MCP authentication failure; never include token material. */
public class McpUnauthorizedException extends RuntimeException {
public McpUnauthorizedException() {
super("유효한 HMM 사용자 Bearer Token이 필요합니다.");
}
}

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.web; package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.service.McpSseService; import com.cloudhandson.vpdbackoffice.service.McpSseService;
import com.cloudhandson.vpdbackoffice.service.McpUnauthorizedException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException; import java.io.IOException;
@@ -40,12 +41,18 @@ public class McpSseController {
@RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization, @RequestHeader(name = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@RequestBody JsonNode request @RequestBody JsonNode request
) { ) {
try {
ObjectNode response = mcpSseService.handle(
"default", request, bearerToken(authorization));
// JSON-RPC notifications never receive a response body. Current MCP // JSON-RPC notifications never receive a response body. Current MCP
// clients send notifications/initialized immediately after initialize. // clients send notifications/initialized immediately after initialize.
if (request != null && !request.has("id")) { if (request != null && !request.has("id")) {
return ResponseEntity.accepted().build(); return ResponseEntity.accepted().build();
} }
return ResponseEntity.ok(mcpSseService.handle("default", request, bearerToken(authorization))); return ResponseEntity.ok(response);
} catch (McpUnauthorizedException exception) {
return ResponseEntity.status(401).build();
}
} }
@GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @GetMapping(path = "/mcp/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@@ -98,8 +105,13 @@ public class McpSseController {
JsonNode request JsonNode request
) throws IOException { ) throws IOException {
String normalizedContextPath = normalizeContextPath(contextPath); String normalizedContextPath = normalizeContextPath(contextPath);
ObjectNode response = mcpSseService.handle( ObjectNode response;
try {
response = mcpSseService.handle(
normalizedContextPath, request, bearerToken(authorization)); normalizedContextPath, request, bearerToken(authorization));
} catch (McpUnauthorizedException exception) {
return ResponseEntity.status(401).build();
}
if (sessionId == null || sessionId.isBlank()) { if (sessionId == null || sessionId.isBlank()) {
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }

View File

@@ -62,7 +62,7 @@ backoffice:
username: ${BACKOFFICE_ORDS_DB_USERNAME:} username: ${BACKOFFICE_ORDS_DB_USERNAME:}
password: ${BACKOFFICE_ORDS_DB_PASSWORD:} password: ${BACKOFFICE_ORDS_DB_PASSWORD:}
mcp: mcp:
public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-mcp.cloud-handson.com/mcp} public-url: ${BACKOFFICE_HMM_MCP_PUBLIC_URL:https://hmm-backoffice.cloud-handson.com/mcp}
ai: ai:
enabled: ${BACKOFFICE_AI_ENABLED:false} enabled: ${BACKOFFICE_AI_ENABLED:false}
provider: ${BACKOFFICE_AI_PROVIDER:openai} provider: ${BACKOFFICE_AI_PROVIDER:openai}

View File

@@ -22,7 +22,7 @@
<div class="mcp-service-grid"> <div class="mcp-service-grid">
<div class="mcp-service-item"> <div class="mcp-service-item">
<span>공개 MCP Endpoint</span> <span>공개 MCP Endpoint</span>
<strong><code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></strong> <strong><code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></strong>
<small>Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.</small> <small>Private Agent Factory와 외부 MCP client가 사용하는 Streamable HTTP Endpoint입니다.</small>
</div> </div>
<div class="mcp-service-item"> <div class="mcp-service-item">
@@ -47,7 +47,7 @@
<tbody> <tbody>
<tr> <tr>
<th>HMM MCP</th> <th>HMM MCP</th>
<td><code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></td> <td><code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></td>
</tr> </tr>
<tr> <tr>
<th>Transport</th> <th>Transport</th>

View File

@@ -31,7 +31,7 @@
Public MCP endpoint Public MCP endpoint
<input class="form-control" th:value="${hmmMcpPublicUrl}" readonly aria-readonly="true"> <input class="form-control" th:value="${hmmMcpPublicUrl}" readonly aria-readonly="true">
</label> </label>
<p class="form-help">Agent Factory 등록 주소: <code th:text="${hmmMcpPublicUrl}">https://hmm-mcp.cloud-handson.com/mcp</code></p> <p class="form-help">Agent Factory 등록 주소: <code th:text="${hmmMcpPublicUrl}">https://hmm-backoffice.cloud-handson.com/mcp</code></p>
</section> </section>
<section class="content-band"> <section class="content-band">

View File

@@ -0,0 +1,65 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.sql.CallableStatement;
import java.sql.Clob;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
class JdbcHmmAiAgentToolRunnerTest {
@Test
void setsAndClearsHMMContextOnTheSameConnection() throws Exception {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
Connection connection = mock(Connection.class);
CallableStatement setContext = mock(CallableStatement.class);
CallableStatement clearContext = mock(CallableStatement.class);
PreparedStatement runTool = mock(PreparedStatement.class);
ResultSet resultSet = mock(ResultSet.class);
Clob clob = mock(Clob.class);
when(jdbcTemplate.execute(any(ConnectionCallback.class))).thenAnswer(invocation -> {
ConnectionCallback<?> callback = invocation.getArgument(0);
return callback.doInConnection(connection);
});
when(connection.prepareCall(
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;"))
.thenReturn(setContext);
when(connection.prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;"))
.thenReturn(clearContext);
when(connection.prepareStatement(any(String.class))).thenReturn(runTool);
when(runTool.executeQuery()).thenReturn(resultSet);
when(resultSet.next()).thenReturn(true);
when(resultSet.getClob(1)).thenReturn(clob);
when(clob.length()).thenReturn(15L);
when(clob.getSubString(1, 15)).thenReturn("{\"status\":\"ok\"}");
var runner = new JdbcHmmAiAgentToolRunner(jdbcTemplate, new ObjectMapper());
var result = runner.run(
"HMM_HR_TERM_RESOLVER",
new ObjectMapper().createObjectNode().put("P_TERM", "annual leave"),
"opaque-user-token");
assertThat(result.path("status").asText()).isEqualTo("ok");
InOrder order = inOrder(connection, setContext, runTool, clearContext);
order.verify(connection).prepareCall(
"BEGIN ADMIN.HMM_ACCESS_CTX_PKG.SET_USER_BY_BEARER(?); END;");
order.verify(setContext).setString(1, "opaque-user-token");
order.verify(setContext).execute();
order.verify(connection).prepareStatement(any(String.class));
order.verify(runTool).executeQuery();
order.verify(connection).prepareCall("BEGIN ADMIN.HMM_ACCESS_CTX_PKG.CLEAR_USER; END;");
order.verify(clearContext).execute();
}
}

View File

@@ -1,6 +1,7 @@
package com.cloudhandson.vpdbackoffice.service; package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -11,11 +12,14 @@ class McpSseServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner(); private final CapturingHmmAiAgentToolRunner agentToolRunner = new CapturingHmmAiAgentToolRunner();
private final McpSseService service = new McpSseService(agentToolRunner, objectMapper); private final HmmMcpBearerAuthenticator bearerAuthenticator =
token -> new HmmMcpPrincipal(1L, "E1001", 1L);
private final McpSseService service =
new McpSseService(agentToolRunner, bearerAuthenticator, objectMapper);
@Test @Test
void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() { void listsHMMTermDataAndPolicyToolsWithTheirActualInputs() {
ObjectNode response = service.handle("default", request(1, "tools/list")); ObjectNode response = service.handle("default", request(1, "tools/list"), "valid-token");
var tools = response.path("result").path("tools"); var tools = response.path("result").path("tools");
assertThat(tools).hasSize(3); assertThat(tools).hasSize(3);
@@ -39,10 +43,11 @@ class McpSseServiceTest {
params.put("name", "resolve_hr_term"); params.put("name", "resolve_hr_term");
params.putObject("arguments").put("term", "연차 이월"); params.putObject("arguments").put("term", "연차 이월");
ObjectNode response = service.handle("default", request, "ignored-by-backoffice-session"); ObjectNode response = service.handle("default", request, "valid-token");
assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER"); assertThat(agentToolRunner.toolName).isEqualTo("HMM_HR_TERM_RESOLVER");
assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월"); assertThat(agentToolRunner.input.path("P_TERM").asText()).isEqualTo("연차 이월");
assertThat(agentToolRunner.bearerToken).isEqualTo("valid-token");
assertThat(response.path("error").isMissingNode()).isTrue(); assertThat(response.path("error").isMissingNode()).isTrue();
assertThat(response.path("result").path("isError").asBoolean()).isFalse(); assertThat(response.path("result").path("isError").asBoolean()).isFalse();
assertThat(response.path("result").path("content").get(0).path("text").asText()) assertThat(response.path("result").path("content").get(0).path("text").asText())
@@ -51,6 +56,20 @@ class McpSseServiceTest {
.contains("ANNUAL_LEAVE_CARRYOVER"); .contains("ANNUAL_LEAVE_CARRYOVER");
} }
@Test
void rejectsDiscoveryWhenBearerAuthenticationFails() {
McpSseService rejectingService = new McpSseService(
agentToolRunner,
token -> {
throw new McpUnauthorizedException();
},
objectMapper);
assertThatThrownBy(() ->
rejectingService.handle("default", request(4, "tools/list"), "invalid-token"))
.isInstanceOf(McpUnauthorizedException.class);
}
@Test @Test
void rejectsUnknownToolsWithoutCallingTheAgentRunner() { void rejectsUnknownToolsWithoutCallingTheAgentRunner() {
ObjectNode request = request(3, "tools/call"); ObjectNode request = request(3, "tools/call");
@@ -58,7 +77,7 @@ class McpSseServiceTest {
params.put("name", "ords.query.kb_select_ai_vpd"); params.put("name", "ords.query.kb_select_ai_vpd");
params.putObject("arguments").put("prompt", "legacy query"); params.putObject("arguments").put("prompt", "legacy query");
ObjectNode response = service.handle("default", request); ObjectNode response = service.handle("default", request, "valid-token");
assertThat(response.path("result").isMissingNode()).isTrue(); assertThat(response.path("result").isMissingNode()).isTrue();
assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool"); assertThat(response.path("error").path("message").asText()).contains("등록되지 않은 HMM MCP tool");
@@ -76,11 +95,17 @@ class McpSseServiceTest {
private String toolName; private String toolName;
private ObjectNode input; private ObjectNode input;
private String bearerToken;
@Override @Override
public JsonNode run(String requestedToolName, ObjectNode requestedInput) { public JsonNode run(
String requestedToolName,
ObjectNode requestedInput,
String bearerToken
) {
toolName = requestedToolName; toolName = requestedToolName;
input = requestedInput.deepCopy(); input = requestedInput.deepCopy();
this.bearerToken = bearerToken;
return objectMapper.createObjectNode() return objectMapper.createObjectNode()
.put("termCode", "ANNUAL_LEAVE_CARRYOVER") .put("termCode", "ANNUAL_LEAVE_CARRYOVER")
.put("termName", "연차 이월"); .put("termName", "연차 이월");

View File

@@ -24,14 +24,14 @@ class SettingsTemplateRenderTest {
var context = new Context(Locale.KOREAN); var context = new Context(Locale.KOREAN);
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token")); context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
context.setVariable("ordsBaseUrl", "https://ords.example.test/ords"); context.setVariable("ordsBaseUrl", "https://ords.example.test/ords");
context.setVariable("hmmMcpPublicUrl", "https://hmm-mcp.cloud-handson.com/mcp"); context.setVariable("hmmMcpPublicUrl", "https://hmm-backoffice.cloud-handson.com/mcp");
String connection = engine.process("settings", context); String connection = engine.process("settings", context);
String database = engine.process("settings-database", context); String database = engine.process("settings-database", context);
assertThat(connection) assertThat(connection)
.contains("HMM HR Agent 도구") .contains("HMM HR Agent 도구")
.contains("https://hmm-mcp.cloud-handson.com/mcp") .contains("https://hmm-backoffice.cloud-handson.com/mcp")
.contains("Legacy ORDS Base URL") .contains("Legacy ORDS Base URL")
.contains("/settings/database") .contains("/settings/database")
.doesNotContain("g329127dfd380ad-kbaipoc") .doesNotContain("g329127dfd380ad-kbaipoc")