From c448ef291fa640a09889ab8913d068153cdef604 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Jul 11 2016 15:25:09 +0000 Subject: [PATCH 1/4] Fix bug in openid check_authentication In the case of check_authentication, the remote side does not need a transaction, since they're sending just one single request, but they might include an ipsilon_transaction_id argument if the other side is an Ipsilon instance. Signed-off-by: Patrick Uiterwijk --- diff --git a/ipsilon/providers/openid/auth.py b/ipsilon/providers/openid/auth.py index 16fa5fc..204f136 100644 --- a/ipsilon/providers/openid/auth.py +++ b/ipsilon/providers/openid/auth.py @@ -23,16 +23,20 @@ class AuthenticateRequest(ProviderPageBase): self.trans = None def _preop(self, *args, **kwargs): - try: - # generate a new id or get current one - self.trans = Transaction('openid', **kwargs) - if (self.trans.cookie and - self.trans.cookie.value != self.trans.provider): - self.debug('Invalid transaction, %s != %s' % ( - self.trans.cookie.value, self.trans.provider)) - except Exception, e: # pylint: disable=broad-except - self.debug('Transaction initialization failed: %s' % repr(e)) - raise cherrypy.HTTPError(400, 'Invalid transaction id') + # If we are doing openid check_auth, the other party does NOT need a + # tranaction, but they might send ipsilon_transaction_id if they are + # an Ipsilon instance using authopenid. + if kwargs.get('openid.mode') != 'check_authentication': + try: + # generate a new id or get current one + self.trans = Transaction('openid', **kwargs) + if (self.trans.cookie and + self.trans.cookie.value != self.trans.provider): + self.debug('Invalid transaction, %s != %s' % ( + self.trans.cookie.value, self.trans.provider)) + except Exception, e: # pylint: disable=broad-except + self.debug('Transaction initialization failed: %s' % repr(e)) + raise cherrypy.HTTPError(400, 'Invalid transaction id') def pre_GET(self, *args, **kwargs): self._preop(*args, **kwargs) From af39e61fe45df1b308192ca06361c838bb007675 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Jul 11 2016 15:25:09 +0000 Subject: [PATCH 2/4] Add the nonce openid table This was referred to by the useNonce code, but the table itself was never actually created. Signed-off-by: Patrick Uiterwijk --- diff --git a/ipsilon/providers/openid/store.py b/ipsilon/providers/openid/store.py index 85fc728..4c22863 100644 --- a/ipsilon/providers/openid/store.py +++ b/ipsilon/providers/openid/store.py @@ -94,6 +94,10 @@ class OpenIDStore(Store, OpenIDStoreInterface): trans=False) q.create() q._con.close() # pylint: disable=protected-access + q = self._query(self._db, 'nonce', UNIQUE_DATA_TABLE, + trans=False) + q.create() + q._con.close() # pylint: disable=protected-access def _upgrade_schema(self, old_version): if old_version == 1: @@ -110,5 +114,16 @@ class OpenIDStore(Store, OpenIDStoreInterface): for index in table.indexes: self._db.add_index(index) return 2 + if old_version == 2: + # In schema version 3, we added the nonce table + q = self._query(self._db, 'nonce', UNIQUE_DATA_TABLE, + trans=False) + q.create() + q._con.close() # pylint: disable=protected-access + return 3 else: raise NotImplementedError() + + def _code_schema_version(self): + # In version 3, we added the nonce table. + return 3 From c26fe2e85a601a039a8e14ca786dfaaf688cd6fb Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Jul 11 2016 15:25:09 +0000 Subject: [PATCH 3/4] Make pasword field optional to have in login forms Some modules might want to use the LoginFormBase base class but not have a password field, so let's enable that. Signed-off-by: Patrick Uiterwijk --- diff --git a/ipsilon/login/common.py b/ipsilon/login/common.py index d0ef41a..46ed8fd 100644 --- a/ipsilon/login/common.py +++ b/ipsilon/login/common.py @@ -301,7 +301,6 @@ class LoginFormBase(LoginPageBase): "action": '%s/%s' % (self.basepath, self.formpage), "service_name": self.lm.service_name, "username_text": self.lm.username_text, - "password_text": self.lm.password_text, "description": self.lm.help_text, "other_stacks": other_stacks, "username": username, @@ -309,6 +308,9 @@ class LoginFormBase(LoginPageBase): "cancel_url": '%s/login/cancel?%s' % (self.basepath, self.trans.get_GET_arg()), } + if 'password_text' in dir(self.lm): + context["password_text"] = self.lm.password_text + context.update(kwargs) if self.trans is not None: t = self.trans.get_POST_tuple() From 7a14d83c14718c43ca9efcfef94a200a506ae445 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Jul 11 2016 15:26:11 +0000 Subject: [PATCH 4/4] Add authopenid plugin Add plugin to allow Ipsilon to be OpenID Relying Party. Signed-off-by: Patrick Uiterwijk --- diff --git a/contrib/fedora/ipsilon.spec b/contrib/fedora/ipsilon.spec index d1c0ab3..8a95c94 100644 --- a/contrib/fedora/ipsilon.spec +++ b/contrib/fedora/ipsilon.spec @@ -176,6 +176,18 @@ BuildArch: noarch Provides a login plugin to authenticate against the Fedora Authentication System +%package authopenid +Summary: OpenID relying party login plugin +Group: System Environment/Base +License: GPLv3+ +Requires: %{name} = %{version}-%{release} +Requires: %{name}-openid = %{version}-%{release} +BuildArch: noarch + +%description authopenid +Provides a login plugin to use Ipsilon as a Relying Party for external OpenID providers + + %package authform Summary: mod_intercept_form_submit login plugin Group: System Environment/Base @@ -384,6 +396,10 @@ fi %files authfas %{python2_sitelib}/ipsilon/login/authfas* +%files authopenid +%{python2_sitelib}/ipsilon/login/authopenid* +%{_datadir}/ipsilon/templates/login/openid.html + %files authform %{python2_sitelib}/ipsilon/login/authform* diff --git a/ipsilon/login/authopenid.py b/ipsilon/login/authopenid.py new file mode 100644 index 0000000..6783ce1 --- /dev/null +++ b/ipsilon/login/authopenid.py @@ -0,0 +1,253 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +from ipsilon.login.common import LoginFormBase, LoginManagerBase, \ + LoginManagerInstaller +from ipsilon.util.plugin import PluginObject +from ipsilon.util.policy import Policy +from ipsilon.util import config as pconfig +from ipsilon.providers.openid.extensions.ax import AP_MAP +from ipsilon.providers.openid.store import OpenIDStore +import cherrypy +import logging +import pickle + +from openid.consumer import consumer +from openid.extensions import pape, sreg, ax + +# These are type URIs where we allow multiple results +multival_type_uris = ['http://fedoauth.org/openid/schema/SSH/key'] + +openid_mapping = [ + ['fullname', 'fullname'], + ['email', 'email'], + ['lastname', 'surname'], + ['nickname', 'nickname'], + ['nickname', 'nickname'], +] + + +class OpenID(LoginFormBase): + + def __init__(self, site, mgr, page, forced_provider=None): + super(OpenID, self).__init__(site, mgr, page) + self.mapper = Policy(openid_mapping) + self.forced_provider = forced_provider + + def root(self, *args, **kwargs): + self.trans = self.get_valid_transaction('login', **kwargs) + context = self.create_tmpl_context() + if self.forced_provider or cherrypy.request.method == 'POST': + if self.forced_provider: + provider = self.forced_provider + else: + provider = kwargs['login_name'] + + context['username'] = provider + openid_ses = {} + oidconsumer = consumer.Consumer(openid_ses, self.lm.datastore) + try: + request = oidconsumer.begin(provider) + except consumer.DiscoveryFailure as exc: + cherrypy.log.error('Failed discovery for %s: %s' % + (provider, exc), + severity=logging.WARN) + return self._template('login/openid.html', + error_username=True, + error='Unable to discover OpenID', + forced_provider=self.forced_provider, + **context) + + if request is None: + cherrypy.log.error('No OpenID request generated') + return self._template('login/openid.html', + error_username=True, + error='Unable to initiate OpenID', + forced_provider=self.forced_provider, + **context) + + request.addExtension(sreg.SRegRequest( + optional=['nickname', 'fullname', 'email', 'timezone'])) + request.addExtension(pape.Request([])) + + ax_req = ax.FetchRequest() + for prop in AP_MAP.keys(): + count = 1 + if prop in multival_type_uris: + count = 'unlimited' + ax_req.add(ax.AttrInfo(type_uri=prop, count=count)) + request.addExtension(ax_req) + + trust_root = cherrypy.url('/') + return_to = '%s/login/openid/response/?%s' % ( + cherrypy.url('/'), self.trans.get_GET_arg()) + + # Store the OpenID Session in the transaction + self.trans.store({'openid_ses': pickle.dumps(openid_ses)}) + + if request.shouldSendRedirect(): + redirect_url = request.redirectURL(trust_root, return_to, + False) + raise cherrypy.HTTPRedirect(redirect_url) + else: + return request.htmlMarkup( + trust_root, return_to, + form_tag_attrs={'id': 'openid_mesage'}, immediate=False) + + context['username'] = self.forced_provider or '' + return self._template('login/openid.html', + forced_provider=self.forced_provider, + **context) + + def response(self, *args, **kwargs): + context = self.create_tmpl_context() + + self.trans = self.get_valid_transaction('login', **kwargs) + openid_ses = pickle.loads(self.trans.retrieve().get('openid_ses')) + oidconsumer = consumer.Consumer(openid_ses, self.lm.datastore) + return_to = '%s/login/openid/response/?%s' % ( + cherrypy.url('/'), self.trans.get_GET_arg()) + # We need to make sure the transaction ID does not get passed on + info = oidconsumer.complete(kwargs, return_to) + display_identifier = info.getDisplayIdentifier() + + if info.status == consumer.FAILURE and display_identifier: + cherrypy.log.error("OpenID auth failure: %s for %s" % + (info.message, display_identifier), + severity=logging.INFO) + return self._template('login/openid.html', + error='Unable to verify OpenID %s' % + display_identifier, + **context) + elif info.status == consumer.CANCEL: + cherrypy.log.error("OpenID auth cancelled", severity=logging.INFO) + return self._template('login/openid.html', + error='Login cancelled', + **context) + elif info.status == consumer.FAILURE: + cherrypy.log.error("OpenID auth failure: %s" % info.message, + severity=logging.INFO) + return self._template('login/openid.html', + error='Unable to verify OpenID', + **context) + elif info.status == consumer.SUCCESS: + if self.forced_provider: + if self.forced_provider != info.endpoint.server_url: + cherrypy.log.error('Claim received for invalid provider:' + ' %s' % info.endpoint.server_url) + return self._template('login/openid.html', + error='Invalid provider!', + error_username=True, + **context) + + userdata = self.make_userdata(info) + return self.lm.auth_successful(self.trans, + info.getDisplayIdentifier(), + userdata=userdata) + else: + return 'Strange state: %s' % info.status + response.exposed = True + + def make_userdata(self, openid_response): + sreg_resp = sreg.SRegResponse.fromSuccessResponse(openid_response) + ax_resp = ax.FetchResponse.fromSuccessResponse(openid_response) + + user_info = {} + + if sreg_resp: + user_info['username'] = sreg_resp.get('nickname') + user_info['fullname'] = sreg_resp.get('fullname') + user_info['email'] = sreg_resp.get('email') + user_info['timezone'] = sreg_resp.get('timezone') + + if ax_resp: + for type_uri in AP_MAP: + value = ax_resp.get(type_uri) + if value: + key = AP_MAP[type_uri] + user_info[key] = value + + userattrs, extras = self.mapper.map_attributes(user_info) + userattrs['_extras'] = {'openid': extras} + + return userattrs + + +class LoginManager(LoginManagerBase): + + def __init__(self, *args, **kwargs): + super(LoginManager, self).__init__(*args, **kwargs) + self.name = 'openid' + self.path = 'openid' + self.page = None + self.service_name = 'openid' + self.datastore = None + self.description = """ +Login provider using OpenID +""" + self.new_config( + self.name, + pconfig.String( + 'database url', + 'Database URL for OpenID temp storage', + 'openid.sqlite'), + pconfig.String( + 'username text', + 'Text used to ask for the identity URL at login time.', + 'OpenID Identity'), + pconfig.String( + 'help text', + 'Text used to guide the user at login time.', + 'Enter your OpenID identity') + ) + + def used_datastores(self): + return [self.datastore] + + @property + def help_text(self): + return self.get_config_value('help text') + + @property + def username_text(self): + return self.get_config_value('username text') + + def get_tree(self, site): + self.page = OpenID(site, self, 'login/openid') + return self.page + + def on_enable(self): + super(LoginManager, self).on_enable() + self.datastore = OpenIDStore(self.get_config_value('database url')) + + +class Installer(LoginManagerInstaller): + + def __init__(self, *pargs): + super(Installer, self).__init__() + self.name = 'openid' + self.pargs = pargs + + def install_args(self, group): + group.add_argument('--login-openid', choices=['yes', 'no'], + default='no', help='Configure OpenID login') + + def configure(self, opts, changes): + if opts['login_openid'] != 'yes': + return + + # Add configuration data to database + po = PluginObject(*self.pargs) + po.name = 'openid' + po.wipe_data() + po.wipe_config_values() + # We reuse the same database used by the openid provider side of + # things. This will not cause any issues, and will result in less + # files on the disk. + config = {'database url': opts['openid_dburi'] or + opts['database_url'] % { + 'datadir': opts['data_dir'], 'dbname': 'openid'}} + po.save_plugin_config(config) + + # Update global config to add login plugin + po.is_enabled = True + po.save_enabled_state() diff --git a/templates/login/openid.html b/templates/login/openid.html new file mode 100644 index 0000000..96ec629 --- /dev/null +++ b/templates/login/openid.html @@ -0,0 +1,56 @@ +{% extends "master.html" %} +{% block main %} + +{% if error %} +
+
+

