|
8 | 8 | from __future__ import annotations
|
9 | 9 |
|
10 | 10 | import binascii
|
| 11 | +import datetime |
11 | 12 | import os
|
12 | 13 | import re
|
13 | 14 | import sys
|
14 | 15 | import uuid
|
15 | 16 | from dataclasses import asdict, dataclass
|
| 17 | +from http.cookies import Morsel |
16 | 18 | from typing import TYPE_CHECKING, Any, Awaitable
|
17 | 19 |
|
18 |
| -from tornado import web |
| 20 | +from tornado import escape, httputil, web |
19 | 21 | from traitlets import Bool, Dict, Type, Unicode, default
|
20 | 22 | from traitlets.config import LoggingConfigurable
|
21 | 23 |
|
|
28 | 30 | from jupyter_server.base.handlers import JupyterHandler
|
29 | 31 | from jupyter_server.serverapp import ServerApp
|
30 | 32 |
|
| 33 | +_non_alphanum = re.compile(r"[^A-Za-z0-9]") |
| 34 | + |
31 | 35 |
|
32 | 36 | @dataclass
|
33 | 37 | class User:
|
@@ -120,7 +124,37 @@ class IdentityProvider(LoggingConfigurable):
|
120 | 124 | .. versionadded:: 2.0
|
121 | 125 | """
|
122 | 126 |
|
123 |
| - cookie_name = Unicode(config=True) |
| 127 | + cookie_name = Unicode( |
| 128 | + "", |
| 129 | + config=True, |
| 130 | + help=_i18n("Name of the cookie to set for persisting login. Default: username-${Host}."), |
| 131 | + ) |
| 132 | + |
| 133 | + cookie_options = Dict( |
| 134 | + config=True, |
| 135 | + help=_i18n( |
| 136 | + "Extra keyword arguments to pass to `set_secure_cookie`." |
| 137 | + " See tornado's set_secure_cookie docs for details." |
| 138 | + ), |
| 139 | + ) |
| 140 | + |
| 141 | + secure_cookie = Bool( |
| 142 | + None, |
| 143 | + allow_none=True, |
| 144 | + config=True, |
| 145 | + help=_i18n( |
| 146 | + "Specify whether login cookie should have the `secure` property (HTTPS-only)." |
| 147 | + "Only needed when protocol-detection gives the wrong answer due to proxies." |
| 148 | + ), |
| 149 | + ) |
| 150 | + |
| 151 | + get_secure_cookie_kwargs = Dict( |
| 152 | + config=True, |
| 153 | + help=_i18n( |
| 154 | + "Extra keyword arguments to pass to `get_secure_cookie`." |
| 155 | + " See tornado's get_secure_cookie docs for details." |
| 156 | + ), |
| 157 | + ) |
124 | 158 |
|
125 | 159 | token = Unicode(
|
126 | 160 | "<generated>",
|
@@ -219,9 +253,11 @@ async def _get_user(self, handler: JupyterHandler) -> User | None:
|
219 | 253 | # If an invalid cookie was sent, clear it to prevent unnecessary
|
220 | 254 | # extra warnings. But don't do this on a request with *no* cookie,
|
221 | 255 | # because that can erroneously log you out (see gh-3365)
|
222 |
| - if handler.get_cookie(handler.cookie_name) is not None: |
223 |
| - handler.log.warning("Clearing invalid/expired login cookie %s", handler.cookie_name) |
224 |
| - handler.clear_login_cookie() |
| 256 | + cookie_name = self.get_cookie_name(handler) |
| 257 | + cookie = handler.get_cookie(cookie_name) |
| 258 | + if cookie is not None: |
| 259 | + self.log.warning(f"Clearing invalid/expired login cookie {cookie_name}") |
| 260 | + self.clear_login_cookie(handler) |
225 | 261 | if not self.auth_enabled:
|
226 | 262 | # Completely insecure! No authentication at all.
|
227 | 263 | # No need to warn here, though; validate_security will have already done that.
|
@@ -260,24 +296,84 @@ def user_from_cookie(self, cookie_value: str) -> User | None:
|
260 | 296 | """Inverse of user_to_cookie"""
|
261 | 297 | return User(username=cookie_value)
|
262 | 298 |
|
| 299 | + def get_cookie_name(self, handler: JupyterHandler) -> str: |
| 300 | + """Return the login cookie name |
| 301 | +
|
| 302 | + Uses IdentityProvider.cookie_name, if defined. |
| 303 | + Default is to generate a string taking host into account to avoid |
| 304 | + collisions for multiple servers on one hostname with different ports. |
| 305 | + """ |
| 306 | + if self.cookie_name: |
| 307 | + return self.cookie_name |
| 308 | + else: |
| 309 | + return _non_alphanum.sub("-", f"username-{handler.request.host}") |
| 310 | + |
263 | 311 | def set_login_cookie(self, handler: JupyterHandler, user: User) -> None:
|
264 | 312 | """Call this on handlers to set the login cookie for success"""
|
265 |
| - cookie_options = handler.settings.get("cookie_options", {}) |
| 313 | + cookie_options = {} |
| 314 | + cookie_options.update(self.cookie_options) |
266 | 315 | cookie_options.setdefault("httponly", True)
|
267 | 316 | # tornado <4.2 has a bug that considers secure==True as soon as
|
268 | 317 | # 'secure' kwarg is passed to set_secure_cookie
|
269 |
| - if handler.settings.get("secure_cookie", handler.request.protocol == "https"): |
| 318 | + secure_cookie = self.secure_cookie |
| 319 | + if secure_cookie is None: |
| 320 | + secure_cookie = handler.request.protocol == "https" |
| 321 | + if secure_cookie: |
270 | 322 | cookie_options.setdefault("secure", True)
|
271 | 323 | cookie_options.setdefault("path", handler.base_url)
|
272 |
| - handler.set_secure_cookie(handler.cookie_name, self.user_to_cookie(user), **cookie_options) |
| 324 | + cookie_name = self.get_cookie_name(handler) |
| 325 | + handler.set_secure_cookie(cookie_name, self.user_to_cookie(user), **cookie_options) |
| 326 | + |
| 327 | + def _force_clear_cookie( |
| 328 | + self, handler: JupyterHandler, name: str, path: str = "/", domain: str | None = None |
| 329 | + ) -> None: |
| 330 | + """Deletes the cookie with the given name. |
| 331 | +
|
| 332 | + Tornado's cookie handling currently (Jan 2018) stores cookies in a dict |
| 333 | + keyed by name, so it can only modify one cookie with a given name per |
| 334 | + response. The browser can store multiple cookies with the same name |
| 335 | + but different domains and/or paths. This method lets us clear multiple |
| 336 | + cookies with the same name. |
| 337 | +
|
| 338 | + Due to limitations of the cookie protocol, you must pass the same |
| 339 | + path and domain to clear a cookie as were used when that cookie |
| 340 | + was set (but there is no way to find out on the server side |
| 341 | + which values were used for a given cookie). |
| 342 | + """ |
| 343 | + name = escape.native_str(name) |
| 344 | + expires = datetime.datetime.utcnow() - datetime.timedelta(days=365) |
| 345 | + |
| 346 | + morsel: Morsel = Morsel() |
| 347 | + morsel.set(name, "", '""') |
| 348 | + morsel["expires"] = httputil.format_timestamp(expires) |
| 349 | + morsel["path"] = path |
| 350 | + if domain: |
| 351 | + morsel["domain"] = domain |
| 352 | + handler.add_header("Set-Cookie", morsel.OutputString()) |
| 353 | + |
| 354 | + def clear_login_cookie(self, handler: JupyterHandler) -> None: |
| 355 | + """Clear the login cookie, effectively logging out the session.""" |
| 356 | + cookie_options = {} |
| 357 | + cookie_options.update(self.cookie_options) |
| 358 | + path = cookie_options.setdefault("path", self.base_url) |
| 359 | + cookie_name = self.get_cookie_name(handler) |
| 360 | + handler.clear_cookie(cookie_name, path=path) |
| 361 | + if path and path != "/": |
| 362 | + # also clear cookie on / to ensure old cookies are cleared |
| 363 | + # after the change in path behavior. |
| 364 | + # N.B. This bypasses the normal cookie handling, which can't update |
| 365 | + # two cookies with the same name. See the method above. |
| 366 | + self._force_clear_cookie(handler, cookie_name) |
273 | 367 |
|
274 | 368 | def get_user_cookie(self, handler: JupyterHandler) -> User | None | Awaitable[User | None]:
|
275 | 369 | """Get user from a cookie
|
276 | 370 |
|
277 | 371 | Calls user_from_cookie to deserialize cookie value
|
278 | 372 | """
|
279 |
| - get_secure_cookie_kwargs = handler.settings.get("get_secure_cookie_kwargs", {}) |
280 |
| - _user_cookie = handler.get_secure_cookie(handler.cookie_name, **get_secure_cookie_kwargs) |
| 373 | + _user_cookie = handler.get_secure_cookie( |
| 374 | + self.get_cookie_name(handler), |
| 375 | + **self.get_secure_cookie_kwargs, |
| 376 | + ) |
281 | 377 | if not _user_cookie:
|
282 | 378 | return None
|
283 | 379 | user_cookie = _user_cookie.decode()
|
|
0 commit comments