diff --git a/examples/azuread-with-group-management/jupyterhub_config.py b/examples/azuread-with-group-management/jupyterhub_config.py new file mode 100644 index 00000000..9614dd70 --- /dev/null +++ b/examples/azuread-with-group-management/jupyterhub_config.py @@ -0,0 +1,30 @@ +"""sample jupyterhub config file for testing + +configures jupyterhub with dummyauthenticator and simplespawner +to enable testing without administrative privileges. +""" + +c = get_config() # noqa +c.Application.log_level = 'DEBUG' + +from oauthenticator.azuread import AzureAdOAuthenticator +import os + +c.JupyterHub.authenticator_class = AzureAdOAuthenticator + +c.AzureAdOAuthenticator.client_id = os.getenv("AAD_CLIENT_ID") +c.AzureAdOAuthenticator.client_secret = os.getenv("AAD_CLIENT_SECRET") +c.AzureAdOAuthenticator.oauth_callback_url = os.getenv("AAD_CALLBACK_URL") +c.AzureAdOAuthenticator.tenant_id = os.getenv("AAD_TENANT_ID") +c.AzureAdOAuthenticator.username_claim = "email" +c.AzureAdOAuthenticator.authorize_url = os.getenv("AAD_AUTHORIZE_URL") +c.AzureAdOAuthenticator.token_url = os.getenv("AAD_TOKEN_URL") +c.Authenticator.authenticator_managed_groups = True +c.Authenticator.refresh_pre_spawn = True + +# Optionally set a global password that all users must use +# c.DummyAuthenticator.password = "your_password" + +from jupyterhub.spawner import SimpleLocalProcessSpawner + +c.JupyterHub.spawner_class = SimpleLocalProcessSpawner diff --git a/examples/azuread-with-group-management/requirements.txt b/examples/azuread-with-group-management/requirements.txt new file mode 100644 index 00000000..6fda532a --- /dev/null +++ b/examples/azuread-with-group-management/requirements.txt @@ -0,0 +1,2 @@ +oauthenticator +pyjwt diff --git a/jupyterhub/apihandlers/groups.py b/jupyterhub/apihandlers/groups.py index 04493796..d587ec17 100644 --- a/jupyterhub/apihandlers/groups.py +++ b/jupyterhub/apihandlers/groups.py @@ -68,6 +68,10 @@ class GroupListAPIHandler(_GroupAPIHandler): @needs_scope('admin:groups') async def post(self): """POST creates Multiple groups""" + + if self.authenticator.manage_groups: + raise web.HTTPError(400, "Group management via API is disabled") + model = self.get_json_body() if not model or not isinstance(model, dict) or not model.get('groups'): raise web.HTTPError(400, "Must specify at least one group to create") diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 5d1590dd..0a793258 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -2001,6 +2001,9 @@ class JupyterHub(Application): async def init_groups(self): """Load predefined groups into the database""" db = self.db + + if self.authenticator.manage_groups and self.load_groups: + raise ValueError("Group management has been offloaded to the authenticator") for name, usernames in self.load_groups.items(): group = orm.Group.find(db, name) if group is None: diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 41ad1058..403bc43d 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -635,6 +635,30 @@ class Authenticator(LoggingConfigurable): """ self.allowed_users.discard(user.name) + manage_groups = Bool( + False, + config=True, + help="""Let authenticator manage user groups + + Authenticator must implement get_user_groups for this to be useful. + """, + ) + + def load_user_groups(self, user, auth_state): + """Hook called allowing authenticator to read user groups + + Updates user group memberships + + Args: + auth_state (dict): Proprietary dict returned by authenticator + user(User): the User object associated with the auth-state + + Returns: + groups (list): + List of user group memberships + """ + return None + auto_login = Bool( False, config=True, diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 56cf1fa8..f8dbe797 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -622,6 +622,9 @@ class BaseHandler(RequestHandler): def authenticate(self, data): return maybe_future(self.authenticator.get_authenticated_user(self, data)) + def load_user_groups(self, user, auth_info): + return maybe_future(self.authenticator.load_user_groups(user, auth_info)) + def get_next_url(self, user=None, default=None): """Get the next_url for login redirect @@ -779,7 +782,15 @@ class BaseHandler(RequestHandler): if not self.authenticator.enable_auth_state: # auth_state is not enabled. Force None. auth_state = None + + if self.authenticator.manage_groups: + # Run authenticator user-group reload hook + user_groups = await self.load_user_groups(user, authenticated) + if user_groups is not None: + user.sync_groups(user_groups) + await user.save_auth_state(auth_state) + return user async def login_user(self, data=None): @@ -793,6 +804,7 @@ class BaseHandler(RequestHandler): self.set_login_cookie(user) self.statsd.incr('login.success') self.statsd.timing('login.authenticate.success', auth_timer.ms) + self.log.info("User logged in: %s", user.name) user._auth_refreshed = time.monotonic() return user diff --git a/jupyterhub/user.py b/jupyterhub/user.py index 039f4d11..0e314594 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -253,6 +253,19 @@ class User: def spawner_class(self): return self.settings.get('spawner_class', LocalProcessSpawner) + def sync_groups(self, user_groups): + """Syncronize groups with database""" + + if user_groups: + groups = ( + self.db.query(orm.Group).filter(orm.Group.name.in_(user_groups)).all() + ) + groups = {g.name: g for g in groups} + + self.groups = [groups.get(g, orm.Group(name=g)) for g in user_groups] + else: + self.groups = [] + async def save_auth_state(self, auth_state): """Encrypt and store auth_state""" if auth_state is None: