|
| 1 | +"""External authentication bridge for code.djangoproject.com. |
| 2 | +
|
| 3 | +The companion djangoproject.com endpoint authenticates the user and returns a |
| 4 | +short-lived JWT assertion to ``/django-auth/callback``. |
| 5 | +""" |
| 6 | + |
| 7 | +import os |
| 8 | +import secrets |
| 9 | +from urllib.parse import urlencode, urlsplit |
| 10 | + |
| 11 | +import jwt |
| 12 | +from trac.core import implements |
| 13 | +from trac.util.html import html as tag |
| 14 | +from trac.web.api import IRequestHandler, HTTPMethodNotAllowed |
| 15 | +from trac.web.auth import LoginModule |
| 16 | +from trac.web.chrome import INavigationContributor, add_warning |
| 17 | + |
| 18 | + |
| 19 | +ASSERTION_ISSUER = "https://www.djangoproject.com/" |
| 20 | +ASSERTION_AUDIENCE = "code.djangoproject.com" |
| 21 | +SHARED_SECRET_ENV = "DJANGO_TRAC_AUTH_SECRET" |
| 22 | +CLOCK_SKEW = 10 |
| 23 | + |
| 24 | + |
| 25 | +class InvalidAssertion(ValueError): |
| 26 | + """Raised when an external authentication assertion is invalid.""" |
| 27 | + |
| 28 | + |
| 29 | +def verify_assertion( |
| 30 | + token: str, |
| 31 | + *, |
| 32 | + secret: str, |
| 33 | + expected_state: str, |
| 34 | +) -> dict: |
| 35 | + """Verify an authentication assertion issued by djangoproject.com.""" |
| 36 | + |
| 37 | + try: |
| 38 | + claims = jwt.decode( |
| 39 | + token, |
| 40 | + secret, |
| 41 | + algorithms=["HS256"], |
| 42 | + audience=ASSERTION_AUDIENCE, |
| 43 | + issuer=ASSERTION_ISSUER, |
| 44 | + leeway=CLOCK_SKEW, |
| 45 | + options={ |
| 46 | + "require": [ |
| 47 | + "sub", |
| 48 | + "aud", |
| 49 | + "iss", |
| 50 | + "iat", |
| 51 | + "exp", |
| 52 | + "state", |
| 53 | + ], |
| 54 | + }, |
| 55 | + ) |
| 56 | + except jwt.PyJWTError as exc: |
| 57 | + raise InvalidAssertion("Invalid authentication assertion") from exc |
| 58 | + |
| 59 | + subject = claims["sub"] |
| 60 | + if ( |
| 61 | + not isinstance(subject, str) |
| 62 | + or not subject |
| 63 | + or subject == "anonymous" |
| 64 | + or any(character.isspace() for character in subject) |
| 65 | + ): |
| 66 | + raise InvalidAssertion("Invalid subject") |
| 67 | + |
| 68 | + state = claims["state"] |
| 69 | + if not isinstance(state, str) or state != expected_state: |
| 70 | + raise InvalidAssertion("Invalid state") |
| 71 | + |
| 72 | + return claims |
| 73 | + |
| 74 | + |
| 75 | +class DjangoExternalLoginModule(LoginModule): |
| 76 | + """Delegate authentication to djangoproject.com and create a Trac session.""" |
| 77 | + |
| 78 | + implements(IRequestHandler, INavigationContributor) |
| 79 | + |
| 80 | + auth_url = "https://www.djangoproject.com/accounts/trac/login/" |
| 81 | + |
| 82 | + login_path = "/django-auth/login" |
| 83 | + callback_path = "/django-auth/callback" |
| 84 | + logout_path = "/django-auth/logout" |
| 85 | + |
| 86 | + state_session_key = "django_auth_state" |
| 87 | + next_session_key = "django_auth_next" |
| 88 | + |
| 89 | + # INavigationContributor |
| 90 | + |
| 91 | + def get_active_navigation_item(self, req): |
| 92 | + return "django_login" |
| 93 | + |
| 94 | + def get_navigation_items(self, req): |
| 95 | + if req.authname and req.authname != "anonymous": |
| 96 | + yield ( |
| 97 | + "metanav", |
| 98 | + "login", |
| 99 | + f"logged in as {req.authname}", |
| 100 | + ) |
| 101 | + yield ( |
| 102 | + "metanav", |
| 103 | + "logout", |
| 104 | + tag.form( |
| 105 | + tag.div( |
| 106 | + tag.input( |
| 107 | + type="hidden", |
| 108 | + name="__FORM_TOKEN", |
| 109 | + value=req.form_token, |
| 110 | + ), |
| 111 | + tag.button( |
| 112 | + "Logout", |
| 113 | + name="logout", |
| 114 | + type="submit", |
| 115 | + ), |
| 116 | + ), |
| 117 | + action=req.href(self.logout_path), |
| 118 | + method="post", |
| 119 | + id="logout", |
| 120 | + class_="trac-logout", |
| 121 | + ), |
| 122 | + ) |
| 123 | + else: |
| 124 | + yield ( |
| 125 | + "metanav", |
| 126 | + "django_login", |
| 127 | + tag.a( |
| 128 | + "Django account login", |
| 129 | + href=req.href(self.login_path), |
| 130 | + ), |
| 131 | + ) |
| 132 | + |
| 133 | + # IRequestHandler |
| 134 | + |
| 135 | + def match_request(self, req): |
| 136 | + return req.path_info in { |
| 137 | + self.login_path, |
| 138 | + self.callback_path, |
| 139 | + self.logout_path, |
| 140 | + } |
| 141 | + |
| 142 | + def process_request(self, req): |
| 143 | + if req.path_info == self.login_path: |
| 144 | + return self._start_external_login(req) |
| 145 | + |
| 146 | + if req.path_info == self.callback_path: |
| 147 | + return self._finish_external_login(req) |
| 148 | + |
| 149 | + if req.path_info == self.logout_path: |
| 150 | + return self._logout(req) |
| 151 | + |
| 152 | + raise AssertionError(f"Unexpected authentication path: {req.path_info}") |
| 153 | + |
| 154 | + def _start_external_login(self, req): |
| 155 | + state = secrets.token_urlsafe(32) |
| 156 | + |
| 157 | + req.session[self.state_session_key] = state |
| 158 | + req.session[self.next_session_key] = self._safe_local_path( |
| 159 | + req.args.get("referer") or req.get_header("Referer") |
| 160 | + ) |
| 161 | + |
| 162 | + callback_url = req.abs_href(self.callback_path.lstrip("/")) |
| 163 | + query = urlencode( |
| 164 | + { |
| 165 | + "state": state, |
| 166 | + "callback": callback_url, |
| 167 | + } |
| 168 | + ) |
| 169 | + |
| 170 | + req.redirect(f"{self.auth_url}?{query}") |
| 171 | + |
| 172 | + def _finish_external_login(self, req): |
| 173 | + state = req.session.pop(self.state_session_key, None) |
| 174 | + if not state: |
| 175 | + self._reject( |
| 176 | + req, |
| 177 | + "Login session expired. Please try again.", |
| 178 | + ) |
| 179 | + |
| 180 | + token = req.args.get("assertion") |
| 181 | + if not token: |
| 182 | + self._reject( |
| 183 | + req, |
| 184 | + "The login response did not include an assertion.", |
| 185 | + ) |
| 186 | + |
| 187 | + secret = os.environ.get(SHARED_SECRET_ENV) |
| 188 | + if not secret: |
| 189 | + self.log.error( |
| 190 | + "Django authentication environment variable %s is not set", |
| 191 | + SHARED_SECRET_ENV, |
| 192 | + ) |
| 193 | + self._reject( |
| 194 | + req, |
| 195 | + "Django account login is not configured.", |
| 196 | + ) |
| 197 | + |
| 198 | + try: |
| 199 | + claims = verify_assertion( |
| 200 | + token, |
| 201 | + secret=secret, |
| 202 | + expected_state=state, |
| 203 | + ) |
| 204 | + except InvalidAssertion as exc: |
| 205 | + self.log.warning( |
| 206 | + "Rejected Django authentication assertion: %s", |
| 207 | + exc, |
| 208 | + ) |
| 209 | + self._reject( |
| 210 | + req, |
| 211 | + "Invalid or expired login response. Please try again.", |
| 212 | + ) |
| 213 | + |
| 214 | + req.environ["REMOTE_USER"] = claims["sub"] |
| 215 | + |
| 216 | + name = claims.get("name") |
| 217 | + if isinstance(name, str): |
| 218 | + req.session["name"] = name |
| 219 | + |
| 220 | + email = claims.get("email") |
| 221 | + if isinstance(email, str): |
| 222 | + req.session["email"] = email |
| 223 | + |
| 224 | + super()._do_login(req) |
| 225 | + |
| 226 | + self._redirect_to_local_path( |
| 227 | + req, |
| 228 | + req.session.pop(self.next_session_key, None), |
| 229 | + ) |
| 230 | + |
| 231 | + def _logout(self, req): |
| 232 | + if req.method != "POST": |
| 233 | + raise HTTPMethodNotAllowed("Logout requires a POST request.") |
| 234 | + |
| 235 | + self._do_logout(req) |
| 236 | + self._redirect_to_local_path(req, req.args.get("referer")) |
| 237 | + |
| 238 | + def _reject(self, req, message): |
| 239 | + req.session.pop(self.next_session_key, None) |
| 240 | + add_warning(req, message) |
| 241 | + self._redirect_to_local_path(req, None) |
| 242 | + |
| 243 | + def _redirect_to_local_path(self, req, candidate): |
| 244 | + req.redirect(req.abs_href(self._safe_local_path(candidate))) |
| 245 | + |
| 246 | + @staticmethod |
| 247 | + def _safe_local_path(candidate): |
| 248 | + """Return a local path, rejecting external redirect targets.""" |
| 249 | + |
| 250 | + if not candidate: |
| 251 | + return "/" |
| 252 | + |
| 253 | + parsed = urlsplit(candidate) |
| 254 | + |
| 255 | + if parsed.scheme or parsed.netloc: |
| 256 | + return "/" |
| 257 | + |
| 258 | + if not parsed.path.startswith("/") or parsed.path.startswith("//"): |
| 259 | + return "/" |
| 260 | + |
| 261 | + result = parsed.path |
| 262 | + if parsed.query: |
| 263 | + result += f"?{parsed.query}" |
| 264 | + |
| 265 | + return result |
0 commit comments