241 lines
9.4 KiB
Python
241 lines
9.4 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""RC V7: Office -> Chat/Kontakte Single-Sign-On ueber den lokalen Test-Proxy.
|
||
|
|
|
||
|
|
Root Cause: der Test-Proxy folgte 3xx-Redirects selbst (Default-urllib-Opener).
|
||
|
|
Beim GET /empfang/shell/launch (302 + Set-Cookie aza_session) wurde dadurch der
|
||
|
|
Einmal-Token serverseitig verbraucht und das Set-Cookie verschluckt -> die
|
||
|
|
WebView blieb unauthentifiziert -> Loginmaske in Chat UND Kontakte.
|
||
|
|
|
||
|
|
Fix: _PROXY_OPENER folgt 3xx NICHT mehr; die 302 (Location lokal, Set-Cookie
|
||
|
|
weitergereicht) geht an die WebView, die dem Redirect selbst folgt. _localize_set_cookie
|
||
|
|
macht das Cookie fuer 127.0.0.1/HTTP annehmbar (Domain/Secure nur im Testmodus entfernt).
|
||
|
|
|
||
|
|
Der Test startet einen Fake-Upstream, der /shell/launch wie das echte Backend
|
||
|
|
beantwortet, und prueft den Proxy end-to-end.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import http.cookiejar
|
||
|
|
import inspect
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import threading
|
||
|
|
import unittest
|
||
|
|
import urllib.error
|
||
|
|
import urllib.request
|
||
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
ROOT = Path(__file__).resolve().parent
|
||
|
|
|
||
|
|
_SESSION_VALUE = "sess-abc123-secret"
|
||
|
|
_USER = {"user_id": "u-andre-001", "practice_id": "p-lindengut-007", "display_name": "Andre M. Surovy"}
|
||
|
|
_used_tokens = set()
|
||
|
|
_used_lock = threading.Lock()
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeUpstreamHandler(BaseHTTPRequestHandler):
|
||
|
|
protocol_version = "HTTP/1.1"
|
||
|
|
|
||
|
|
def log_message(self, *a):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _json(self, code, payload):
|
||
|
|
body = json.dumps(payload).encode("utf-8")
|
||
|
|
self.send_response(code)
|
||
|
|
self.send_header("Content-Type", "application/json")
|
||
|
|
self.send_header("Content-Length", str(len(body)))
|
||
|
|
self.end_headers()
|
||
|
|
self.wfile.write(body)
|
||
|
|
|
||
|
|
def do_GET(self):
|
||
|
|
if self.path.startswith("/empfang/shell/launch"):
|
||
|
|
# Token-Query auslesen
|
||
|
|
tok = ""
|
||
|
|
if "token=" in self.path:
|
||
|
|
tok = self.path.split("token=", 1)[1].split("&", 1)[0]
|
||
|
|
with _used_lock:
|
||
|
|
if not tok or tok in _used_tokens:
|
||
|
|
self._json(401, {"detail": "Shell-Token ungueltig/verbraucht"})
|
||
|
|
return
|
||
|
|
_used_tokens.add(tok)
|
||
|
|
# 302 + Set-Cookie wie echtes Backend (mit Domain + Secure, mehrere Cookies)
|
||
|
|
self.send_response(302)
|
||
|
|
self.send_header("Location", "/empfang/?empfang_chat_shell=1&shell_source=empfang_chat_shell")
|
||
|
|
self.send_header(
|
||
|
|
"Set-Cookie",
|
||
|
|
f"aza_session={_SESSION_VALUE}; HttpOnly; Path=/; SameSite=Lax; Secure; Domain=api.aza-medwork.ch",
|
||
|
|
)
|
||
|
|
self.send_header("Set-Cookie", "aza_csrf=csrf-xyz; Path=/; SameSite=Lax")
|
||
|
|
self.send_header("Content-Length", "0")
|
||
|
|
self.end_headers()
|
||
|
|
return
|
||
|
|
if self.path.startswith("/empfang/shell/context/me") or self.path.startswith("/auth/me"):
|
||
|
|
cookie = self.headers.get("Cookie") or ""
|
||
|
|
if f"aza_session={_SESSION_VALUE}" in cookie:
|
||
|
|
self._json(200, {"authenticated": True, "success": True, **_USER})
|
||
|
|
else:
|
||
|
|
self._json(401, {"detail": "nicht angemeldet"})
|
||
|
|
return
|
||
|
|
self._json(404, {"detail": "nf"})
|
||
|
|
|
||
|
|
|
||
|
|
def _no_redirect_opener():
|
||
|
|
class _NR(urllib.request.HTTPRedirectHandler):
|
||
|
|
def redirect_request(self, *a, **k):
|
||
|
|
return None
|
||
|
|
|
||
|
|
return urllib.request.build_opener(_NR)
|
||
|
|
|
||
|
|
|
||
|
|
class OfficeChatContactsSSOTests(unittest.TestCase):
|
||
|
|
@classmethod
|
||
|
|
def setUpClass(cls):
|
||
|
|
os.environ["AZA_DOKU_PROMPT_TEST"] = "1"
|
||
|
|
_used_tokens.clear()
|
||
|
|
cls.upstream = ThreadingHTTPServer(("127.0.0.1", 0), _FakeUpstreamHandler)
|
||
|
|
cls.up_port = cls.upstream.server_address[1]
|
||
|
|
threading.Thread(target=cls.upstream.serve_forever, daemon=True).start()
|
||
|
|
os.environ["AZA_EMPFANG_TEST_UPSTREAM"] = f"http://127.0.0.1:{cls.up_port}"
|
||
|
|
|
||
|
|
import aza_empfang_test_html_proxy as px
|
||
|
|
|
||
|
|
cls.px = px
|
||
|
|
cls.srv, cls.port = px._start_server_on_port(0)
|
||
|
|
cls.base = f"http://127.0.0.1:{cls.port}"
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def tearDownClass(cls):
|
||
|
|
try:
|
||
|
|
cls.srv.shutdown()
|
||
|
|
cls.srv.server_close()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
cls.upstream.shutdown()
|
||
|
|
cls.upstream.server_close()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# --- Root-Cause-Beweis + Fix: Proxy folgt 302 NICHT, reicht Cookie durch ---
|
||
|
|
def _launch_once(self, token):
|
||
|
|
opener = _no_redirect_opener()
|
||
|
|
req = urllib.request.Request(f"{self.base}/empfang/shell/launch?token={token}&target=empfang_chat_shell")
|
||
|
|
try:
|
||
|
|
resp = opener.open(req, timeout=8)
|
||
|
|
return resp.status, resp.headers
|
||
|
|
except urllib.error.HTTPError as e:
|
||
|
|
return e.code, e.headers
|
||
|
|
|
||
|
|
def test_launch_returns_302_not_followed(self):
|
||
|
|
status, headers = self._launch_once("tok-chat-1")
|
||
|
|
self.assertEqual(status, 302, "Proxy darf den Redirect nicht selbst folgen")
|
||
|
|
loc = headers.get("Location") or ""
|
||
|
|
self.assertTrue(loc.startswith(self.base + "/empfang/"), f"Location nicht lokal: {loc}")
|
||
|
|
self.assertIn("empfang_chat_shell=1", loc)
|
||
|
|
|
||
|
|
def test_set_cookie_forwarded_and_localized(self):
|
||
|
|
status, headers = self._launch_once("tok-chat-2")
|
||
|
|
cookies = headers.get_all("Set-Cookie") or []
|
||
|
|
joined = " || ".join(cookies)
|
||
|
|
self.assertIn("aza_session=" + _SESSION_VALUE, joined)
|
||
|
|
# Mehrere Set-Cookie bleiben erhalten
|
||
|
|
self.assertTrue(any(c.startswith("aza_csrf=") for c in cookies), f"2. Cookie fehlt: {cookies}")
|
||
|
|
# Domain (Live) und Secure (ueber HTTP) im Testmodus entfernt
|
||
|
|
self.assertNotIn("domain=", joined.lower())
|
||
|
|
self.assertNotIn("secure", joined.lower())
|
||
|
|
# Schutzattribute bleiben
|
||
|
|
self.assertIn("HttpOnly", joined)
|
||
|
|
self.assertIn("SameSite", joined)
|
||
|
|
|
||
|
|
def test_context_me_authenticated_after_launch(self):
|
||
|
|
# 1) Launch -> Cookie holen
|
||
|
|
_status, headers = self._launch_once("tok-chat-3")
|
||
|
|
session_cookie = ""
|
||
|
|
for c in headers.get_all("Set-Cookie") or []:
|
||
|
|
if c.startswith("aza_session="):
|
||
|
|
session_cookie = c.split(";", 1)[0]
|
||
|
|
break
|
||
|
|
self.assertTrue(session_cookie, "kein aza_session-Cookie erhalten")
|
||
|
|
# 2) context/me mit Cookie ueber den Proxy -> derselbe Benutzer wie Office
|
||
|
|
req = urllib.request.Request(
|
||
|
|
f"{self.base}/empfang/shell/context/me",
|
||
|
|
headers={"Cookie": session_cookie},
|
||
|
|
)
|
||
|
|
with urllib.request.urlopen(req, timeout=8) as r:
|
||
|
|
self.assertEqual(r.status, 200)
|
||
|
|
data = json.loads(r.read().decode("utf-8"))
|
||
|
|
self.assertEqual(data.get("user_id"), _USER["user_id"])
|
||
|
|
self.assertEqual(data.get("practice_id"), _USER["practice_id"])
|
||
|
|
self.assertEqual(data.get("display_name"), _USER["display_name"])
|
||
|
|
|
||
|
|
def test_context_me_401_without_cookie(self):
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(f"{self.base}/empfang/shell/context/me", timeout=8) as r:
|
||
|
|
code = r.status
|
||
|
|
except urllib.error.HTTPError as e:
|
||
|
|
code = e.code
|
||
|
|
self.assertEqual(code, 401)
|
||
|
|
|
||
|
|
def test_token_is_one_time(self):
|
||
|
|
s1, _ = self._launch_once("tok-once")
|
||
|
|
s2, _ = self._launch_once("tok-once")
|
||
|
|
self.assertEqual(s1, 302)
|
||
|
|
self.assertEqual(s2, 401, "verbrauchter Token muss abgelehnt werden")
|
||
|
|
|
||
|
|
# --- Einheiten: Cookie-Lokalisierung + No-Redirect-Opener ---
|
||
|
|
def test_localize_set_cookie_strips_domain_and_secure(self):
|
||
|
|
h = self.px._EmpfangTestProxyHandler
|
||
|
|
inst = h.__new__(h)
|
||
|
|
out = inst._localize_set_cookie(
|
||
|
|
"aza_session=V; HttpOnly; Path=/; SameSite=Lax; Secure; Domain=api.aza-medwork.ch"
|
||
|
|
)
|
||
|
|
self.assertIn("aza_session=V", out)
|
||
|
|
self.assertIn("HttpOnly", out)
|
||
|
|
self.assertIn("SameSite=Lax", out)
|
||
|
|
self.assertIn("Path=/", out)
|
||
|
|
self.assertNotIn("Domain", out)
|
||
|
|
self.assertNotIn("Secure", out)
|
||
|
|
|
||
|
|
def test_localize_set_cookie_keeps_plain_cookie(self):
|
||
|
|
h = self.px._EmpfangTestProxyHandler
|
||
|
|
inst = h.__new__(h)
|
||
|
|
out = inst._localize_set_cookie("aza_session=V; HttpOnly; Path=/; SameSite=Lax")
|
||
|
|
self.assertEqual(out, "aza_session=V; HttpOnly; Path=/; SameSite=Lax")
|
||
|
|
|
||
|
|
def test_proxy_does_not_follow_redirects(self):
|
||
|
|
self.assertIsNone(
|
||
|
|
self.px._NoRedirectHandler.redirect_request(
|
||
|
|
self.px._NoRedirectHandler(), None, None, 302, "", {}, "http://x/"
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_proxy_does_not_log_requests(self):
|
||
|
|
src = inspect.getsource(self.px._EmpfangTestProxyHandler.log_message)
|
||
|
|
self.assertIn("pass", src)
|
||
|
|
|
||
|
|
|
||
|
|
class ServerSideHandoffSourceTests(unittest.TestCase):
|
||
|
|
"""Office bleibt Quelle der Wahrheit; Token einmalig + an user_id/practice_id gebunden."""
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def setUpClass(cls):
|
||
|
|
cls.src = (ROOT / "empfang_routes.py").read_text(encoding="utf-8")
|
||
|
|
|
||
|
|
def test_token_one_time_consumed(self):
|
||
|
|
self.assertIn("del _shell_store[stok]", self.src)
|
||
|
|
|
||
|
|
def test_launch_sets_httponly_lax_cookie(self):
|
||
|
|
self.assertIn('"aza_session"', self.src)
|
||
|
|
self.assertIn("httponly=True", self.src)
|
||
|
|
self.assertIn('samesite="lax"', self.src)
|
||
|
|
|
||
|
|
def test_token_bound_to_user_and_practice(self):
|
||
|
|
self.assertIn('"user_id": uid', self.src)
|
||
|
|
self.assertIn('"practice_id": pid', self.src)
|
||
|
|
self.assertIn('"purpose": _SHELL_PURPOSE', self.src)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|