Skip to content

Commit da50f2a

Browse files
authored
Add an option to have authentication enabled for all endpoints by default (#1392)
1 parent b3caa3c commit da50f2a

File tree

17 files changed

+632
-26
lines changed

17 files changed

+632
-26
lines changed

jupyter_server/auth/decorator.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,58 @@ async def inner(self, *args, **kwargs):
8585
return cast(FuncT, wrapper(method))
8686

8787
return cast(FuncT, wrapper)
88+
89+
90+
def allow_unauthenticated(method: FuncT) -> FuncT:
91+
"""A decorator for tornado.web.RequestHandler methods
92+
that allows any user to make the following request.
93+
94+
Selectively disables the 'authentication' layer of REST API which
95+
is active when `ServerApp.allow_unauthenticated_access = False`.
96+
97+
To be used exclusively on endpoints which may be considered public,
98+
for example the login page handler.
99+
100+
.. versionadded:: 2.13
101+
102+
Parameters
103+
----------
104+
method : bound callable
105+
the endpoint method to remove authentication from.
106+
"""
107+
108+
@wraps(method)
109+
def wrapper(self, *args, **kwargs):
110+
return method(self, *args, **kwargs)
111+
112+
setattr(wrapper, "__allow_unauthenticated", True)
113+
114+
return cast(FuncT, wrapper)
115+
116+
117+
def ws_authenticated(method: FuncT) -> FuncT:
118+
"""A decorator for websockets derived from `WebSocketHandler`
119+
that authenticates user before allowing to proceed.
120+
121+
Differently from tornado.web.authenticated, does not redirect
122+
to the login page, which would be meaningless for websockets.
123+
124+
.. versionadded:: 2.13
125+
126+
Parameters
127+
----------
128+
method : bound callable
129+
the endpoint method to add authentication for.
130+
"""
131+
132+
@wraps(method)
133+
def wrapper(self, *args, **kwargs):
134+
user = self.current_user
135+
if user is None:
136+
self.log.warning("Couldn't authenticate WebSocket connection")
137+
raise HTTPError(403)
138+
return method(self, *args, **kwargs)
139+
140+
setattr(wrapper, "__allow_unauthenticated", False)
141+
142+
return cast(FuncT, wrapper)

jupyter_server/auth/login.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from tornado.escape import url_escape
1010

1111
from ..base.handlers import JupyterHandler
12+
from .decorator import allow_unauthenticated
1213
from .security import passwd_check, set_password
1314

1415

@@ -73,6 +74,7 @@ def _redirect_safe(self, url, default=None):
7374
url = default
7475
self.redirect(url)
7576

77+
@allow_unauthenticated
7678
def get(self):
7779
"""Get the login form."""
7880
if self.current_user:
@@ -81,6 +83,7 @@ def get(self):
8183
else:
8284
self._render()
8385

86+
@allow_unauthenticated
8487
def post(self):
8588
"""Post a login."""
8689
user = self.current_user = self.identity_provider.process_login_form(self)
@@ -110,6 +113,7 @@ def passwd_check(self, a, b):
110113
"""Check a passwd."""
111114
return passwd_check(a, b)
112115

116+
@allow_unauthenticated
113117
def post(self):
114118
"""Post a login form."""
115119
typed_password = self.get_argument("password", default="")

jupyter_server/auth/logout.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
# Copyright (c) Jupyter Development Team.
44
# Distributed under the terms of the Modified BSD License.
55
from ..base.handlers import JupyterHandler
6+
from .decorator import allow_unauthenticated
67

78

89
class LogoutHandler(JupyterHandler):
910
"""An auth logout handler."""
1011

12+
@allow_unauthenticated
1113
def get(self):
1214
"""Handle a logout."""
1315
self.identity_provider.clear_login_cookie(self)

jupyter_server/base/handlers.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import warnings
1515
from http.client import responses
1616
from logging import Logger
17-
from typing import TYPE_CHECKING, Any, Awaitable, Sequence, cast
17+
from typing import TYPE_CHECKING, Any, Awaitable, Coroutine, Sequence, cast
1818
from urllib.parse import urlparse
1919

2020
import prometheus_client
@@ -29,7 +29,7 @@
2929
from jupyter_server import CallContext
3030
from jupyter_server._sysinfo import get_sys_info
3131
from jupyter_server._tz import utcnow
32-
from jupyter_server.auth.decorator import authorized
32+
from jupyter_server.auth.decorator import allow_unauthenticated, authorized
3333
from jupyter_server.auth.identity import User
3434
from jupyter_server.i18n import combine_translations
3535
from jupyter_server.services.security import csp_report_uri
@@ -589,7 +589,7 @@ def check_host(self) -> bool:
589589
)
590590
return allow
591591

592-
async def prepare(self) -> Awaitable[None] | None: # type:ignore[override]
592+
async def prepare(self, *, _redirect_to_login=True) -> Awaitable[None] | None: # type:ignore[override]
593593
"""Prepare a response."""
594594
# Set the current Jupyter Handler context variable.
595595
CallContext.set(CallContext.JUPYTER_HANDLER, self)
@@ -630,6 +630,25 @@ async def prepare(self) -> Awaitable[None] | None: # type:ignore[override]
630630
self.set_cors_headers()
631631
if self.request.method not in {"GET", "HEAD", "OPTIONS"}:
632632
self.check_xsrf_cookie()
633+
634+
if not self.settings.get("allow_unauthenticated_access", False):
635+
if not self.request.method:
636+
raise HTTPError(403)
637+
method = getattr(self, self.request.method.lower())
638+
if not getattr(method, "__allow_unauthenticated", False):
639+
if _redirect_to_login:
640+
# reuse `web.authenticated` logic, which redirects to the login
641+
# page on GET and HEAD and otherwise raises 403
642+
return web.authenticated(lambda _: super().prepare())(self)
643+
else:
644+
# raise 403 if user is not known without redirecting to login page
645+
user = self.current_user
646+
if user is None:
647+
self.log.warning(
648+
f"Couldn't authenticate {self.__class__.__name__} connection"
649+
)
650+
raise web.HTTPError(403)
651+
633652
return super().prepare()
634653

635654
# ---------------------------------------------------------------
@@ -726,7 +745,7 @@ def write_error(self, status_code: int, **kwargs: Any) -> None:
726745
class APIHandler(JupyterHandler):
727746
"""Base class for API handlers"""
728747

729-
async def prepare(self) -> None:
748+
async def prepare(self) -> None: # type:ignore[override]
730749
"""Prepare an API response."""
731750
await super().prepare()
732751
if not self.check_origin():
@@ -794,6 +813,7 @@ def finish(self, *args: Any, **kwargs: Any) -> Future[Any]:
794813
self.set_header("Content-Type", set_content_type)
795814
return super().finish(*args, **kwargs)
796815

816+
@allow_unauthenticated
797817
def options(self, *args: Any, **kwargs: Any) -> None:
798818
"""Get the options."""
799819
if "Access-Control-Allow-Headers" in self.settings.get("headers", {}):
@@ -837,7 +857,7 @@ def options(self, *args: Any, **kwargs: Any) -> None:
837857
class Template404(JupyterHandler):
838858
"""Render our 404 template"""
839859

840-
async def prepare(self) -> None:
860+
async def prepare(self) -> None: # type:ignore[override]
841861
"""Prepare a 404 response."""
842862
await super().prepare()
843863
raise web.HTTPError(404)
@@ -1002,6 +1022,18 @@ def compute_etag(self) -> str | None:
10021022
"""Compute the etag."""
10031023
return None
10041024

1025+
# access is allowed as this class is used to serve static assets on login page
1026+
# TODO: create an allow-list of files used on login page and remove this decorator
1027+
@allow_unauthenticated
1028+
def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]:
1029+
return super().get(path, include_body)
1030+
1031+
# access is allowed as this class is used to serve static assets on login page
1032+
# TODO: create an allow-list of files used on login page and remove this decorator
1033+
@allow_unauthenticated
1034+
def head(self, path: str) -> Awaitable[None]:
1035+
return super().head(path)
1036+
10051037
@classmethod
10061038
def get_absolute_path(cls, roots: Sequence[str], path: str) -> str:
10071039
"""locate a file to serve on our static file search path"""
@@ -1036,6 +1068,7 @@ class APIVersionHandler(APIHandler):
10361068

10371069
_track_activity = False
10381070

1071+
@allow_unauthenticated
10391072
def get(self) -> None:
10401073
"""Get the server version info."""
10411074
# not authenticated, so give as few info as possible
@@ -1048,6 +1081,7 @@ class TrailingSlashHandler(web.RequestHandler):
10481081
This should be the first, highest priority handler.
10491082
"""
10501083

1084+
@allow_unauthenticated
10511085
def get(self) -> None:
10521086
"""Handle trailing slashes in a get."""
10531087
assert self.request.uri is not None
@@ -1064,6 +1098,7 @@ def get(self) -> None:
10641098
class MainHandler(JupyterHandler):
10651099
"""Simple handler for base_url."""
10661100

1101+
@allow_unauthenticated
10671102
def get(self) -> None:
10681103
"""Get the main template."""
10691104
html = self.render_template("main.html")
@@ -1104,18 +1139,20 @@ async def redirect_to_files(self: Any, path: str) -> None:
11041139
self.log.debug("Redirecting %s to %s", self.request.path, url)
11051140
self.redirect(url)
11061141

1142+
@allow_unauthenticated
11071143
async def get(self, path: str = "") -> None:
11081144
return await self.redirect_to_files(self, path)
11091145

11101146

11111147
class RedirectWithParams(web.RequestHandler):
1112-
"""Sam as web.RedirectHandler, but preserves URL parameters"""
1148+
"""Same as web.RedirectHandler, but preserves URL parameters"""
11131149

11141150
def initialize(self, url: str, permanent: bool = True) -> None:
11151151
"""Initialize a redirect handler."""
11161152
self._url = url
11171153
self._permanent = permanent
11181154

1155+
@allow_unauthenticated
11191156
def get(self) -> None:
11201157
"""Get a redirect."""
11211158
sep = "&" if "?" in self._url else "?"
@@ -1128,6 +1165,7 @@ class PrometheusMetricsHandler(JupyterHandler):
11281165
Return prometheus metrics for this server
11291166
"""
11301167

1168+
@allow_unauthenticated
11311169
def get(self) -> None:
11321170
"""Get prometheus metrics."""
11331171
if self.settings["authenticate_prometheus"] and not self.logged_in:
@@ -1137,6 +1175,18 @@ def get(self) -> None:
11371175
self.write(prometheus_client.generate_latest(prometheus_client.REGISTRY))
11381176

11391177

1178+
class PublicStaticFileHandler(web.StaticFileHandler):
1179+
"""Same as web.StaticFileHandler, but decorated to acknowledge that auth is not required."""
1180+
1181+
@allow_unauthenticated
1182+
def head(self, path: str) -> Awaitable[None]:
1183+
return super().head(path)
1184+
1185+
@allow_unauthenticated
1186+
def get(self, path: str, include_body: bool = True) -> Coroutine[Any, Any, None]:
1187+
return super().get(path, include_body)
1188+
1189+
11401190
# -----------------------------------------------------------------------------
11411191
# URL pattern fragments for reuse
11421192
# -----------------------------------------------------------------------------
@@ -1152,6 +1202,6 @@ def get(self) -> None:
11521202
default_handlers = [
11531203
(r".*/", TrailingSlashHandler),
11541204
(r"api", APIVersionHandler),
1155-
(r"/(robots\.txt|favicon\.ico)", web.StaticFileHandler),
1205+
(r"/(robots\.txt|favicon\.ico)", PublicStaticFileHandler),
11561206
(r"/metrics", PrometheusMetricsHandler),
11571207
]

jupyter_server/base/websocket.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Base websocket classes."""
22
import re
3+
import warnings
34
from typing import Optional, no_type_check
45
from urllib.parse import urlparse
56

6-
from tornado import ioloop
7+
from tornado import ioloop, web
78
from tornado.iostream import IOStream
89

10+
from jupyter_server.base.handlers import JupyterHandler
11+
from jupyter_server.utils import JupyterServerAuthWarning
12+
913
# ping interval for keeping websockets alive (30 seconds)
1014
WS_PING_INTERVAL = 30000
1115

@@ -82,6 +86,40 @@ def check_origin(self, origin: Optional[str] = None) -> bool:
8286
def clear_cookie(self, *args, **kwargs):
8387
"""meaningless for websockets"""
8488

89+
@no_type_check
90+
def _maybe_auth(self):
91+
"""Verify authentication if required.
92+
93+
Only used when the websocket class does not inherit from JupyterHandler.
94+
"""
95+
if not self.settings.get("allow_unauthenticated_access", False):
96+
if not self.request.method:
97+
raise web.HTTPError(403)
98+
method = getattr(self, self.request.method.lower())
99+
if not getattr(method, "__allow_unauthenticated", False):
100+
# rather than re-using `web.authenticated` which also redirects
101+
# to login page on GET, just raise 403 if user is not known
102+
user = self.current_user
103+
if user is None:
104+
self.log.warning("Couldn't authenticate WebSocket connection")
105+
raise web.HTTPError(403)
106+
107+
@no_type_check
108+
def prepare(self, *args, **kwargs):
109+
"""Handle a get request."""
110+
if not isinstance(self, JupyterHandler):
111+
should_authenticate = not self.settings.get("allow_unauthenticated_access", False)
112+
if "identity_provider" in self.settings and should_authenticate:
113+
warnings.warn(
114+
"WebSocketMixin sub-class does not inherit from JupyterHandler"
115+
" preventing proper authentication using custom identity provider.",
116+
JupyterServerAuthWarning,
117+
stacklevel=2,
118+
)
119+
self._maybe_auth()
120+
return super().prepare(*args, **kwargs)
121+
return super().prepare(*args, **kwargs, _redirect_to_login=False)
122+
85123
@no_type_check
86124
def open(self, *args, **kwargs):
87125
"""Open the websocket."""

jupyter_server/extension/application.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ def _prepare_handlers(self):
358358
)
359359
new_handlers.append(handler)
360360

361-
webapp.add_handlers(".*$", new_handlers) # type:ignore[arg-type]
361+
webapp.add_handlers(".*$", new_handlers)
362362

363363
def _prepare_templates(self):
364364
"""Add templates to web app settings if extension has templates."""

0 commit comments

Comments
 (0)