From 50b23aa4ed08270ab5b1a4f0448de597b080e961 Mon Sep 17 00:00:00 2001 From: devmrko Date: Wed, 22 Jul 2026 10:33:16 +0900 Subject: [PATCH] feat(poc4): add remembered portal login --- .../HMM_BRAND_REFRESH.md | 1 + .../apps/poc4/mcp_discovery_ui.py | 77 ++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/poc4_active_source_20260714/HMM_BRAND_REFRESH.md b/poc4_active_source_20260714/HMM_BRAND_REFRESH.md index 28b4450..43f9ce2 100644 --- a/poc4_active_source_20260714/HMM_BRAND_REFRESH.md +++ b/poc4_active_source_20260714/HMM_BRAND_REFRESH.md @@ -5,3 +5,4 @@ - 공식 페이지의 `Connect Values Navigate Growth` 메시지와 해운·물류·디지털 솔루션 맥락을 반영한다. - 외부 KB 로고·전용 글꼴 의존성을 제거하고, 애플리케이션 내부 SVG 워드마크와 해양 청색 계열로 표시한다. - 기존 MCP, VPD, 데이터베이스 스키마 및 도구 계약은 변경하지 않는다. +- 로그인 유지 기능은 서버 비밀키로 서명한 7일 만료 토큰을 사용하며, 로그아웃 시 즉시 폐기한다. diff --git a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py index 1d7b746..16ec566 100644 --- a/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py +++ b/poc4_active_source_20260714/apps/poc4/mcp_discovery_ui.py @@ -11,6 +11,7 @@ with the Bearer token entered on the screen. from __future__ import annotations import base64 +import binascii import hashlib import hmac import html @@ -77,6 +78,8 @@ VPD_OPERATIONS_URL = "https://kb.cloud-handson.com/" PORTAL_AUTHENTICATED_KEY = "poc4_portal_authenticated" PORTAL_AUTH_USER_KEY = "poc4_portal_auth_user" PORTAL_LOGIN_FAILURE_KEY = "poc4_portal_login_failed" +PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember" +PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 AUDIT_SCHEMA = "POC_2" AUDIT_DB_ENV_FILE = Path( os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env") @@ -1722,6 +1725,63 @@ def _portal_credentials_are_valid(username: str, password: str) -> bool: 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 + st.session_state[PORTAL_AUTHENTICATED_KEY] = True + 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() -> None: logo_url = html.escape(_header_logo_url(), quote=True) with st.container(key="kb_login_container"): @@ -1751,12 +1811,23 @@ def _render_portal_login() -> None: 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): @@ -1770,6 +1841,7 @@ def _render_portal_login() -> None: 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) @@ -6765,8 +6837,11 @@ def _process_submitted_question( def main() -> None: - st.set_page_config(page_title="KB손해보험 AI 콘솔", page_icon="🟨", layout="wide") + st.set_page_config( + page_title="HMM AI Operations Console", page_icon="⛴️", layout="wide" + ) _apply_kb_theme() + _restore_portal_remembered_session() if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False): _render_portal_login() return