From b3d1d7227c81a9bf0c1918b190f718b2df040e55 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 27 Oct 2016 15:35:52 -0400 Subject: [PATCH] Add support to Keystone Auth for external user linking Also adds Keystone V3 support --- data/users/__init__.py | 8 +- data/users/keystone.py | 96 ++++++++++- .../directives/config/config-setup-tool.html | 9 + test/test_keystone_auth.py | 161 +++++++++++++++++- util/config/validator.py | 5 +- 5 files changed, 262 insertions(+), 17 deletions(-) diff --git a/data/users/__init__.py b/data/users/__init__.py index fc504c530..210902115 100644 --- a/data/users/__init__.py +++ b/data/users/__init__.py @@ -9,7 +9,7 @@ from data import model from data.users.database import DatabaseUsers from data.users.externalldap import LDAPUsers from data.users.externaljwt import ExternalJWTAuthN -from data.users.keystone import KeystoneUsers +from data.users.keystone import get_keystone_users from util.security.aes import AESCipher logger = logging.getLogger(__name__) @@ -63,12 +63,14 @@ def get_users_handler(config, config_provider, override_config_dir): if authentication_type == 'Keystone': auth_url = config.get('KEYSTONE_AUTH_URL') + auth_version = int(config.get('KEYSTONE_AUTH_VERSION', 2)) timeout = config.get('KEYSTONE_AUTH_TIMEOUT') keystone_admin_username = config.get('KEYSTONE_ADMIN_USERNAME') keystone_admin_password = config.get('KEYSTONE_ADMIN_PASSWORD') keystone_admin_tenant = config.get('KEYSTONE_ADMIN_TENANT') - return KeystoneUsers(auth_url, keystone_admin_username, keystone_admin_password, - keystone_admin_tenant, timeout) + + return get_keystone_users(auth_version, auth_url, keystone_admin_username, + keystone_admin_password, keystone_admin_tenant, timeout) raise RuntimeError('Unknown authentication type: %s' % authentication_type) diff --git a/data/users/keystone.py b/data/users/keystone.py index 4c9a191f7..ecff5b701 100644 --- a/data/users/keystone.py +++ b/data/users/keystone.py @@ -1,19 +1,34 @@ import logging import os +import itertools from keystoneclient.v2_0 import client as kclient +from keystoneclient.v3 import client as kv3client from keystoneclient.exceptions import AuthorizationFailure as KeystoneAuthorizationFailure from keystoneclient.exceptions import Unauthorized as KeystoneUnauthorized -from data.users.federated import FederatedUsers, VerifiedCredentials +from data.users.federated import FederatedUsers, UserInformation logger = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 # seconds -class KeystoneUsers(FederatedUsers): - """ Delegates authentication to OpenStack Keystone. """ +def _take(n, iterable): + "Return first n items of the iterable as a list" + return list(itertools.islice(iterable, n)) + + +def get_keystone_users(auth_version, auth_url, admin_username, admin_password, admin_tenant, + timeout=None): + if auth_version == 3: + return KeystoneV3Users(auth_url, admin_username, admin_password, admin_tenant, timeout) + else: + return KeystoneV2Users(auth_url, admin_username, admin_password, admin_tenant, timeout) + + +class KeystoneV2Users(FederatedUsers): + """ Delegates authentication to OpenStack Keystone V2. """ def __init__(self, auth_url, admin_username, admin_password, admin_tenant, timeout=None): - super(KeystoneUsers, self).__init__('keystone') + super(KeystoneV2Users, self).__init__('keystone') self.auth_url = auth_url self.admin_username = admin_username self.admin_password = admin_password @@ -43,4 +58,75 @@ class KeystoneUsers(FederatedUsers): logger.exception('Keystone unauthorized admin') return (None, 'Keystone admin credentials are invalid: %s' % kut.message) - return (VerifiedCredentials(username=username_or_email, email=user.email), None) + return (UserInformation(username=username_or_email, email=user.email, id=user_id), None) + + def query_users(self, query, limit=20): + return (None, 'Unsupported in Keystone V2') + + def get_user(self, username_or_email): + return (None, 'Unsupported in Keystone V2') + + +class KeystoneV3Users(FederatedUsers): + """ Delegates authentication to OpenStack Keystone V3. """ + def __init__(self, auth_url, admin_username, admin_password, admin_tenant, timeout=None): + super(KeystoneV3Users, self).__init__('keystone') + self.auth_url = auth_url + self.admin_username = admin_username + self.admin_password = admin_password + self.admin_tenant = admin_tenant + self.timeout = timeout or DEFAULT_TIMEOUT + self.debug = os.environ.get('USERS_DEBUG') == '1' + + def verify_credentials(self, username_or_email, password): + try: + keystone_client = kv3client.Client(username=username_or_email, password=password, + auth_url=self.auth_url, timeout=self.timeout, + debug=self.debug) + user_id = keystone_client.user_id + user = keystone_client.users.get(user_id) + return (self._user_info(user), None) + except KeystoneAuthorizationFailure as kaf: + logger.exception('Keystone auth failure for user: %s', username_or_email) + return (None, kaf.message or 'Invalid username or password') + except KeystoneUnauthorized as kut: + logger.exception('Keystone unauthorized for user: %s', username_or_email) + return (None, kut.message or 'Invalid username or password') + + def get_user(self, username_or_email): + users_found, err_msg = self.query_users(username_or_email) + if err_msg is not None: + return (None, err_msg) + + if len(users_found) != 1: + return (None, 'Single user not found') + + return (users_found[0], None) + + @staticmethod + def _user_info(user): + # Because Keystone uses defined attributes... + email = user.email if hasattr(user, 'email') else '' + return UserInformation(user.name, email, user.id) + + def query_users(self, query, limit=20): + if len(query) < 3: + return ([], None) + + try: + keystone_client = kv3client.Client(username=self.admin_username, password=self.admin_password, + tenant_name=self.admin_tenant, auth_url=self.auth_url, + timeout=self.timeout, debug=self.debug) + found_users = list(_take(limit, keystone_client.users.list(name=query))) + logger.debug('For Keystone query %s found users: %s', query, found_users) + if not found_users: + return ([], None) + + return ([self._user_info(user) for user in found_users], None) + except KeystoneAuthorizationFailure as kaf: + logger.exception('Keystone auth failure for admin user for query %s', query) + return (None, kaf.message or 'Invalid admin username or password') + except KeystoneUnauthorized as kut: + logger.exception('Keystone unauthorized for admin user for query %s', query) + return (None, kut.message or 'Invalid admin username or password') + diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index 96a1c0758..daf7cf2a2 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -523,6 +523,15 @@ + + + +
Keystone API Version: + +
Keystone Authentication URL: diff --git a/test/test_keystone_auth.py b/test/test_keystone_auth.py index 0603c7f25..990264e74 100644 --- a/test/test_keystone_auth.py +++ b/test/test_keystone_auth.py @@ -4,16 +4,15 @@ import unittest import requests -from flask import Flask, request, abort +from flask import Flask, request, abort, make_response from flask_testing import LiveServerTestCase -from data.users.keystone import KeystoneUsers - +from data.users.keystone import get_keystone_users +from initdb import setup_database_for_testing, finished_database_for_testing _PORT_NUMBER = 5001 - -class KeystoneAuthTests(LiveServerTestCase): +class KeystoneAuthTestsMixin(): maxDiff = None def create_app(self): @@ -44,6 +43,106 @@ class KeystoneAuthTests(LiveServerTestCase): abort(404) + @ks_app.route('/v3/identity/users/', methods=['GET']) + def getv3user(userid): + for user in users: + if user['username'] == userid: + return json.dumps({ + 'user': { + "domain_id": "default", + "enabled": True, + "id": user['username'], + "links": {}, + "name": user['username'], + "email": user['username'] + '@example.com', + } + }) + + abort(404) + + @ks_app.route('/v3/identity/users', methods=['GET']) + def v3identity(): + returned = [] + for user in users: + if not request.args.get('name') or user['username'].startswith(request.args.get('name')): + returned.append({ + "domain_id": "default", + "enabled": True, + "id": user['username'], + "links": {}, + "name": user['username'], + "email": user['username'] + '@example.com', + }) + + return json.dumps({"users": returned}) + + @ks_app.route('/v3/auth/tokens', methods=['POST']) + def v3tokens(): + creds = request.json['auth']['identity']['password']['user'] + for user in users: + if creds['name'] == user['username'] and creds['password'] == user['password']: + data = json.dumps({ + "token": { + "methods": [ + "password" + ], + "roles": [ + { + "id": "9fe2ff9ee4384b1894a90878d3e92bab", + "name": "_member_" + }, + { + "id": "c703057be878458588961ce9a0ce686b", + "name": "admin" + } + ], + "project": { + "domain": { + "id": "default", + "name": "Default" + }, + "id": "8538a3f13f9541b28c2620eb19065e45", + "name": "admin" + }, + "catalog": [ + { + "endpoints": [ + { + "url": self.get_server_url() + '/v3/identity', + "region": "RegionOne", + "interface": "admin", + "id": "29beb2f1567642eb810b042b6719ea88" + }, + ], + "type": "identity", + "id": "bd73972c0e14fb69bae8ff76e112a90", + "name": "keystone" + } + ], + "extras": { + + }, + "user": { + "domain": { + "id": "default", + "name": "Default" + }, + "id": user['username'], + "name": "admin" + }, + "audit_ids": [ + "yRt0UrxJSs6-WYJgwEMMmg" + ], + "issued_at": "2014-06-16T22:24:26.089380", + "expires_at": "2020-06-16T23:24:26Z", + } + }) + + response = make_response(data, 200) + response.headers['X-Subject-Token'] = 'sometoken' + return response + + abort(403) @ks_app.route('/v2.0/auth/tokens', methods=['POST']) def tokens(): @@ -89,9 +188,15 @@ class KeystoneAuthTests(LiveServerTestCase): return ks_app def setUp(self): + setup_database_for_testing(self) self.session = requests.Session() - self.keystone = KeystoneUsers(self.get_server_url() + '/v2.0/auth', 'adminuser', 'adminpass', - 'admintenant') + + def tearDown(self): + finished_database_for_testing(self) + + @property + def keystone(self): + raise NotImplementedError def test_invalid_user(self): (user, _) = self.keystone.verify_credentials('unknownuser', 'password') @@ -111,6 +216,48 @@ class KeystoneAuthTests(LiveServerTestCase): self.assertEquals(user.username, 'some.neat.user') self.assertEquals(user.email, 'some.neat.user@example.com') +class KeystoneV2AuthTests(KeystoneAuthTestsMixin, LiveServerTestCase): + @property + def keystone(self): + return get_keystone_users(2, self.get_server_url() + '/v2.0/auth', + 'adminuser', 'adminpass', 'admintenant') + +class KeystoneV3AuthTests(KeystoneAuthTestsMixin, LiveServerTestCase): + @property + def keystone(self): + return get_keystone_users(3, self.get_server_url() + '/v3', + 'adminuser', 'adminpass', 'admintenant') + + def test_query(self): + # Lookup cool. + (response, error_message) = self.keystone.query_users('cool') + self.assertIsNone(error_message) + self.assertEquals(1, len(response)) + + user_info = response[0] + self.assertEquals("cooluser", user_info.username) + + # Lookup unknown. + (response, error_message) = self.keystone.query_users('unknown') + self.assertIsNone(error_message) + self.assertEquals(0, len(response)) + + def test_link_user(self): + # Link someuser. + user, error_message = self.keystone.link_user('cooluser') + self.assertIsNone(error_message) + self.assertIsNotNone(user) + self.assertEquals('cooluser', user.username) + self.assertEquals('cooluser@example.com', user.email) + + # Link again. Should return the same user record. + user_again, _ = self.keystone.link_user('cooluser') + self.assertEquals(user_again.id, user.id) + + # Confirm someuser. + result, _ = self.keystone.confirm_existing_user('cooluser', 'password') + self.assertIsNotNone(result) + self.assertEquals('cooluser', result.username) if __name__ == '__main__': unittest.main() diff --git a/util/config/validator.py b/util/config/validator.py index 2d34c8f76..5a11e1a4a 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -21,7 +21,7 @@ from data.database import validate_database_url from data.users import LDAP_CERT_FILENAME from data.users.externaljwt import ExternalJWTAuthN from data.users.externalldap import LDAPConnection, LDAPUsers -from data.users.keystone import KeystoneUsers +from data.users.keystone import get_keystone_users from storage import get_storage_driver from util.config.oauth import GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig from util.secscan.api import SecurityScannerAPI @@ -422,6 +422,7 @@ def _validate_keystone(config, password): return auth_url = config.get('KEYSTONE_AUTH_URL') + auth_version = int(config.get('KEYSTONE_AUTH_VERSION', 2)) admin_username = config.get('KEYSTONE_ADMIN_USERNAME') admin_password = config.get('KEYSTONE_ADMIN_PASSWORD') admin_tenant = config.get('KEYSTONE_ADMIN_TENANT') @@ -438,7 +439,7 @@ def _validate_keystone(config, password): if not admin_tenant: raise Exception('Missing admin tenant') - users = KeystoneUsers(auth_url, admin_username, admin_password, admin_tenant) + users = get_keystone_users(auth_version, auth_url, admin_username, admin_password, admin_tenant) # Verify that the superuser exists. If not, raise an exception. username = get_authenticated_user().username