feat(poc4): add remembered portal login
This commit is contained in:
@@ -5,3 +5,4 @@
|
|||||||
- 공식 페이지의 `Connect Values Navigate Growth` 메시지와 해운·물류·디지털 솔루션 맥락을 반영한다.
|
- 공식 페이지의 `Connect Values Navigate Growth` 메시지와 해운·물류·디지털 솔루션 맥락을 반영한다.
|
||||||
- 외부 KB 로고·전용 글꼴 의존성을 제거하고, 애플리케이션 내부 SVG 워드마크와 해양 청색 계열로 표시한다.
|
- 외부 KB 로고·전용 글꼴 의존성을 제거하고, 애플리케이션 내부 SVG 워드마크와 해양 청색 계열로 표시한다.
|
||||||
- 기존 MCP, VPD, 데이터베이스 스키마 및 도구 계약은 변경하지 않는다.
|
- 기존 MCP, VPD, 데이터베이스 스키마 및 도구 계약은 변경하지 않는다.
|
||||||
|
- 로그인 유지 기능은 서버 비밀키로 서명한 7일 만료 토큰을 사용하며, 로그아웃 시 즉시 폐기한다.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ with the Bearer token entered on the screen.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import binascii
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import html
|
import html
|
||||||
@@ -77,6 +78,8 @@ VPD_OPERATIONS_URL = "https://kb.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_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_SCHEMA = "POC_2"
|
||||||
AUDIT_DB_ENV_FILE = Path(
|
AUDIT_DB_ENV_FILE = Path(
|
||||||
os.environ.get("POC4_AUDIT_DB_ENV_FILE", "/home/opc/kbmcp/.env")
|
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
|
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:
|
def _render_portal_login() -> None:
|
||||||
logo_url = html.escape(_header_logo_url(), quote=True)
|
logo_url = html.escape(_header_logo_url(), quote=True)
|
||||||
with st.container(key="kb_login_container"):
|
with st.container(key="kb_login_container"):
|
||||||
@@ -1751,12 +1811,23 @@ def _render_portal_login() -> None:
|
|||||||
max_chars=200,
|
max_chars=200,
|
||||||
placeholder="비밀번호를 입력하세요.",
|
placeholder="비밀번호를 입력하세요.",
|
||||||
)
|
)
|
||||||
|
remember_login = st.checkbox(
|
||||||
|
"로그인 유지 (7일)",
|
||||||
|
disabled=not bool(_portal_remember_secret()),
|
||||||
|
help="이 브라우저에서 7일 동안 로그인 상태를 유지합니다.",
|
||||||
|
)
|
||||||
submitted = st.form_submit_button("로그인", use_container_width=True)
|
submitted = st.form_submit_button("로그인", use_container_width=True)
|
||||||
if submitted:
|
if submitted:
|
||||||
if _portal_credentials_are_valid(username, password):
|
if _portal_credentials_are_valid(username, password):
|
||||||
st.session_state[PORTAL_AUTHENTICATED_KEY] = True
|
st.session_state[PORTAL_AUTHENTICATED_KEY] = True
|
||||||
st.session_state[PORTAL_AUTH_USER_KEY] = username.strip()
|
st.session_state[PORTAL_AUTH_USER_KEY] = username.strip()
|
||||||
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = False
|
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.rerun()
|
||||||
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = True
|
st.session_state[PORTAL_LOGIN_FAILURE_KEY] = True
|
||||||
if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False):
|
if st.session_state.get(PORTAL_LOGIN_FAILURE_KEY, False):
|
||||||
@@ -1770,6 +1841,7 @@ def _render_portal_login() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _logout_portal() -> None:
|
def _logout_portal() -> None:
|
||||||
|
_clear_portal_remembered_session()
|
||||||
st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None)
|
st.session_state.pop(PORTAL_AUTHENTICATED_KEY, None)
|
||||||
st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
|
st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
|
||||||
st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None)
|
st.session_state.pop(PORTAL_LOGIN_FAILURE_KEY, None)
|
||||||
@@ -6765,8 +6837,11 @@ def _process_submitted_question(
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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()
|
_apply_kb_theme()
|
||||||
|
_restore_portal_remembered_session()
|
||||||
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
|
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
|
||||||
_render_portal_login()
|
_render_portal_login()
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user