refs #709: replace portal query token with HttpOnly auth

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

View File

@@ -10,8 +10,6 @@ with the Bearer token entered on the screen.
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import html
@@ -91,9 +89,8 @@ DEFAULT_VPD_USER_ID = "E1001"
VPD_OPERATIONS_URL = "https://hmm-backoffice.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
PORTAL_AUTH_PROXY_USER_HEADER = "X-HMM-Authenticated-User"
PORTAL_AUTH_PROXY_EXPIRY_HEADER = "X-HMM-Auth-Expires"
AUDIT_DB_ENV_FILE = Path(
os.environ.get("POC4_AUDIT_DB_ENV_FILE", str(ENV_FILE))
).expanduser()
@@ -1914,150 +1911,48 @@ def _portal_auth_value(name: str) -> str:
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:
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)
headers = st.context.headers
username = str(headers.get(PORTAL_AUTH_PROXY_USER_HEADER) or "").strip()
expires_at = int(
str(headers.get(PORTAL_AUTH_PROXY_EXPIRY_HEADER) or "0").strip()
)
except (AttributeError, TypeError, ValueError):
return "", 0
return username, expires_at
def _portal_auth_configured() -> bool:
return bool(
_portal_auth_value("POC4_LOGIN_USER")
and _portal_auth_value("POC4_LOGIN_PASSWORD_PBKDF2")
)
def _portal_credentials_are_valid(username: str, password: str) -> bool:
def _restore_portal_proxy_session() -> None:
username, expires_at = _proxy_auth_headers()
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):
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)
st.session_state.pop(PORTAL_AUTH_USER_KEY, None)
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(profile: AppProfile) -> None:
with st.container(key="console_login_container"):
render_login_brand(st, profile)
if not _portal_auth_configured():
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.error(
"인증 게이트웨이의 사용자 확인 정보가 없습니다. "
"공식 포털 주소로 다시 접속해 주세요."
)
st.markdown(
f'<p class="console-muted">{html.escape(profile.login_footer)}</p>',
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:
render_console_header(st, profile)
@@ -6750,7 +6645,7 @@ def main() -> None:
page_title=profile.page_title, page_icon=profile.page_icon, layout="wide"
)
_apply_console_theme(profile)
_restore_portal_remembered_session()
_restore_portal_proxy_session()
if not st.session_state.get(PORTAL_AUTHENTICATED_KEY, False):
_render_portal_login(profile)
return
@@ -6822,8 +6717,11 @@ def main() -> None:
st.caption(
f"포털 사용자 · {st.session_state.get(PORTAL_AUTH_USER_KEY, '')}"
)
if st.button("로그아웃", use_container_width=True):
_logout_portal()
st.markdown(
'<a class="console-logout-button" href="/auth/logout" '
'target="_self">로그아웃</a>',
unsafe_allow_html=True,
)
st.divider()
st.markdown('<div class="kb-panel-title">AI 사용자 설정</div>', unsafe_allow_html=True)
selected_query_model_profile = st.selectbox(