mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 18:44:10 +00:00
add username normalization
Handlers call `get_authenticated_user`, which in turn calls - authenticate - normalize_username - check_whitelist get_authenticated_user shouldn't need to be overridden. Normalization can be handled via overriding normalize_username.
This commit is contained in:
@@ -34,6 +34,7 @@ class UserListAPIHandler(APIHandler):
|
|||||||
|
|
||||||
to_create = []
|
to_create = []
|
||||||
for name in usernames:
|
for name in usernames:
|
||||||
|
name = self.authenticator.normalize_username(name)
|
||||||
user = self.find_user(name)
|
user = self.find_user(name)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
self.log.warn("User %s already exists" % name)
|
self.log.warn("User %s already exists" % name)
|
||||||
|
@@ -630,7 +630,10 @@ class JupyterHub(Application):
|
|||||||
"\nUse Authenticator.admin_users instead."
|
"\nUse Authenticator.admin_users instead."
|
||||||
)
|
)
|
||||||
self.authenticator.admin_users = self.admin_users
|
self.authenticator.admin_users = self.admin_users
|
||||||
admin_users = self.authenticator.admin_users
|
admin_users = [
|
||||||
|
self.authenticator.normalize_username(name)
|
||||||
|
for name in self.authenticator.admin_users
|
||||||
|
]
|
||||||
|
|
||||||
if not admin_users:
|
if not admin_users:
|
||||||
self.log.warning("No admin users, admin interface will be unavailable.")
|
self.log.warning("No admin users, admin interface will be unavailable.")
|
||||||
@@ -651,7 +654,10 @@ class JupyterHub(Application):
|
|||||||
# the admin_users config variable will never be used after this point.
|
# the admin_users config variable will never be used after this point.
|
||||||
# only the database values will be referenced.
|
# only the database values will be referenced.
|
||||||
|
|
||||||
whitelist = self.authenticator.whitelist
|
whitelist = [
|
||||||
|
self.authenticator.normalize_username(name)
|
||||||
|
for name in self.authenticator.whitelist
|
||||||
|
]
|
||||||
|
|
||||||
if not whitelist:
|
if not whitelist:
|
||||||
self.log.info("Not using whitelist. Any authenticated user will be allowed.")
|
self.log.info("Not using whitelist. Any authenticated user will be allowed.")
|
||||||
@@ -671,7 +677,7 @@ class JupyterHub(Application):
|
|||||||
# but changes to the whitelist can occur in the database,
|
# but changes to the whitelist can occur in the database,
|
||||||
# and persist across sessions.
|
# and persist across sessions.
|
||||||
for user in db.query(orm.User):
|
for user in db.query(orm.User):
|
||||||
whitelist.add(user.name)
|
self.authenticator.whitelist.add(user.name)
|
||||||
|
|
||||||
# The whitelist set and the users in the db are now the same.
|
# The whitelist set and the users in the db are now the same.
|
||||||
# From this point on, any user changes should be done simultaneously
|
# From this point on, any user changes should be done simultaneously
|
||||||
|
@@ -53,6 +53,56 @@ class Authenticator(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def normalize_username(self, username):
|
||||||
|
"""Normalize a username.
|
||||||
|
|
||||||
|
Override in subclasses if usernames should have some normalization.
|
||||||
|
Default: cast to lowercase, lookup in username_map.
|
||||||
|
"""
|
||||||
|
username = username.lower()
|
||||||
|
return username
|
||||||
|
|
||||||
|
def check_whitelist(self, username):
|
||||||
|
"""Check a username against our whitelist.
|
||||||
|
|
||||||
|
Return True if username is allowed, False otherwise.
|
||||||
|
No whitelist means any username should be allowed.
|
||||||
|
|
||||||
|
Names are normalized *before* being checked against the whitelist.
|
||||||
|
"""
|
||||||
|
if not self.whitelist:
|
||||||
|
# No whitelist means any name is allowed
|
||||||
|
return True
|
||||||
|
return username in self.whitelist
|
||||||
|
|
||||||
|
@gen.coroutine
|
||||||
|
def get_authenticated_user(self, handler, data):
|
||||||
|
"""This is the outer API for authenticating a user.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Subclasses should not need to override this method.
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
username = yield self.authenticate(handler, data)
|
||||||
|
if username is None:
|
||||||
|
return
|
||||||
|
username = self.normalize_username(username)
|
||||||
|
if not self.validate_username(username):
|
||||||
|
self.log.warning("Disallowing invalid username %r.", username)
|
||||||
|
return
|
||||||
|
if self.check_whitelist(username):
|
||||||
|
return username
|
||||||
|
else:
|
||||||
|
self.log.warning("User %r not in whitelist.", username)
|
||||||
|
return
|
||||||
|
|
||||||
@gen.coroutine
|
@gen.coroutine
|
||||||
def authenticate(self, handler, data):
|
def authenticate(self, handler, data):
|
||||||
"""Authenticate a user with login form data.
|
"""Authenticate a user with login form data.
|
||||||
@@ -60,6 +110,8 @@ class Authenticator(LoggingConfigurable):
|
|||||||
This must be a tornado gen.coroutine.
|
This must be a tornado gen.coroutine.
|
||||||
It must return the username on successful authentication,
|
It must return the username on successful authentication,
|
||||||
and return None on failed authentication.
|
and return None on failed authentication.
|
||||||
|
|
||||||
|
Checking the whitelist is handled separately by the caller.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def pre_spawn_start(self, user, spawner):
|
def pre_spawn_start(self, user, spawner):
|
||||||
@@ -74,13 +126,6 @@ class Authenticator(LoggingConfigurable):
|
|||||||
Can be used to do auth-related cleanup, e.g. closing PAM sessions.
|
Can be used to do auth-related cleanup, e.g. closing PAM sessions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def check_whitelist(self, user):
|
|
||||||
"""
|
|
||||||
Return True if the whitelist is empty or user is in the whitelist.
|
|
||||||
"""
|
|
||||||
# Parens aren't necessary here, but they make this easier to parse.
|
|
||||||
return (not self.whitelist) or (user in self.whitelist)
|
|
||||||
|
|
||||||
def add_user(self, user):
|
def add_user(self, user):
|
||||||
"""Add a new user
|
"""Add a new user
|
||||||
|
|
||||||
@@ -240,8 +285,6 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
Return None otherwise.
|
Return None otherwise.
|
||||||
"""
|
"""
|
||||||
username = data['username']
|
username = data['username']
|
||||||
if not self.check_whitelist(username):
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
pamela.authenticate(username, data['password'], service=self.service)
|
pamela.authenticate(username, data['password'], service=self.service)
|
||||||
except pamela.PAMError as e:
|
except pamela.PAMError as e:
|
||||||
|
@@ -247,7 +247,7 @@ class BaseHandler(RequestHandler):
|
|||||||
def authenticate(self, data):
|
def authenticate(self, data):
|
||||||
auth = self.authenticator
|
auth = self.authenticator
|
||||||
if auth is not None:
|
if auth is not None:
|
||||||
result = yield auth.authenticate(self, data)
|
result = yield auth.get_authenticated_user(self, data)
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
self.log.error("No authentication function, login is impossible!")
|
self.log.error("No authentication function, login is impossible!")
|
||||||
|
@@ -13,13 +13,13 @@ from jupyterhub import auth, orm
|
|||||||
|
|
||||||
def test_pam_auth(io_loop):
|
def test_pam_auth(io_loop):
|
||||||
authenticator = MockPAMAuthenticator()
|
authenticator = MockPAMAuthenticator()
|
||||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||||
'username': 'match',
|
'username': 'match',
|
||||||
'password': 'match',
|
'password': 'match',
|
||||||
}))
|
}))
|
||||||
assert authorized == 'match'
|
assert authorized == 'match'
|
||||||
|
|
||||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||||
'username': 'match',
|
'username': 'match',
|
||||||
'password': 'nomatch',
|
'password': 'nomatch',
|
||||||
}))
|
}))
|
||||||
@@ -27,19 +27,19 @@ def test_pam_auth(io_loop):
|
|||||||
|
|
||||||
def test_pam_auth_whitelist(io_loop):
|
def test_pam_auth_whitelist(io_loop):
|
||||||
authenticator = MockPAMAuthenticator(whitelist={'wash', 'kaylee'})
|
authenticator = MockPAMAuthenticator(whitelist={'wash', 'kaylee'})
|
||||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||||
'username': 'kaylee',
|
'username': 'kaylee',
|
||||||
'password': 'kaylee',
|
'password': 'kaylee',
|
||||||
}))
|
}))
|
||||||
assert authorized == 'kaylee'
|
assert authorized == 'kaylee'
|
||||||
|
|
||||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||||
'username': 'wash',
|
'username': 'wash',
|
||||||
'password': 'nomatch',
|
'password': 'nomatch',
|
||||||
}))
|
}))
|
||||||
assert authorized is None
|
assert authorized is None
|
||||||
|
|
||||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||||
'username': 'mal',
|
'username': 'mal',
|
||||||
'password': 'mal',
|
'password': 'mal',
|
||||||
}))
|
}))
|
||||||
@@ -59,14 +59,14 @@ def test_pam_auth_group_whitelist(io_loop):
|
|||||||
authenticator = MockPAMAuthenticator(group_whitelist={'group'})
|
authenticator = MockPAMAuthenticator(group_whitelist={'group'})
|
||||||
|
|
||||||
with mock.patch.object(auth, 'getgrnam', getgrnam):
|
with mock.patch.object(auth, 'getgrnam', getgrnam):
|
||||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||||
'username': 'kaylee',
|
'username': 'kaylee',
|
||||||
'password': 'kaylee',
|
'password': 'kaylee',
|
||||||
}))
|
}))
|
||||||
assert authorized == 'kaylee'
|
assert authorized == 'kaylee'
|
||||||
|
|
||||||
with mock.patch.object(auth, 'getgrnam', getgrnam):
|
with mock.patch.object(auth, 'getgrnam', getgrnam):
|
||||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||||
'username': 'mal',
|
'username': 'mal',
|
||||||
'password': 'mal',
|
'password': 'mal',
|
||||||
}))
|
}))
|
||||||
@@ -75,7 +75,7 @@ def test_pam_auth_group_whitelist(io_loop):
|
|||||||
|
|
||||||
def test_pam_auth_no_such_group(io_loop):
|
def test_pam_auth_no_such_group(io_loop):
|
||||||
authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'})
|
authenticator = MockPAMAuthenticator(group_whitelist={'nosuchcrazygroup'})
|
||||||
authorized = io_loop.run_sync(lambda : authenticator.authenticate(None, {
|
authorized = io_loop.run_sync(lambda : authenticator.get_authenticated_user(None, {
|
||||||
'username': 'kaylee',
|
'username': 'kaylee',
|
||||||
'password': 'kaylee',
|
'password': 'kaylee',
|
||||||
}))
|
}))
|
||||||
@@ -144,3 +144,12 @@ def test_handlers(app):
|
|||||||
assert handlers[0][0] == '/login'
|
assert handlers[0][0] == '/login'
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_names(io_loop):
|
||||||
|
a = MockPAMAuthenticator()
|
||||||
|
authorized = io_loop.run_sync(lambda : a.get_authenticated_user(None, {
|
||||||
|
'username': 'ZOE',
|
||||||
|
'password': 'ZOE',
|
||||||
|
}))
|
||||||
|
assert authorized == 'zoe'
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user