From e37ccd8151679cf4a1a646d32bd48dab3b11b7d5 Mon Sep 17 00:00:00 2001 From: Chenxiong Qi Date: Jun 29 2017 06:56:49 +0000 Subject: Support OpenIDC authentication Similar with Kerberos authentication, authentication is done by mod_auth_openidc in Apache. Client must request protected resources by passing token got from Fedora OpenIDC. After succeeding to authenticate, ODCS will get chance to create new user for the client or just query existing one from database. Then, authorization can be in protected resource view methods based on FAS groups. Major changes: * Configuration of openidc is added. * httpd configuration for mod_auth_openidc is added. * method to create or load users and FAS groups. --- diff --git a/conf/config.py b/conf/config.py index 089be24..6062238 100644 --- a/conf/config.py +++ b/conf/config.py @@ -51,6 +51,7 @@ class BaseConfiguration(object): # Select which authentication backend to work with. There are 3 choices # noauth: no authentication is enabled. Useful for development particularly. # kerberos: Kerberos authentication is enabled. + # openidc: OpenIDC authentication is enabled. AUTH_BACKEND = 'noauth' # Used for Kerberos authentication and to query user's groups. @@ -62,6 +63,17 @@ class BaseConfiguration(object): # Generally, it would be, for example, ou=groups,dc=example,dc=com AUTH_LDAP_GROUP_BASE = '' + AUTH_OPENIDC_USERINFO_URI = 'https://id.fedoraproject.org/openidc/UserInfo' + + # Scope requested from Fedora Infra for permission of submitting request to + # run a new compose. + # See also: https://fedoraproject.org/wiki/Infrastructure/Authentication + # Add additional required scope in following list + AUTH_OPENIDC_REQUIRED_SCOPES = [ + 'openid', + 'https://id.fedoraproject.org/scope/groups', + ] + class DevConfiguration(BaseConfiguration): DEBUG = True @@ -77,6 +89,8 @@ class DevConfiguration(BaseConfiguration): except: pass + AUTH_OPENIDC_USERINFO_URI = 'https://iddev.fedorainfracloud.org/openidc/UserInfo' + class TestConfiguration(BaseConfiguration): LOG_BACKEND = 'console' diff --git a/conf/odcs-httpd-openidc.conf b/conf/odcs-httpd-openidc.conf new file mode 100644 index 0000000..af0614c --- /dev/null +++ b/conf/odcs-httpd-openidc.conf @@ -0,0 +1,17 @@ +# ODCS client id registered in Fedora OpenIDC +# Replace client_id with real id value +OIDCOAuthClientID client_id + +# ODCS client secret registered in Fedora OpenIDC +# Replace notsecret with real secret value +OIDCOAuthClientSecret notsecret + +# Endpoint to get token so that mod_auth_openidc is able to validate incoming token +# For development, it is https://iddev.fedorainfracloud.org/openidc/TokenInfo +OIDCOAuthIntrospectionEndpoint https://id.fedoraproject.org/openidc/TokenInfo + +OIDCOAuthIntrospectionEndpointAuth client_secret_post +OIDCOAuthIntrospectionEndpointParams token_type_hint=Bearer + +Authtype oauth20 +Require valid-user diff --git a/odcs/auth.py b/odcs/auth.py index 9872bcf..1d3c039 100644 --- a/odcs/auth.py +++ b/odcs/auth.py @@ -22,10 +22,12 @@ # Written by Chenxiong Qi +import requests import ldap from itertools import chain +from six.moves import urllib_parse from flask import abort from flask import g from flask import request @@ -34,7 +36,30 @@ from odcs.models import User, Group from odcs import db, conf, log +def find_user_by_email(email): + try: + return db.session.query(User).filter(User.email == email)[0] + except IndexError: + return None + + +def create_user(username, email, krb_realm=None, groups=[]): + user = User(username=username, email=email, krb_realm=krb_realm) + db.session.add(user) + + for group in groups: + user.groups.append(Group(name=group)) + db.session.commit() + + return user + + def load_krb_user_from_request(): + """Load Kerberos user from current request + + REMOTE_USER needs to be set in environment variable, that is set by + frontend Apache authentication module. + """ remote_user = request.environ.get('REMOTE_USER') if not remote_user: abort(401, 'REMOTE_USER is not present in request.') @@ -50,16 +75,13 @@ def load_krb_user_from_request(): email = remote_user.lower() - q = db.session.query(User).filter(User.email == email) - if not db.session.query(User.id).filter(q.exists()).scalar(): - user = User(username=username, email=email, krb_realm=realm) - db.session.add(user) - - for group in groups: - user.groups.append(Group(name=group)) - db.session.commit() - - g.user = db.session.query(User).filter(User.email == email)[0] + user = find_user_by_email(email) + if not user: + user = create_user(username=username, + email=email, + krb_realm=realm, + groups=groups) + g.user = user def query_ldap_groups(uid): @@ -79,9 +101,69 @@ def query_ldap_groups(uid): return group_names +def load_openidc_user(): + """Load FAS user from current request""" + username = request.environ.get('REMOTE_USER') + if not username: + abort(401, 'REMOTE_USER is not present in request.') + + token = request.environ.get('OIDC_access_token') + if not token: + abort(401, 'Missing token passed into ODCS.') + + scope = request.environ.get('OIDC_CLAIM_scope') + if not scope: + abort(401, 'Missing OIDC_CLAIM_scope.') + validate_scopes(scope) + + user_info = get_user_info(token) + email = user_info.get('email') + if not email: + log.warning('Seems email is not present. Please check scope in client.' + ' Fallback to use iss to construct email address.') + domain = urllib_parse.urlparse(request.environ['OIDC_CLAIM_iss']).netloc + email = '{0}@{1}'.format(username, domain) + groups = user_info.get('groups', []) + + user = find_user_by_email(email) + if not user: + user = create_user(username=username, email=email, groups=groups) + g.user = user + + +def validate_scopes(scope): + """Validate if request scopes are all in required scope + + :param str scope: scope passed in from. + :raises: Unauthorized if any of required scopes is not present. + """ + scopes = scope.split(' ') + required_scopes = conf.auth_openidc_required_scopes + for scope in required_scopes: + if scope not in scopes: + abort(401, 'Required OIDC scope {0} not present.'.format(scope)) + + +def get_user_info(token): + """Query FAS groups from Fedora""" + headers = { + 'authorization': 'Bearer {0}'.format(token) + } + r = requests.get(conf.auth_openidc_userinfo_uri, headers=headers) + if r.status_code != 200: + abort(401, 'Cannot get user information from {0} endpoint.'.format( + conf.auth_openidc_userinfo_uri)) + return r.json() + + def init_auth(app, backend=None): if backend is None or backend == 'noauth': return - if backend == 'kerbers': + if backend == 'kerberos': global load_krb_user_from_request load_krb_user_from_request = app.before_request(load_krb_user_from_request) + elif backend == 'openidc': + global load_openidc_user + load_openidc_user = app.before_request(load_openidc_user) + else: + raise ValueError('Unknown backend name {0}.'.format(backend)) diff --git a/odcs/config.py b/odcs/config.py index 1091691..503625b 100644 --- a/odcs/config.py +++ b/odcs/config.py @@ -171,6 +171,14 @@ class Config(object): 'default': 'noauth', 'desc': "Select which authentication backend is enabled and work " "with frond-end authentication together."}, + 'auth_openidc_userinfo_uri': { + 'type': str, + 'default': '', + 'desc': 'UserInfo endpoint to get user information from FAS.'}, + 'auth_openidc_required_scopes': { + 'type': list, + 'default': [], + 'desc': 'Required scopes for submitting request to run new compose.'}, } def __init__(self, conf_section_obj): diff --git a/odcs/migrations/versions/ea5e525120a0_add_user_and_group_models.py b/odcs/migrations/versions/ea5e525120a0_add_user_and_group_models.py index 22709c2..13760f5 100644 --- a/odcs/migrations/versions/ea5e525120a0_add_user_and_group_models.py +++ b/odcs/migrations/versions/ea5e525120a0_add_user_and_group_models.py @@ -23,9 +23,9 @@ def upgrade(): ) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('username', sa.String(length=50), nullable=False), - sa.Column('email', sa.String(length=75), nullable=False), - sa.Column('krb_realm', sa.String(length=30), nullable=True), + sa.Column('username', sa.String(length=200), nullable=False), + sa.Column('email', sa.String(length=200), nullable=False), + sa.Column('krb_realm', sa.String(length=50), nullable=True), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('email') ) diff --git a/odcs/models.py b/odcs/models.py index bfe2eef..f64d5ea 100644 --- a/odcs/models.py +++ b/odcs/models.py @@ -80,9 +80,9 @@ class User(ODCSBase): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(50), nullable=False) - email = db.Column(db.String(75), nullable=False, unique=True) - krb_realm = db.Column(db.String(30), nullable=True, default='') + username = db.Column(db.String(200), nullable=False) + email = db.Column(db.String(200), nullable=False, unique=True) + krb_realm = db.Column(db.String(50), nullable=True, default='') groups = db.relationship('Group', secondary=user_group_rels, diff --git a/tests/test_auth.py b/tests/test_auth.py index dd48744..c2bb060 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -22,14 +22,19 @@ # Written by Chenxiong Qi +import flask import unittest -from mock import patch +from mock import patch, Mock + +import odcs.auth -from odcs import db -from odcs.models import User from odcs.auth import load_krb_user_from_request +from odcs.auth import load_openidc_user from odcs.auth import query_ldap_groups +from odcs.auth import init_auth +from odcs import app, db +from odcs.models import User from utils import ModelsBaseTest from werkzeug.exceptions import Unauthorized @@ -92,6 +97,134 @@ class TestLoadKrbUserFromRequest(ModelsBaseTest): self.assertRaises(Unauthorized, load_krb_user_from_request) +class TestLoadOpenIDCUserFromRequest(ModelsBaseTest): + + def setUp(self): + super(TestLoadOpenIDCUserFromRequest, self).setUp() + + self.user = User(username='tester1', email='tester1@example.com') + db.session.add(self.user) + db.session.commit() + + @patch('odcs.auth.requests.get') + def test_create_new_user(self, get): + get.return_value.status_code = 200 + get.return_value.json.return_value = { + 'email': 'new_user@example.com', + 'groups': ['tester', 'admin'], + 'name': 'new_user', + } + + environ_base = { + 'REMOTE_USER': 'new_user', + 'OIDC_access_token': '39283', + 'OIDC_CLAIM_iss': 'https://iddev.fedorainfracloud.org/openidc/', + 'OIDC_CLAIM_scope': 'openid https://id.fedoraproject.org/scope/groups', + } + with app.test_request_context(environ_base=environ_base): + load_openidc_user() + + new_user = db.session.query(User).filter( + User.email == 'new_user@example.com')[0] + + self.assertEqual(new_user, flask.g.user) + self.assertEqual('new_user', flask.g.user.username) + self.assertEqual('new_user@example.com', flask.g.user.email) + self.assertEqual(sorted(['admin', 'tester']), + sorted([grp.name for grp in flask.g.user.groups])) + + @patch('odcs.auth.requests.get') + def test_return_existing_user(self, get): + get.return_value.status_code = 200 + get.return_value.json.return_value = { + 'email': self.user.email, + 'groups': ['tester', 'admin'], + 'name': self.user.username, + } + + environ_base = { + 'REMOTE_USER': self.user.username, + 'OIDC_access_token': '39283', + 'OIDC_CLAIM_iss': 'https://iddev.fedorainfracloud.org/openidc/', + 'OIDC_CLAIM_scope': 'openid https://id.fedoraproject.org/scope/groups', + } + with app.test_request_context(environ_base=environ_base): + original_users_count = db.session.query(User.id).count() + + load_openidc_user() + + users_count = db.session.query(User.id).count() + self.assertEqual(original_users_count, users_count) + + # Ensure existing user is set in g + self.assertEqual(self.user, flask.g.user) + + def test_401_if_remote_user_not_present(self): + environ_base = { + # Missing REMOTE_USER here + 'OIDC_access_token': '39283', + 'OIDC_CLAIM_iss': 'https://iddev.fedorainfracloud.org/openidc/', + 'OIDC_CLAIM_scope': 'openid https://id.fedoraproject.org/scope/groups', + } + with app.test_request_context(environ_base=environ_base): + self.assertRaises(Unauthorized, load_openidc_user) + + def test_401_if_access_token_not_present(self): + environ_base = { + 'REMOTE_USER': 'tester1', + # Missing OIDC_access_token here + 'OIDC_CLAIM_iss': 'https://iddev.fedorainfracloud.org/openidc/', + 'OIDC_CLAIM_scope': 'openid https://id.fedoraproject.org/scope/groups', + } + with app.test_request_context(environ_base=environ_base): + self.assertRaises(Unauthorized, load_openidc_user) + + @patch('odcs.auth.requests.get') + def test_use_iss_to_construct_email_if_email_is_missing(self, get): + get.return_value.status_code = 200 + get.return_value.json.return_value = { + 'groups': ['tester', 'admin'], + 'name': self.user.username, + } + + environ_base = { + 'REMOTE_USER': 'new_user', + 'OIDC_access_token': '39283', + 'OIDC_CLAIM_iss': 'https://iddev.fedorainfracloud.org/openidc/', + 'OIDC_CLAIM_scope': 'openid https://id.fedoraproject.org/scope/groups', + } + with app.test_request_context(environ_base=environ_base): + load_openidc_user() + self.assertEqual('new_user@iddev.fedorainfracloud.org', + flask.g.user.email) + + def test_401_if_scope_not_present(self): + environ_base = { + 'REMOTE_USER': 'tester1', + 'OIDC_access_token': '39283', + 'OIDC_CLAIM_iss': 'https://iddev.fedorainfracloud.org/openidc/', + # Missing OIDC_CLAIM_scope here + } + with app.test_request_context(environ_base=environ_base): + self.assertRaises(Unauthorized, load_openidc_user) + + def test_401_if_required_scope_not_present_in_token_scope(self): + environ_base = { + 'REMOTE_USER': 'new_user', + 'OIDC_access_token': '39283', + 'OIDC_CLAIM_iss': 'https://iddev.fedorainfracloud.org/openidc/', + 'OIDC_CLAIM_scope': 'openid https://id.fedoraproject.org/scope/groups', + } + with patch.object(odcs.auth.conf, + 'auth_openidc_required_scopes', + ['new-compose']): + with app.test_request_context(environ_base=environ_base): + self.assertRaisesRegexp( + Unauthorized, + 'Required OIDC scope new-compose not present.', + load_openidc_user) + + class TestQueryLdapGroups(unittest.TestCase): """Test auth.query_ldap_groups""" @@ -109,3 +242,30 @@ class TestQueryLdapGroups(unittest.TestCase): groups = query_ldap_groups('me') self.assertEqual(sorted(['odcsdev', 'freshmakerdev', 'devel']), sorted(groups)) + + +class TestInitAuth(unittest.TestCase): + """Test init_auth""" + + def test_select_kerberos_auth_backend(self): + app = Mock() + init_auth(app, 'kerberos') + app.before_request.assert_called_once_with(load_krb_user_from_request) + + def test_select_openidc_auth_backend(self): + app = Mock() + init_auth(app, 'openidc') + app.before_request.assert_called_once_with(load_openidc_user) + + def test_not_use_auth_backend(self): + app = Mock() + init_auth(app) + app.before_request.assert_not_called() + + init_auth(app, 'noauth') + app.before_request.assert_not_called() + + def test_error_if_select_an_unknown_backend(self): + app = Mock() + self.assertRaises(ValueError, init_auth, app, 'xxx') + self.assertRaises(ValueError, init_auth, app, '')