diff --git a/data/users/__init__.py b/data/users/__init__.py index 6f61b28f8..fc504c530 100644 --- a/data/users/__init__.py +++ b/data/users/__init__.py @@ -54,8 +54,12 @@ def get_users_handler(config, config_provider, override_config_dir): verify_url = config.get('JWT_VERIFY_ENDPOINT') issuer = config.get('JWT_AUTH_ISSUER') max_fresh_s = config.get('JWT_AUTH_MAX_FRESH_S', 300) - return ExternalJWTAuthN(verify_url, issuer, override_config_dir, config['HTTPCLIENT'], - max_fresh_s) + + query_url = config.get('JWT_QUERY_ENDPOINT', None) + getuser_url = config.get('JWT_GETUSER_ENDPOINT', None) + + return ExternalJWTAuthN(verify_url, query_url, getuser_url, issuer, override_config_dir, + config['HTTPCLIENT'], max_fresh_s) if authentication_type == 'Keystone': auth_url = config.get('KEYSTONE_AUTH_URL') diff --git a/data/users/externaljwt.py b/data/users/externaljwt.py index 59740efd5..696b081d5 100644 --- a/data/users/externaljwt.py +++ b/data/users/externaljwt.py @@ -2,7 +2,7 @@ import logging import json import os -from data.users.federated import FederatedUsers, VerifiedCredentials +from data.users.federated import FederatedUsers, UserInformation from util.security import jwtutil @@ -13,10 +13,13 @@ class ExternalJWTAuthN(FederatedUsers): """ Delegates authentication to a REST endpoint that returns JWTs. """ PUBLIC_KEY_FILENAME = 'jwt-authn.cert' - def __init__(self, verify_url, issuer, override_config_dir, http_client, max_fresh_s, - public_key_path=None): + def __init__(self, verify_url, query_url, getuser_url, issuer, override_config_dir, http_client, + max_fresh_s, public_key_path=None): super(ExternalJWTAuthN, self).__init__('jwtauthn') self.verify_url = verify_url + self.query_url = query_url + self.getuser_url = getuser_url + self.issuer = issuer self.client = http_client self.max_fresh_s = max_fresh_s @@ -32,34 +35,80 @@ class ExternalJWTAuthN(FederatedUsers): with open(public_key_path) as public_key_file: self.public_key = public_key_file.read() - def verify_credentials(self, username_or_email, password): - result = self.client.get(self.verify_url, timeout=2, auth=(username_or_email, password)) + def get_user(self, username_or_email): + if self.getuser_url is None: + return (None, 'No endpoint defined for retrieving user') + + (payload, err_msg) = self._execute_call(self.getuser_url, 'quay.io/jwtauthn/getuser', + params=dict(username=username_or_email)) + if err_msg is not None: + return (None, err_msg) + + if not 'sub' in payload: + raise Exception('Missing sub field in JWT') + + if not 'email' in payload: + raise Exception('Missing email field in JWT') + + # Parse out the username and email. + user_info = UserInformation(username=payload['sub'], email=payload['email'], id=payload['sub']) + return (user_info, None) + + + def query_users(self, query, limit=20): + if self.query_url is None: + return (None, self.federated_service, 'No endpoint defined for querying users') + + (payload, err_msg) = self._execute_call(self.query_url, 'quay.io/jwtauthn/query', + params=dict(query=query, limit=limit)) + if err_msg is not None: + return (None, self.federated_service, err_msg) + + query_results = [] + for result in payload['results'][0:limit]: + user_info = UserInformation(username=result['username'], email=result['email'], + id=result['username']) + query_results.append(user_info) + + return (query_results, self.federated_service, None) + + + def verify_credentials(self, username_or_email, password): + (payload, err_msg) = self._execute_call(self.verify_url, 'quay.io/jwtauthn', + auth=(username_or_email, password)) + if err_msg is not None: + return (None, err_msg) + + if not 'sub' in payload: + raise Exception('Missing sub field in JWT') + + if not 'email' in payload: + raise Exception('Missing email field in JWT') + + user_info = UserInformation(username=payload['sub'], email=payload['email'], id=payload['sub']) + return (user_info, None) + + + def _execute_call(self, url, aud, auth=None, params=None): + """ Executes a call to the external JWT auth provider. """ + result = self.client.get(url, timeout=2, auth=auth, params=params) if result.status_code != 200: - return (None, result.text or 'Invalid username or password') + return (None, result.text or 'Could not make JWT auth call') try: result_data = json.loads(result.text) except ValueError: - raise Exception('Returned JWT Authentication body does not contain JSON') + raise Exception('Returned JWT body for url %s does not contain JSON', url) # Load the JWT returned. encoded = result_data.get('token', '') exp_limit_options = jwtutil.exp_max_s_option(self.max_fresh_s) try: payload = jwtutil.decode(encoded, self.public_key, algorithms=['RS256'], - audience='quay.io/jwtauthn', issuer=self.issuer, + audience=aud, issuer=self.issuer, options=exp_limit_options) + return (payload, None) except jwtutil.InvalidTokenError: - logger.exception('Exception when decoding returned JWT') - return (None, 'Invalid username or password') - - if not 'sub' in payload: - raise Exception('Missing username field in JWT') - - if not 'email' in payload: - raise Exception('Missing email field in JWT') - - # Parse out the username and email. - return (VerifiedCredentials(username=payload['sub'], email=payload['email']), None) - + logger.exception('Exception when decoding returned JWT for url %s', url) + return (None, 'Exception when decoding returned JWT') diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index d810ad1a7..96a1c0758 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -573,20 +573,6 @@ - - - - + + + + + + + + + + + +
User Verification Endpoint: - -
- The URL (starting with http or https) on the JWT authentication server for verifying username and password credentials. -
- -
- Credentials will be sent in the Authorization header as Basic Auth, and this endpoint should return 200 OK on success (or a 4** otherwise). -
-
Authentication Issuer: @@ -606,6 +592,50 @@
User Verification Endpoint: + +
+ The URL (starting with http or https) on the JWT authentication server for verifying username and password credentials. +
+ +
+ Credentials will be sent in the Authorization header as Basic Auth, and this endpoint should return 200 OK on success (or a 4** otherwise). +
+
User Query Endpoint: + +
+ The URL (starting with http or https) on the JWT authentication server for looking up + users based on a prefix query. This is optional. +
+ +
+ The prefix query will be sent as a query parameter with name query. +
+
User Lookup Endpoint: + +
+ The URL (starting with http or https) on the JWT authentication server for looking up + a user by username or email address. +
+ +
+ The username or email address will be sent as a query parameter with name username. +
+
diff --git a/test/test_external_jwt_authn.py b/test/test_external_jwt_authn.py index 9b561d0fb..6bedf6d7a 100644 --- a/test/test_external_jwt_authn.py +++ b/test/test_external_jwt_authn.py @@ -53,6 +53,58 @@ class JWTAuthTestCase(LiveServerTestCase): data = base64.b64decode(request.headers['Authorization'][len('Basic '):]) return data.split(':', 1) + @jwt_app.route('/user/query', methods=['GET']) + def query_users(): + query = request.args.get('query') + results = [] + + for user in users: + if user['name'].startswith(query): + results.append({ + 'username': user['name'], + 'email': user['email'], + }) + + token_data = { + 'iss': 'authy', + 'aud': 'quay.io/jwtauthn/query', + 'nbf': datetime.utcnow(), + 'iat': datetime.utcnow(), + 'exp': datetime.utcnow() + timedelta(seconds=60), + 'results': results, + } + + encoded = jwt.encode(token_data, private_key, 'RS256') + return jsonify({ + 'token': encoded + }) + + @jwt_app.route('/user/get', methods=['GET']) + def get_user(): + username = request.args.get('username') + + if username == 'disabled': + return make_response('User is currently disabled', 401) + + for user in users: + if user['name'] == username or user['email'] == username: + token_data = { + 'iss': 'authy', + 'aud': 'quay.io/jwtauthn/getuser', + 'nbf': datetime.utcnow(), + 'iat': datetime.utcnow(), + 'exp': datetime.utcnow() + timedelta(seconds=60), + 'sub': user['name'], + 'email': user['email'] + } + + encoded = jwt.encode(token_data, private_key, 'RS256') + return jsonify({ + 'token': encoded + }) + + return make_response('Invalid username or password', 404) + @jwt_app.route('/user/verify', methods=['GET']) def verify_user(): username, password = _get_basic_auth() @@ -80,7 +132,7 @@ class JWTAuthTestCase(LiveServerTestCase): 'token': encoded }) - return make_response('', 404) + return make_response('Invalid username or password', 404) jwt_app.config['TESTING'] = True return jwt_app @@ -94,7 +146,11 @@ class JWTAuthTestCase(LiveServerTestCase): self.session = requests.Session() - self.jwt_auth = ExternalJWTAuthN(self.get_server_url() + '/user/verify', 'authy', '', + verify_url = self.get_server_url() + '/user/verify' + query_url = self.get_server_url() + '/user/query' + getuser_url = self.get_server_url() + '/user/get' + + self.jwt_auth = ExternalJWTAuthN(verify_url, query_url, getuser_url, 'authy', '', app.config['HTTPCLIENT'], 300, JWTAuthTestCase.public_key.name) def tearDown(self): @@ -142,11 +198,78 @@ class JWTAuthTestCase(LiveServerTestCase): self.assertIsNotNone(result) self.assertEquals('some_neat_user', result.username) - def test_disabled_user_custom_erorr(self): + def test_disabled_user_custom_error(self): result, error_message = self.jwt_auth.verify_and_link_user('disabled', 'password') self.assertIsNone(result) self.assertEquals('User is currently disabled', error_message) + def test_query(self): + # Lookup `cool`. + results, identifier, error_message = self.jwt_auth.query_users('cool') + self.assertIsNone(error_message) + self.assertEquals('jwtauthn', identifier) + self.assertEquals(1, len(results)) + + self.assertEquals('cooluser', results[0].username) + self.assertEquals('user@domain.com', results[0].email) + + # Lookup `some`. + results, identifier, error_message = self.jwt_auth.query_users('some') + self.assertIsNone(error_message) + self.assertEquals('jwtauthn', identifier) + self.assertEquals(1, len(results)) + + self.assertEquals('some.neat.user', results[0].username) + self.assertEquals('neat@domain.com', results[0].email) + + # Lookup `unknown`. + results, identifier, error_message = self.jwt_auth.query_users('unknown') + self.assertIsNone(error_message) + self.assertEquals('jwtauthn', identifier) + self.assertEquals(0, len(results)) + + def test_get_user(self): + # Lookup cooluser. + result, error_message = self.jwt_auth.get_user('cooluser') + self.assertIsNone(error_message) + self.assertIsNotNone(result) + + self.assertEquals('cooluser', result.username) + self.assertEquals('user@domain.com', result.email) + + # Lookup some.neat.user. + result, error_message = self.jwt_auth.get_user('some.neat.user') + self.assertIsNone(error_message) + self.assertIsNotNone(result) + + self.assertEquals('some.neat.user', result.username) + self.assertEquals('neat@domain.com', result.email) + + # Lookup unknown user. + result, error_message = self.jwt_auth.get_user('unknownuser') + self.assertIsNone(result) + + def test_link_user(self): + # Link cooluser. + user, error_message = self.jwt_auth.link_user('cooluser') + self.assertIsNone(error_message) + self.assertIsNotNone(user) + self.assertEquals('cooluser', user.username) + + # Link again. Should return the same user record. + user_again, _ = self.jwt_auth.link_user('cooluser') + self.assertEquals(user_again.id, user.id) + + # Confirm cooluser. + result, _ = self.jwt_auth.confirm_existing_user('cooluser', 'password') + self.assertIsNotNone(result) + self.assertEquals('cooluser', result.username) + + def test_link_invalid_user(self): + user, error_message = self.jwt_auth.link_user('invaliduser') + self.assertIsNotNone(error_message) + self.assertIsNone(user) + if __name__ == '__main__': unittest.main() diff --git a/util/config/validator.py b/util/config/validator.py index e824b210c..2d34c8f76 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -372,6 +372,9 @@ def _validate_jwt(config, password): return verify_endpoint = config.get('JWT_VERIFY_ENDPOINT') + query_endpoint = config.get('JWT_QUERY_ENDPOINT', None) + getuser_endpoint = config.get('JWT_GETUSER_ENDPOINT', None) + issuer = config.get('JWT_AUTH_ISSUER') if not verify_endpoint: @@ -382,7 +385,8 @@ def _validate_jwt(config, password): # Try to instatiate the JWT authentication mechanism. This will raise an exception if # the key cannot be found. - users = ExternalJWTAuthN(verify_endpoint, issuer, OVERRIDE_CONFIG_DIRECTORY, + users = ExternalJWTAuthN(verify_endpoint, query_endpoint, getuser_endpoint, issuer, + OVERRIDE_CONFIG_DIRECTORY, app.config['HTTPCLIENT'], app.config.get('JWT_AUTH_MAX_FRESH_S', 300)) @@ -392,7 +396,24 @@ def _validate_jwt(config, password): if not result: raise Exception(('Verification of superuser %s failed: %s. \n\nThe user either does not ' + 'exist in the remote authentication system ' + - 'OR JWT auth is misconfigured.') % (username, err_msg)) + 'OR JWT auth is misconfigured') % (username, err_msg)) + + # If the query endpoint exists, ensure we can query to find the current user and that we can + # look up users directly. + if query_endpoint: + (results, err_msg) = users.query_users(username) + if not results: + err_msg = err_msg or ('Could not find users matching query: %s' % username) + raise Exception('Query endpoint is misconfigured or not returning proper users: %s' % err_msg) + + # Make sure the get user endpoint is also configured. + if not getuser_endpoint: + raise Exception('The lookup user endpoint must be configured if the query endpoint is set') + + (result, err_msg) = users.get_user(username) + if not result: + err_msg = err_msg or ('Could not find user %s' % username) + raise Exception('Lookup endpoint is misconfigured or not returning properly: %s' % err_msg) def _validate_keystone(config, password):