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(

View File

@@ -0,0 +1,41 @@
location = /auth/check {
internal;
proxy_pass http://127.0.0.1:8621/auth/check;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Real-IP $remote_addr;
}
location /auth/ {
proxy_pass http://127.0.0.1:8621;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location @hmm_portal_login {
return 302 /auth/login;
}
location / {
auth_request /auth/check;
error_page 401 = @hmm_portal_login;
auth_request_set $hmm_auth_user $upstream_http_x_auth_user;
auth_request_set $hmm_auth_expires $upstream_http_x_auth_expires;
proxy_pass http://127.0.0.1:8622;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-HMM-Authenticated-User $hmm_auth_user;
proxy_set_header X-HMM-Auth-Expires $hmm_auth_expires;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300;
proxy_send_timeout 300;
}

View File

@@ -0,0 +1,29 @@
[Unit]
Description=HMM Portal HttpOnly Cookie Authentication
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=opc
Group=opc
WorkingDirectory=/opt/hmm-poc4
Environment=PYTHONUNBUFFERED=1
EnvironmentFile=/opt/hmm-poc4/.env
ExecStart=/opt/hmm-poc4/.venv/bin/python -m src.agent_console.auth_gateway
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_INET6
UMask=0077
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,549 @@
"""Small localhost authentication service for the HMM Streamlit portal.
Nginx owns the public security boundary. This module validates the existing
PBKDF2 login, issues a signed HttpOnly cookie, and answers Nginx auth_request
subrequests. Authentication values are never accepted from a URL.
"""
from __future__ import annotations
import base64
import binascii
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import datetime, timezone
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import hashlib
import hmac
import html
import json
import logging
import os
import secrets
import threading
import time
from typing import Deque
from urllib.parse import parse_qs, urlsplit
LOG = logging.getLogger("hmm_portal_auth")
SESSION_COOKIE_NAME = "__Host-HMM_PORTAL_SESSION"
CSRF_COOKIE_NAME = "__Host-HMM_LOGIN_CSRF"
SESSION_TOKEN_VERSION = 2
MAX_REQUEST_BYTES = 8_192
MAX_FIELD_CHARS = 200
@dataclass(frozen=True)
class AuthConfig:
username: str
password_pbkdf2: str
cookie_secret: str
bind_address: str = "127.0.0.1"
port: int = 8621
session_seconds: int = 12 * 60 * 60
remember_seconds: int = 7 * 24 * 60 * 60
product_name: str = "HMM AI 업무 에이전트"
login_title: str = "HMM AI 업무 에이전트"
login_description: str = "사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다."
login_footer: str = "승인된 사용자만 접속할 수 있습니다."
primary_color: str = "#004b87"
@classmethod
def from_environment(cls) -> "AuthConfig":
config = cls(
username=os.environ.get("POC4_LOGIN_USER", "").strip(),
password_pbkdf2=os.environ.get(
"POC4_LOGIN_PASSWORD_PBKDF2", ""
).strip(),
cookie_secret=os.environ.get(
"POC4_LOGIN_COOKIE_SECRET", ""
).strip(),
bind_address=os.environ.get(
"PORTAL_AUTH_BIND_ADDRESS", "127.0.0.1"
).strip(),
port=int(os.environ.get("PORTAL_AUTH_PORT", "8621")),
session_seconds=int(
os.environ.get("PORTAL_AUTH_SESSION_SECONDS", str(12 * 60 * 60))
),
remember_seconds=int(
os.environ.get(
"PORTAL_AUTH_REMEMBER_SECONDS", str(7 * 24 * 60 * 60)
)
),
product_name=os.environ.get(
"AGENT_CONSOLE_NAME", "HMM AI 업무 에이전트"
).strip(),
login_title=os.environ.get(
"AGENT_CONSOLE_LOGIN_TITLE", "HMM AI 업무 에이전트"
).strip(),
login_description=os.environ.get(
"AGENT_CONSOLE_LOGIN_DESCRIPTION",
"사용자 인증 후 AI 업무 질의 기능을 이용할 수 있습니다.",
).strip(),
login_footer=os.environ.get(
"AGENT_CONSOLE_LOGIN_FOOTER",
"승인된 사용자만 접속할 수 있습니다.",
).strip(),
primary_color=os.environ.get(
"AGENT_CONSOLE_PRIMARY_COLOR", "#004b87"
).strip(),
)
config.validate()
return config
def validate(self) -> None:
if not self.username or not self.password_pbkdf2:
raise ValueError("POC4 portal login credentials are not configured")
if len(self.cookie_secret.encode("utf-8")) < 32:
raise ValueError("POC4_LOGIN_COOKIE_SECRET must be at least 32 bytes")
if self.bind_address not in {"127.0.0.1", "::1"}:
raise ValueError("Portal authentication service must bind to loopback")
if not 1 <= self.port <= 65535:
raise ValueError("PORTAL_AUTH_PORT is invalid")
if not 300 <= self.session_seconds <= 24 * 60 * 60:
raise ValueError("PORTAL_AUTH_SESSION_SECONDS is outside the safe range")
if not self.session_seconds <= self.remember_seconds <= 30 * 24 * 60 * 60:
raise ValueError("PORTAL_AUTH_REMEMBER_SECONDS is outside the safe range")
@dataclass(frozen=True)
class AuthenticatedSession:
username: str
expires_at: int
class SessionTokenCodec:
def __init__(self, secret: str):
self._secret = secret.encode("utf-8")
def issue(self, username: str, lifetime_seconds: int, now: int | None = None) -> str:
issued_at = int(time.time()) if now is None else now
payload = {
"v": SESSION_TOKEN_VERSION,
"u": username,
"i": issued_at,
"e": issued_at + lifetime_seconds,
"n": secrets.token_urlsafe(18),
}
encoded = _base64url_encode(
json.dumps(payload, separators=(",", ":")).encode("utf-8")
)
signature = hmac.new(
self._secret, encoded.encode("ascii"), hashlib.sha256
).hexdigest()
return f"{encoded}.{signature}"
def verify(self, token: str, expected_username: str, now: int | None = None) -> AuthenticatedSession | None:
if not token or len(token) > 2048:
return None
current_time = int(time.time()) if now is None else now
try:
encoded, supplied_signature = token.split(".", 1)
expected_signature = hmac.new(
self._secret, encoded.encode("ascii"), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(supplied_signature, expected_signature):
return None
payload = json.loads(_base64url_decode(encoded).decode("utf-8"))
version = int(payload["v"])
username = str(payload["u"])
issued_at = int(payload["i"])
expires_at = int(payload["e"])
except (
binascii.Error,
KeyError,
TypeError,
ValueError,
UnicodeDecodeError,
json.JSONDecodeError,
):
return None
if version != SESSION_TOKEN_VERSION:
return None
if issued_at > current_time + 30 or expires_at <= current_time:
return None
if expires_at - issued_at > 30 * 24 * 60 * 60:
return None
if not hmac.compare_digest(username, expected_username):
return None
return AuthenticatedSession(username=username, expires_at=expires_at)
class LoginAttemptLimiter:
def __init__(self, maximum_failures: int = 5, window_seconds: int = 300):
self._maximum_failures = maximum_failures
self._window_seconds = window_seconds
self._failures: dict[str, Deque[float]] = defaultdict(deque)
self._lock = threading.Lock()
def blocked(self, key: str, now: float | None = None) -> bool:
current_time = time.monotonic() if now is None else now
with self._lock:
failures = self._failures[key]
self._prune(failures, current_time)
return len(failures) >= self._maximum_failures
def record_failure(self, key: str, now: float | None = None) -> None:
current_time = time.monotonic() if now is None else now
with self._lock:
failures = self._failures[key]
self._prune(failures, current_time)
failures.append(current_time)
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
def _prune(self, failures: Deque[float], now: float) -> None:
cutoff = now - self._window_seconds
while failures and failures[0] < cutoff:
failures.popleft()
def password_matches(password: str, encoded_password: str) -> bool:
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)
def session_cookie_header(token: str, max_age: int | None) -> str:
attributes = [
f"{SESSION_COOKIE_NAME}={token}",
"Path=/",
"Secure",
"HttpOnly",
"SameSite=Lax",
]
if max_age is not None:
attributes.append(f"Max-Age={max_age}")
return "; ".join(attributes)
def clear_session_cookie_header() -> str:
return (
f"{SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; "
"Secure; HttpOnly; SameSite=Lax"
)
def csrf_cookie_header(value: str, max_age: int = 600) -> str:
return (
f"{CSRF_COOKIE_NAME}={value}; Path=/; Max-Age={max_age}; "
"Secure; HttpOnly; SameSite=Strict"
)
def clear_csrf_cookie_header() -> str:
return (
f"{CSRF_COOKIE_NAME}=; Path=/; Max-Age=0; "
"Secure; HttpOnly; SameSite=Strict"
)
def _base64url_encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def _base64url_decode(value: str) -> bytes:
padded = value + "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(padded)
def _cookie_value(cookie_header: str, name: str) -> str:
try:
cookies = SimpleCookie()
cookies.load(cookie_header)
morsel = cookies.get(name)
return morsel.value if morsel is not None else ""
except (KeyError, TypeError):
return ""
def _login_page(config: AuthConfig, csrf_value: str, error: str = "") -> bytes:
error_html = (
f'<div class="error" role="alert">{html.escape(error)}</div>'
if error
else ""
)
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{html.escape(config.product_name)}</title>
<style>
:root {{ --primary:{html.escape(config.primary_color)}; --text:#17232d;
--muted:#60717f; --border:#d9e0e5; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:#fff; color:var(--text);
font-family:"Noto Sans KR","Malgun Gothic",sans-serif; }}
main {{ width:min(420px,calc(100% - 40px)); margin:12vh auto 0; }}
.wordmark {{ color:var(--primary); font-size:1.25rem; font-weight:800;
letter-spacing:.08em; }}
h1 {{ margin:16px 0 8px; font-size:1.75rem; }}
.description,.footer {{ color:var(--muted); line-height:1.55; }}
form {{ margin-top:28px; }}
label {{ display:block; margin:0 0 18px; font-weight:700; }}
input[type="text"],input[type="password"] {{ width:100%; margin-top:8px;
padding:12px 13px; border:1px solid var(--border); border-radius:5px;
font:inherit; color:var(--text); background:#fff; }}
.remember {{ display:flex; align-items:center; gap:8px; font-weight:500; }}
.remember input {{ width:17px; height:17px; }}
button {{ width:100%; padding:12px; border:1px solid var(--primary);
border-radius:5px; background:var(--primary); color:#fff; font:inherit;
font-weight:800; cursor:pointer; }}
.error {{ margin:18px 0 0; padding:11px 12px; border:1px solid #d99898;
border-radius:5px; color:#8a2222; background:#fff7f7; }}
.footer {{ margin-top:22px; font-size:.9rem; }}
</style>
</head>
<body>
<main>
<div class="wordmark">HMM</div>
<h1>{html.escape(config.login_title)}</h1>
<p class="description">{html.escape(config.login_description)}</p>
{error_html}
<form action="/auth/login" method="post" autocomplete="on">
<input type="hidden" name="csrf" value="{html.escape(csrf_value)}">
<label>사용자 ID
<input name="username" type="text" maxlength="80" autocomplete="username"
required autofocus>
</label>
<label>비밀번호
<input name="password" type="password" maxlength="200"
autocomplete="current-password" required>
</label>
<label class="remember">
<input name="remember" type="checkbox" value="yes"> 로그인 유지 (7일)
</label>
<button type="submit">로그인</button>
</form>
<p class="footer">{html.escape(config.login_footer)}</p>
</main>
</body>
</html>""".encode("utf-8")
def build_handler(config: AuthConfig) -> type[BaseHTTPRequestHandler]:
codec = SessionTokenCodec(config.cookie_secret)
limiter = LoginAttemptLimiter()
class PortalAuthHandler(BaseHTTPRequestHandler):
server_version = "HMMPortalAuth/1.0"
sys_version = ""
def do_HEAD(self) -> None:
self._route(send_body=False)
def do_GET(self) -> None:
self._route(send_body=True)
def do_POST(self) -> None:
path = urlsplit(self.path).path
if path == "/auth/login":
self._login()
elif path == "/auth/logout":
self._logout()
else:
self._send_text(HTTPStatus.NOT_FOUND, "Not found")
def _route(self, send_body: bool) -> None:
path = urlsplit(self.path).path
if path == "/auth/check":
self._check()
elif path == "/auth/login":
self._show_login(send_body=send_body)
elif path == "/auth/logout":
self._logout()
elif path == "/auth/healthz":
self._send_text(HTTPStatus.OK, "ok", send_body=send_body)
else:
self._send_text(HTTPStatus.NOT_FOUND, "Not found", send_body=send_body)
def _check(self) -> None:
session = self._session()
if session is None:
self._send_empty(HTTPStatus.UNAUTHORIZED)
return
self.send_response(HTTPStatus.NO_CONTENT)
self._security_headers()
self.send_header("X-Auth-User", session.username)
self.send_header("X-Auth-Expires", str(session.expires_at))
self.end_headers()
def _show_login(self, send_body: bool = True, error: str = "") -> None:
if self._session() is not None and not error:
self._redirect("/")
return
csrf_value = secrets.token_urlsafe(32)
body = _login_page(config, csrf_value, error)
self.send_response(HTTPStatus.OK)
self._security_headers()
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Set-Cookie", csrf_cookie_header(csrf_value))
self.end_headers()
if send_body:
self.wfile.write(body)
def _login(self) -> None:
client_key = self._client_key()
if limiter.blocked(client_key):
self._show_login(error="로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해 주세요.")
return
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
content_length = 0
if not 1 <= content_length <= MAX_REQUEST_BYTES:
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
return
raw_body = self.rfile.read(content_length)
try:
form = parse_qs(
raw_body.decode("utf-8"),
keep_blank_values=True,
strict_parsing=False,
max_num_fields=8,
)
except (UnicodeDecodeError, ValueError):
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
return
username = _form_value(form, "username")
password = _form_value(form, "password")
csrf_form = _form_value(form, "csrf")
csrf_cookie = _cookie_value(
self.headers.get("Cookie", ""), CSRF_COOKIE_NAME
)
if (
not csrf_form
or not csrf_cookie
or not hmac.compare_digest(csrf_form, csrf_cookie)
):
self._send_text(HTTPStatus.BAD_REQUEST, "Invalid request")
return
valid_credentials = (
len(username) <= 80
and len(password) <= MAX_FIELD_CHARS
and hmac.compare_digest(username.strip(), config.username)
and password_matches(password, config.password_pbkdf2)
)
if not valid_credentials:
limiter.record_failure(client_key)
self._show_login(error="사용자 ID 또는 비밀번호를 확인해 주세요.")
return
limiter.reset(client_key)
remember = _form_value(form, "remember") == "yes"
lifetime = (
config.remember_seconds if remember else config.session_seconds
)
token = codec.issue(config.username, lifetime)
self.send_response(HTTPStatus.SEE_OTHER)
self._security_headers()
self.send_header("Location", "/")
self.send_header(
"Set-Cookie",
session_cookie_header(token, lifetime if remember else None),
)
self.send_header("Set-Cookie", clear_csrf_cookie_header())
self.end_headers()
def _logout(self) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self._security_headers()
self.send_header("Location", "/auth/login")
self.send_header("Set-Cookie", clear_session_cookie_header())
self.send_header("Set-Cookie", clear_csrf_cookie_header())
self.end_headers()
def _session(self) -> AuthenticatedSession | None:
token = _cookie_value(
self.headers.get("Cookie", ""), SESSION_COOKIE_NAME
)
return codec.verify(token, config.username)
def _client_key(self) -> str:
forwarded = self.headers.get("X-Real-IP", "").strip()
return forwarded or self.client_address[0]
def _redirect(self, location: str) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self._security_headers()
self.send_header("Location", location)
self.end_headers()
def _send_empty(self, status: HTTPStatus) -> None:
self.send_response(status)
self._security_headers()
self.send_header("Content-Length", "0")
self.end_headers()
def _send_text(
self,
status: HTTPStatus,
message: str,
send_body: bool = True,
) -> None:
body = message.encode("utf-8")
self.send_response(status)
self._security_headers()
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if send_body:
self.wfile.write(body)
def _security_headers(self) -> None:
self.send_header("Cache-Control", "no-store")
self.send_header("Pragma", "no-cache")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; "
"form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
)
def log_message(self, _format: str, *args: object) -> None:
# Do not log query strings, cookies, form bodies, or tokens.
LOG.info("%s %s", self.command, urlsplit(self.path).path)
return PortalAuthHandler
def _form_value(form: dict[str, list[str]], name: str) -> str:
values = form.get(name)
return values[0] if values else ""
def main() -> None:
logging.basicConfig(
level=os.environ.get("PORTAL_AUTH_LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
config = AuthConfig.from_environment()
server = ThreadingHTTPServer(
(config.bind_address, config.port), build_handler(config)
)
LOG.info("HMM portal authentication service listening on loopback port %s", config.port)
server.serve_forever()
if __name__ == "__main__":
main()

View File

@@ -94,6 +94,12 @@ def apply_console_theme(st: Any, profile: AppProfile) -> None:
.st-key-console_login_container {{ max-width:440px; margin:12vh auto 0; }}
.console-login {{ text-align:left; }}
.console-login h1 {{ margin:12px 0 8px; font-size:1.7rem; }}
a.console-logout-button {{ display:block; width:100%; padding:.55rem .8rem;
margin:.25rem 0 .75rem; background:#fff; color:var(--console-text) !important;
-webkit-text-fill-color:var(--console-text) !important;
border:1px solid var(--console-border); border-radius:4px;
text-align:center; text-decoration:none; font-weight:700; }}
a.console-logout-button:hover {{ background:#f6f8fa; }}
@media (max-width:760px) {{ .block-container {{ padding:1.25rem 1.25rem 3rem; }} .st-key-console_login_container {{ margin-top:8vh; }} }}
</style>
""",

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import hashlib
import http.client
import os
from pathlib import Path
import sys
import threading
import unittest
from urllib.parse import urlencode
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.agent_console.auth_gateway import ( # noqa: E402
AuthConfig,
LoginAttemptLimiter,
SESSION_COOKIE_NAME,
SessionTokenCodec,
build_handler,
clear_session_cookie_header,
password_matches,
session_cookie_header,
)
class AuthGatewayTest(unittest.TestCase):
def setUp(self) -> None:
self.secret = "s" * 48
self.codec = SessionTokenCodec(self.secret)
def test_session_token_round_trip_and_tamper_rejection(self) -> None:
token = self.codec.issue("demo-admin", 3600, now=1_000)
session = self.codec.verify(token, "demo-admin", now=1_001)
self.assertIsNotNone(session)
self.assertEqual("demo-admin", session.username)
self.assertEqual(4_600, session.expires_at)
self.assertIsNone(self.codec.verify(token + "x", "demo-admin", now=1_001))
self.assertIsNone(self.codec.verify(token, "other-user", now=1_001))
def test_expired_session_token_is_rejected(self) -> None:
token = self.codec.issue("demo-admin", 300, now=1_000)
self.assertIsNone(self.codec.verify(token, "demo-admin", now=1_300))
def test_remember_cookie_has_required_security_attributes(self) -> None:
header = session_cookie_header("signed-value", 604_800)
self.assertIn(f"{SESSION_COOKIE_NAME}=signed-value", header)
self.assertIn("Path=/", header)
self.assertIn("Secure", header)
self.assertIn("HttpOnly", header)
self.assertIn("SameSite=Lax", header)
self.assertIn("Max-Age=604800", header)
self.assertNotIn("Domain=", header)
def test_session_cookie_omits_persistent_max_age(self) -> None:
header = session_cookie_header("signed-value", None)
self.assertNotIn("Max-Age", header)
self.assertIn("HttpOnly", header)
def test_logout_cookie_expires_immediately(self) -> None:
header = clear_session_cookie_header()
self.assertIn("Max-Age=0", header)
self.assertIn("Secure", header)
self.assertIn("HttpOnly", header)
def test_pbkdf2_password_verification(self) -> None:
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
expected = hashlib.pbkdf2_hmac(
"sha256", b"correct-password", salt, 200_000
).hex()
encoded = f"pbkdf2_sha256$200000${salt.hex()}${expected}"
self.assertTrue(password_matches("correct-password", encoded))
self.assertFalse(password_matches("wrong-password", encoded))
def test_rate_limiter_blocks_only_after_threshold(self) -> None:
limiter = LoginAttemptLimiter(maximum_failures=2, window_seconds=10)
limiter.record_failure("client", now=1)
self.assertFalse(limiter.blocked("client", now=2))
limiter.record_failure("client", now=3)
self.assertTrue(limiter.blocked("client", now=4))
self.assertFalse(limiter.blocked("client", now=20))
def test_environment_config_requires_new_cookie_secret(self) -> None:
previous = dict(os.environ)
try:
os.environ["POC4_LOGIN_USER"] = "demo-admin"
os.environ["POC4_LOGIN_PASSWORD_PBKDF2"] = "encoded"
os.environ.pop("POC4_LOGIN_COOKIE_SECRET", None)
with self.assertRaisesRegex(ValueError, "COOKIE_SECRET"):
AuthConfig.from_environment()
finally:
os.environ.clear()
os.environ.update(previous)
def test_http_login_check_and_logout_flow_never_uses_url_token(self) -> None:
salt = bytes.fromhex("00112233445566778899aabbccddeeff")
expected = hashlib.pbkdf2_hmac(
"sha256", b"correct-password", salt, 200_000
).hex()
config = AuthConfig(
username="demo-admin",
password_pbkdf2=(
f"pbkdf2_sha256$200000${salt.hex()}${expected}"
),
cookie_secret=self.secret,
port=8621,
)
from http.server import ThreadingHTTPServer
server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(config))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
connection = http.client.HTTPConnection(
"127.0.0.1", server.server_address[1], timeout=3
)
try:
connection.request("GET", "/auth/login")
login_page = connection.getresponse()
body = login_page.read().decode("utf-8")
self.assertEqual(200, login_page.status)
csrf_header = next(
value
for name, value in login_page.getheaders()
if name.lower() == "set-cookie"
and value.startswith("__Host-HMM_LOGIN_CSRF=")
)
csrf_value = csrf_header.split("=", 1)[1].split(";", 1)[0]
self.assertIn(
f'name="csrf" value="{csrf_value}"',
body,
)
payload = urlencode(
{
"csrf": csrf_value,
"username": "demo-admin",
"password": "correct-password",
"remember": "yes",
}
)
connection.request(
"POST",
"/auth/login",
body=payload,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": f"__Host-HMM_LOGIN_CSRF={csrf_value}",
},
)
logged_in = connection.getresponse()
logged_in.read()
self.assertEqual(303, logged_in.status)
self.assertEqual("/", logged_in.getheader("Location"))
self.assertNotRegex(logged_in.getheader("Location"), r"token|remember")
session_header = next(
value
for name, value in logged_in.getheaders()
if name.lower() == "set-cookie"
and value.startswith(f"{SESSION_COOKIE_NAME}=")
)
session_value = session_header.split("=", 1)[1].split(";", 1)[0]
self.assertIn("Secure", session_header)
self.assertIn("HttpOnly", session_header)
self.assertIn("SameSite=Lax", session_header)
connection.request(
"GET",
"/auth/check",
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
)
check = connection.getresponse()
check.read()
self.assertEqual(204, check.status)
self.assertEqual("demo-admin", check.getheader("X-Auth-User"))
connection.request(
"GET",
"/auth/check",
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}x"},
)
tampered = connection.getresponse()
tampered.read()
self.assertEqual(401, tampered.status)
connection.request(
"GET",
"/auth/logout",
headers={"Cookie": f"{SESSION_COOKIE_NAME}={session_value}"},
)
logout = connection.getresponse()
logout.read()
self.assertEqual(303, logout.status)
self.assertEqual("/auth/login", logout.getheader("Location"))
self.assertTrue(
any(
name.lower() == "set-cookie" and "Max-Age=0" in value
for name, value in logout.getheaders()
)
)
finally:
connection.close()
server.shutdown()
server.server_close()
thread.join(timeout=3)
if __name__ == "__main__":
unittest.main()