feat(dashboard): persist administrator login
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise persistent-auth HTTP responses without printing secrets or tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from streamlit.proto.BackMsg_pb2 import BackMsg
|
||||
from streamlit.proto.ClientState_pb2 import ClientState
|
||||
from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
|
||||
from websockets.sync.client import connect
|
||||
|
||||
COOKIE_NAME = "__Host-jarca_dashboard_auth"
|
||||
|
||||
|
||||
def _args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default="https://127.0.0.1:18506")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _connection(parsed):
|
||||
if parsed.scheme != "https" or parsed.hostname not in {"127.0.0.1", "localhost"}:
|
||||
raise SystemExit("Evidence target must be loopback HTTPS")
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
return http.client.HTTPSConnection(parsed.hostname, parsed.port, context=context, timeout=10)
|
||||
|
||||
|
||||
def _request(parsed, method: str, path: str, *, body: bytes | None = None, headers=None):
|
||||
connection = _connection(parsed)
|
||||
connection.request(method, path, body=body, headers=headers or {})
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
result = response.status, {key.lower(): value for key, value in response.getheaders()}, payload
|
||||
connection.close()
|
||||
return result
|
||||
|
||||
|
||||
def _assert_streamlit_restore(parsed, cookie_pair: str) -> int:
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
origin = f"https://{parsed.hostname}:{parsed.port}"
|
||||
websocket_url = f"wss://{parsed.hostname}:{parsed.port}/_stcore/stream"
|
||||
with connect(
|
||||
websocket_url,
|
||||
ssl=context,
|
||||
origin=origin,
|
||||
subprotocols=["streamlit"],
|
||||
additional_headers={"Cookie": cookie_pair},
|
||||
proxy=None,
|
||||
open_timeout=30,
|
||||
) as websocket:
|
||||
back_message = BackMsg()
|
||||
back_message.rerun_script.CopyFrom(
|
||||
ClientState(query_string="page=%EC%9A%94%EC%95%BD")
|
||||
)
|
||||
websocket.send(back_message.SerializeToString())
|
||||
messages: list[str] = []
|
||||
for _ in range(100):
|
||||
raw = websocket.recv(timeout=15)
|
||||
if isinstance(raw, str):
|
||||
continue
|
||||
forward_message = ForwardMsg()
|
||||
forward_message.ParseFromString(raw)
|
||||
messages.append(str(forward_message))
|
||||
if forward_message.HasField("script_finished"):
|
||||
break
|
||||
combined = "\n".join(messages)
|
||||
if "이번 달 수입/지출 현황" not in combined or "관리자 로그인" in combined:
|
||||
raise SystemExit("Signed cookie did not restore the authenticated Streamlit page")
|
||||
return len(messages)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parsed = urlsplit(_args().base_url)
|
||||
admin_id = os.environ["JARCA_DASHBOARD_ADMIN_ID"]
|
||||
password = os.environ["JARCA_DASHBOARD_ADMIN_PASSWORD"]
|
||||
origin = f"https://{parsed.hostname}:{parsed.port}"
|
||||
|
||||
body = urlencode(
|
||||
{"admin_id": admin_id, "admin_password": password, "remember_me": "on"}
|
||||
).encode()
|
||||
status, headers, _ = _request(
|
||||
parsed,
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
body=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Content-Length": str(len(body)),
|
||||
"Origin": origin,
|
||||
},
|
||||
)
|
||||
cookie_header = headers.get("set-cookie", "")
|
||||
required = [COOKIE_NAME + "=", "HttpOnly", "Secure", "SameSite=lax", "Path=/", "Max-Age=2592000"]
|
||||
if status != 303 or headers.get("location") != "/" or not all(
|
||||
marker in cookie_header for marker in required
|
||||
):
|
||||
raise SystemExit("Login response did not satisfy the hardened cookie contract")
|
||||
cookie_pair = cookie_header.split(";", 1)[0]
|
||||
|
||||
status, _, _ = _request(
|
||||
parsed,
|
||||
"GET",
|
||||
"/",
|
||||
headers={"Cookie": cookie_pair},
|
||||
)
|
||||
if status != 200:
|
||||
raise SystemExit("Authenticated reconnect request failed")
|
||||
websocket_messages = _assert_streamlit_restore(parsed, cookie_pair)
|
||||
|
||||
status, headers, _ = _request(
|
||||
parsed,
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
headers={"Origin": origin, "Cookie": cookie_pair, "Content-Length": "0"},
|
||||
)
|
||||
deletion = headers.get("set-cookie", "")
|
||||
if status != 303 or not all(
|
||||
marker in deletion
|
||||
for marker in [COOKIE_NAME + "=", "HttpOnly", "Secure", "SameSite=lax", "Path=/", "Max-Age=0"]
|
||||
):
|
||||
raise SystemExit("Logout response did not satisfy the cookie deletion contract")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"transport": "loopback HTTPS",
|
||||
"login_status": 303,
|
||||
"reconnect_status": 200,
|
||||
"streamlit_websocket_authenticated": True,
|
||||
"streamlit_messages": websocket_messages,
|
||||
"logout_status": 303,
|
||||
"cookie_name": COOKIE_NAME,
|
||||
"cookie_value": "[REDACTED]",
|
||||
"remember_me_max_age": 2592000,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user