From d2eaf90df2f89ca9384b5aa1382f4d42e9369e51 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 11 Mar 2022 13:02:46 +0100 Subject: [PATCH] authorize subsets of roles - oauth clients can request a list of roles - authorization will proceed with the _subset_ of those roles held by the user - in the future, this subsetting will be refined to the scope level --- examples/custom-scopes/grades.py | 23 +++++++++++- examples/custom-scopes/jupyterhub_config.py | 8 +++- jupyterhub/apihandlers/auth.py | 19 ++++++++++ jupyterhub/tests/mockservice.py | 8 ++++ jupyterhub/tests/test_pages.py | 1 + jupyterhub/tests/test_services_auth.py | 41 +++++++++++++++------ 6 files changed, 85 insertions(+), 15 deletions(-) diff --git a/examples/custom-scopes/grades.py b/examples/custom-scopes/grades.py index e3831536..4b805264 100644 --- a/examples/custom-scopes/grades.py +++ b/examples/custom-scopes/grades.py @@ -43,13 +43,31 @@ def require_scope(scopes): return wrap +class MyGradesHandler(HubOAuthenticated, RequestHandler): + # no hub_scopes, anyone with access to this service + # will be able to visit this URL + + @authenticated + def get(self): + self.write("

My grade

") + name = self.current_user["name"] + grades = self.settings["grades"] + self.write(f"

My name is: {escape(name)}

") + if name in grades: + self.write(f"

My grade is: {escape(str(grades[name]))}

") + else: + self.write("

No grade entered

") + if READ_SCOPE in self.current_user["scopes"]: + self.write('enter grades') + + class GradesHandler(HubOAuthenticated, RequestHandler): # default scope for this Handler: read-only hub_scopes = [READ_SCOPE] def _render(self): grades = self.settings["grades"] - self.write("

Grades

") + self.write("

All grades

