From 066637f496e46ca82ae797619c464c77875546ac Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 13 Jul 2015 12:34:32 +0300 Subject: [PATCH 1/4] Basic Keystone Auth support Note: This has been verified as working by the end customer --- ...2bf8af5bad95_add_keystone_login_service.py | 26 +++++++++++ data/users.py | 41 ++++++++++++++++++ initdb.py | 1 + .../directives/config/config-setup-tool.html | 43 +++++++++++++++++++ static/js/core-config-setup.js | 4 ++ util/config/validator.py | 37 +++++++++++++++- 6 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 data/migrations/versions/2bf8af5bad95_add_keystone_login_service.py diff --git a/data/migrations/versions/2bf8af5bad95_add_keystone_login_service.py b/data/migrations/versions/2bf8af5bad95_add_keystone_login_service.py new file mode 100644 index 000000000..440e7ec98 --- /dev/null +++ b/data/migrations/versions/2bf8af5bad95_add_keystone_login_service.py @@ -0,0 +1,26 @@ +"""Add keystone login service + +Revision ID: 2bf8af5bad95 +Revises: 154f2befdfbe +Create Date: 2015-06-29 21:19:13.053165 + +""" + +# revision identifiers, used by Alembic. +revision = '2bf8af5bad95' +down_revision = '154f2befdfbe' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + op.bulk_insert(tables.loginservice, [{'id': 6, 'name': 'keystone'}]) + + +def downgrade(tables): + op.execute( + tables.loginservice.delete() + .where(tables.loginservice.c.name == op.inline_literal('keystone')) + ) + diff --git a/data/users.py b/data/users.py index 4b917162e..83b003401 100644 --- a/data/users.py +++ b/data/users.py @@ -8,6 +8,9 @@ import jwt from collections import namedtuple from datetime import datetime, timedelta +from keystoneclient.v2_0 import client as kclient +from keystoneclient.exceptions import AuthorizationFailure as KeystoneAuthorizationFailure +from keystoneclient.exceptions import Unauthorized as KeystoneUnauthorized import features @@ -53,6 +56,37 @@ def _get_federated_user(username, email, federated_service, create_new_user): return (db_user, None) +class KeystoneUsers(object): + """ Delegates authentication to OpenStack Keystone. """ + def __init__(self, auth_url, admin_username, admin_password, admin_tenant): + self.auth_url = auth_url + self.admin_username = admin_username + self.admin_password = admin_password + self.admin_tenant = admin_tenant + + def verify_user(self, username_or_email, password, create_new_user=True): + try: + keystone_client = kclient.Client(username=username_or_email, password=password, + auth_url=self.auth_url) + user_id = keystone_client.user_id + 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') + + try: + admin_client = kclient.Client(username=self.admin_username, password=self.admin_password, + tenant_name=self.admin_tenant, auth_url=self.auth_url) + user = admin_client.users.get(user_id) + except KeystoneUnauthorized as kut: + logger.exception('Keystone unauthorized admin') + return (None, 'Keystone admin credentials are invalid: %s' % kut.message) + + return _get_federated_user(username_or_email, user.email, 'keystone', create_new_user) + + class ExternalJWTAuthN(object): """ Delegates authentication to a REST endpoint that returns JWTs. """ PUBLIC_KEY_FILENAME = 'jwt-authn.cert' @@ -336,6 +370,13 @@ class UserAuthentication(object): max_fresh_s = app.config.get('JWT_AUTH_MAX_FRESH_S', 300) users = ExternalJWTAuthN(verify_url, issuer, override_config_dir, max_fresh_s, app.config['HTTPCLIENT']) + elif authentication_type == 'Keystone': + auth_url = app.config.get('KEYSTONE_AUTH_URL') + keystone_admin_username = app.config.get('KEYSTONE_ADMIN_USERNAME') + keystone_admin_password = app.config.get('KEYSTONE_ADMIN_PASSWORD') + keystone_admin_tenant = app.config.get('KEYSTONE_ADMIN_TENANT') + users = KeystoneUsers(auth_url, keystone_admin_username, keystone_admin_password, + keystone_admin_tenant) else: raise RuntimeError('Unknown authentication type: %s' % authentication_type) diff --git a/initdb.py b/initdb.py index bb9a3c141..d0181ebca 100644 --- a/initdb.py +++ b/initdb.py @@ -205,6 +205,7 @@ def initialize_database(): LoginService.create(name='quayrobot') LoginService.create(name='ldap') LoginService.create(name='jwtauthn') + LoginService.create(name='keystone') BuildTriggerService.create(name='github') BuildTriggerService.create(name='custom-git') diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index b1c0c7da4..43bff96f3 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -353,12 +353,55 @@ + + + + + + + + + + + + + + + + + + +
Keystone Authentication URL: + +
+ The URL (starting with http or https) of the Keystone Server endpoint for auth. +
+
Keystone Administrator Username: + +
+ The username for the Keystone admin. +
+
Keystone Administrator Password: + +
+ The password for the Keystone admin. +
+
Keystone Administrator Tenant: + +
+ The tenant (project/group) that contains the administrator user. +
+
+
JSON Web Token authentication allows your organization to provide an HTTP endpoint that diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js index 2027d76cb..48f3081fb 100644 --- a/static/js/core-config-setup.js +++ b/static/js/core-config-setup.js @@ -31,6 +31,10 @@ angular.module("core-config-setup", ['angularFileUpload']) return config.AUTHENTICATION_TYPE == 'JWT'; }, 'password': true}, + {'id': 'keystone', 'title': 'Keystone Authentication', 'condition': function(config) { + return config.AUTHENTICATION_TYPE == 'Keystone'; + }, 'password': true}, + {'id': 'mail', 'title': 'E-mail Support', 'condition': function(config) { return config.FEATURE_MAILING; }}, diff --git a/util/config/validator.py b/util/config/validator.py index 3181b2811..2f80441c9 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -7,7 +7,7 @@ import OpenSSL import logging from fnmatch import fnmatch -from data.users import LDAPConnection, ExternalJWTAuthN, LDAPUsers +from data.users import LDAPConnection, ExternalJWTAuthN, LDAPUsers, KeystoneUsers from flask import Flask from flask.ext.mail import Mail, Message from data.database import validate_database_url, User @@ -352,6 +352,40 @@ def _validate_jwt(config, password): 'OR JWT auth is misconfigured.') % (username, err_msg)) +def _validate_keystone(config, password): + """ Validates the Keystone authentication system. """ + if config.get('AUTHENTICATION_TYPE', 'Database') != 'Keystone': + return + + auth_url = config.get('KEYSTONE_AUTH_URL') + admin_username = config.get('KEYSTONE_ADMIN_USERNAME') + admin_password = config.get('KEYSTONE_ADMIN_PASSWORD') + admin_tenant = config.get('KEYSTONE_ADMIN_TENANT') + + if not auth_url: + raise Exception('Missing authentication URL') + + if not admin_username: + raise Exception('Missing admin username') + + if not admin_password: + raise Exception('Missing admin password') + + if not admin_tenant: + raise Exception('Missing admin tenant') + + users = KeystoneUsers(auth_url, admin_username, admin_password, admin_tenant) + + # Verify that the superuser exists. If not, raise an exception. + username = get_authenticated_user().username + + (result, err_msg) = users.verify_user(username, 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 Keystone auth is misconfigured.') % (username, err_msg)) + + _VALIDATORS = { 'database': _validate_database, 'redis': _validate_redis, @@ -365,4 +399,5 @@ _VALIDATORS = { 'ssl': _validate_ssl, 'ldap': _validate_ldap, 'jwt': _validate_jwt, + 'keystone': _validate_keystone, } \ No newline at end of file From 1245385808dcc7cf1c0987fdf72a1b28761ddf2f Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 13 Jul 2015 12:38:19 +0300 Subject: [PATCH 2/4] Fix typo --- data/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/users.py b/data/users.py index 83b003401..676771945 100644 --- a/data/users.py +++ b/data/users.py @@ -452,7 +452,7 @@ class UserAuthentication(object): if decrypted is None: # This is a normal password. if features.REQUIRE_ENCRYPTED_BASIC_AUTH: - msg = ('Client login with unecrypted passwords is disabled. Please generate an ' + + msg = ('Client login with unencrypted passwords is disabled. Please generate an ' + 'encrypted password in the user admin panel for use here.') return (None, msg) else: From 33b54218cc139f86d2ba3537da7463cbb17942eb Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 20 Jul 2015 11:39:59 -0400 Subject: [PATCH 3/4] Refactor the users class into their own files, add a common base class for federated users and add a `verify_credentials` method which only does the verification, without the linking. We use this in the superuser verification pass --- auth/auth.py | 4 +- data/users.py | 471 -------------------------------- data/users/__init__.py | 154 +++++++++++ data/users/database.py | 18 ++ data/users/externaljwt.py | 72 +++++ data/users/externalldap.py | 159 +++++++++++ data/users/federated.py | 71 +++++ data/users/keystone.py | 39 +++ endpoints/api/user.py | 4 +- endpoints/v1/index.py | 3 +- test/test_external_jwt_authn.py | 16 +- test/test_ldap.py | 14 +- util/config/validator.py | 11 +- 13 files changed, 541 insertions(+), 495 deletions(-) delete mode 100644 data/users.py create mode 100644 data/users/__init__.py create mode 100644 data/users/database.py create mode 100644 data/users/externaljwt.py create mode 100644 data/users/externalldap.py create mode 100644 data/users/federated.py create mode 100644 data/users/keystone.py diff --git a/auth/auth.py b/auth/auth.py index 6109a2eec..72fce24ce 100644 --- a/auth/auth.py +++ b/auth/auth.py @@ -124,8 +124,8 @@ def _process_basic_auth(auth): logger.debug('Invalid robot or password for robot: %s', credentials[0]) else: - (authenticated, error_message) = authentication.verify_user(credentials[0], credentials[1], - basic_auth=True) + (authenticated, _) = authentication.verify_and_link_user(credentials[0], credentials[1], + basic_auth=True) if authenticated: logger.debug('Successfully validated user: %s', authenticated.username) diff --git a/data/users.py b/data/users.py deleted file mode 100644 index 676771945..000000000 --- a/data/users.py +++ /dev/null @@ -1,471 +0,0 @@ -import ldap -import logging -import json -import itertools -import uuid -import os -import jwt - -from collections import namedtuple -from datetime import datetime, timedelta -from keystoneclient.v2_0 import client as kclient -from keystoneclient.exceptions import AuthorizationFailure as KeystoneAuthorizationFailure -from keystoneclient.exceptions import Unauthorized as KeystoneUnauthorized - -import features - -from data import model -from util.aes import AESCipher -from util.validation import generate_valid_usernames - -logger = logging.getLogger(__name__) - - -if os.environ.get('LDAP_DEBUG') == '1': - logger.setLevel(logging.DEBUG) - - ch = logging.StreamHandler() - ch.setLevel(logging.DEBUG) - - logger.addHandler(ch) - - -def _get_federated_user(username, email, federated_service, create_new_user): - db_user = model.user.verify_federated_login(federated_service, username) - if not db_user: - if not create_new_user: - return (None, 'Invalid user') - - # We must create the user in our db - valid_username = None - for valid_username in generate_valid_usernames(username): - if model.user.is_username_unique(valid_username): - break - - if not valid_username: - logger.error('Unable to pick a username for user: %s', username) - return (None, 'Unable to pick a username. Please report this to your administrator.') - - db_user = model.user.create_federated_user(valid_username, email, federated_service, username, - set_password_notification=False) - else: - # Update the db attributes from ldap - db_user.email = email - db_user.save() - - return (db_user, None) - - -class KeystoneUsers(object): - """ Delegates authentication to OpenStack Keystone. """ - def __init__(self, auth_url, admin_username, admin_password, admin_tenant): - self.auth_url = auth_url - self.admin_username = admin_username - self.admin_password = admin_password - self.admin_tenant = admin_tenant - - def verify_user(self, username_or_email, password, create_new_user=True): - try: - keystone_client = kclient.Client(username=username_or_email, password=password, - auth_url=self.auth_url) - user_id = keystone_client.user_id - 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') - - try: - admin_client = kclient.Client(username=self.admin_username, password=self.admin_password, - tenant_name=self.admin_tenant, auth_url=self.auth_url) - user = admin_client.users.get(user_id) - except KeystoneUnauthorized as kut: - logger.exception('Keystone unauthorized admin') - return (None, 'Keystone admin credentials are invalid: %s' % kut.message) - - return _get_federated_user(username_or_email, user.email, 'keystone', create_new_user) - - -class ExternalJWTAuthN(object): - """ 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): - self.verify_url = verify_url - self.issuer = issuer - self.client = http_client - self.max_fresh_s = max_fresh_s - - default_key_path = os.path.join(override_config_dir, ExternalJWTAuthN.PUBLIC_KEY_FILENAME) - public_key_path = public_key_path or default_key_path - if not os.path.exists(public_key_path): - error_message = ('JWT Authentication public key file "%s" not found in directory %s' % - (ExternalJWTAuthN.PUBLIC_KEY_FILENAME, override_config_dir)) - - raise Exception(error_message) - - with open(public_key_path) as public_key_file: - self.public_key = public_key_file.read() - - def verify_user(self, username_or_email, password, create_new_user=True): - result = self.client.get(self.verify_url, timeout=2, auth=(username_or_email, password)) - - if result.status_code != 200: - return (None, result.text or 'Invalid username or password') - - try: - result_data = json.loads(result.text) - except ValueError: - raise Exception('Returned JWT Authentication body does not contain JSON') - - # Load the JWT returned. - encoded = result_data.get('token', '') - try: - payload = jwt.decode(encoded, self.public_key, algorithms=['RS256'], - audience='quay.io/jwtauthn', issuer=self.issuer) - except jwt.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') - - if not 'exp' in payload: - raise Exception('Missing exp field in JWT') - - # Verify that the expiration is no more than self.max_fresh_s seconds in the future. - expiration = datetime.utcfromtimestamp(payload['exp']) - if expiration > datetime.utcnow() + timedelta(seconds=self.max_fresh_s): - logger.debug('Payload expiration is outside of the %s second window: %s', self.max_fresh_s, - payload['exp']) - return (None, 'Invalid username or password') - - # Parse out the username and email. - return _get_federated_user(payload['sub'], payload['email'], 'jwtauthn', create_new_user) - - def confirm_existing_user(self, username, password): - db_user = model.user.get_user(username) - if not db_user: - return (None, 'Invalid user') - - federated_login = model.user.lookup_federated_login(db_user, 'jwtauthn') - if not federated_login: - return (None, 'Invalid user') - - return self.verify_user(federated_login.service_ident, password, create_new_user=False) - - -class DatabaseUsers(object): - def verify_user(self, username_or_email, password): - """ Simply delegate to the model implementation. """ - result = model.user.verify_user(username_or_email, password) - if not result: - return (None, 'Invalid Username or Password') - - return (result, None) - - def confirm_existing_user(self, username, password): - return self.verify_user(username, password) - - -class LDAPConnection(object): - def __init__(self, ldap_uri, user_dn, user_pw): - self._ldap_uri = ldap_uri - self._user_dn = user_dn - self._user_pw = user_pw - self._conn = None - - def __enter__(self): - trace_level = 2 if os.environ.get('LDAP_DEBUG') == '1' else 0 - self._conn = ldap.initialize(self._ldap_uri, trace_level=trace_level) - self._conn.set_option(ldap.OPT_REFERRALS, 1) - - try: - self._conn.simple_bind_s(self._user_dn, self._user_pw) - except ldap.INVALID_CREDENTIALS: - logger.exception('LDAP admin dn or password are invalid') - return None - - return self._conn - - def __exit__(self, exc_type, value, tb): - self._conn.unbind_s() - - -class LDAPUsers(object): - _LDAPResult = namedtuple('LDAPResult', ['dn', 'attrs']) - - def __init__(self, ldap_uri, base_dn, admin_dn, admin_passwd, user_rdn, uid_attr, email_attr): - self._ldap_conn = LDAPConnection(ldap_uri, admin_dn, admin_passwd) - self._ldap_uri = ldap_uri - self._base_dn = base_dn - self._user_rdn = user_rdn - self._uid_attr = uid_attr - self._email_attr = email_attr - - def _get_ldap_referral_dn(self, referral_exception): - logger.debug('Got referral: %s', referral_exception.args[0]) - if not referral_exception.args[0] or not referral_exception.args[0].get('info'): - logger.debug('LDAP referral missing info block') - return None - - referral_info = referral_exception.args[0]['info'] - if not referral_info.startswith('Referral:\n'): - logger.debug('LDAP referral missing Referral header') - return None - - referral_uri = referral_info[len('Referral:\n'):] - if not referral_uri.startswith('ldap:///'): - logger.debug('LDAP referral URI does not start with ldap:///') - return None - - referral_dn = referral_uri[len('ldap:///'):] - return referral_dn - - def _ldap_user_search(self, username_or_email): - with self._ldap_conn as conn: - if conn is None: - return (None, 'LDAP Admin dn or password is invalid') - - logger.debug('Incoming username or email param: %s', username_or_email.__repr__()) - user_search_dn = ','.join(self._user_rdn + self._base_dn) - query = u'(|({0}={2})({1}={2}))'.format(self._uid_attr, self._email_attr, - username_or_email) - - logger.debug('Conducting user search: %s under %s', query, user_search_dn) - try: - pairs = conn.search_s(user_search_dn, ldap.SCOPE_SUBTREE, query.encode('utf-8')) - except ldap.REFERRAL as re: - referral_dn = self._get_ldap_referral_dn(re) - if not referral_dn: - return (None, 'Failed to follow referral when looking up username') - - try: - subquery = u'(%s=%s)' % (self._uid_attr, username_or_email) - pairs = conn.search_s(referral_dn, ldap.SCOPE_BASE, subquery) - except ldap.LDAPError: - logger.exception('LDAP referral search exception') - return (None, 'Username not found') - - except ldap.LDAPError: - logger.exception('LDAP search exception') - return (None, 'Username not found') - - logger.debug('Found matching pairs: %s', pairs) - - results = [LDAPUsers._LDAPResult(*pair) for pair in pairs] - - # Filter out pairs without DNs. Some LDAP impls will return such - # pairs. - with_dns = [result for result in results if result.dn] - if len(with_dns) < 1: - return (None, 'Username not found') - - # If we have found a single pair, then return it. - if len(with_dns) == 1: - return (with_dns[0], None) - - # Otherwise, there are multiple pairs with DNs, so find the one with the mail - # attribute (if any). - with_mail = [result for result in results if result.attrs.get(self._email_attr)] - return (with_mail[0] if with_mail else with_dns[0], None) - - def confirm_existing_user(self, username, password): - """ Verify the username and password by looking up the *LDAP* username and confirming the - password. - """ - db_user = model.user.get_user(username) - if not db_user: - return (None, 'Invalid user') - - federated_login = model.user.lookup_federated_login(db_user, 'ldap') - if not federated_login: - return (None, 'Invalid user') - - return self.verify_user(federated_login.service_ident, password, create_new_user=False) - - def verify_user(self, username_or_email, password, create_new_user=True): - """ Verify the credentials with LDAP and if they are valid, create or update the user - in our database. """ - - # Make sure that even if the server supports anonymous binds, we don't allow it - if not password: - return (None, 'Anonymous binding not allowed') - - (found_user, err_msg) = self._ldap_user_search(username_or_email) - if found_user is None: - return (None, err_msg) - - found_dn, found_response = found_user - logger.debug('Found user for LDAP username %s; validating password', username_or_email) - logger.debug('DN %s found: %s', found_dn, found_response) - - # First validate the password by binding as the user - try: - with LDAPConnection(self._ldap_uri, found_dn, password.encode('utf-8')): - pass - except ldap.REFERRAL as re: - referral_dn = self._get_ldap_referral_dn(re) - if not referral_dn: - return (None, 'Invalid username') - - try: - with LDAPConnection(self._ldap_uri, referral_dn, password.encode('utf-8')): - pass - except ldap.INVALID_CREDENTIALS: - logger.exception('Invalid LDAP credentials') - return (None, 'Invalid password') - - except ldap.INVALID_CREDENTIALS: - logger.exception('Invalid LDAP credentials') - return (None, 'Invalid password') - - # Now check if we have a federated login for this user - if not found_response.get(self._uid_attr): - return (None, 'Missing uid field "%s" in user record' % self._uid_attr) - - if not found_response.get(self._email_attr): - return (None, 'Missing mail field "%s" in user record' % self._email_attr) - - username = found_response[self._uid_attr][0].decode('utf-8') - email = found_response[self._email_attr][0] - return _get_federated_user(username, email, 'ldap', create_new_user) - - - -class UserAuthentication(object): - def __init__(self, app=None, override_config_dir=None): - self.app_secret_key = None - self.app = app - if app is not None: - self.state = self.init_app(app, override_config_dir) - else: - self.state = None - - def init_app(self, app, override_config_dir): - self.app_secret_key = app.config['SECRET_KEY'] - - authentication_type = app.config.get('AUTHENTICATION_TYPE', 'Database') - - if authentication_type == 'Database': - users = DatabaseUsers() - elif authentication_type == 'LDAP': - ldap_uri = app.config.get('LDAP_URI', 'ldap://localhost') - base_dn = app.config.get('LDAP_BASE_DN') - admin_dn = app.config.get('LDAP_ADMIN_DN') - admin_passwd = app.config.get('LDAP_ADMIN_PASSWD') - user_rdn = app.config.get('LDAP_USER_RDN', []) - uid_attr = app.config.get('LDAP_UID_ATTR', 'uid') - email_attr = app.config.get('LDAP_EMAIL_ATTR', 'mail') - - users = LDAPUsers(ldap_uri, base_dn, admin_dn, admin_passwd, user_rdn, uid_attr, email_attr) - elif authentication_type == 'JWT': - verify_url = app.config.get('JWT_VERIFY_ENDPOINT') - issuer = app.config.get('JWT_AUTH_ISSUER') - max_fresh_s = app.config.get('JWT_AUTH_MAX_FRESH_S', 300) - users = ExternalJWTAuthN(verify_url, issuer, override_config_dir, max_fresh_s, - app.config['HTTPCLIENT']) - elif authentication_type == 'Keystone': - auth_url = app.config.get('KEYSTONE_AUTH_URL') - keystone_admin_username = app.config.get('KEYSTONE_ADMIN_USERNAME') - keystone_admin_password = app.config.get('KEYSTONE_ADMIN_PASSWORD') - keystone_admin_tenant = app.config.get('KEYSTONE_ADMIN_TENANT') - users = KeystoneUsers(auth_url, keystone_admin_username, keystone_admin_password, - keystone_admin_tenant) - else: - raise RuntimeError('Unknown authentication type: %s' % authentication_type) - - # register extension with app - app.extensions = getattr(app, 'extensions', {}) - app.extensions['authentication'] = users - - return users - - def _get_secret_key(self): - """ Returns the secret key to use for encrypting and decrypting. """ - secret_key = None - - # First try parsing the key as an int. - try: - big_int = int(self.app_secret_key) - secret_key = str(bytearray.fromhex('{:02x}'.format(big_int))) - except ValueError: - pass - - # Next try parsing it as an UUID. - if secret_key is None: - try: - secret_key = uuid.UUID(self.app_secret_key).bytes - except ValueError: - pass - - if secret_key is None: - secret_key = str(bytearray(map(ord, self.app_secret_key))) - - # Otherwise, use the bytes directly. - return ''.join(itertools.islice(itertools.cycle(secret_key), 32)) - - def encrypt_user_password(self, password): - """ Returns an encrypted version of the user's password. """ - data = { - 'password': password - } - - message = json.dumps(data) - cipher = AESCipher(self._get_secret_key()) - return cipher.encrypt(message) - - def _decrypt_user_password(self, encrypted): - """ Attempts to decrypt the given password and returns it. """ - cipher = AESCipher(self._get_secret_key()) - - try: - message = cipher.decrypt(encrypted) - except ValueError: - return None - except TypeError: - return None - - try: - data = json.loads(message) - except ValueError: - return None - - return data.get('password', encrypted) - - def confirm_existing_user(self, username, password): - """ Verifies that the given password matches to the given DB username. Unlike verify_user, this - call first translates the DB user via the FederatedLogin table (where applicable). - """ - return self.state.confirm_existing_user(username, password) - - - def verify_user(self, username_or_email, password, basic_auth=False): - # First try to decode the password as a signed token. - if basic_auth: - decrypted = self._decrypt_user_password(password) - if decrypted is None: - # This is a normal password. - if features.REQUIRE_ENCRYPTED_BASIC_AUTH: - msg = ('Client login with unencrypted passwords is disabled. Please generate an ' + - 'encrypted password in the user admin panel for use here.') - return (None, msg) - else: - password = decrypted - - (result, err_msg) = self.state.verify_user(username_or_email, password) - if not result: - return (result, err_msg) - - if not result.enabled: - return (None, 'This user has been disabled. Please contact your administrator.') - - return (result, err_msg) - - def __getattr__(self, name): - return getattr(self.state, name, None) diff --git a/data/users/__init__.py b/data/users/__init__.py new file mode 100644 index 000000000..826727bc1 --- /dev/null +++ b/data/users/__init__.py @@ -0,0 +1,154 @@ +import logging +import itertools +import json +import uuid + +import features + +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 util.aes import AESCipher + +logger = logging.getLogger(__name__) + +class UserAuthentication(object): + def __init__(self, app=None, override_config_dir=None): + self.app_secret_key = None + self.app = app + if app is not None: + self.state = self.init_app(app, override_config_dir) + else: + self.state = None + + def init_app(self, app, override_config_dir): + self.app_secret_key = app.config['SECRET_KEY'] + + authentication_type = app.config.get('AUTHENTICATION_TYPE', 'Database') + + if authentication_type == 'Database': + users = DatabaseUsers() + elif authentication_type == 'LDAP': + ldap_uri = app.config.get('LDAP_URI', 'ldap://localhost') + base_dn = app.config.get('LDAP_BASE_DN') + admin_dn = app.config.get('LDAP_ADMIN_DN') + admin_passwd = app.config.get('LDAP_ADMIN_PASSWD') + user_rdn = app.config.get('LDAP_USER_RDN', []) + uid_attr = app.config.get('LDAP_UID_ATTR', 'uid') + email_attr = app.config.get('LDAP_EMAIL_ATTR', 'mail') + + users = LDAPUsers(ldap_uri, base_dn, admin_dn, admin_passwd, user_rdn, uid_attr, email_attr) + elif authentication_type == 'JWT': + verify_url = app.config.get('JWT_VERIFY_ENDPOINT') + issuer = app.config.get('JWT_AUTH_ISSUER') + max_fresh_s = app.config.get('JWT_AUTH_MAX_FRESH_S', 300) + users = ExternalJWTAuthN(verify_url, issuer, override_config_dir, max_fresh_s, + app.config['HTTPCLIENT']) + elif authentication_type == 'Keystone': + auth_url = app.config.get('KEYSTONE_AUTH_URL') + keystone_admin_username = app.config.get('KEYSTONE_ADMIN_USERNAME') + keystone_admin_password = app.config.get('KEYSTONE_ADMIN_PASSWORD') + keystone_admin_tenant = app.config.get('KEYSTONE_ADMIN_TENANT') + users = KeystoneUsers(auth_url, keystone_admin_username, keystone_admin_password, + keystone_admin_tenant) + else: + raise RuntimeError('Unknown authentication type: %s' % authentication_type) + + # register extension with app + app.extensions = getattr(app, 'extensions', {}) + app.extensions['authentication'] = users + + return users + + def _get_secret_key(self): + """ Returns the secret key to use for encrypting and decrypting. """ + secret_key = None + + # First try parsing the key as an int. + try: + big_int = int(self.app_secret_key) + secret_key = str(bytearray.fromhex('{:02x}'.format(big_int))) + except ValueError: + pass + + # Next try parsing it as an UUID. + if secret_key is None: + try: + secret_key = uuid.UUID(self.app_secret_key).bytes + except ValueError: + pass + + if secret_key is None: + secret_key = str(bytearray(map(ord, self.app_secret_key))) + + # Otherwise, use the bytes directly. + return ''.join(itertools.islice(itertools.cycle(secret_key), 32)) + + def encrypt_user_password(self, password): + """ Returns an encrypted version of the user's password. """ + data = { + 'password': password + } + + message = json.dumps(data) + cipher = AESCipher(self._get_secret_key()) + return cipher.encrypt(message) + + def _decrypt_user_password(self, encrypted): + """ Attempts to decrypt the given password and returns it. """ + cipher = AESCipher(self._get_secret_key()) + + try: + message = cipher.decrypt(encrypted) + except ValueError: + return None + except TypeError: + return None + + try: + data = json.loads(message) + except ValueError: + return None + + return data.get('password', encrypted) + + def confirm_existing_user(self, username, password): + """ Verifies that the given password matches to the given DB username. Unlike + verify_credentials, this call first translates the DB user via the FederatedLogin table + (where applicable). + """ + return self.state.confirm_existing_user(username, password) + + def verify_credentials(self, username_or_email, password): + """ Verifies that the given username and password credentials are valid. """ + return self.state.verify_credentials(username_or_email, password) + + def verify_and_link_user(self, username_or_email, password, basic_auth=False): + """ Verifies that the given username and password credentials are valid and, if so, + creates or links the database user to the federated identity. """ + # First try to decode the password as a signed token. + if basic_auth: + decrypted = self._decrypt_user_password(password) + if decrypted is None: + # This is a normal password. + if features.REQUIRE_ENCRYPTED_BASIC_AUTH: + msg = ('Client login with unencrypted passwords is disabled. Please generate an ' + + 'encrypted password in the user admin panel for use here.') + return (None, msg) + else: + password = decrypted + + (result, err_msg) = self.state.verify_and_link_user(username_or_email, password) + if not result: + return (result, err_msg) + + if not result.enabled: + return (None, 'This user has been disabled. Please contact your administrator.') + + return (result, err_msg) + + def __getattr__(self, name): + return getattr(self.state, name, None) diff --git a/data/users/database.py b/data/users/database.py new file mode 100644 index 000000000..e8cb3faad --- /dev/null +++ b/data/users/database.py @@ -0,0 +1,18 @@ +from data import model + +class DatabaseUsers(object): + def verify_credentials(self, username_or_email, password): + """ Simply delegate to the model implementation. """ + result = model.user.verify_user(username_or_email, password) + if not result: + return (None, 'Invalid Username or Password') + + return (result, None) + + def verify_and_link_user(self, username_or_email, password): + """ Simply delegate to the model implementation. """ + return self.verify_credentials(username_or_email, password) + + def confirm_existing_user(self, username, password): + return self.verify_credentials(username, password) + diff --git a/data/users/externaljwt.py b/data/users/externaljwt.py new file mode 100644 index 000000000..241cfa947 --- /dev/null +++ b/data/users/externaljwt.py @@ -0,0 +1,72 @@ +import logging +import json +import os +import jwt + +from datetime import datetime, timedelta +from data.users.federated import FederatedUsers, VerifiedCredentials + +logger = logging.getLogger(__name__) + +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): + super(ExternalJWTAuthN, self).__init__('jwtauthn') + self.verify_url = verify_url + self.issuer = issuer + self.client = http_client + self.max_fresh_s = max_fresh_s + + default_key_path = os.path.join(override_config_dir, ExternalJWTAuthN.PUBLIC_KEY_FILENAME) + public_key_path = public_key_path or default_key_path + if not os.path.exists(public_key_path): + error_message = ('JWT Authentication public key file "%s" not found in directory %s' % + (ExternalJWTAuthN.PUBLIC_KEY_FILENAME, override_config_dir)) + + raise Exception(error_message) + + 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)) + + if result.status_code != 200: + return (None, result.text or 'Invalid username or password') + + try: + result_data = json.loads(result.text) + except ValueError: + raise Exception('Returned JWT Authentication body does not contain JSON') + + # Load the JWT returned. + encoded = result_data.get('token', '') + try: + payload = jwt.decode(encoded, self.public_key, algorithms=['RS256'], + audience='quay.io/jwtauthn', issuer=self.issuer) + except jwt.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') + + if not 'exp' in payload: + raise Exception('Missing exp field in JWT') + + # Verify that the expiration is no more than self.max_fresh_s seconds in the future. + expiration = datetime.utcfromtimestamp(payload['exp']) + if expiration > datetime.utcnow() + timedelta(seconds=self.max_fresh_s): + logger.debug('Payload expiration is outside of the %s second window: %s', self.max_fresh_s, + payload['exp']) + return (None, 'Invalid username or password') + + # Parse out the username and email. + return (VerifiedCredentials(username=payload['sub'], email=payload['email']), None) + diff --git a/data/users/externalldap.py b/data/users/externalldap.py new file mode 100644 index 000000000..9a488b283 --- /dev/null +++ b/data/users/externalldap.py @@ -0,0 +1,159 @@ +import ldap +import logging +import os + +from collections import namedtuple +from datetime import datetime +from data.users.federated import FederatedUsers, VerifiedCredentials + +logger = logging.getLogger(__name__) + + +class LDAPConnection(object): + def __init__(self, ldap_uri, user_dn, user_pw): + self._ldap_uri = ldap_uri + self._user_dn = user_dn + self._user_pw = user_pw + self._conn = None + + def __enter__(self): + trace_level = 2 if os.environ.get('USERS_DEBUG') == '1' else 0 + self._conn = ldap.initialize(self._ldap_uri, trace_level=trace_level) + self._conn.set_option(ldap.OPT_REFERRALS, 1) + + try: + self._conn.simple_bind_s(self._user_dn, self._user_pw) + except ldap.INVALID_CREDENTIALS: + logger.exception('LDAP admin dn or password are invalid') + return None + + return self._conn + + def __exit__(self, exc_type, value, tb): + self._conn.unbind_s() + + +class LDAPUsers(FederatedUsers): + _LDAPResult = namedtuple('LDAPResult', ['dn', 'attrs']) + + def __init__(self, ldap_uri, base_dn, admin_dn, admin_passwd, user_rdn, uid_attr, email_attr): + super(LDAPUsers, self).__init__('ldap') + self._ldap_conn = LDAPConnection(ldap_uri, admin_dn, admin_passwd) + self._ldap_uri = ldap_uri + self._base_dn = base_dn + self._user_rdn = user_rdn + self._uid_attr = uid_attr + self._email_attr = email_attr + + def _get_ldap_referral_dn(self, referral_exception): + logger.debug('Got referral: %s', referral_exception.args[0]) + if not referral_exception.args[0] or not referral_exception.args[0].get('info'): + logger.debug('LDAP referral missing info block') + return None + + referral_info = referral_exception.args[0]['info'] + if not referral_info.startswith('Referral:\n'): + logger.debug('LDAP referral missing Referral header') + return None + + referral_uri = referral_info[len('Referral:\n'):] + if not referral_uri.startswith('ldap:///'): + logger.debug('LDAP referral URI does not start with ldap:///') + return None + + referral_dn = referral_uri[len('ldap:///'):] + return referral_dn + + def _ldap_user_search(self, username_or_email): + with self._ldap_conn as conn: + if conn is None: + return (None, 'LDAP Admin dn or password is invalid') + + logger.debug('Incoming username or email param: %s', username_or_email.__repr__()) + user_search_dn = ','.join(self._user_rdn + self._base_dn) + query = u'(|({0}={2})({1}={2}))'.format(self._uid_attr, self._email_attr, + username_or_email) + + logger.debug('Conducting user search: %s under %s', query, user_search_dn) + try: + pairs = conn.search_s(user_search_dn, ldap.SCOPE_SUBTREE, query.encode('utf-8')) + except ldap.REFERRAL as re: + referral_dn = self._get_ldap_referral_dn(re) + if not referral_dn: + return (None, 'Failed to follow referral when looking up username') + + try: + subquery = u'(%s=%s)' % (self._uid_attr, username_or_email) + pairs = conn.search_s(referral_dn, ldap.SCOPE_BASE, subquery) + except ldap.LDAPError: + logger.exception('LDAP referral search exception') + return (None, 'Username not found') + + except ldap.LDAPError: + logger.exception('LDAP search exception') + return (None, 'Username not found') + + logger.debug('Found matching pairs: %s', pairs) + + results = [LDAPUsers._LDAPResult(*pair) for pair in pairs] + + # Filter out pairs without DNs. Some LDAP impls will return such + # pairs. + with_dns = [result for result in results if result.dn] + if len(with_dns) < 1: + return (None, 'Username not found') + + # If we have found a single pair, then return it. + if len(with_dns) == 1: + return (with_dns[0], None) + + # Otherwise, there are multiple pairs with DNs, so find the one with the mail + # attribute (if any). + with_mail = [result for result in results if result.attrs.get(self._email_attr)] + return (with_mail[0] if with_mail else with_dns[0], None) + + def verify_credentials(self, username_or_email, password): + """ Verify the credentials with LDAP. """ + # Make sure that even if the server supports anonymous binds, we don't allow it + if not password: + return (None, 'Anonymous binding not allowed') + + (found_user, err_msg) = self._ldap_user_search(username_or_email) + if found_user is None: + return (None, err_msg) + + found_dn, found_response = found_user + logger.debug('Found user for LDAP username %s; validating password', username_or_email) + logger.debug('DN %s found: %s', found_dn, found_response) + + # First validate the password by binding as the user + try: + with LDAPConnection(self._ldap_uri, found_dn, password.encode('utf-8')): + pass + except ldap.REFERRAL as re: + referral_dn = self._get_ldap_referral_dn(re) + if not referral_dn: + return (None, 'Invalid username') + + try: + with LDAPConnection(self._ldap_uri, referral_dn, password.encode('utf-8')): + pass + except ldap.INVALID_CREDENTIALS: + logger.exception('Invalid LDAP credentials') + return (None, 'Invalid password') + + except ldap.INVALID_CREDENTIALS: + logger.exception('Invalid LDAP credentials') + return (None, 'Invalid password') + + # Now check if we have a federated login for this user + if not found_response.get(self._uid_attr): + return (None, 'Missing uid field "%s" in user record' % self._uid_attr) + + if not found_response.get(self._email_attr): + return (None, 'Missing mail field "%s" in user record' % self._email_attr) + + username = found_response[self._uid_attr][0].decode('utf-8') + email = found_response[self._email_attr][0] + return (VerifiedCredentials(username=username, email=email), None) + diff --git a/data/users/federated.py b/data/users/federated.py new file mode 100644 index 000000000..e70740917 --- /dev/null +++ b/data/users/federated.py @@ -0,0 +1,71 @@ +import logging + +from collections import namedtuple + +from data import model +from util.validation import generate_valid_usernames + +logger = logging.getLogger(__name__) + +VerifiedCredentials = namedtuple('VerifiedCredentials', ['username', 'email']) + +class FederatedUsers(object): + """ Base class for all federated users systems. """ + + def __init__(self, federated_service): + self._federated_service = federated_service + + def verify_credentials(self, username_or_email, password): + """ Verifies the given credentials against the backing federated service, returning + a tuple containing a VerifiedCredentials (if success) and the error message (if failed). """ + raise NotImplementedError + + def _get_federated_user(self, username, email): + db_user = model.user.verify_federated_login(self._federated_service, username) + if not db_user: + # We must create the user in our db + valid_username = None + for valid_username in generate_valid_usernames(username): + if model.user.is_username_unique(valid_username): + break + + if not valid_username: + logger.error('Unable to pick a username for user: %s', username) + return (None, 'Unable to pick a username. Please report this to your administrator.') + + db_user = model.user.create_federated_user(valid_username, email, self._federated_service, + username, set_password_notification=False) + else: + # Update the db attributes from the federated service. + db_user.email = email + db_user.save() + + return (db_user, None) + + def verify_and_link_user(self, username_or_email, password): + """ Verifies the given credentials and, if valid, creates/links a database user to the + associated federated service. + """ + (credentials, err_msg) = self.verify_credentials(username_or_email, password) + if credentials is None: + return (None, err_msg) + + return self._get_federated_user(credentials.username, credentials.email) + + def confirm_existing_user(self, username, password): + """ Confirms that the given *database* username and service password are valid for the linked + service. This method is used when the federated service's username is not known. + """ + db_user = model.user.get_user(username) + if not db_user: + return (None, 'Invalid user') + + federated_login = model.user.lookup_federated_login(db_user, self._federated_service) + if not federated_login: + return (None, 'Invalid user') + + (credentials, err_msg) = self.verify_credentials(federated_login.service_ident, password) + if credentials is None: + return (None, err_msg) + + return (db_user, None) diff --git a/data/users/keystone.py b/data/users/keystone.py new file mode 100644 index 000000000..a7feb9a4f --- /dev/null +++ b/data/users/keystone.py @@ -0,0 +1,39 @@ +import logging + +from keystoneclient.v2_0 import client as kclient +from keystoneclient.exceptions import AuthorizationFailure as KeystoneAuthorizationFailure +from keystoneclient.exceptions import Unauthorized as KeystoneUnauthorized +from data.users.federated import FederatedUsers, VerifiedCredentials + +logger = logging.getLogger(__name__) + +class KeystoneUsers(FederatedUsers): + """ Delegates authentication to OpenStack Keystone. """ + def __init__(self, auth_url, admin_username, admin_password, admin_tenant): + super(KeystoneUsers, self).__init__('keystone') + self.auth_url = auth_url + self.admin_username = admin_username + self.admin_password = admin_password + self.admin_tenant = admin_tenant + + def verify_credentials(self, username_or_email, password): + try: + keystone_client = kclient.Client(username=username_or_email, password=password, + auth_url=self.auth_url) + user_id = keystone_client.user_id + 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') + + try: + admin_client = kclient.Client(username=self.admin_username, password=self.admin_password, + tenant_name=self.admin_tenant, auth_url=self.auth_url) + user = admin_client.users.get(user_id) + except KeystoneUnauthorized as kut: + 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) diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 24f37d1e3..6d7f85f4f 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -395,7 +395,7 @@ def conduct_signin(username_or_email, password): verified = None try: - (verified, error_message) = authentication.verify_user(username_or_email, password) + (verified, error_message) = authentication.verify_and_link_user(username_or_email, password) except model.user.TooManyUsersException as ex: raise license_error(exception=ex) @@ -457,7 +457,7 @@ class ConvertToOrganization(ApiResource): # Ensure that the sign in credentials work. admin_username = convert_data['adminUser'] admin_password = convert_data['adminPassword'] - (admin_user, _) = authentication.verify_user(admin_username, admin_password) + (admin_user, _) = authentication.verify_and_link_user(admin_username, admin_password) if not admin_user: raise request_error(reason='invaliduser', message='The admin user credentials are not valid') diff --git a/endpoints/v1/index.py b/endpoints/v1/index.py index f634fa7a7..457ffd43d 100644 --- a/endpoints/v1/index.py +++ b/endpoints/v1/index.py @@ -105,7 +105,8 @@ def create_user(): abort(400, 'Invalid robot account or password.', issue='robot-login-failure') - (verified, error_message) = authentication.verify_user(username, password, basic_auth=True) + (verified, error_message) = authentication.verify_and_link_user(username, password, + basic_auth=True) if verified: # Mark that the user was logged in. event = userevents.get_event(username) diff --git a/test/test_external_jwt_authn.py b/test/test_external_jwt_authn.py index 77e35c9dd..b911e8078 100644 --- a/test/test_external_jwt_authn.py +++ b/test/test_external_jwt_authn.py @@ -89,28 +89,28 @@ class JWTAuthTestCase(LiveServerTestCase): finished_database_for_testing(self) self.ctx.__exit__(True, None, None) - def test_verify_user(self): - result, error_message = self.jwt_auth.verify_user('invaliduser', 'foobar') + def test_verify_and_link_user(self): + result, error_message = self.jwt_auth.verify_and_link_user('invaliduser', 'foobar') self.assertEquals('Invalid username or password', error_message) self.assertIsNone(result) - result, _ = self.jwt_auth.verify_user('cooluser', 'invalidpassword') + result, _ = self.jwt_auth.verify_and_link_user('cooluser', 'invalidpassword') self.assertIsNone(result) - result, _ = self.jwt_auth.verify_user('cooluser', 'password') + result, _ = self.jwt_auth.verify_and_link_user('cooluser', 'password') self.assertIsNotNone(result) self.assertEquals('cooluser', result.username) - result, _ = self.jwt_auth.verify_user('some.neat.user', 'foobar') + result, _ = self.jwt_auth.verify_and_link_user('some.neat.user', 'foobar') self.assertIsNotNone(result) self.assertEquals('some_neat_user', result.username) def test_confirm_existing_user(self): # Create the users in the DB. - result, _ = self.jwt_auth.verify_user('cooluser', 'password') + result, _ = self.jwt_auth.verify_and_link_user('cooluser', 'password') self.assertIsNotNone(result) - result, _ = self.jwt_auth.verify_user('some.neat.user', 'foobar') + result, _ = self.jwt_auth.verify_and_link_user('some.neat.user', 'foobar') self.assertIsNotNone(result) # Confirm a user with the same internal and external username. @@ -128,7 +128,7 @@ class JWTAuthTestCase(LiveServerTestCase): self.assertEquals('some_neat_user', result.username) def test_disabled_user_custom_erorr(self): - result, error_message = self.jwt_auth.verify_user('disabled', 'password') + result, error_message = self.jwt_auth.verify_and_link_user('disabled', 'password') self.assertIsNone(result) self.assertEquals('User is currently disabled', error_message) diff --git a/test/test_ldap.py b/test/test_ldap.py index c7d54ca35..df8342620 100644 --- a/test/test_ldap.py +++ b/test/test_ldap.py @@ -99,13 +99,13 @@ class TestLDAP(unittest.TestCase): self.ldap = ldap # Try to login. - (response, err_msg) = self.ldap.verify_user('someuser', 'somepass') + (response, err_msg) = self.ldap.verify_and_link_user('someuser', 'somepass') self.assertIsNone(response) self.assertEquals('LDAP Admin dn or password is invalid', err_msg) def test_login(self): # Verify we can login. - (response, _) = self.ldap.verify_user('someuser', 'somepass') + (response, _) = self.ldap.verify_and_link_user('someuser', 'somepass') self.assertEquals(response.username, 'someuser') # Verify we can confirm the user. @@ -113,13 +113,13 @@ class TestLDAP(unittest.TestCase): self.assertEquals(response.username, 'someuser') def test_missing_mail(self): - (response, err_msg) = self.ldap.verify_user('nomail', 'somepass') + (response, err_msg) = self.ldap.verify_and_link_user('nomail', 'somepass') self.assertIsNone(response) self.assertEquals('Missing mail field "mail" in user record', err_msg) def test_confirm_different_username(self): # Verify that the user is logged in and their username was adjusted. - (response, _) = self.ldap.verify_user('cool.user', 'somepass') + (response, _) = self.ldap.verify_and_link_user('cool.user', 'somepass') self.assertEquals(response.username, 'cool_user') # Verify we can confirm the user's quay username. @@ -131,7 +131,7 @@ class TestLDAP(unittest.TestCase): self.assertIsNone(response) def test_referral(self): - (response, _) = self.ldap.verify_user('referred', 'somepass') + (response, _) = self.ldap.verify_and_link_user('referred', 'somepass') self.assertEquals(response.username, 'cool_user') # Verify we can confirm the user's quay username. @@ -139,11 +139,11 @@ class TestLDAP(unittest.TestCase): self.assertEquals(response.username, 'cool_user') def test_invalid_referral(self): - (response, _) = self.ldap.verify_user('invalidreferred', 'somepass') + (response, _) = self.ldap.verify_and_link_user('invalidreferred', 'somepass') self.assertIsNone(response) def test_multientry(self): - (response, _) = self.ldap.verify_user('multientry', 'somepass') + (response, _) = self.ldap.verify_and_link_user('multientry', 'somepass') self.assertEquals(response.username, 'multientry') if __name__ == '__main__': diff --git a/util/config/validator.py b/util/config/validator.py index 2f80441c9..976d69e5b 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -7,7 +7,10 @@ import OpenSSL import logging from fnmatch import fnmatch -from data.users import LDAPConnection, ExternalJWTAuthN, LDAPUsers, KeystoneUsers +from data.users.keystone import KeystoneUsers +from data.users.externaljwt import ExternalJWTAuthN +from data.users.externalldap import LDAPConnection, LDAPUsers + from flask import Flask from flask.ext.mail import Mail, Message from data.database import validate_database_url, User @@ -317,7 +320,7 @@ def _validate_ldap(config, password): users = LDAPUsers(ldap_uri, base_dn, admin_dn, admin_passwd, user_rdn, uid_attr, email_attr) username = get_authenticated_user().username - (result, err_msg) = users.verify_user(username, password) + (result, err_msg) = users.verify_credentials(username, password) if not result: raise Exception(('Verification of superuser %s failed: %s. \n\nThe user either does not exist ' + 'in the remote authentication system ' + @@ -345,7 +348,7 @@ def _validate_jwt(config, password): # Verify that the superuser exists. If not, raise an exception. username = get_authenticated_user().username - (result, err_msg) = users.verify_user(username, password) + (result, err_msg) = users.verify_credentials(username, password) if not result: raise Exception(('Verification of superuser %s failed: %s. \n\nThe user either does not ' + 'exist in the remote authentication system ' + @@ -379,7 +382,7 @@ def _validate_keystone(config, password): # Verify that the superuser exists. If not, raise an exception. username = get_authenticated_user().username - (result, err_msg) = users.verify_user(username, password) + (result, err_msg) = users.verify_credentials(username, password) if not result: raise Exception(('Verification of superuser %s failed: %s \n\nThe user either does not ' + 'exist in the remote authentication system ' + From 38a6b3621cc9f1003fa2a033528d813a7be78b1c Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 20 Jul 2015 13:18:07 -0400 Subject: [PATCH 4/4] Automatically link the superuser account to federated service for auth When the user commits the configuration, if they have chosen a non-DB auth system, we now auto-link the superuser account to that auth system, to ensure they can login again after restart. --- data/model/user.py | 9 +++++++++ data/users/__init__.py | 17 +++++++++++++++-- endpoints/api/suconfig.py | 9 +++++++++ static/js/core-config-setup.js | 2 +- util/config/validator.py | 5 +++-- 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/data/model/user.py b/data/model/user.py index 83a7497a0..e5b34a099 100644 --- a/data/model/user.py +++ b/data/model/user.py @@ -295,6 +295,15 @@ def list_entity_robot_permission_teams(entity_name, include_permissions=False): return TupleSelector(query, fields) +def confirm_attached_federated_login(user, service_name): + """ Verifies that the given user has a federated service identity for the specified service. + If none found, a row is added for that service and user. + """ + with db_transaction(): + if not lookup_federated_login(user, service_name): + attach_federated_login(user, service_name, user.username) + + def create_federated_user(username, email, service_name, service_id, set_password_notification, metadata={}): if not is_create_user_allowed(): diff --git a/data/users/__init__.py b/data/users/__init__.py index 826727bc1..c9f7d41dd 100644 --- a/data/users/__init__.py +++ b/data/users/__init__.py @@ -15,6 +15,19 @@ from util.aes import AESCipher logger = logging.getLogger(__name__) +def get_federated_service_name(authentication_type): + if authentication_type == 'LDAP': + return 'ldap' + + if authentication_type == 'JWT': + return 'jwtauthn' + + if authentication_type == 'Keystone': + return 'keystone' + + raise Exception('Unknown auth type: %s' % authentication_type) + + class UserAuthentication(object): def __init__(self, app=None, override_config_dir=None): self.app_secret_key = None @@ -45,8 +58,8 @@ class UserAuthentication(object): verify_url = app.config.get('JWT_VERIFY_ENDPOINT') issuer = app.config.get('JWT_AUTH_ISSUER') max_fresh_s = app.config.get('JWT_AUTH_MAX_FRESH_S', 300) - users = ExternalJWTAuthN(verify_url, issuer, override_config_dir, max_fresh_s, - app.config['HTTPCLIENT']) + users = ExternalJWTAuthN(verify_url, issuer, override_config_dir, + app.config['HTTPCLIENT'], max_fresh_s) elif authentication_type == 'Keystone': auth_url = app.config.get('KEYSTONE_AUTH_URL') keystone_admin_username = app.config.get('KEYSTONE_ADMIN_USERNAME') diff --git a/endpoints/api/suconfig.py b/endpoints/api/suconfig.py index d2c21e7d1..576965bea 100644 --- a/endpoints/api/suconfig.py +++ b/endpoints/api/suconfig.py @@ -13,10 +13,12 @@ from app import app, CONFIG_PROVIDER, superusers from data import model from data.database import configure from auth.permissions import SuperUserPermission +from auth.auth_context import get_authenticated_user from data.database import User from util.config.configutil import add_enterprise_config_defaults from util.config.validator import validate_service_for_config, CONFIG_FILENAMES from data.runmigration import run_alembic_migration +from data.users import get_federated_service_name import features @@ -208,6 +210,13 @@ class SuperUserConfig(ApiResource): # Write the configuration changes to the YAML file. CONFIG_PROVIDER.save_yaml(config_object) + # If the authentication system is not the database, link the superuser account to the + # the authentication system chosen. + if config_object.get('AUTHENTICATION_TYPE', 'Database') != 'Database': + service_name = get_federated_service_name(config_object['AUTHENTICATION_TYPE']) + current_user = get_authenticated_user() + model.user.confirm_attached_federated_login(current_user, service_name) + return { 'exists': True, 'config': config_object diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js index 48f3081fb..c7ad79b76 100644 --- a/static/js/core-config-setup.js +++ b/static/js/core-config-setup.js @@ -207,7 +207,7 @@ angular.module("core-config-setup", ['angularFileUpload']) '', "title": 'Enter Password', "buttons": { - "verify": { + "success": { "label": "Validate Config", "className": "btn-success", "callback": function() { diff --git a/util/config/validator.py b/util/config/validator.py index 976d69e5b..82a15fff7 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -344,7 +344,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, - app.config['HTTPCLIENT']) + app.config['HTTPCLIENT'], + app.config.get('JWT_AUTH_MAX_FRESH_S', 300)) # Verify that the superuser exists. If not, raise an exception. username = get_authenticated_user().username @@ -403,4 +404,4 @@ _VALIDATORS = { 'ldap': _validate_ldap, 'jwt': _validate_jwt, 'keystone': _validate_keystone, -} \ No newline at end of file +}