{{ error }}

+
+
+ +{% endif %} + +{% if login_target %} +
+

You are being asked to login by {{login_target}}

+
+
+{% endif %} + + +
+

{{description}}

+{% if other_stacks %} +
+

Other authentication methods: +

+

+{% endif %} +
+ +{% endblock %} diff --git a/tests/helpers/http.py b/tests/helpers/http.py index a385353..2bb6a32 100755 --- a/tests/helpers/http.py +++ b/tests/helpers/http.py @@ -145,7 +145,8 @@ class HttpSessions(object): # replace known values payload['login_name'] = srv['user'] - payload['login_password'] = srv['pwd'] + if 'pwd' in srv: + payload['login_password'] = srv['pwd'] return [method, self.new_url(referer, action_url), {'headers': headers, 'data': payload}] diff --git a/tests/openid.py b/tests/openid.py index a695096..4cafbd2 100755 --- a/tests/openid.py +++ b/tests/openid.py @@ -32,6 +32,18 @@ idp_a = {'hostname': '${ADDRESS}:${PORT}', 'ipa': 'no', 'server_debugging': 'True'} +idp_b = {'hostname': '${ADDRESS}:${PORT}', + 'admin_user': '${TEST_USER}', + 'system_user': '${TEST_USER}', + 'instance': '${NAME}', + 'secure': 'no', + 'testauth': 'no', + 'login_openid': 'yes', + 'pam': 'no', + 'gssapi': 'no', + 'ipa': 'no', + 'server_debugging': 'True'} + def fixup_sp_httpd(httpdir, testdir): client_wsgi = """ @@ -60,20 +72,30 @@ class IpsilonTest(IpsilonTestBase): super(IpsilonTest, self).__init__('openid', __file__) def setup_servers(self, env=None): - print "Installing IDP server" + print "Installing IDP 1 server" name = 'idp1' addr = '127.0.0.10' port = '45080' - idp = self.generate_profile(idp_g, idp_a, name, addr, port) - conf = self.setup_idp_server(idp, name, addr, port, env) + idp1 = self.generate_profile(idp_g, idp_a, name, addr, port) + conf = self.setup_idp_server(idp1, name, addr, port, env) - print "Starting IDP's httpd server" + print "Starting IDP 1's httpd server" self.start_http_server(conf, env) - print "Installing first SP server" - name = 'sp1' + print "Installing IDP 2 server" + name = 'idp2' addr = '127.0.0.11' port = '45081' + idp2 = self.generate_profile(idp_g, idp_b, name, addr, port) + conf = self.setup_idp_server(idp2, name, addr, port, env) + + print "Starting IDP 2's httpd server" + self.start_http_server(conf, env) + + print "Installing first SP server" + name = 'sp1' + addr = '127.0.0.12' + port = '45082' conf = self.setup_http(name, addr, port) testdir = os.path.dirname(os.path.abspath(inspect.getfile( inspect.currentframe()))) @@ -85,17 +107,20 @@ class IpsilonTest(IpsilonTestBase): if __name__ == '__main__': - idpname = 'idp1' + idp1name = 'idp1' + idp2name = 'idp2' sp1name = 'sp1' user = pwd.getpwuid(os.getuid())[0] sess = HttpSessions() - sess.add_server(idpname, 'https://127.0.0.10:45080', user, 'ipsilon') - sess.add_server(sp1name, 'https://127.0.0.11:45081') + sess.add_server(idp1name, 'https://127.0.0.10:45080', user, 'ipsilon') + sess.add_server(idp2name, 'https://127.0.0.11:45081', + 'http://127.0.0.10:45080/idp1/openid/') + sess.add_server(sp1name, 'https://127.0.0.12:45082') print "openid: Authenticate to IDP ...", try: - sess.auth_to_idp(idpname) + sess.auth_to_idp(idp1name) except Exception as e: # pylint: disable=broad-except print >> sys.stderr, " ERROR: %s" % repr(e) sys.exit(1) @@ -103,8 +128,8 @@ if __name__ == '__main__': print "openid: Run OpenID Protocol ...", try: - page = sess.fetch_page(idpname, - 'https://127.0.0.11:45081/?extensions=NO') + page = sess.fetch_page(idp1name, + 'https://127.0.0.12:45082/?extensions=NO') page.expected_value('text()', 'SUCCESS, WITHOUT EXTENSIONS') except ValueError as e: print >> sys.stderr, " ERROR: %s" % repr(e) @@ -113,10 +138,23 @@ if __name__ == '__main__': print "openid: Run OpenID Protocol with extensions ...", try: - page = sess.fetch_page(idpname, - 'https://127.0.0.11:45081/?extensions=YES') + page = sess.fetch_page(idp1name, + 'https://127.0.0.12:45082/?extensions=YES') page.expected_value('text()', 'SUCCESS, WITH EXTENSIONS') except ValueError as e: print >> sys.stderr, " ERROR: %s" % repr(e) sys.exit(1) print " SUCCESS" + + print "openid: Run nested OpenID Protocol ...", + try: + page = sess.fetch_page(idp2name, + 'http://127.0.0.11:45081/idp2/login/') + page.expected_value('//div[@id="welcome"]/p/text()', + 'Welcome http://127.0.0.10:45080/idp1/' + 'openid/id/%s/!' + % user) + except Exception as e: # pylint: disable=broad-except + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS"