Show an existing NetKingdom sign-in before the account site continues it.
The account site said "Not signed in" while Authelia still had a session, and Sign in reused that identity. Ask Authelia who the session cookie is, show that name, and send a fresh sign-in only when a different identity is requested. Assistant: grok Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f
This commit is contained in:
parent
b987a3de9e
commit
560cdeed46
9 changed files with 500 additions and 19 deletions
106
tests/test_account_identity_disclosure.py
Normal file
106
tests/test_account_identity_disclosure.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""An existing NetKingdom sign-in is shown before the account site continues it."""
|
||||
import unittest
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import test_portal_navigation
|
||||
from test_web import invoke
|
||||
|
||||
|
||||
class RecordingLookup:
|
||||
def __init__(self, result):
|
||||
self.result = result
|
||||
self.headers = []
|
||||
|
||||
def username(self, header):
|
||||
self.headers.append(header)
|
||||
if isinstance(self.result, BaseException):
|
||||
raise self.result
|
||||
if callable(self.result):
|
||||
return self.result(header)
|
||||
return self.result
|
||||
|
||||
|
||||
class AccountIdentityDisclosureTests(unittest.TestCase):
|
||||
setUp = test_portal_navigation.PortalNavigationTests.setUp
|
||||
|
||||
def test_confirmed_sign_in_is_named_before_the_account_site_continues(self):
|
||||
def answer(header):
|
||||
if "authelia_session=super-secret-session" in header:
|
||||
return "platform-root"
|
||||
return None
|
||||
|
||||
self.app.identity_lookup = RecordingLookup(answer)
|
||||
cookie = "ue_session=absent; authelia_session=super-secret-session"
|
||||
response, body = invoke(self.app, "/", cookie=cookie)
|
||||
self.assertEqual("200 OK", response["status"])
|
||||
self.assertEqual(1, len(self.app.identity_lookup.headers))
|
||||
self.assertIn(b"NetKingdom sign-in is <strong>platform-root</strong>", body)
|
||||
self.assertIn(b"This account site has no session yet.", body)
|
||||
self.assertIn(b'href="/login">Continue as platform-root', body)
|
||||
self.assertIn(b'href="/login?fresh=1">Use a different identity', body)
|
||||
self.assertNotIn(b"Not signed in", body)
|
||||
self.assertNotIn(b"You are not signed in.", body)
|
||||
self.assertNotIn(b"Signed in as", body)
|
||||
self.assertNotIn(b"super-secret-session", body)
|
||||
self.assertNotIn(b"Active now", body)
|
||||
|
||||
_response, body = invoke(self.app, "/")
|
||||
self.assertIn(b'href="/login">Sign in', body)
|
||||
self.assertIn(b"Not signed in", body)
|
||||
self.assertNotIn(b"platform-root", body)
|
||||
self.assertEqual(2, len(self.app.identity_lookup.headers))
|
||||
|
||||
def test_account_session_is_not_replaced_by_the_netkingdom_cookie(self):
|
||||
self.app.identity_lookup = RecordingLookup("platform-root")
|
||||
_, body = invoke(
|
||||
self.app, "/", cookie="ue_session=member; authelia_session=super-secret-session"
|
||||
)
|
||||
self.assertIn(b"Signed in as", body)
|
||||
self.assertIn(b"sample.user", body)
|
||||
self.assertNotIn(b"platform-root", body)
|
||||
self.assertNotIn(b'href="/login"', body)
|
||||
self.assertEqual([], self.app.identity_lookup.headers)
|
||||
|
||||
def test_lookup_failure_or_unsafe_name_stays_signed_out(self):
|
||||
for result in [TimeoutError("slow"), "<script>alert(1)</script>", "platform root"]:
|
||||
with self.subTest(result=result):
|
||||
self.app.identity_lookup = RecordingLookup(result)
|
||||
_, body = invoke(self.app, "/", cookie="authelia_session=opaque")
|
||||
self.assertIn(b'href="/login">Sign in', body)
|
||||
self.assertIn(b"You are not signed in.", body)
|
||||
self.assertNotIn(b"Signed in as", body)
|
||||
self.assertNotIn(b"<script>", body)
|
||||
self.assertNotIn(b"platform root", body)
|
||||
|
||||
def test_logged_out_and_recovery_name_the_same_sign_in(self):
|
||||
self.app.identity_lookup = RecordingLookup("platform-root")
|
||||
cookie = "authelia_session=super-secret-session"
|
||||
_, logged_out = invoke(self.app, "/logged-out", cookie=cookie)
|
||||
self.assertIn(b"NetKingdom sign-in is <strong>platform-root</strong>", logged_out)
|
||||
self.assertIn(b"This account site has no session yet.", logged_out)
|
||||
self.assertNotIn(b"may still be active", logged_out)
|
||||
self.assertNotIn(b"super-secret-session", logged_out)
|
||||
self.assertIn(b"https://kc.example/account/logout", logged_out)
|
||||
_, recovery = invoke(self.app, "/access-recovery", cookie=cookie)
|
||||
self.assertIn(b"NetKingdom sign-in is <strong>platform-root</strong>", recovery)
|
||||
self.assertNotIn(b"Signed in as", recovery)
|
||||
self.assertIn(b"/logout", recovery)
|
||||
|
||||
def test_different_identity_requests_a_fresh_sign_in(self):
|
||||
response, _body = invoke(self.app, "/login", query="fresh=1")
|
||||
location = response["headers"]["Location"]
|
||||
query = parse_qs(urlparse(location).query)
|
||||
self.assertEqual(["login"], query["prompt"])
|
||||
self.assertEqual(["0"], query["max_age"])
|
||||
self.assertNotIn("acr_values", query)
|
||||
|
||||
def test_query_parameters_do_not_invent_the_shown_identity(self):
|
||||
self.app.identity_lookup = RecordingLookup("platform-root")
|
||||
_, body = invoke(
|
||||
self.app,
|
||||
"/",
|
||||
query="username=forged",
|
||||
cookie="authelia_session=super-secret-session",
|
||||
)
|
||||
self.assertIn(b"platform-root", body)
|
||||
self.assertNotIn(b"forged", body)
|
||||
141
tests/test_identity_state.py
Normal file
141
tests/test_identity_state.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""The account site may name a NetKingdom sign-in only after Authelia confirms it."""
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from user_engine.identity_state import (
|
||||
AutheliaIdentityState,
|
||||
authelia_session_token,
|
||||
username_from_state,
|
||||
validate_identity_state_url,
|
||||
_read_state,
|
||||
)
|
||||
|
||||
|
||||
class IdentityStateUrlTests(unittest.TestCase):
|
||||
def test_accepts_the_cluster_state_endpoint_and_public_https(self):
|
||||
cluster = "http://authelia.sso.svc.cluster.local:9091/api/state"
|
||||
public = "https://login.coulomb.social/api/state"
|
||||
self.assertEqual(cluster, validate_identity_state_url(cluster))
|
||||
self.assertEqual(public, validate_identity_state_url(public))
|
||||
|
||||
def test_rejects_anything_that_could_carry_the_session_cookie_elsewhere(self):
|
||||
for url in [
|
||||
"http://login.coulomb.social/api/state",
|
||||
"http://authelia.sso.svc.cluster.local.example/api/state",
|
||||
"http://169.254.169.254/api/state",
|
||||
"https://user:pass@login.coulomb.social/api/state",
|
||||
"https://login.coulomb.social/api/state?next=1",
|
||||
"https://login.coulomb.social/api/state#fragment",
|
||||
"https://login.coulomb.social/api/userinfo",
|
||||
"https://login.coulomb.social/api/state/",
|
||||
" https://login.coulomb.social/api/state",
|
||||
"http://svc.cluster.local/api/state",
|
||||
]:
|
||||
with self.subTest(url=url):
|
||||
with self.assertRaises(ValueError):
|
||||
validate_identity_state_url(url)
|
||||
|
||||
|
||||
class IdentityStateParseTests(unittest.TestCase):
|
||||
def test_wrapped_and_flat_confirmed_usernames_are_accepted(self):
|
||||
wrapped = {
|
||||
"status": "OK",
|
||||
"data": {"username": "platform-root", "authentication_level": 1},
|
||||
}
|
||||
flat = {"username": "bernd.worsch-99", "authentication_level": 2}
|
||||
self.assertEqual("platform-root", username_from_state(wrapped))
|
||||
self.assertEqual("bernd.worsch-99", username_from_state(flat))
|
||||
|
||||
def test_unconfirmed_or_unsafe_answers_are_ignored(self):
|
||||
for payload in [
|
||||
{"status": "OK", "data": {"username": "", "authentication_level": 0}},
|
||||
{"status": "OK", "data": {"username": "platform-root", "authentication_level": 0}},
|
||||
{"status": "OK", "data": {"username": "platform-root", "authentication_level": True}},
|
||||
{"status": "KO", "data": {"username": "platform-root", "authentication_level": 1}},
|
||||
{"username": "<script>", "authentication_level": 1},
|
||||
{"username": "platform root", "authentication_level": 1},
|
||||
{"data": {"username": "platform-root"}},
|
||||
[],
|
||||
]:
|
||||
with self.subTest(payload=payload):
|
||||
self.assertIsNone(username_from_state(payload))
|
||||
|
||||
def test_cookie_token_rejects_injection_and_other_cookies(self):
|
||||
header = "ue_session=keep; authelia_session=opaque.token-1; other=no"
|
||||
self.assertEqual("opaque.token-1", authelia_session_token(header))
|
||||
self.assertIsNone(authelia_session_token("authelia_session=bad\r\nCookie: x"))
|
||||
self.assertIsNone(authelia_session_token("authelia_session=" + ("a" * 4097)))
|
||||
self.assertIsNone(authelia_session_token(""))
|
||||
|
||||
def test_lookup_sends_only_the_session_token_and_fails_closed(self):
|
||||
seen = []
|
||||
|
||||
def reader(url, token, timeout):
|
||||
seen.append((url, token, timeout))
|
||||
if token == "broken":
|
||||
raise TimeoutError("slow")
|
||||
if token == "odd":
|
||||
return b'{"status":"OK","data":{"username":"platform-root","authentication_level":1}}'
|
||||
return b"not-json"
|
||||
|
||||
state = AutheliaIdentityState(
|
||||
"http://authelia.sso.svc.cluster.local:9091/api/state",
|
||||
reader=reader,
|
||||
)
|
||||
header = "ue_session=secret; authelia_session=odd; theme=dark"
|
||||
self.assertEqual("platform-root", state.username(header))
|
||||
self.assertEqual(
|
||||
[("http://authelia.sso.svc.cluster.local:9091/api/state", "odd", 2.0)],
|
||||
seen,
|
||||
)
|
||||
self.assertIsNone(state.username("authelia_session=broken"))
|
||||
self.assertIsNone(state.username("authelia_session=plain"))
|
||||
self.assertIsNone(state.username("authelia_session=bad\r\nX"))
|
||||
self.assertNotIn("secret", json.dumps(seen))
|
||||
|
||||
|
||||
class IdentityStateTransportTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.seen = []
|
||||
parent = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
parent.seen.append((self.path, self.headers.get("Cookie")))
|
||||
if self.path == "/api/state":
|
||||
body = json.dumps(
|
||||
{"status": "OK", "data": {"username": "platform-root", "authentication_level": 1}}
|
||||
).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "http://127.0.0.1:9/stolen")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.port = self.server.server_address[1]
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
def test_transport_sends_one_cookie_and_does_not_follow_redirects(self):
|
||||
url = f"http://127.0.0.1:{self.port}/api/state"
|
||||
body = _read_state(url, "opaque-token", 1)
|
||||
self.assertIn(b"platform-root", body)
|
||||
self.assertEqual([("/api/state", "authelia_session=opaque-token")], self.seen)
|
||||
with self.assertRaises(Exception):
|
||||
_read_state(f"http://127.0.0.1:{self.port}/redirect", "opaque-token", 1)
|
||||
self.assertEqual("/redirect", self.seen[-1][0])
|
||||
self.assertNotIn("/stolen", [path for path, _cookie in self.seen])
|
||||
|
|
@ -21,6 +21,12 @@ class OIDCClientTests(unittest.TestCase):
|
|||
self.assertIn(query["state"][0], self.client.pending)
|
||||
self.assertNotIn(self.client.pending[query["state"][0]].verifier, url.query)
|
||||
|
||||
def test_fresh_begin_asks_for_a_new_sign_in_without_raising_assurance(self):
|
||||
query = parse_qs(urlparse(self.client.begin(fresh=True)).query)
|
||||
self.assertEqual(["login"], query["prompt"])
|
||||
self.assertEqual(["0"], query["max_age"])
|
||||
self.assertNotIn("acr_values", query)
|
||||
|
||||
def test_begin_can_forward_a_tenant_hint_without_changing_session_authority(self):
|
||||
url = urlparse(self.client.begin(tenant_hint="tenant:friendly:binky"))
|
||||
self.assertEqual(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue