Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an option to have authentication enabled for all endpoints by default #1392

Merged
merged 20 commits into from
Mar 4, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3f65147
Add `allow_unauthenticated_access` traitlet and `@allow_unauthenticated`
krassowski Feb 10, 2024
a9cff16
Tests, correct coroutine handling, add decorators where needed,
krassowski Feb 10, 2024
646739e
Align ordering of test cases, improve wording of comments
krassowski Feb 11, 2024
4cbb504
Implement auth and tests for websockets
krassowski Feb 11, 2024
faee488
Use `allow_unauthenticated` in `FilesRedirectHandler` for now
krassowski Feb 11, 2024
204d29f
Do not touch coroutines
krassowski Feb 11, 2024
dccc423
Add runtime check to ensure handlers have auth decorators
krassowski Feb 12, 2024
8e13727
Do not ever raise for extension endpoints, and only
krassowski Feb 13, 2024
e6a8d9a
Add test for extension handler warning, correct type
krassowski Feb 13, 2024
2882516
Fix test for authentication enforcement for extensions
krassowski Feb 13, 2024
4e1d664
Add test for GET redirect in handler tests
krassowski Feb 13, 2024
cd84175
Add `JUPYTER_SERVER_ALLOW_UNAUTHENTICATED_ACCESS` env variable
krassowski Feb 13, 2024
5f6bc16
Better heuristic for `@tornado.web.authenticated`
krassowski Feb 13, 2024
0facfec
Improve docstring/fix typo
krassowski Feb 13, 2024
90d7044
Improve and test tornado.web.authenticated heuristic
krassowski Feb 13, 2024
54c2ea2
Merge branch 'main' into auth-by-default
krassowski Feb 15, 2024
319a4d1
Call `super.prepare()` in all code paths, test it
krassowski Feb 20, 2024
5e7615d
Add test for identity provider being used
krassowski Feb 20, 2024
4265f4e
Ensure that identity provider is used for auth,
krassowski Feb 20, 2024
c3f5d8f
Update minimum `pytest-jupyter`; we need 0.7 as it
krassowski Feb 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions jupyter_server/auth/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,30 @@ async def inner(self, *args, **kwargs):
return cast(FuncT, wrapper(method))

return cast(FuncT, wrapper)


def allow_unauthenticated(method: FuncT) -> FuncT:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps something even more aggressive, e.g. @UNAUTHENTICATED.

"""A decorator for tornado.web.RequestHandler methods
that allows any user to make the following request.

Selectively disables the 'authentication' layer of REST API which
is active when `ServerApp.allow_unauthenticated_access = False`.

To be used exclusively on endpoints which may be considered public,
for example the logic page handler.

.. versionadded:: 2.13

Parameters
----------
method : bound callable
the endpoint method to remove authentication from.
"""

@wraps(method)
def wrapper(self, *args, **kwargs):
return method(self, *args, **kwargs)

setattr(wrapper, "__allow_unauthenticated", True)

return cast(FuncT, wrapper)
4 changes: 4 additions & 0 deletions jupyter_server/auth/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from tornado.escape import url_escape

from ..base.handlers import JupyterHandler
from .decorator import allow_unauthenticated
from .security import passwd_check, set_password


Expand Down Expand Up @@ -73,6 +74,7 @@ def _redirect_safe(self, url, default=None):
url = default
self.redirect(url)

@allow_unauthenticated
def get(self):
"""Get the login form."""
if self.current_user:
Expand All @@ -81,6 +83,7 @@ def get(self):
else:
self._render()

@allow_unauthenticated
def post(self):
"""Post a login."""
user = self.current_user = self.identity_provider.process_login_form(self)
Expand Down Expand Up @@ -110,6 +113,7 @@ def passwd_check(self, a, b):
"""Check a passwd."""
return passwd_check(a, b)

@allow_unauthenticated
def post(self):
"""Post a login form."""
typed_password = self.get_argument("password", default="")
Expand Down
2 changes: 2 additions & 0 deletions jupyter_server/auth/logout.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from ..base.handlers import JupyterHandler
from .decorator import allow_unauthenticated


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

