563
ai-web-agent-console/ai_web_agent_console/auth_gateway.py
Normal file
563
ai-web-agent-console/ai_web_agent_console/auth_gateway.py
Normal file
@@ -0,0 +1,563 @@
|
||||
"""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
|
||||
|
||||
|
||||
def _environment_value(*names: str, default: str = "") -> str:
|
||||
for name in names:
|
||||
value = os.environ.get(name)
|
||||
if value is not None and value.strip():
|
||||
return value.strip()
|
||||
return default
|
||||
|
||||
|
||||
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=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_USER", "POC4_LOGIN_USER"
|
||||
),
|
||||
password_pbkdf2=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_PASSWORD_PBKDF2",
|
||||
"POC4_LOGIN_PASSWORD_PBKDF2",
|
||||
),
|
||||
cookie_secret=_environment_value(
|
||||
"AI_WEB_AGENT_CONSOLE_LOGIN_COOKIE_SECRET",
|
||||
"POC4_LOGIN_COOKIE_SECRET",
|
||||
),
|
||||
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("AI web agent console login credentials are not configured")
|
||||
if len(self.cookie_secret.encode("utf-8")) < 32:
|
||||
raise ValueError(
|
||||
"AI_WEB_AGENT_CONSOLE_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()
|
||||
Reference in New Issue
Block a user