diff --git a/docs/source/changelog.md b/docs/source/changelog.md index 5589b968..6eb740e7 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -7,6 +7,8 @@ command line for details. ## [Unreleased] + + ## 1.1 ### [1.1.0] 2020-01-17 @@ -116,7 +118,7 @@ Thanks to everyone who has contributed to this release! - Log JupyterHub version on startup [#2752](https://github.com/jupyterhub/jupyterhub/pull/2752) ([@consideRatio](https://github.com/consideRatio)) - Reduce verbosity for "Failing suspected API request to not-running server" (new) [#2751](https://github.com/jupyterhub/jupyterhub/pull/2751) ([@rkdarst](https://github.com/rkdarst)) - Add missing package for json schema doc build [#2744](https://github.com/jupyterhub/jupyterhub/pull/2744) ([@willingc](https://github.com/willingc)) -- blacklist urllib3 versions with encoding bug [#2743](https://github.com/jupyterhub/jupyterhub/pull/2743) ([@minrk](https://github.com/minrk)) +- block urllib3 versions with encoding bug [#2743](https://github.com/jupyterhub/jupyterhub/pull/2743) ([@minrk](https://github.com/minrk)) - Remove tornado deprecated/unnecessary AsyncIOMainLoop().install() call [#2740](https://github.com/jupyterhub/jupyterhub/pull/2740) ([@kinow](https://github.com/kinow)) - Fix deprecated call [#2739](https://github.com/jupyterhub/jupyterhub/pull/2739) ([@kinow](https://github.com/kinow)) - Remove duplicate hub and authenticator traitlets from Spawner [#2736](https://github.com/jupyterhub/jupyterhub/pull/2736) ([@eslavich](https://github.com/eslavich)) @@ -231,8 +233,8 @@ whether it was through discussion, testing, documentation, or development. This hook may transform the return value of `Authenticator.authenticate()` and return a new authentication dictionary, e.g. specifying admin privileges, group membership, - or custom white/blacklisting logic. - This hook is called *after* existing normalization and whitelist checking. + or custom allowed/blocked logic. + This hook is called *after* existing normalization and allowed-username checking. - `Spawner.options_from_form` may now be async - Added `JupyterHub.shutdown_on_logout` option to trigger shutdown of a user's servers when they log out. @@ -418,7 +420,7 @@ and tornado < 5.0. launching an IPython session connected to your JupyterHub database. - Include `User.auth_state` in user model on single-user REST endpoints for admins only. - Include `Server.state` in server model on REST endpoints for admins only. -- Add `Authenticator.blacklist` for blacklisting users instead of whitelisting. +- Add `Authenticator.blacklist` for blocking users instead of allowing. - Pass `c.JupyterHub.tornado_settings['cookie_options']` down to Spawners so that cookie options (e.g. `expires_days`) can be set globally for the whole application. - SIGINFO (`ctrl-t`) handler showing the current status of all running threads, diff --git a/docs/source/getting-started/authenticators-users-basics.md b/docs/source/getting-started/authenticators-users-basics.md index b954f88d..ec078fed 100644 --- a/docs/source/getting-started/authenticators-users-basics.md +++ b/docs/source/getting-started/authenticators-users-basics.md @@ -4,23 +4,23 @@ The default Authenticator uses [PAM][] to authenticate system users with their username and password. With the default Authenticator, any user with an account and password on the system will be allowed to login. -## Create a whitelist of users +## Create a set of allowed users -You can restrict which users are allowed to login with a whitelist, -`Authenticator.whitelist`: +You can restrict which users are allowed to login with a set, +`Authenticator.allowed_users`: ```python -c.Authenticator.whitelist = {'mal', 'zoe', 'inara', 'kaylee'} +c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'} ``` -Users in the whitelist are added to the Hub database when the Hub is +Users in the `allowed_users` set are added to the Hub database when the Hub is started. ## Configure admins (`admin_users`) Admin users of JupyterHub, `admin_users`, can add and remove users from -the user `whitelist`. `admin_users` can take actions on other users' +the user `allowed_users` set. `admin_users` can take actions on other users' behalf, such as stopping and restarting their servers. A set of initial admin users, `admin_users` can configured be as follows: @@ -28,7 +28,7 @@ A set of initial admin users, `admin_users` can configured be as follows: ```python c.Authenticator.admin_users = {'mal', 'zoe'} ``` -Users in the admin list are automatically added to the user `whitelist`, +Users in the admin set are automatically added to the user `allowed_users` set, if they are not already present. Each authenticator may have different ways of determining whether a user is an @@ -53,12 +53,12 @@ sure your users know if admin_access is enabled.** Users can be added to and removed from the Hub via either the admin panel or the REST API. When a user is **added**, the user will be -automatically added to the whitelist and database. Restarting the Hub -will not require manually updating the whitelist in your config file, +automatically added to the allowed users set and database. Restarting the Hub +will not require manually updating the allowed users set in your config file, as the users will be loaded from the database. After starting the Hub once, it is not sufficient to **remove** a user -from the whitelist in your config file. You must also remove the user +from the allowed users set in your config file. You must also remove the user from the Hub's database, either by deleting the user from JupyterHub's admin page, or you can clear the `jupyterhub.sqlite` database and start fresh. diff --git a/docs/source/reference/config-ghoauth.md b/docs/source/reference/config-ghoauth.md index b120b3b2..6ec46e1c 100644 --- a/docs/source/reference/config-ghoauth.md +++ b/docs/source/reference/config-ghoauth.md @@ -52,7 +52,7 @@ c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL'] c.LocalAuthenticator.create_system_users = True # specify users and admin -c.Authenticator.whitelist = {'rgbkrk', 'minrk', 'jhamrick'} +c.Authenticator.allowed_users = {'rgbkrk', 'minrk', 'jhamrick'} c.Authenticator.admin_users = {'jhamrick', 'rgbkrk'} # uses the default spawner diff --git a/docs/source/reference/config-sudo.md b/docs/source/reference/config-sudo.md index fc0d4865..310f1a93 100644 --- a/docs/source/reference/config-sudo.md +++ b/docs/source/reference/config-sudo.md @@ -57,7 +57,7 @@ To do this we add to `/etc/sudoers` (use `visudo` for safe editing of sudoers): For example: ```bash -# comma-separated whitelist of users that can spawn single-user servers +# comma-separated list of users that can spawn single-user servers # this should include all of your Hub users Runas_Alias JUPYTER_USERS = rhea, zoe, wash diff --git a/docs/source/reference/services.md b/docs/source/reference/services.md index ec446c8e..d940034b 100644 --- a/docs/source/reference/services.md +++ b/docs/source/reference/services.md @@ -313,7 +313,7 @@ class MyHandler(HubAuthenticated, web.RequestHandler): The HubAuth will automatically load the desired configuration from the Service environment variables. -If you want to limit user access, you can whitelist users through either the +If you want to limit user access, you can specify allowed users through either the `.hub_users` attribute or `.hub_groups`. These are sets that check against the username and user group list, respectively. If a user matches neither the user list nor the group list, they will not be allowed access. If both are left diff --git a/docs/source/troubleshooting.md b/docs/source/troubleshooting.md index d92c594b..3958e590 100644 --- a/docs/source/troubleshooting.md +++ b/docs/source/troubleshooting.md @@ -7,8 +7,8 @@ problem and how to resolve it. [*Behavior*](#behavior) - JupyterHub proxy fails to start - sudospawner fails to run -- What is the default behavior when none of the lists (admin, whitelist, - group whitelist) are set? +- What is the default behavior when none of the lists (admin, allowed, + allowed groups) are set? - JupyterHub Docker container not accessible at localhost [*Errors*](#errors) @@ -55,14 +55,14 @@ or add: to the config file, `jupyterhub_config.py`. -### What is the default behavior when none of the lists (admin, whitelist, group whitelist) are set? +### What is the default behavior when none of the lists (admin, allowed, allowed groups) are set? When nothing is given for these lists, there will be no admins, and all users who can authenticate on the system (i.e. all the unix users on the server with -a password) will be allowed to start a server. The whitelist lets you limit -this to a particular set of users, and the admin_users lets you specify who +a password) will be allowed to start a server. The allowed username set lets you limit +this to a particular set of users, and admin_users lets you specify who among them may use the admin interface (not necessary, unless you need to do -things like inspect other users' servers, or modify the userlist at runtime). +things like inspect other users' servers, or modify the user list at runtime). ### JupyterHub Docker container not accessible at localhost @@ -332,8 +332,7 @@ notebook servers to default to JupyterLab: ### How do I set up JupyterHub for a workshop (when users are not known ahead of time)? 1. Set up JupyterHub using OAuthenticator for GitHub authentication -2. Configure whitelist to be an empty list in` jupyterhub_config.py` -3. Configure admin list to have workshop leaders be listed with administrator privileges. +2. Configure admin list to have workshop leaders be listed with administrator privileges. Users will need a GitHub account to login and be authenticated by the Hub. diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 15a4d783..b7c4df0e 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -201,7 +201,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler): def needs_oauth_confirm(self, user, oauth_client): """Return whether the given oauth client needs to prompt for access for the given user - Checks whitelist for oauth clients + Checks list for oauth clients that don't need confirmation (i.e. the user's own server) @@ -214,9 +214,8 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler): if ( # it's the user's own server oauth_client.identifier in own_oauth_client_ids - # or it's in the global whitelist - or oauth_client.identifier - in self.settings.get('oauth_no_confirm_whitelist', set()) + # or it's in the global no-confirm list + or oauth_client.identifier in self.settings.get('oauth_no_confirm', set()) ): return False # default: require confirmation @@ -229,7 +228,7 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler): Render oauth confirmation page: "Server at ... would like permission to ...". - Users accessing their own server or a service whitelist + Users accessing their own server or a blessed service will skip confirmation. """ diff --git a/jupyterhub/app.py b/jupyterhub/app.py index dd8f6db2..ac0829d9 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -1689,22 +1689,22 @@ class JupyterHub(Application): # the admin_users config variable will never be used after this point. # only the database values will be referenced. - whitelist = [ + allowed_users = [ self.authenticator.normalize_username(name) - for name in self.authenticator.whitelist + for name in self.authenticator.allowed_users ] - self.authenticator.whitelist = set(whitelist) # force normalization - for username in whitelist: + self.authenticator.allowed_users = set(allowed_users) # force normalization + for username in allowed_users: if not self.authenticator.validate_username(username): raise ValueError("username %r is not valid" % username) - if not whitelist: + if not allowed_users: self.log.info( - "Not using whitelist. Any authenticated user will be allowed." + "Not using allowed_users. Any authenticated user will be allowed." ) - # add whitelisted users to the db - for name in whitelist: + # add allowed users to the db + for name in allowed_users: user = orm.User.find(db, name) if user is None: user = orm.User(name=name) @@ -1714,9 +1714,9 @@ class JupyterHub(Application): db.commit() # Notify authenticator of all users. - # This ensures Auth whitelist is up-to-date with the database. - # This lets whitelist be used to set up initial list, - # but changes to the whitelist can occur in the database, + # This ensures Authenticator.allowed_users is up-to-date with the database. + # This lets .allowed_users be used to set up initial list, + # but changes to the allowed_users set can occur in the database, # and persist across sessions. total_users = 0 for user in db.query(orm.User): @@ -1753,9 +1753,9 @@ class JupyterHub(Application): user.created = user.last_activity or datetime.utcnow() db.commit() - # The whitelist set and the users in the db are now the same. + # The allowed_users set and the users in the db are now the same. # From this point on, any user changes should be done simultaneously - # to the whitelist set and user db, unless the whitelist is empty (all users allowed). + # to the allowed_users set and user db, unless the allowed set is empty (all users allowed). TOTAL_USERS.set(total_users) @@ -1770,11 +1770,11 @@ class JupyterHub(Application): for username in usernames: username = self.authenticator.normalize_username(username) if not ( - await maybe_future( - self.authenticator.check_whitelist(username, None) - ) + await maybe_future(self.authenticator.check_allowed(username, None)) ): - raise ValueError("Username %r is not in whitelist" % username) + raise ValueError( + "Username %r is not in Authenticator.allowed_users" % username + ) user = orm.User.find(db, name=username) if user is None: if not self.authenticator.validate_username(username): @@ -1798,11 +1798,14 @@ class JupyterHub(Application): if kind == 'user': name = self.authenticator.normalize_username(name) if not ( - await maybe_future(self.authenticator.check_whitelist(name, None)) + await maybe_future(self.authenticator.check_allowed(name, None)) ): - raise ValueError("Token name %r is not in whitelist" % name) + raise ValueError( + "Token user name %r is not in Authenticator.allowed_users" + % name + ) if not self.authenticator.validate_username(name): - raise ValueError("Token name %r is not valid" % name) + raise ValueError("Token user name %r is not valid" % name) if kind == 'service': if not any(service["name"] == name for service in self.services): self.log.warning( @@ -2183,14 +2186,14 @@ class JupyterHub(Application): else: version_hash = datetime.now().strftime("%Y%m%d%H%M%S") - oauth_no_confirm_whitelist = set() + oauth_no_confirm_list = set() for service in self._service_map.values(): if service.oauth_no_confirm: self.log.warning( "Allowing service %s to complete OAuth without confirmation on an authorization web page", service.name, ) - oauth_no_confirm_whitelist.add(service.oauth_client_id) + oauth_no_confirm_list.add(service.oauth_client_id) settings = dict( log_function=log_request, @@ -2226,7 +2229,7 @@ class JupyterHub(Application): default_server_name=self._default_server_name, named_server_limit_per_user=self.named_server_limit_per_user, oauth_provider=self.oauth_provider, - oauth_no_confirm_whitelist=oauth_no_confirm_whitelist, + oauth_no_confirm_list=oauth_no_confirm_list, concurrent_spawn_limit=self.concurrent_spawn_limit, spawn_throttle_retry_range=self.spawn_throttle_retry_range, active_server_limit=self.active_server_limit, diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index ef18fead..bc9c1ef8 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -7,6 +7,7 @@ import re import sys import warnings from concurrent.futures import ThreadPoolExecutor +from functools import partial from shutil import which from subprocess import PIPE from subprocess import Popen @@ -100,41 +101,74 @@ class Authenticator(LoggingConfigurable): """ ).tag(config=True) - whitelist = Set( + whitelist = Set(help="Deprecated, use `Authenticator.allowed_users`", config=True,) + + allowed_users = Set( help=""" - Whitelist of usernames that are allowed to log in. + Set of usernames that are allowed to log in. Use this with supported authenticators to restrict which users can log in. This is an - additional whitelist that further restricts users, beyond whatever restrictions the + additional list that further restricts users, beyond whatever restrictions the authenticator has in place. If empty, does not perform any additional restriction. + + .. versionchanged:: 1.2 + `Authenticator.whitelist` renamed to `allowed_users` """ ).tag(config=True) - blacklist = Set( + blocked_users = Set( help=""" - Blacklist of usernames that are not allowed to log in. + Set of usernames that are not allowed to log in. Use this with supported authenticators to restrict which users can not log in. This is an - additional blacklist that further restricts users, beyond whatever restrictions the + additional block list that further restricts users, beyond whatever restrictions the authenticator has in place. If empty, does not perform any additional restriction. .. versionadded: 0.9 + + .. versionchanged:: 1.2 + `Authenticator.blacklist` renamed to `blocked_users` """ ).tag(config=True) - @observe('whitelist') - def _check_whitelist(self, change): + _deprecated_aliases = { + "whitelist": ("allowed_users", "1.2"), + "blacklist": ("blocked_users", "1.2"), + } + + @observe(*list(_deprecated_aliases)) + def _deprecated_trait(self, change): + """observer for deprecated traits""" + old_attr = change.name + new_attr, version = self._deprecated_aliases.get(old_attr) + new_value = getattr(self, new_attr) + if new_value != change.new: + # only warn if different + # protects backward-compatible config from warnings + # if they set the same value under both names + self.log.warning( + "{cls}.{old} is deprecated in JupyterHub {version}, use {cls}.{new} instead".format( + cls=self.__class__.__name__, + old=old_attr, + new=new_attr, + version=version, + ) + ) + setattr(self, new_attr, change.new) + + @observe('allowed_users') + def _check_allowed_users(self, change): short_names = [name for name in change['new'] if len(name) <= 1] if short_names: sorted_names = sorted(short_names) single = ''.join(sorted_names) string_set_typo = "set('%s')" % single self.log.warning( - "whitelist contains single-character names: %s; did you mean set([%r]) instead of %s?", + "Allowed set contains single-character names: %s; did you mean set([%r]) instead of %s?", sorted_names[:8], single, string_set_typo, @@ -261,39 +295,74 @@ class Authenticator(LoggingConfigurable): def __init__(self, **kwargs): super().__init__(**kwargs) - for method_name in ( - 'check_whitelist', - 'check_blacklist', - 'check_group_whitelist', + self._init_deprecated_methods() + + def _init_deprecated_methods(self): + # handles deprecated signature *and* name + # with correct subclass override priority! + for old_name, new_name in ( + ('check_whitelist', 'check_allowed'), + ('check_blacklist', 'check_blocked_users'), + ('check_group_whitelist', 'check_allowed_groups'), ): - original_method = getattr(self, method_name, None) - if original_method is None: + old_method = getattr(self, old_name, None) + if old_method is None: # no such method (check_group_whitelist is optional) continue - signature = inspect.signature(original_method) - if 'authentication' not in signature.parameters: + + # allow old name to have higher priority + # if and only if it's defined in a later subclass + # than the new name + for cls in self.__class__.mro(): + has_old_name = old_name in cls.__dict__ + has_new_name = new_name in cls.__dict__ + if has_new_name: + break + if has_old_name and not has_new_name: + warnings.warn( + "{0}.{1} should be renamed to {0}.{2} for JupyterHub >= 1.2".format( + cls.__name__, old_name, new_name + ), + DeprecationWarning, + ) + # use old name instead of new + # if old name is overridden in subclass + def _new_calls_old(old_name, *args, **kwargs): + return getattr(self, old_name)(*args, **kwargs) + + setattr(self, new_name, partial(_new_calls_old, old_name)) + break + + # deprecate pre-1.0 method signatures + signature = inspect.signature(old_method) + if 'authentication' not in signature.parameters and not any( + param.kind == inspect.Parameter.VAR_KEYWORD + for param in signature.parameters.values() + ): # adapt to pre-1.0 signature for compatibility warnings.warn( """ {0}.{1} does not support the authentication argument, - added in JupyterHub 1.0. + added in JupyterHub 1.0. and is renamed to {2} in JupyterHub 1.2. It should have the signature: - def {1}(self, username, authentication=None): + def {2}(self, username, authentication=None): ... Adapting for compatibility. """.format( - self.__class__.__name__, method_name + self.__class__.__name__, old_name, new_name ), DeprecationWarning, ) - def wrapped_method(username, authentication=None, **kwargs): + def wrapped_method( + original_method, username, authentication=None, **kwargs + ): return original_method(username, **kwargs) - setattr(self, method_name, wrapped_method) + setattr(self, old_name, partial(wrapped_method, old_method)) async def run_post_auth_hook(self, handler, authentication): """ @@ -327,39 +396,45 @@ class Authenticator(LoggingConfigurable): username = self.username_map.get(username, username) return username - def check_whitelist(self, username, authentication=None): - """Check if a username is allowed to authenticate based on whitelist configuration + def check_allowed(self, username, authentication=None): + """Check if a username is allowed to authenticate based on configuration Return True if username is allowed, False otherwise. - No whitelist means any username is allowed. + No allowed_users set means any username is allowed. - Names are normalized *before* being checked against the whitelist. + Names are normalized *before* being checked against the allowed set. .. versionchanged:: 1.0 Signature updated to accept authentication data and any future changes - """ - if not self.whitelist: - # No whitelist means any name is allowed - return True - return username in self.whitelist - def check_blacklist(self, username, authentication=None): - """Check if a username is blocked to authenticate based on blacklist configuration + .. versionchanged:: 1.2 + Renamed check_whitelist to check_allowed + """ + if not self.allowed_users: + # No allowed set means any name is allowed + return True + return username in self.allowed_users + + def check_blocked_users(self, username, authentication=None): + """Check if a username is blocked to authenticate based on Authenticator.blocked configuration Return True if username is allowed, False otherwise. - No blacklist means any username is allowed. + No block list means any username is allowed. - Names are normalized *before* being checked against the blacklist. + Names are normalized *before* being checked against the block list. .. versionadded: 0.9 .. versionchanged:: 1.0 Signature updated to accept authentication data as second argument + + .. versionchanged:: 1.2 + Renamed check_blacklist to check_blocked_users """ - if not self.blacklist: - # No blacklist means any name is allowed + if not self.blocked_users: + # No block list means any name is allowed return True - return username not in self.blacklist + return username not in self.blocked_users async def get_authenticated_user(self, handler, data): """Authenticate the user who is attempting to log in @@ -368,7 +443,7 @@ class Authenticator(LoggingConfigurable): This calls `authenticate`, which should be overridden in subclasses, normalizes the username if any normalization should be done, - and then validates the name in the whitelist. + and then validates the name in the allowed set. This is the outer API for authenticating a user. Subclasses should not override this method. @@ -376,7 +451,7 @@ class Authenticator(LoggingConfigurable): The various stages can be overridden separately: - `authenticate` turns formdata into a username - `normalize_username` normalizes the username - - `check_whitelist` checks against the user whitelist + - `check_allowed` checks against the allowed usernames .. versionchanged:: 0.8 return dict instead of username @@ -390,7 +465,7 @@ class Authenticator(LoggingConfigurable): else: authenticated = {'name': authenticated} authenticated.setdefault('auth_state', None) - # Leave the default as None, but reevaluate later post-whitelist + # Leave the default as None, but reevaluate later post-allowed-check authenticated.setdefault('admin', None) # normalize the username @@ -401,20 +476,18 @@ class Authenticator(LoggingConfigurable): self.log.warning("Disallowing invalid username %r.", username) return - blacklist_pass = await maybe_future( - self.check_blacklist(username, authenticated) - ) - whitelist_pass = await maybe_future( - self.check_whitelist(username, authenticated) + blocked_pass = await maybe_future( + self.check_blocked_users(username, authenticated) ) + allowed_pass = await maybe_future(self.check_allowed(username, authenticated)) - if blacklist_pass: + if blocked_pass: pass else: - self.log.warning("User %r in blacklist. Stop authentication", username) + self.log.warning("User %r blocked. Stop authentication", username) return - if whitelist_pass: + if allowed_pass: if authenticated['admin'] is None: authenticated['admin'] = await maybe_future( self.is_admin(handler, authenticated) @@ -424,7 +497,7 @@ class Authenticator(LoggingConfigurable): return authenticated else: - self.log.warning("User %r not in whitelist.", username) + self.log.warning("User %r not allowed.", username) return async def refresh_user(self, user, handler=None): @@ -480,7 +553,7 @@ class Authenticator(LoggingConfigurable): It must return the username on successful authentication, and return None on failed authentication. - Checking the whitelist is handled separately by the caller. + Checking allowed_users/blocked_users is handled separately by the caller. .. versionchanged:: 0.8 Allow `authenticate` to return a dict containing auth_state. @@ -521,10 +594,10 @@ class Authenticator(LoggingConfigurable): This method may be a coroutine. - By default, this just adds the user to the whitelist. + By default, this just adds the user to the allowed_users set. Subclasses may do more extensive things, such as adding actual unix users, - but they should call super to ensure the whitelist is updated. + but they should call super to ensure the allowed_users set is updated. Note that this should be idempotent, since it is called whenever the hub restarts for all users. @@ -534,19 +607,19 @@ class Authenticator(LoggingConfigurable): """ if not self.validate_username(user.name): raise ValueError("Invalid username: %s" % user.name) - if self.whitelist: - self.whitelist.add(user.name) + if self.allowed_users: + self.allowed_users.add(user.name) def delete_user(self, user): """Hook called when a user is deleted - Removes the user from the whitelist. - Subclasses should call super to ensure the whitelist is updated. + Removes the user from the allowed_users set. + Subclasses should call super to ensure the allowed_users set is updated. Args: user (User): The User wrapper object """ - self.whitelist.discard(user.name) + self.allowed_users.discard(user.name) auto_login = Bool( False, @@ -611,6 +684,41 @@ class Authenticator(LoggingConfigurable): return [('/login', LoginHandler)] +def _deprecated_method(old_name, new_name, version): + """Create a deprecated method wrapper for a deprecated method name""" + + def deprecated(self, *args, **kwargs): + warnings.warn( + ( + "{cls}.{old_name} is deprecated in JupyterHub {version}." + " Please use {cls}.{new_name} instead." + ).format( + cls=self.__class__.__name__, + old_name=old_name, + new_name=new_name, + version=version, + ), + DeprecationWarning, + stacklevel=2, + ) + old_method = getattr(self, new_name) + return old_method(*args, **kwargs) + + return deprecated + + +import types + +# deprecate white/blacklist method names +for _old_name, _new_name, _version in [ + ("check_whitelist", "check_allowed", "1.2"), + ("check_blacklist", "check_blocked_users", "1.2"), +]: + setattr( + Authenticator, _old_name, _deprecated_method(_old_name, _new_name, _version), + ) + + class LocalAuthenticator(Authenticator): """Base class for Authenticators that work with local Linux/UNIX users @@ -670,37 +778,37 @@ class LocalAuthenticator(Authenticator): """ ).tag(config=True) - group_whitelist = Set( - help=""" - Whitelist all users from this UNIX group. + group_whitelist = Set(help="""DEPRECATED: use allowed_groups""",).tag(config=True) - This makes the username whitelist ineffective. + allowed_groups = Set( + help=""" + Allow login from all users in these UNIX groups. + + If set, allowed username set is ignored. """ ).tag(config=True) - @observe('group_whitelist') - def _group_whitelist_changed(self, change): - """ - Log a warning if both group_whitelist and user whitelist are set. - """ - if self.whitelist: + @observe('allowed_groups') + def _allowed_groups_changed(self, change): + """Log a warning if mutually exclusive user and group allowed sets are specified.""" + if self.allowed_users: self.log.warning( - "Ignoring username whitelist because group whitelist supplied!" + "Ignoring Authenticator.allowed_users set because Authenticator.allowed_groups supplied!" ) - def check_whitelist(self, username, authentication=None): - if self.group_whitelist: - return self.check_group_whitelist(username, authentication) + def check_allowed(self, username, authentication=None): + if self.allowed_groups: + return self.check_allowed_groups(username, authentication) else: - return super().check_whitelist(username, authentication) + return super().check_allowed(username, authentication) - def check_group_whitelist(self, username, authentication=None): + def check_allowed_groups(self, username, authentication=None): """ - If group_whitelist is configured, check if authenticating user is part of group. + If allowed_groups is configured, check if authenticating user is part of group. """ - if not self.group_whitelist: + if not self.allowed_groups: return False - for grnam in self.group_whitelist: + for grnam in self.allowed_groups: try: group = self._getgrnam(grnam) except KeyError: @@ -844,7 +952,7 @@ class PAMAuthenticator(LocalAuthenticator): Authoritative list of user groups that determine admin access. Users not in these groups can still be granted admin status through admin_users. - White/blacklisting rules still apply. + allowed/blocked rules still apply. """ ).tag(config=True) @@ -987,6 +1095,16 @@ class PAMAuthenticator(LocalAuthenticator): return super().normalize_username(username) +for _old_name, _new_name, _version in [ + ("check_group_whitelist", "check_group_allowed", "1.2"), +]: + setattr( + LocalAuthenticator, + _old_name, + _deprecated_method(_old_name, _new_name, _version), + ) + + class DummyAuthenticator(Authenticator): """Dummy Authenticator for testing diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py index 518fd7e8..5ad6d163 100644 --- a/jupyterhub/services/auth.py +++ b/jupyterhub/services/auth.py @@ -860,15 +860,15 @@ class HubAuthenticated(object): if kind == 'service': # it's a service, check hub_services if self.hub_services and name in self.hub_services: - app_log.debug("Allowing whitelisted Hub service %s", name) + app_log.debug("Allowing Hub service %s", name) return model else: app_log.warning("Not allowing Hub service %s", name) raise UserNotAllowed(model) if self.hub_users and name in self.hub_users: - # user in whitelist - app_log.debug("Allowing whitelisted Hub user %s", name) + # user in allowed list + app_log.debug("Allowing Hub user %s", name) return model elif self.hub_groups and set(model['groups']).intersection(self.hub_groups): allowed_groups = set(model['groups']).intersection(self.hub_groups) @@ -877,7 +877,7 @@ class HubAuthenticated(object): name, ','.join(sorted(allowed_groups)), ) - # group in whitelist + # group in allowed list return model else: app_log.warning("Not allowing Hub user %s", name) diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 7c0bf9c4..cdca3c74 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -435,9 +435,9 @@ class Spawner(LoggingConfigurable): 'LC_ALL', ], help=""" - Whitelist of environment variables for the single-user server to inherit from the JupyterHub process. + List of environment variables for the single-user server to inherit from the JupyterHub process. - This whitelist is used to ensure that sensitive information in the JupyterHub process's environment + This list is used to ensure that sensitive information in the JupyterHub process's environment (such as `CONFIGPROXY_AUTH_TOKEN`) is not passed to the single-user server's process. """, ).tag(config=True) @@ -456,7 +456,7 @@ class Spawner(LoggingConfigurable): Environment variables that end up in the single-user server's process come from 3 sources: - This `environment` configurable - - The JupyterHub process' environment variables that are whitelisted in `env_keep` + - The JupyterHub process' environment variables that are listed in `env_keep` - Variables to establish contact between the single-user notebook and the hub (such as JUPYTERHUB_API_TOKEN) The `environment` configurable should be set by JupyterHub administrators to add diff --git a/jupyterhub/tests/test_app.py b/jupyterhub/tests/test_app.py index bc59176b..e00c699e 100644 --- a/jupyterhub/tests/test_app.py +++ b/jupyterhub/tests/test_app.py @@ -93,7 +93,7 @@ def test_generate_config(): os.remove(cfg_file) assert cfg_file in out assert 'Spawner.cmd' in cfg_text - assert 'Authenticator.whitelist' in cfg_text + assert 'Authenticator.allowed_users' in cfg_text async def test_init_tokens(request): diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index 10ae0b1a..51cd4c2a 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -1,11 +1,14 @@ """Tests for PAM authentication""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +import logging import os +import warnings from unittest import mock import pytest from requests import HTTPError +from traitlets.config import Config from .mocking import MockPAMAuthenticator from .mocking import MockStructGroup @@ -137,8 +140,8 @@ async def test_pam_auth_admin_groups(): assert authorized['admin'] is False -async def test_pam_auth_whitelist(): - authenticator = MockPAMAuthenticator(whitelist={'wash', 'kaylee'}) +async def test_pam_auth_allowed(): + authenticator = MockPAMAuthenticator(allowed_users={'wash', 'kaylee'}) authorized = await authenticator.get_authenticated_user( None, {'username': 'kaylee', 'password': 'kaylee'} ) @@ -155,11 +158,11 @@ async def test_pam_auth_whitelist(): assert authorized is None -async def test_pam_auth_group_whitelist(): +async def test_pam_auth_allowed_groups(): def getgrnam(name): return MockStructGroup('grp', ['kaylee']) - authenticator = MockPAMAuthenticator(group_whitelist={'group'}) + authenticator = MockPAMAuthenticator(allowed_groups={'group'}) with mock.patch.object(authenticator, '_getgrnam', getgrnam): authorized = await authenticator.get_authenticated_user( @@ -174,7 +177,7 @@ async def test_pam_auth_group_whitelist(): assert authorized is None -async def test_pam_auth_blacklist(): +async def test_pam_auth_blocked(): # Null case compared to next case authenticator = MockPAMAuthenticator() authorized = await authenticator.get_authenticated_user( @@ -183,33 +186,33 @@ async def test_pam_auth_blacklist(): assert authorized['name'] == 'wash' # Blacklist basics - authenticator = MockPAMAuthenticator(blacklist={'wash'}) + authenticator = MockPAMAuthenticator(blocked_users={'wash'}) authorized = await authenticator.get_authenticated_user( None, {'username': 'wash', 'password': 'wash'} ) assert authorized is None - # User in both white and blacklists: default deny. Make error someday? + # User in both allowed and blocked: default deny. Make error someday? authenticator = MockPAMAuthenticator( - blacklist={'wash'}, whitelist={'wash', 'kaylee'} + blocked_users={'wash'}, allowed_users={'wash', 'kaylee'} ) authorized = await authenticator.get_authenticated_user( None, {'username': 'wash', 'password': 'wash'} ) assert authorized is None - # User not in blacklist can log in + # User not in blocked set can log in authenticator = MockPAMAuthenticator( - blacklist={'wash'}, whitelist={'wash', 'kaylee'} + blocked_users={'wash'}, allowed_users={'wash', 'kaylee'} ) authorized = await authenticator.get_authenticated_user( None, {'username': 'kaylee', 'password': 'kaylee'} ) assert authorized['name'] == 'kaylee' - # User in whitelist, blacklist irrelevent + # User in allowed, blocked irrelevent authenticator = MockPAMAuthenticator( - blacklist={'mal'}, whitelist={'wash', 'kaylee'} + blocked_users={'mal'}, allowed_users={'wash', 'kaylee'} ) authorized = await authenticator.get_authenticated_user( None, {'username': 'wash', 'password': 'wash'} @@ -218,15 +221,16 @@ async def test_pam_auth_blacklist(): # User in neither list authenticator = MockPAMAuthenticator( - blacklist={'mal'}, whitelist={'wash', 'kaylee'} + blocked_users={'mal'}, allowed_users={'wash', 'kaylee'} ) authorized = await authenticator.get_authenticated_user( None, {'username': 'simon', 'password': 'simon'} ) assert authorized is None - # blacklist == {} - authenticator = MockPAMAuthenticator(blacklist=set(), whitelist={'wash', 'kaylee'}) + authenticator = MockPAMAuthenticator( + blocked_users=set(), allowed_users={'wash', 'kaylee'} + ) authorized = await authenticator.get_authenticated_user( None, {'username': 'kaylee', 'password': 'kaylee'} ) @@ -253,7 +257,7 @@ async def test_deprecated_signatures(): async def test_pam_auth_no_such_group(): - authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'}) + authenticator = MockPAMAuthenticator(allowed_groups={'nosuchcrazygroup'}) authorized = await authenticator.get_authenticated_user( None, {'username': 'kaylee', 'password': 'kaylee'} ) @@ -262,7 +266,7 @@ async def test_pam_auth_no_such_group(): async def test_wont_add_system_user(): user = orm.User(name='lioness4321') - authenticator = auth.PAMAuthenticator(whitelist={'mal'}) + authenticator = auth.PAMAuthenticator(allowed_users={'mal'}) authenticator.create_system_users = False with pytest.raises(KeyError): await authenticator.add_user(user) @@ -270,7 +274,7 @@ async def test_wont_add_system_user(): async def test_cant_add_system_user(): user = orm.User(name='lioness4321') - authenticator = auth.PAMAuthenticator(whitelist={'mal'}) + authenticator = auth.PAMAuthenticator(allowed_users={'mal'}) authenticator.add_user_cmd = ['jupyterhub-fake-command'] authenticator.create_system_users = True @@ -296,7 +300,7 @@ async def test_cant_add_system_user(): async def test_add_system_user(): user = orm.User(name='lioness4321') - authenticator = auth.PAMAuthenticator(whitelist={'mal'}) + authenticator = auth.PAMAuthenticator(allowed_users={'mal'}) authenticator.create_system_users = True authenticator.add_user_cmd = ['echo', '/home/USERNAME'] @@ -317,13 +321,13 @@ async def test_add_system_user(): async def test_delete_user(): user = orm.User(name='zoe') - a = MockPAMAuthenticator(whitelist={'mal'}) + a = MockPAMAuthenticator(allowed_users={'mal'}) - assert 'zoe' not in a.whitelist + assert 'zoe' not in a.allowed_users await a.add_user(user) - assert 'zoe' in a.whitelist + assert 'zoe' in a.allowed_users a.delete_user(user) - assert 'zoe' not in a.whitelist + assert 'zoe' not in a.allowed_users def test_urls(): @@ -461,3 +465,55 @@ async def test_post_auth_hook(): ) assert authorized['testkey'] == 'testvalue' + + +class MyAuthenticator(auth.Authenticator): + def check_whitelist(self, username, authentication=None): + return username == "subclass-allowed" + + +def test_deprecated_config(caplog): + cfg = Config() + cfg.Authenticator.whitelist = {'user'} + log = logging.getLogger("testlog") + authenticator = auth.Authenticator(config=cfg, log=log) + assert caplog.record_tuples == [ + ( + log.name, + logging.WARNING, + 'Authenticator.whitelist is deprecated in JupyterHub 1.2, use ' + 'Authenticator.allowed_users instead', + ) + ] + assert authenticator.allowed_users == {'user'} + + +def test_deprecated_methods(): + cfg = Config() + cfg.Authenticator.whitelist = {'user'} + authenticator = auth.Authenticator(config=cfg) + + assert authenticator.check_allowed("user") + with pytest.deprecated_call(): + assert authenticator.check_whitelist("user") + assert not authenticator.check_allowed("otheruser") + with pytest.deprecated_call(): + assert not authenticator.check_whitelist("otheruser") + + +def test_deprecated_config_subclass(): + cfg = Config() + cfg.MyAuthenticator.whitelist = {'user'} + with pytest.deprecated_call(): + authenticator = MyAuthenticator(config=cfg) + assert authenticator.allowed_users == {'user'} + + +def test_deprecated_methods_subclass(): + with pytest.deprecated_call(): + authenticator = MyAuthenticator() + + assert authenticator.check_allowed("subclass-allowed") + assert authenticator.check_whitelist("subclass-allowed") + assert not authenticator.check_allowed("otheruser") + assert not authenticator.check_whitelist("otheruser") diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index 74de03a5..fbb67e8c 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -744,7 +744,7 @@ async def test_shutdown_on_logout(app, shutdown_on_logout): assert spawner.ready == (not shutdown_on_logout) -async def test_login_no_whitelist_adds_user(app): +async def test_login_no_allowed_adds_user(app): auth = app.authenticator mock_add_user = mock.Mock() with mock.patch.object(auth, 'add_user', mock_add_user): diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index de4d73e9..752091d2 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -185,7 +185,7 @@ def test_hub_authenticated(request): m.get(good_url, text=json.dumps(mock_model)) - # no whitelist + # no specific allowed user r = requests.get( 'http://127.0.0.1:%i' % port, cookies={'jubal': 'early'}, @@ -194,7 +194,7 @@ def test_hub_authenticated(request): r.raise_for_status() assert r.status_code == 200 - # pass whitelist + # pass allowed user TestHandler.hub_users = {'jubalearly'} r = requests.get( 'http://127.0.0.1:%i' % port, @@ -204,7 +204,7 @@ def test_hub_authenticated(request): r.raise_for_status() assert r.status_code == 200 - # no pass whitelist + # no pass allowed ser TestHandler.hub_users = {'kaylee'} r = requests.get( 'http://127.0.0.1:%i' % port, @@ -213,7 +213,7 @@ def test_hub_authenticated(request): ) assert r.status_code == 403 - # pass group whitelist + # pass allowed group TestHandler.hub_groups = {'lions'} r = requests.get( 'http://127.0.0.1:%i' % port, @@ -223,7 +223,7 @@ def test_hub_authenticated(request): r.raise_for_status() assert r.status_code == 200 - # no pass group whitelist + # no pass allowed group TestHandler.hub_groups = {'tigers'} r = requests.get( 'http://127.0.0.1:%i' % port,