@allow_unauthenticated
def get(self):
"""Handle a logout."""
self.identity_provider.clear_login_cookie(self)
Expand Down
31 changes: 30 additions & 1 deletion jupyter_server/base/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from jupyter_server import CallContext
from jupyter_server._sysinfo import get_sys_info
from jupyter_server._tz import utcnow
from jupyter_server.auth.decorator import authorized
from jupyter_server.auth.decorator import allow_unauthenticated, authorized
from jupyter_server.auth.identity import User
from jupyter_server.i18n import combine_translations
from jupyter_server.services.security import csp_report_uri
Expand Down Expand Up @@ -630,6 +630,16 @@ async def prepare(self) -> Awaitable[None] | None: # type:ignore[override]
self.set_cors_headers()
if self.request.method not in {"GET", "HEAD", "OPTIONS"}:
self.check_xsrf_cookie()

if not self.settings.get("allow_unauthenticated_access", False):
if not self.request.method:
raise HTTPError(403)
method = getattr(self, self.request.method.lower())
if not getattr(method, "__allow_unauthenticated", False):
# reuse `web.authenticated` logic, which redirects to the login
# page on GET and HEAD and otherwise raises 403
return web.authenticated(lambda _method: None)(self)
krassowski marked this conversation as resolved.
Show resolved Hide resolved

return super().prepare()

# ---------------------------------------------------------------
Expand Down Expand Up @@ -794,6 +804,7 @@ def finish(self, *args: Any, **kwargs: Any) -> Future[Any]:
self.set_header("Content-Type", set_content_type)
return super().finish(*args, **kwargs)

@allow_unauthenticated
def options(self, *args: Any, **kwargs: Any) -> None:
"""Get the options."""
if "Access-Control-Allow-Headers" in self.settings.get("headers", {}):
Expand Down Expand Up @@ -1002,6 +1013,18 @@ def compute_etag(self) -> str | None:
"""Compute the etag."""
return None

# access is allowed as this class is used to serve static assets on login page
# TODO: create an allow-list of files used on login page and remove this decorator
@allow_unauthenticated
def get(self, *args, **kwargs) -> None:
return super().get(*args, **kwargs)

# access is allowed as this class is used to serve static assets on login page
# TODO: create an allow-list of files used on login page and remove this decorator
@allow_unauthenticated
def head(self, *args, **kwargs) -> None:
return super().head(*args, **kwargs)

@classmethod
def get_absolute_path(cls, roots: Sequence[str], path: str) -> str:
"""locate a file to serve on our static file search path"""
Expand Down Expand Up @@ -1036,6 +1059,7 @@ class APIVersionHandler(APIHandler):

_track_activity = False

@allow_unauthenticated
def get(self) -> None:
"""Get the server version info."""
# not authenticated, so give as few info as possible
Expand All @@ -1048,6 +1072,7 @@ class TrailingSlashHandler(web.RequestHandler):
This should be the first, highest priority handler.
"""

# does not require `allow_unauthenticated` (inherits from `web.RequestHandler`)
def get(self) -> None:
"""Handle trailing slashes in a get."""
assert self.request.uri is not None
Expand All @@ -1064,6 +1089,7 @@ def get(self) -> None:
class MainHandler(JupyterHandler):
"""Simple handler for base_url."""

@allow_unauthenticated
def get(self) -> None:
"""Get the main template."""
html = self.render_template("main.html")
Expand Down Expand Up @@ -1104,6 +1130,7 @@ async def redirect_to_files(self: Any, path: str) -> None:
self.log.debug("Redirecting %s to %s", self.request.path, url)
self.redirect(url)

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

Expand All @@ -1116,6 +1143,7 @@ def initialize(self, url: str, permanent: bool = True) -> None:
self._url = url
self._permanent = permanent

# does not require `allow_unauthenticated` (inherits from `web.RequestHandler`)
def get(self) -> None:
"""Get a redirect."""
sep = "&" if "?" in self._url else "?"
Expand All @@ -1128,6 +1156,7 @@ class PrometheusMetricsHandler(JupyterHandler):
Return prometheus metrics for this server
"""

@allow_unauthenticated
def get(self) -> None:
"""Get prometheus metrics."""
if self.settings["authenticate_prometheus"] and not self.logged_in:
Expand Down
22 changes: 21 additions & 1 deletion jupyter_server/base/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Optional, no_type_check
from urllib.parse import urlparse

from tornado import ioloop
from tornado import ioloop, web
from tornado.iostream import IOStream

