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:
Min RK
2016-01-08 15:45:57 +01:00
parent 8a5a85a489
commit 887fdaf9d3
5 changed files with 80 additions and 21 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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!")

View File

@@ -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'