refs #710: remove POC4 URL remember tokens
This commit is contained in:
27
docs/design/710-smilegate-poc4-url-token-security/README.md
Normal file
27
docs/design/710-smilegate-poc4-url-token-security/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# #710 POC4 URL 로그인 토큰 제거
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
Smilegate DATA & AI PoC의 POC4 Streamlit 콘솔은 게임 데이터 MCP와 Select AI Text2SQL 데모를 제공한다. 포털 로그인은 콘솔 접근을 보호한다.
|
||||||
|
|
||||||
|
## 문제
|
||||||
|
|
||||||
|
로그인 유지용 서명 토큰이 `poc4_remember` query parameter로 URL에 포함됐다. URL은 브라우저 기록, 프록시 로그, 공유 링크, Referrer에 남을 수 있으므로 인증 정보를 전달하는 경로로 사용하면 안 된다.
|
||||||
|
|
||||||
|
## 조치 설계
|
||||||
|
|
||||||
|
1. Streamlit 코드에서 URL 토큰 생성·검증·삭제를 모두 제거한다.
|
||||||
|
2. 로그인 상태는 현재 Streamlit 브라우저 세션에서만 유지한다. 서버가 `HttpOnly`, `Secure`, `SameSite` cookie를 발급하는 전용 인증 경로가 마련되기 전에는 영구 로그인 기능을 제공하지 않는다.
|
||||||
|
3. `POC4_LOGIN_REMEMBER_SECRET`을 교체해 기존 서명 링크를 무효화한다.
|
||||||
|
4. Caddy가 기존 `poc4_remember` query 요청을 애플리케이션으로 전달하지 않고 `https://smilegate.cloud-handson.com/`으로 303 redirect한다.
|
||||||
|
|
||||||
|
## 검증 기준
|
||||||
|
|
||||||
|
- `mcp_discovery_ui.py`에 `poc4_remember` 또는 `st.query_params` 로그인 토큰 코드가 없다.
|
||||||
|
- 기존 query URL 요청은 query가 없는 루트 URL로 303 응답한다.
|
||||||
|
- `smilegate-poc4-console.service`가 정상 기동한다.
|
||||||
|
- 토큰, password hash, signing key는 Git·Redmine·명령 출력에 기록하지 않는다.
|
||||||
|
|
||||||
|
## 후속 개선
|
||||||
|
|
||||||
|
영구 로그인 요구가 다시 생기면 POC4 자체가 아닌 서버 인증 endpoint가 `HttpOnly; Secure; SameSite=Lax` cookie를 발급하고, Streamlit은 요청 cookie의 서버 검증 결과만 읽는 구조로 구현한다.
|
||||||
@@ -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
|
||||||
@@ -86,8 +84,6 @@ VPD_OPERATIONS_URL = "https://smilegate-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_LOGIN_FAILURE_KEY = "poc4_portal_login_failed"
|
||||||
PORTAL_REMEMBER_TOKEN_PARAM = "poc4_remember"
|
|
||||||
PORTAL_REMEMBER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
|
||||||
AUDIT_SCHEMA = "SGMP_POC"
|
AUDIT_SCHEMA = "SGMP_POC"
|
||||||
REFERENCE_EVIDENCE_ENABLED = (
|
REFERENCE_EVIDENCE_ENABLED = (
|
||||||
os.environ.get("POC4_REFERENCE_EVIDENCE_ENABLED", "").strip().lower()
|
os.environ.get("POC4_REFERENCE_EVIDENCE_ENABLED", "").strip().lower()
|
||||||
@@ -2106,63 +2102,6 @@ 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(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)
|
||||||
@@ -2181,23 +2120,12 @@ def _render_portal_login(profile: AppProfile) -> 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):
|
||||||
@@ -2209,7 +2137,6 @@ def _render_portal_login(profile: AppProfile) -> 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)
|
||||||
@@ -7221,7 +7148,6 @@ 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()
|
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user