# ping interval for keeping websockets alive (30 seconds)
Expand Down Expand Up @@ -82,6 +82,26 @@ def check_origin(self, origin: Optional[str] = None) -> bool:
def clear_cookie(self, *args, **kwargs):
"""meaningless for websockets"""

@no_type_check
def _maybe_auth(self):
"""Verify authentication if required"""
if not self.settings.get("allow_unauthenticated_access", False):
if not self.request.method:
raise web.HTTPError(403)
method = getattr(self, self.request.method.lower())
if not getattr(method, "__allow_unauthenticated", False):
# rather than re-using `web.authenticated` which also redirects
# to login page on GET, just raise 403 if user is not known
user = self.current_user
if user is None:
self.log.warning("Couldn't authenticate WebSocket connection")
raise web.HTTPError(403)

def prepare(self, *args, **kwargs):
"""Handle a get request."""
self._maybe_auth()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

._maybe_auth accesses .current_user, but .prepare() below is where it is set. The order needs to be reversed, but I assume there's a reason this is first. I think the override may need to be done a different way.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I assume there's a reason this is first

Yes, this is because JupyterHandler.prepare() would raise HTTPError with redirect which does not make sense for websocket.

The order needs to be reversed

So this works in test because .current_user is nominally a getter implemented in tornado.web.RequestHandler. Unfortunately JupyterHandler.prepare() overrides it in a way which will cause a problem for this logic for a non-trivial identity provider (if I see it right, the default user will be used instead of the one provided by IdentityProvider).

It feels like we should move the implementation of setting the current_user from JupyterHandler.prepare to JupyterHandler.get_current_user. Thoughts?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, why is IdentityProvider setting the current user in JupyterHandler and not in AuthenticatedHandler in the first place? I do not see rationale for it in #671

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see the user from identity provider may require awaiting. I guess I can just pass an argument to JupyterHandler.prepare() instructing it to not redirect to login page.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JupyterHandler is responsible for .current_user being set, since it may be async, it cannot be done in a property. It would be appropriate to override the .current_user getter, which should make it very clear it can never be accessed before .prepare() is called:

@property
def current_user(self):
    if not hasattr(self, "_jupyter_current_user"):
        raise RuntimeError(".current_user accessed before being populated in JupyterHandler.prepare()")
    return self. _jupyter_current_user

@current_user.setter
def current_user(self, user):
    self._jupyter_current_user = user

Any time that error is hit is necessarily code that is bypassing Jupyter Server authentication.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 5e7615d (added test) and 4265f4e (fixed it).

Would you like me to add the RuntimeError on accessing current_user too early (from the last comment) in this PR, or would it be better to ensure that this PR does not introduce potentially breaking changes and can be adopted in existing deployments easily?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the code, I guess we already have this RuntimeError in get_current_user. So maybe it's just the message that wants clarifying?

Because it's already there, I don't think moving the RuntimeError to the .current_user getter can break anything. But at the same time, since folks may call get_current_user() or access the (correct) .current_user, then I guess .get_current_user covers both cases. So maybe it only makes sense to put it in .current_user if it helps to have two distinct, clear error messages.

return super().prepare(*args, **kwargs)

@no_type_check
def open(self, *args, **kwargs):
"""Open the websocket."""
Expand Down
16 changes: 16 additions & 0 deletions jupyter_server/serverapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ def init_settings(
"login_url": url_path_join(base_url, "/login"),
"xsrf_cookies": True,
"disable_check_xsrf": jupyter_app.disable_check_xsrf,
"allow_unauthenticated_access": jupyter_app.allow_unauthenticated_access,
"allow_remote_access": jupyter_app.allow_remote_access,
"local_hostnames": jupyter_app.local_hostnames,
"authenticate_prometheus": jupyter_app.authenticate_prometheus,
Expand Down Expand Up @@ -1214,6 +1215,21 @@ def _deprecated_password_config(self, change: t.Any) -> None:
""",
)

allow_unauthenticated_access = Bool(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For ease of use in JupyterHub deployments, I would also suggest making this available as an env variable. I know you can setup a _config.json file reasonably easily, but given the possible high impact here, I think it's worth making an env var for this too. JUPYTER_SERVER_ALLOW_UNAUTHENTICATED_ACCESS or similar?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in cd84175

True,
config=True,
help="""Allow requests unauthenticated access to endpoints without authentication rules.

When set to `True` (default in jupyter-server 2.0, subject to change
in the future), any request to an endpoint without an authentication rule
(either `@tornado.web.authenticated`, or `@allow_unauthenticated`)
will be permitted, regardless of whether user has logged in or not.

When set to `False`, logging in will be required for access to each endpoint,
excluding the endpoints marked with `@allow_unauthenticated` decorator.
""",
)

allow_remote_access = Bool(
config=True,
help="""Allow requests where the Host header doesn't point to a local server
Expand Down
3 changes: 2 additions & 1 deletion jupyter_server/services/contents/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from jupyter_core.utils import ensure_async
from tornado import web

from jupyter_server.auth.decorator import authorized
from jupyter_server.auth.decorator import allow_unauthenticated, authorized
from jupyter_server.base.handlers import APIHandler, JupyterHandler, path_regex
from jupyter_server.utils import url_escape, url_path_join

Expand Down Expand Up @@ -400,6 +400,7 @@ class NotebooksRedirectHandler(JupyterHandler):
"DELETE",
) # type:ignore[assignment]

