Merge pull request #197 from coreos-inc/keystone

Add Keystone Auth
This commit is contained in:
Jake Moshenko 2015-07-22 13:38:47 -04:00
commit 5d86fa80e7
19 changed files with 684 additions and 456 deletions

View file

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

View file

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

View file

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

View file

@ -1,430 +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
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 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'])
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 unecrypted 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)

167
data/users/__init__.py Normal file
View file

@ -0,0 +1,167 @@
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__)
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
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,
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')
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)

18
data/users/database.py Normal file
View file

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

72
data/users/externaljwt.py Normal file
View file

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

159
data/users/externalldap.py Normal file
View file

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

71
data/users/federated.py Normal file
View file

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

39
data/users/keystone.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -353,12 +353,55 @@
<select ng-model="config.AUTHENTICATION_TYPE">
<option value="Database">Local Database</option>
<option value="LDAP">LDAP</option>
<option value="Keystone">Keystone (OpenStack Identity)</option>
<option value="JWT">JWT Custom Authentication</option>
</select>
</td>
</tr>
</table>
<!-- Keystone Authentication -->
<table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'Keystone'">
<tr>
<td>Keystone Authentication URL:</td>
<td>
<span class="config-string-field" binding="config.KEYSTONE_AUTH_URL"
pattern="http(s)?://.+"></span>
<div class="help-text">
The URL (starting with http or https) of the Keystone Server endpoint for auth.
</div>
</td>
</tr>
<tr>
<td>Keystone Administrator Username:</td>
<td>
<span class="config-string-field" binding="config.KEYSTONE_ADMIN_USERNAME"></span>
<div class="help-text">
The username for the Keystone admin.
</div>
</td>
</tr>
<tr>
<td>Keystone Administrator Password:</td>
<td>
<input type="password" ng-model="config.KEYSTONE_ADMIN_PASSWORD"
class="form-control" required></span>
<div class="help-text">
The password for the Keystone admin.
</div>
</td>
</tr>
<tr>
<td>Keystone Administrator Tenant:</td>
<td>
<span class="config-string-field" binding="config.KEYSTONE_ADMIN_TENANT"></span>
<div class="help-text">
The tenant (project/group) that contains the administrator user.
</div>
</td>
</tr>
</table>
<!-- JWT Custom Authentication -->
<div class="co-alert co-alert-info" ng-if="config.AUTHENTICATION_TYPE == 'JWT'">
JSON Web Token authentication allows your organization to provide an HTTP endpoint that

View file

@ -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;
}},
@ -203,7 +207,7 @@ angular.module("core-config-setup", ['angularFileUpload'])
'</form>',
"title": 'Enter Password',
"buttons": {
"verify": {
"success": {
"label": "Validate Config",
"className": "btn-success",
"callback": function() {

View file

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

View file

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

View file

@ -7,7 +7,10 @@ import OpenSSL
import logging
from fnmatch import fnmatch
from data.users import LDAPConnection, ExternalJWTAuthN, LDAPUsers
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 ' +
@ -341,17 +344,52 @@ 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
(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 ' +
'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_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 ' +
'OR Keystone auth is misconfigured.') % (username, err_msg))
_VALIDATORS = {
'database': _validate_database,
'redis': _validate_redis,
@ -365,4 +403,5 @@ _VALIDATORS = {
'ssl': _validate_ssl,
'ldap': _validate_ldap,
'jwt': _validate_jwt,
}
'keystone': _validate_keystone,
}