") self.write("") self.write("") for student, grade in grades.items(): @@ -92,7 +110,8 @@ def main(): app = Application( [ - (base_url, GradesHandler), + (base_url, MyGradesHandler), + (url_path_join(base_url, 'grades/'), GradesHandler), ( url_path_join(base_url, 'oauth_callback'), HubOAuthCallbackHandler, diff --git a/examples/custom-scopes/jupyterhub_config.py b/examples/custom-scopes/jupyterhub_config.py index cc285215..735b1092 100644 --- a/examples/custom-scopes/jupyterhub_config.py +++ b/examples/custom-scopes/jupyterhub_config.py @@ -31,7 +31,13 @@ c.JupyterHub.load_roles = [ "name": "grader", # grant graders access to write grades "scopes": ["custom:grades:write"], - "users": ["grader", "instructor"], + "users": ["grader"], + }, + { + "name": "instructor", + # grant instructors access to read, but not write grades + "scopes": ["custom:grades:read"], + "users": ["instructor"], }, ] diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 2ec2dec0..9d05f1cb 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -283,6 +283,21 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler): raise web.HTTPError( 403, f"You do not have permission to access {client.description}" ) + + # subset role names to those held by authenticating user + requested_role_names = set(role_names) + user = self.current_user + user_role_names = {role.name for role in user.roles} + allowed_role_names = requested_role_names.intersection(user_role_names) + excluded_role_names = requested_role_names.difference(allowed_role_names) + if excluded_role_names: + self.log.info( + f"Service {client.description} requested roles {','.join(role_names)}" + f" for user {self.current_user.name}," + f" granting only {','.join(allowed_role_names) or '[]'}." + ) + role_names = list(allowed_role_names) + if not self.needs_oauth_confirm(self.current_user, client, role_names): self.log.debug( "Skipping oauth confirmation for %s accessing %s", @@ -381,6 +396,10 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler): # The scopes the user actually authorized, i.e. checkboxes # that were selected. scopes = self.get_arguments('scopes') + if scopes == []: + # avoid triggering default scopes (provider selects default scopes when scopes is falsy) + # when an explicit empty list is authorized + scopes = ["identify"] # credentials we need in the validator credentials = self.add_credentials() diff --git a/jupyterhub/tests/mockservice.py b/jupyterhub/tests/mockservice.py index 082aa65d..92d6501e 100644 --- a/jupyterhub/tests/mockservice.py +++ b/jupyterhub/tests/mockservice.py @@ -23,6 +23,7 @@ from tornado import httpserver from tornado import ioloop from tornado import log from tornado import web +from tornado.httputil import url_concat from jupyterhub.services.auth import HubAuthenticated from jupyterhub.services.auth import HubOAuthCallbackHandler @@ -76,6 +77,13 @@ class OWhoAmIHandler(HubOAuthenticated, web.RequestHandler): Uses OAuth login flow """ + def get_login_url(self): + login_url = super().get_login_url() + scopes = self.get_argument("request-scope", None) + if scopes is not None: + login_url = url_concat(login_url, {"scope": scopes}) + return login_url + @web.authenticated def get(self): self.write(self.get_current_user()) diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index 0edfd2db..a4f3e805 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -1134,6 +1134,7 @@ async def test_oauth_page_scope_appearance( ) service = mockservice_url user = create_user_with_scopes("access:services") + roles.grant_role(app.db, user, service_role) oauth_client = ( app.db.query(orm.OAuthClient) .filter_by(identifier=service.oauth_client_id) diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index 17474fd2..8e2054fa 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -5,9 +5,11 @@ import sys from binascii import hexlify from unittest import mock from urllib.parse import parse_qs +from urllib.parse import quote from urllib.parse import urlparse import pytest +from bs4 import BeautifulSoup from pytest import raises from tornado.httputil import url_concat @@ -227,6 +229,9 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo (["admin", "user"], ["user"], ["user"]), (["user", "token", "server"], ["token", "user"], ["token", "user"]), (["admin", "user", "read-only"], ["read-only"], ["read-only"]), + # requesting valid subset, some not held by user + (["admin", "user"], ["admin", "user"], ["user"]), + (["admin", "user"], ["admin"], []), ], ) async def test_oauth_service_roles( @@ -267,6 +272,8 @@ async def test_oauth_service_roles( ] app.db.commit() url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x') + if request_roles: + url = url_concat(url, {"request-scope": " ".join(request_roles)}) # first request is only going to login and get us to the oauth form page s = AsyncSession() user = create_user_with_scopes("access:services") @@ -276,17 +283,6 @@ async def test_oauth_service_roles( s.cookies = await app.login_user(name) r = await s.get(url) - r.raise_for_status() - # we should be looking at the oauth confirmation page - assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize' - # verify oauth state cookie was set at some point - assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name} - - # submit the oauth form to complete authorization - data = {} - if request_roles: - data["scopes"] = request_roles - r = await s.post(r.url, data=data, headers={'Referer': r.url}) if expected_roles is None: # expected failed auth, stop here # verify expected 'invalid scope' error, not server error @@ -296,6 +292,21 @@ async def test_oauth_service_roles( assert r.status_code == 400 return r.raise_for_status() + # we should be looking at the oauth confirmation page + assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize' + # verify oauth state cookie was set at some point + assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name} + + page = BeautifulSoup(r.text, "html.parser") + scope_inputs = page.find_all("input", {"name": "scopes"}) + scope_values = [input["value"] for input in scope_inputs] + print("Submitting request with scope values", scope_values) + # submit the oauth form to complete authorization + data = {} + if scope_values: + data["scopes"] = scope_values + r = await s.post(r.url, data=data, headers={'Referer': r.url}) + r.raise_for_status() assert r.url == url # verify oauth cookie is set assert 'service-%s' % service.name in set(s.cookies.keys()) @@ -395,7 +406,11 @@ async def test_oauth_access_scopes( @pytest.mark.parametrize( "token_roles, hits_page", - [([], True), (['writer'], True), (['writer', 'reader'], False)], + [ + ([], True), + (['writer'], True), + (['writer', 'reader'], False), + ], ) async def test_oauth_page_hit( app, @@ -411,6 +426,8 @@ async def test_oauth_page_hit( } service = mockservice_url user = create_user_with_scopes("access:services", "self") + for role in test_roles.values(): + roles.grant_role(app.db, user, role) user.new_api_token() token = user.api_tokens[0] token.roles = [test_roles[t] for t in token_roles]
StudentGrade