@allow_unauthenticated
def get(self, path):
"""Handle a notebooks redirect."""
self.log.warning("/api/notebooks is deprecated, use /api/contents")
Expand Down
63 changes: 63 additions & 0 deletions tests/base/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import warnings
from unittest.mock import MagicMock

import pytest
from tornado.httpclient import HTTPClientError
from tornado.httpserver import HTTPRequest
from tornado.httputil import HTTPHeaders

from jupyter_server.auth import AllowAllAuthorizer, IdentityProvider
from jupyter_server.auth.decorator import allow_unauthenticated
from jupyter_server.base.handlers import (
APIHandler,
APIVersionHandler,
Expand All @@ -18,6 +21,7 @@
RedirectWithParams,
)
from jupyter_server.serverapp import ServerApp
from jupyter_server.utils import url_path_join


def test_authenticated_handler(jp_serverapp):
Expand Down Expand Up @@ -61,6 +65,65 @@ def test_jupyter_handler(jp_serverapp):
assert handler.check_referer() is True


class NoAuthRulesHandler(JupyterHandler):
def options(self) -> None:
self.finish({})


class PermissiveHandler(JupyterHandler):
@allow_unauthenticated
def options(self) -> None:
self.finish({})


@pytest.mark.parametrize(
"jp_server_config", [{"ServerApp": {"allow_unauthenticated_access": True}}]
)
async def test_jupyter_handler_auth_permissive(jp_serverapp, jp_fetch):
app: ServerApp = jp_serverapp
app.web_app.add_handlers(
".*$",
[
(url_path_join(app.base_url, "no-rules"), NoAuthRulesHandler),
(url_path_join(app.base_url, "permissive"), PermissiveHandler),
],
)

# should always permit access when `@allow_unauthenticated` is used
res = await jp_fetch("permissive", method="OPTIONS", headers={"Authorization": ""})
assert res.code == 200

# should allow access when no authentication rules are set up
res = await jp_fetch("no-rules", method="OPTIONS", headers={"Authorization": ""})
assert res.code == 200


@pytest.mark.parametrize(
"jp_server_config", [{"ServerApp": {"allow_unauthenticated_access": False}}]
)
async def test_jupyter_handler_auth_required(jp_serverapp, jp_fetch):
app: ServerApp = jp_serverapp
app.web_app.add_handlers(
".*$",
[
(url_path_join(app.base_url, "no-rules"), NoAuthRulesHandler),
(url_path_join(app.base_url, "permissive"), PermissiveHandler),
],
)

# should always permit access when `@allow_unauthenticated` is used
res = await jp_fetch("permissive", method="OPTIONS", headers={"Authorization": ""})
assert res.code == 200

# should forbid access when no authentication rules are set up
with pytest.raises(HTTPClientError) as exception:
# note: using OPTIONS because GET and HEAD cause redirects to login page
# which prevents the test from finishing; disabling `follow_redirects`
# is not supported by `jp_fetch` yet.
res = await jp_fetch("no-rules", method="OPTIONS", headers={"Authorization": ""})
assert exception.value.code == 403


def test_api_handler(jp_serverapp):
app: ServerApp = jp_serverapp
headers = HTTPHeaders({"Origin": "foo"})
Expand Down
Loading
Loading