From 7097c100fa041c4379dcfe2570b9fb833e48b98c Mon Sep 17 00:00:00 2001 From: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:43:51 +0200 Subject: [PATCH] Added an external login to djangoproject.com. --- .TRACFREEZE.txt | 3 +- .github/workflows/tests.yml | 17 + DjangoPlugin/tracdjangoplugin/plugins.py | 2 + Dockerfile | 2 + ExternalAuthPlugin/setup.py | 14 + .../tracdjangoprojectauth/__init__.py | 0 .../tracdjangoprojectauth/plugins.py | 265 ++++++++ .../tracdjangoprojectauth/tests.py | 631 ++++++++++++++++++ requirements.txt | 2 + trac-env/conf/trac.ini | 3 +- trac-env/templates/plainlogin.html | 8 + 11 files changed, 945 insertions(+), 2 deletions(-) create mode 100644 ExternalAuthPlugin/setup.py create mode 100644 ExternalAuthPlugin/tracdjangoprojectauth/__init__.py create mode 100644 ExternalAuthPlugin/tracdjangoprojectauth/plugins.py create mode 100644 ExternalAuthPlugin/tracdjangoprojectauth/tests.py diff --git a/.TRACFREEZE.txt b/.TRACFREEZE.txt index 0640603..d49cab7 100644 --- a/.TRACFREEZE.txt +++ b/.TRACFREEZE.txt @@ -1,4 +1,4 @@ -# generated by traccheck.py on 2026-04-27 09:42:14 with Trac version 1.6 +# generated by traccheck.py on 2026-07-17 05:42:27 with Trac version 1.6 trac.admin.api.admincommandmanager trac.admin.console.tracadminhelpmacro trac.admin.web_ui.adminmodule @@ -109,6 +109,7 @@ tracdjangoplugin.plugins.githubbrowserwithsvnchangesets tracdjangoplugin.plugins.plainlogincomponent tracdjangoplugin.plugins.reservedusernamescomponent tracdjangoplugin.plugins.timelineticketcomponentfilter +tracdjangoprojectauth.plugins.djangoexternalloginmodule tracdragdrop.web_ui.tracdragdropmodule tracext.github.githubloginmodule tracext.github.githubpostcommithook diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4bdcd98..bf325e4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,23 @@ jobs: - name: Run tests run: python -m django test tracdjangoplugin.tests + tracdjangoprojectauthplugin: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install system package dependencies + run: | + sudo apt-get update + sudo apt-get -y install subversion + - name: Install requirements + run: python -m pip install -r requirements.txt + - name: Run tests + run: python -m unittest -v ExternalAuthPlugin.tracdjangoprojectauth.tests + traccheck: runs-on: ubuntu-24.04 steps: diff --git a/DjangoPlugin/tracdjangoplugin/plugins.py b/DjangoPlugin/tracdjangoplugin/plugins.py index 14a86cb..9148402 100644 --- a/DjangoPlugin/tracdjangoplugin/plugins.py +++ b/DjangoPlugin/tracdjangoplugin/plugins.py @@ -1,3 +1,4 @@ +import os from urllib.parse import urlparse from trac.config import ListOption @@ -243,6 +244,7 @@ def do_get(self, req): return "plainlogin.html", { "form": AuthenticationForm(), "referer": req.args.get("referer", ""), + "external_auth_enabled": bool(os.environ.get("DJANGO_TRAC_AUTH_SECRET")), } def do_post(self, req): diff --git a/Dockerfile b/Dockerfile index e0a8601..fb4667c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ WORKDIR /code # set environment varibles ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +ENV DJANGO_TRAC_AUTH_SECRET=examplesecret # getting postgres from PGDG (https://wiki.postgresql.org/wiki/Apt) # gnupg is required to run apt.postgresql.org.sh @@ -31,6 +32,7 @@ RUN apt-get update \ # install python dependencies COPY ./requirements.txt ./requirements.txt COPY ./DjangoPlugin ./DjangoPlugin +COPY ./ExternalAuthPlugin ./ExternalAuthPlugin RUN apt-get update \ && apt-get install --assume-yes --no-install-recommends \ diff --git a/ExternalAuthPlugin/setup.py b/ExternalAuthPlugin/setup.py new file mode 100644 index 0000000..e41a2c5 --- /dev/null +++ b/ExternalAuthPlugin/setup.py @@ -0,0 +1,14 @@ +from setuptools import find_packages, setup + +setup( + name="website-auth-plugin", + version="1.0", + packages=find_packages(), + install_requires=[ + "Trac>=1.6", + "PyJWT>=2,<3", + ], + entry_points={ + "trac.plugins": ["tracdjangoprojectauth = tracdjangoprojectauth.plugins"] + }, +) diff --git a/ExternalAuthPlugin/tracdjangoprojectauth/__init__.py b/ExternalAuthPlugin/tracdjangoprojectauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ExternalAuthPlugin/tracdjangoprojectauth/plugins.py b/ExternalAuthPlugin/tracdjangoprojectauth/plugins.py new file mode 100644 index 0000000..d8c3798 --- /dev/null +++ b/ExternalAuthPlugin/tracdjangoprojectauth/plugins.py @@ -0,0 +1,265 @@ +"""External authentication bridge for code.djangoproject.com. + +The companion djangoproject.com endpoint authenticates the user and returns a +short-lived JWT assertion to ``/django-auth/callback``. +""" + +import os +import secrets +from urllib.parse import urlencode, urlsplit + +import jwt +from trac.core import implements +from trac.util.html import html as tag +from trac.web.api import IRequestHandler, HTTPMethodNotAllowed +from trac.web.auth import LoginModule +from trac.web.chrome import INavigationContributor, add_warning + + +ASSERTION_ISSUER = "https://www.djangoproject.com/" +ASSERTION_AUDIENCE = "code.djangoproject.com" +SHARED_SECRET_ENV = "DJANGO_TRAC_AUTH_SECRET" +CLOCK_SKEW = 10 + + +class InvalidAssertion(ValueError): + """Raised when an external authentication assertion is invalid.""" + + +def verify_assertion( + token: str, + *, + secret: str, + expected_state: str, +) -> dict: + """Verify an authentication assertion issued by djangoproject.com.""" + + try: + claims = jwt.decode( + token, + secret, + algorithms=["HS256"], + audience=ASSERTION_AUDIENCE, + issuer=ASSERTION_ISSUER, + leeway=CLOCK_SKEW, + options={ + "require": [ + "sub", + "aud", + "iss", + "iat", + "exp", + "state", + ], + }, + ) + except jwt.PyJWTError as exc: + raise InvalidAssertion("Invalid authentication assertion") from exc + + subject = claims["sub"] + if ( + not isinstance(subject, str) + or not subject + or subject == "anonymous" + or any(character.isspace() for character in subject) + ): + raise InvalidAssertion("Invalid subject") + + state = claims["state"] + if not isinstance(state, str) or state != expected_state: + raise InvalidAssertion("Invalid state") + + return claims + + +class DjangoExternalLoginModule(LoginModule): + """Delegate authentication to djangoproject.com and create a Trac session.""" + + implements(IRequestHandler, INavigationContributor) + + auth_url = "https://www.djangoproject.com/accounts/trac/login/" + + login_path = "/django-auth/login" + callback_path = "/django-auth/callback" + logout_path = "/django-auth/logout" + + state_session_key = "django_auth_state" + next_session_key = "django_auth_next" + + # INavigationContributor + + def get_active_navigation_item(self, req): + return "django_login" + + def get_navigation_items(self, req): + if req.authname and req.authname != "anonymous": + yield ( + "metanav", + "login", + f"logged in as {req.authname}", + ) + yield ( + "metanav", + "logout", + tag.form( + tag.div( + tag.input( + type="hidden", + name="__FORM_TOKEN", + value=req.form_token, + ), + tag.button( + "Logout", + name="logout", + type="submit", + ), + ), + action=req.href(self.logout_path), + method="post", + id="logout", + class_="trac-logout", + ), + ) + else: + yield ( + "metanav", + "django_login", + tag.a( + "Django account login", + href=req.href(self.login_path), + ), + ) + + # IRequestHandler + + def match_request(self, req): + return req.path_info in { + self.login_path, + self.callback_path, + self.logout_path, + } + + def process_request(self, req): + if req.path_info == self.login_path: + return self._start_external_login(req) + + if req.path_info == self.callback_path: + return self._finish_external_login(req) + + if req.path_info == self.logout_path: + return self._logout(req) + + raise AssertionError(f"Unexpected authentication path: {req.path_info}") + + def _start_external_login(self, req): + state = secrets.token_urlsafe(32) + + req.session[self.state_session_key] = state + req.session[self.next_session_key] = self._safe_local_path( + req.args.get("referer") or req.get_header("Referer") + ) + + callback_url = req.abs_href(self.callback_path.lstrip("/")) + query = urlencode( + { + "state": state, + "callback": callback_url, + } + ) + + req.redirect(f"{self.auth_url}?{query}") + + def _finish_external_login(self, req): + state = req.session.pop(self.state_session_key, None) + if not state: + self._reject( + req, + "Login session expired. Please try again.", + ) + + token = req.args.get("assertion") + if not token: + self._reject( + req, + "The login response did not include an assertion.", + ) + + secret = os.environ.get(SHARED_SECRET_ENV) + if not secret: + self.log.error( + "Django authentication environment variable %s is not set", + SHARED_SECRET_ENV, + ) + self._reject( + req, + "Django account login is not configured.", + ) + + try: + claims = verify_assertion( + token, + secret=secret, + expected_state=state, + ) + except InvalidAssertion as exc: + self.log.warning( + "Rejected Django authentication assertion: %s", + exc, + ) + self._reject( + req, + "Invalid or expired login response. Please try again.", + ) + + req.environ["REMOTE_USER"] = claims["sub"] + + name = claims.get("name") + if isinstance(name, str): + req.session["name"] = name + + email = claims.get("email") + if isinstance(email, str): + req.session["email"] = email + + super()._do_login(req) + + self._redirect_to_local_path( + req, + req.session.pop(self.next_session_key, None), + ) + + def _logout(self, req): + if req.method != "POST": + raise HTTPMethodNotAllowed("Logout requires a POST request.") + + self._do_logout(req) + self._redirect_to_local_path(req, req.args.get("referer")) + + def _reject(self, req, message): + req.session.pop(self.next_session_key, None) + add_warning(req, message) + self._redirect_to_local_path(req, None) + + def _redirect_to_local_path(self, req, candidate): + req.redirect(req.abs_href(self._safe_local_path(candidate))) + + @staticmethod + def _safe_local_path(candidate): + """Return a local path, rejecting external redirect targets.""" + + if not candidate: + return "/" + + parsed = urlsplit(candidate) + + if parsed.scheme or parsed.netloc: + return "/" + + if not parsed.path.startswith("/") or parsed.path.startswith("//"): + return "/" + + result = parsed.path + if parsed.query: + result += f"?{parsed.query}" + + return result diff --git a/ExternalAuthPlugin/tracdjangoprojectauth/tests.py b/ExternalAuthPlugin/tracdjangoprojectauth/tests.py new file mode 100644 index 0000000..ef22dce --- /dev/null +++ b/ExternalAuthPlugin/tracdjangoprojectauth/tests.py @@ -0,0 +1,631 @@ +"""Tests for the external Django authentication bridge.""" + +import os +import time +import unittest +from types import SimpleNamespace +from unittest.mock import ANY, Mock, patch +from urllib.parse import parse_qs, urlsplit + +import jwt +from trac.test import EnvironmentStub +from trac.web.api import HTTPMethodNotAllowed +from trac.web.auth import LoginModule + +from .plugins import ( + ASSERTION_AUDIENCE, + ASSERTION_ISSUER, + SHARED_SECRET_ENV, + DjangoExternalLoginModule, + InvalidAssertion, + verify_assertion, +) + + +SECRET = "test-secret-that-is-long-enough-for-tests" +STATE = "test-login-state" +USERNAME = "alice" + + +class Redirected(Exception): + """Raised by test doubles when code attempts to redirect.""" + + +def make_assertion( + *, + secret=SECRET, + state=STATE, + subject=USERNAME, + issuer=ASSERTION_ISSUER, + audience=ASSERTION_AUDIENCE, + issued_at=None, + expires_at=None, + algorithm="HS256", + **extra_claims, +): + """Create a JWT assertion for tests.""" + + now = int(time.time()) + issued_at = now if issued_at is None else issued_at + expires_at = now + 60 if expires_at is None else expires_at + + claims = { + "sub": subject, + "aud": audience, + "iss": issuer, + "iat": issued_at, + "exp": expires_at, + "state": state, + **extra_claims, + } + + return jwt.encode( + claims, + secret, + algorithm=algorithm, + ) + + +def make_request( + *, + path_info="/", + method="GET", + args=None, + session=None, + authname="anonymous", + referer=None, +): + """Create the minimal request object required by the component.""" + + headers = {} + if referer is not None: + headers["Referer"] = referer + + request = SimpleNamespace( + path_info=path_info, + method=method, + args={} if args is None else args, + session={} if session is None else session, + environ={}, + authname=authname, + form_token="test-form-token", + href=lambda path: path, + abs_href=lambda path="": f"https://code.djangoproject.com/{path}", + get_header=lambda name: headers.get(name), + ) + + return request + + +class VerifyAssertionTestCase(unittest.TestCase): + def test_valid_assertion(self): + token = make_assertion( + name="Alice Example", + email="alice@example.com", + ) + + claims = verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + self.assertEqual(USERNAME, claims["sub"]) + self.assertEqual("Alice Example", claims["name"]) + self.assertEqual("alice@example.com", claims["email"]) + + def test_rejects_wrong_secret(self): + token = make_assertion() + + with self.assertRaisesRegex( + InvalidAssertion, + "Invalid authentication assertion", + ): + verify_assertion( + token, + secret="wrong-secret", + expected_state=STATE, + ) + + def test_rejects_wrong_state(self): + token = make_assertion() + + with self.assertRaisesRegex(InvalidAssertion, "Invalid state"): + verify_assertion( + token, + secret=SECRET, + expected_state="different-state", + ) + + def test_rejects_non_string_state(self): + token = make_assertion(state=123) + + with self.assertRaisesRegex(InvalidAssertion, "Invalid state"): + verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + def test_rejects_wrong_audience(self): + token = make_assertion(audience="another-service") + + with self.assertRaisesRegex( + InvalidAssertion, + "Invalid authentication assertion", + ): + verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + def test_rejects_wrong_issuer(self): + token = make_assertion(issuer="https://example.com/") + + with self.assertRaisesRegex( + InvalidAssertion, + "Invalid authentication assertion", + ): + verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + def test_rejects_expired_assertion(self): + now = int(time.time()) + token = make_assertion( + issued_at=now - 120, + expires_at=now - 60, + ) + + with self.assertRaisesRegex( + InvalidAssertion, + "Invalid authentication assertion", + ): + verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + def test_allows_expiration_within_clock_skew(self): + now = int(time.time()) + token = make_assertion( + issued_at=now - 60, + expires_at=now - 5, + ) + + claims = verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + self.assertEqual(USERNAME, claims["sub"]) + + def test_rejects_missing_required_claim(self): + now = int(time.time()) + token = jwt.encode( + { + "sub": USERNAME, + "aud": ASSERTION_AUDIENCE, + "iss": ASSERTION_ISSUER, + "iat": now, + "exp": now + 60, + # state deliberately omitted + }, + SECRET, + algorithm="HS256", + ) + + with self.assertRaisesRegex( + InvalidAssertion, + "Invalid authentication assertion", + ): + verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + def test_rejects_empty_subject(self): + token = make_assertion(subject="") + + with self.assertRaisesRegex(InvalidAssertion, "Invalid subject"): + verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + def test_rejects_anonymous_subject(self): + token = make_assertion(subject="anonymous") + + with self.assertRaisesRegex(InvalidAssertion, "Invalid subject"): + verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + def test_rejects_subject_containing_whitespace(self): + for subject in ("alice smith", "alice\nsmith", " alice"): + with self.subTest(subject=subject): + token = make_assertion(subject=subject) + + with self.assertRaisesRegex( + InvalidAssertion, + "Invalid subject", + ): + verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + def test_rejects_non_string_subject(self): + token = make_assertion(subject=123) + + with self.assertRaisesRegex( + InvalidAssertion, + "Invalid authentication assertion", + ): + verify_assertion( + token, + secret=SECRET, + expected_state=STATE, + ) + + +class DjangoExternalLoginModuleTestCase(unittest.TestCase): + def setUp(self): + self.env = EnvironmentStub() + self.module = DjangoExternalLoginModule(self.env) + + def tearDown(self): + self.env.shutdown() + + def test_matches_authentication_paths(self): + matching_paths = ( + "/django-auth/login", + "/django-auth/callback", + "/django-auth/logout", + ) + + for path in matching_paths: + with self.subTest(path=path): + req = make_request(path_info=path) + self.assertTrue(self.module.match_request(req)) + + def test_does_not_match_other_or_trailing_slash_paths(self): + nonmatching_paths = ( + "/", + "/login", + "/django-auth/login/", + "/django-auth/callback/", + "/django-auth/logout/", + ) + + for path in nonmatching_paths: + with self.subTest(path=path): + req = make_request(path_info=path) + self.assertFalse(self.module.match_request(req)) + + def test_safe_local_path_accepts_local_path(self): + self.assertEqual( + "/ticket/123?format=rss", + self.module._safe_local_path("/ticket/123?format=rss"), + ) + + def test_safe_local_path_ignores_fragment(self): + self.assertEqual( + "/ticket/123", + self.module._safe_local_path("/ticket/123#comment:1"), + ) + + def test_safe_local_path_rejects_external_urls(self): + unsafe_values = ( + None, + "", + "ticket/123", + "https://example.com/", + "//example.com/path", + "javascript:alert(1)", + ) + + for value in unsafe_values: + with self.subTest(value=value): + self.assertEqual( + "/", + self.module._safe_local_path(value), + ) + + def test_start_login_saves_state_and_safe_destination(self): + session = {} + req = make_request( + path_info=self.module.login_path, + args={"referer": "/ticket/123?format=rss"}, + session=session, + ) + req.redirect = Mock(side_effect=Redirected) + + with self.assertRaises(Redirected): + self.module._start_external_login(req) + + self.assertIn(self.module.state_session_key, session) + self.assertTrue(session[self.module.state_session_key]) + self.assertEqual( + "/ticket/123?format=rss", + session[self.module.next_session_key], + ) + + redirect_url = req.redirect.call_args.args[0] + parsed = urlsplit(redirect_url) + query = parse_qs(parsed.query) + + self.assertEqual( + "https://www.djangoproject.com/accounts/trac/login/", + f"{parsed.scheme}://{parsed.netloc}{parsed.path}", + ) + self.assertEqual( + [session[self.module.state_session_key]], + query["state"], + ) + self.assertEqual( + ["https://code.djangoproject.com/django-auth/callback"], + query["callback"], + ) + + def test_start_login_rejects_external_referer(self): + session = {} + req = make_request( + path_info=self.module.login_path, + args={"referer": "https://attacker.example/path"}, + session=session, + ) + req.redirect = Mock(side_effect=Redirected) + + with self.assertRaises(Redirected): + self.module._start_external_login(req) + + self.assertEqual("/", session[self.module.next_session_key]) + + def test_start_login_uses_referer_header(self): + session = {} + req = make_request( + path_info=self.module.login_path, + session=session, + referer="/wiki/Django", + ) + req.redirect = Mock(side_effect=Redirected) + + with self.assertRaises(Redirected): + self.module._start_external_login(req) + + self.assertEqual( + "/wiki/Django", + session[self.module.next_session_key], + ) + + @patch.dict(os.environ, {SHARED_SECRET_ENV: SECRET}, clear=False) + def test_finish_login_authenticates_and_redirects(self): + token = make_assertion( + name="Alice Example", + email="alice@example.com", + ) + session = { + self.module.state_session_key: STATE, + self.module.next_session_key: "/ticket/123", + } + req = make_request( + path_info=self.module.callback_path, + args={"assertion": token}, + session=session, + ) + + with ( + patch.object(LoginModule, "_do_login") as do_login, + patch.object( + self.module, + "_redirect_to_local_path", + side_effect=Redirected, + ) as redirect, + ): + with self.assertRaises(Redirected): + self.module._finish_external_login(req) + + self.assertEqual(USERNAME, req.environ["REMOTE_USER"]) + self.assertEqual("Alice Example", session["name"]) + self.assertEqual("alice@example.com", session["email"]) + self.assertNotIn(self.module.state_session_key, session) + self.assertNotIn(self.module.next_session_key, session) + + do_login.assert_called_once_with(req) + redirect.assert_called_once_with(req, "/ticket/123") + + @patch.dict(os.environ, {SHARED_SECRET_ENV: SECRET}, clear=False) + def test_finish_login_ignores_non_string_optional_claims(self): + token = make_assertion( + name=["Alice"], + email={"address": "alice@example.com"}, + ) + session = { + self.module.state_session_key: STATE, + self.module.next_session_key: "/", + } + req = make_request( + path_info=self.module.callback_path, + args={"assertion": token}, + session=session, + ) + + with ( + patch.object(LoginModule, "_do_login"), + patch.object( + self.module, + "_redirect_to_local_path", + side_effect=Redirected, + ), + ): + with self.assertRaises(Redirected): + self.module._finish_external_login(req) + + self.assertNotIn("name", session) + self.assertNotIn("email", session) + + def test_finish_login_rejects_missing_session_state(self): + req = make_request( + path_info=self.module.callback_path, + args={"assertion": make_assertion()}, + ) + + with patch.object( + self.module, + "_reject", + side_effect=Redirected, + ) as reject: + with self.assertRaises(Redirected): + self.module._finish_external_login(req) + + reject.assert_called_once_with(req, ANY) + + @patch.dict(os.environ, {SHARED_SECRET_ENV: SECRET}, clear=False) + def test_finish_login_rejects_missing_assertion(self): + req = make_request( + path_info=self.module.callback_path, + session={self.module.state_session_key: STATE}, + ) + + with patch.object( + self.module, + "_reject", + side_effect=Redirected, + ) as reject: + with self.assertRaises(Redirected): + self.module._finish_external_login(req) + + reject.assert_called_once_with(req, ANY) + self.assertNotIn(self.module.state_session_key, req.session) + + @patch.dict(os.environ, {}, clear=True) + def test_finish_login_rejects_missing_secret(self): + req = make_request( + path_info=self.module.callback_path, + args={"assertion": make_assertion()}, + session={self.module.state_session_key: STATE}, + ) + + with patch.object( + self.module, + "_reject", + side_effect=Redirected, + ) as reject: + with self.assertRaises(Redirected): + self.module._finish_external_login(req) + + reject.assert_called_once_with(req, ANY) + + @patch.dict(os.environ, {SHARED_SECRET_ENV: SECRET}, clear=False) + def test_finish_login_rejects_invalid_assertion(self): + req = make_request( + path_info=self.module.callback_path, + args={"assertion": make_assertion(state="wrong-state")}, + session={ + self.module.state_session_key: STATE, + self.module.next_session_key: "/ticket/123", + }, + ) + + with patch.object( + self.module, + "_reject", + side_effect=Redirected, + ) as reject: + with self.assertRaises(Redirected): + self.module._finish_external_login(req) + + reject.assert_called_once_with(req, ANY) + + def test_logout_requires_post(self): + req = make_request( + path_info=self.module.logout_path, + method="GET", + ) + + with self.assertRaises(HTTPMethodNotAllowed): + self.module._logout(req) + + def test_logout_posts_and_redirects(self): + req = make_request( + path_info=self.module.logout_path, + method="POST", + args={"referer": "/ticket/123"}, + ) + + with ( + patch.object(self.module, "_do_logout") as do_logout, + patch.object( + self.module, + "_redirect_to_local_path", + side_effect=Redirected, + ) as redirect, + ): + with self.assertRaises(Redirected): + self.module._logout(req) + + do_logout.assert_called_once_with(req) + redirect.assert_called_once_with(req, "/ticket/123") + + def test_process_request_routes_login(self): + req = make_request(path_info=self.module.login_path) + + with patch.object( + self.module, + "_start_external_login", + return_value="login-result", + ) as handler: + result = self.module.process_request(req) + + self.assertEqual("login-result", result) + handler.assert_called_once_with(req) + + def test_process_request_routes_callback(self): + req = make_request(path_info=self.module.callback_path) + + with patch.object( + self.module, + "_finish_external_login", + return_value="callback-result", + ) as handler: + result = self.module.process_request(req) + + self.assertEqual("callback-result", result) + handler.assert_called_once_with(req) + + def test_process_request_routes_logout(self): + req = make_request(path_info=self.module.logout_path) + + with patch.object( + self.module, + "_logout", + return_value="logout-result", + ) as handler: + result = self.module.process_request(req) + + self.assertEqual("logout-result", result) + handler.assert_called_once_with(req) + + def test_process_request_rejects_unexpected_path(self): + req = make_request(path_info="/unexpected") + + with self.assertRaisesRegex( + AssertionError, + "Unexpected authentication path", + ): + self.module.process_request(req) + + +if __name__ == "__main__": + unittest.main() diff --git a/requirements.txt b/requirements.txt index bd8f9c8..b949465 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ Trac[babel, pygments, rest]==1.6.0 psycopg2==2.9.9 --no-binary=psycopg2 Django==5.2.14 libsass==0.23.0 +PyJWT==2.13.0 # Optional Trac dependencies that make DeprecationWarning go away. # When upgrading Trac or Python, check if these dependencies are still needed @@ -22,3 +23,4 @@ gunicorn==23.0.0 sentry-sdk==2.8.0 -e ./DjangoPlugin +-e ./ExternalAuthPlugin diff --git a/trac-env/conf/trac.ini b/trac-env/conf/trac.ini index ffce553..24f4328 100644 --- a/trac-env/conf/trac.ini +++ b/trac-env/conf/trac.ini @@ -27,6 +27,7 @@ trac.versioncontrol.web_ui.log.logmodule = disabled trac.web.auth.loginmodule = disabled trac.wiki.web_ui.wikimodule = disabled tracdjangoplugin.* = enabled +tracdjangoprojectauth.plugins.* = enabled tracdragdrop.* = enabled tracext.github.githubloginmodule = enabled tracext.github.githubpostcommithook = enabled @@ -282,7 +283,7 @@ ticket_show_component = enabled auth_cookie_lifetime = 1209600 base_url = https://code.djangoproject.com check_auth_ip = enabled -database = postgres://code.djangoproject@/code.djangoproject +database = postgres://code.djangoproject:secret@db/code.djangoproject default_charset = utf-8 default_handler = CustomWikiModule ignore_auth_case = disabled diff --git a/trac-env/templates/plainlogin.html b/trac-env/templates/plainlogin.html index b91c9d9..5644704 100644 --- a/trac-env/templates/plainlogin.html +++ b/trac-env/templates/plainlogin.html @@ -18,6 +18,7 @@