From 9436a3e5f79b0dc269630c522ee9fb1af8e2d41c Mon Sep 17 00:00:00 2001 From: Howard Johnson Date: Oct 19 2016 21:00:48 +0000 Subject: [PATCH 1/5] When saving options, remove options from the db that no longer exist Signed-off-by: Howard Johnson --- diff --git a/ipsilon/util/data.py b/ipsilon/util/data.py index 5a06093..516cb6f 100644 --- a/ipsilon/util/data.py +++ b/ipsilon/util/data.py @@ -544,6 +544,10 @@ class Store(Log): else: q.insert((name, opt, options[opt])) + for opt in curvals: + if opt not in options: + q.delete({'name': name, 'option': opt}) + q.commit() except Exception, e: # pylint: disable=broad-except if q: From c910922e3f3d1706db50a05b793acee0687a091d Mon Sep 17 00:00:00 2001 From: Howard Johnson Date: Oct 20 2016 22:49:30 +0000 Subject: [PATCH 2/5] Add consent system Add a consent system for recording whether a user has granted access consent to a client/SP. This tracks which clients consent has been granted to, and what user attributes were provided. Signed-off-by: Howard Johnson --- diff --git a/ipsilon/util/data.py b/ipsilon/util/data.py index 516cb6f..cb20860 100644 --- a/ipsilon/util/data.py +++ b/ipsilon/util/data.py @@ -733,11 +733,78 @@ class UserStore(Store): def load_plugin_data(self, plugin, user): return self.load_options(plugin+"_data", user) - def _initialize_schema(self): - q = self._query(self._db, 'users', OPTIONS_TABLE, trans=False) + def _cons_key(self, provider, clientid): + return '%s-%s' % (provider, clientid) + + def _split_cons_key(self, key): + return key.split('-', 1) + + def store_consent(self, user, provider, clientid, parameters): + q = None + try: + key = self._cons_key(provider, clientid) + q = self._query(self._db, 'user_consent', OPTIONS_TABLE) + rows = q.select({'name': user, 'option': key}, ['value']) + if len(list(rows)) > 0: + q.update({'value': parameters}, {'name': user, 'option': key}) + else: + q.insert((user, key, parameters)) + q.commit() + except Exception, e: # pylint: disable=broad-except + if q: + q.rollback() + self.error('Failed to store consent: [%s]' % e) + raise + + def delete_consent(self, user, provider, clientid): + q = None + try: + q = self._query(self._db, 'user_consent', OPTIONS_TABLE) + q.delete({'name': user, + 'option': self._cons_key(provider, clientid)}) + q.commit() + except Exception, e: # pylint: disable=broad-except + if q: + q.rollback() + self.error('Failed to delete consent: [%s]' % e) + raise + + def get_consent(self, user, provider, clientid): + try: + q = self._query(self._db, 'user_consent', OPTIONS_TABLE) + rows = q.select({'name': user, + 'option': self._cons_key(provider, clientid)}, + ['value']) + data = list(rows) + if len(data) > 0: + return data[0][0] + else: + return None + except Exception, e: # pylint: disable=broad-except + self.error('Failed to get consent: [%s]' % e) + return None + + def get_all_consents(self, user): + d = [] + try: + q = self._query(self._db, 'user_consent', OPTIONS_TABLE) + rows = q.select({'name': user}, ['option', 'value']) + for r in rows: + prov, clientid = self._split_cons_key(r[0]) + d.append((prov, clientid, r[1])) + except Exception, e: # pylint: disable=broad-except + self.error('Failed to get consents: [%s]' % e) + return d + + def _initialize_table(self, tablename): + q = self._query(self._db, tablename, OPTIONS_TABLE, trans=False) q.create() q._con.close() # pylint: disable=protected-access + def _initialize_schema(self): + self._initialize_table('users') + self._initialize_table('user_consent') + def _upgrade_schema(self, old_version): if old_version == 1: # In schema version 2, we added indexes and primary keys @@ -755,11 +822,7 @@ class UserStore(Store): def create_plugin_data_table(self, plugin_name): if not self.is_readonly: - table = plugin_name+'_data' - q = self._query(self._db, table, OPTIONS_TABLE, - trans=False) - q.create() - q._con.close() # pylint: disable=protected-access + self._initialize_table(plugin_name + '_data') class TranStore(Store): diff --git a/ipsilon/util/user.py b/ipsilon/util/user.py index 1158d31..1b7b9af 100644 --- a/ipsilon/util/user.py +++ b/ipsilon/util/user.py @@ -4,6 +4,7 @@ from ipsilon.util.data import UserStore from ipsilon.util.log import Log import cherrypy import logging +import json class Site(object): @@ -90,6 +91,36 @@ class User(object): store = UserStore() return store.load_plugin_data(plugin, self.name) + def grant_consent(self, provider, clientid, parameters): + store = UserStore() + store.store_consent(self.name, provider, clientid, + json.dumps(parameters)) + + def revoke_consent(self, provider, clientid): + store = UserStore() + store.delete_consent(self.name, provider, clientid) + + def get_consent(self, provider, clientid): + store = UserStore() + data = store.get_consent(self.name, provider, clientid) + if data is not None: + return json.loads(data) + return None + + def list_consents(self, provider=None): + store = UserStore() + d = [] + for prov, clientid, parameters in store.get_all_consents(self.name): + if provider is not None: + if prov != provider: + continue + d.append({ + 'provider': prov, + 'client': clientid, + 'attrs': json.loads(parameters) + }) + return d + class UserSession(Log): def __init__(self): diff --git a/quickrun.py b/quickrun.py index 5d73081..847f6c6 100755 --- a/quickrun.py +++ b/quickrun.py @@ -48,6 +48,7 @@ INSERT INTO authz_config VALUES('global', 'enabled', 'allow'); USERS_TEMPLATE=''' CREATE TABLE users(name TEXT, option TEXT, value TEXT); INSERT INTO users VALUES('admin', 'is_admin', '1'); +CREATE TABLE user_consent(name TEXT, option TEXT, value TEXT); ''' def config(workdir): From 4dbe6066ae02c9dd4191d27ff4b1c0656b7a66ef Mon Sep 17 00:00:00 2001 From: Howard Johnson Date: Oct 20 2016 22:50:18 +0000 Subject: [PATCH 3/5] Plumb openidc into the consent system Signed-off-by: Howard Johnson --- diff --git a/ipsilon/providers/openidc/auth.py b/ipsilon/providers/openidc/auth.py index b749b60..ce70ae2 100644 --- a/ipsilon/providers/openidc/auth.py +++ b/ipsilon/providers/openidc/auth.py @@ -530,6 +530,24 @@ class Continue(AuthenticateRequest): h = hashlib.sha256(msg.encode()).digest() return base64.urlsafe_b64encode(h[:16]).rstrip(b'=').decode() + def _valid_claims(self, claims, userattrs): + d = [] + for claimtype in claims: + for claim in claims[claimtype]: + if claim in userattrs: + d.append(claim) + return sorted(d) + + def _valid_scopes(self, scopes): + d = [] + for dummy_n, e in self.cfg.extensions.available().items(): + if e.enabled: + extscopes = e.get_scopes() + for scope in scopes: + if scope in extscopes: + d.append(scope) + return sorted(d) + def _perform_continue(self, *args, **kwargs): us = UserSession() user = us.get_user() @@ -571,6 +589,27 @@ class Continue(AuthenticateRequest): user, userattrs) + consentdata = user.get_consent('openidc', request_data['client_id']) + if consentdata is not None: + # Consent has already been granted + self.debug('Consent already granted') + + consclaimset = set(consentdata['claims']) + claimset = set(self._valid_claims(request_data['claims'], + userattrs)) + consscopeset = set(consentdata['scopes']) + scopeset = set(self._valid_scopes(request_data['scope'])) + + if claimset.issubset(consclaimset) and \ + scopeset.issubset(consscopeset): + return self._respond_success(request_data, + client, + user, + userattrs) + else: + self.debug('Client is asking for new claims or scopes, user ' + 'must give consent again') + if 'none' in request_data['prompt']: # We were asked to not show any UI return self._respond_error(request_data, @@ -582,6 +621,17 @@ class Continue(AuthenticateRequest): # The user has been shown the form, let's process his choice if 'decided_allow' in kwargs: # User allowed the request + + # Record the consent for the future, including the list of + # claims that the user was informed of at the time. + consentdata = { + 'claims': self._valid_claims(request_data['claims'], + userattrs), + 'scopes': self._valid_scopes(request_data['scope']) + } + user.grant_consent('openidc', request_data['client_id'], + consentdata) + return self._respond_success(request_data, client, user, @@ -599,7 +649,6 @@ class Continue(AuthenticateRequest): 'openidc_request': json.dumps(request_data)} self.trans.store(data) - userattrs = self._source_attributes(us) claim_requests = {} for claimtype in request_data['claims']: for claim in request_data['claims'][claimtype]: From f661a370d2c7b2305147bd0a5e6bc0d95c191214 Mon Sep 17 00:00:00 2001 From: Howard Johnson Date: Oct 22 2016 17:27:46 +0000 Subject: [PATCH 4/5] Add a user self-service portal for managing consent Signed-off-by: Howard Johnson --- diff --git a/ipsilon/providers/common.py b/ipsilon/providers/common.py index b48c301..5ec369b 100644 --- a/ipsilon/providers/common.py +++ b/ipsilon/providers/common.py @@ -48,10 +48,11 @@ class UnauthorizedRequest(ProviderException): class ProviderBase(ConfigHelper, PluginObject): - def __init__(self, name, path, *pargs): + def __init__(self, name, displayname, path, *pargs): ConfigHelper.__init__(self) PluginObject.__init__(self, *pargs) self.name = name + self.displayname = displayname self._root = None self.path = path self.tree = None @@ -75,6 +76,15 @@ class ProviderBase(ConfigHelper, PluginObject): def get_providers(self): return [] + def get_display_name(self): + return self.displayname + + def get_client_display_name(self, clientid): + raise NotImplementedError + + def consent_to_display(self, consentdata): + raise NotImplementedError + class ProviderPageBase(Page): diff --git a/ipsilon/providers/openidcp.py b/ipsilon/providers/openidcp.py index ee6bb6f..54fd06c 100644 --- a/ipsilon/providers/openidcp.py +++ b/ipsilon/providers/openidcp.py @@ -21,7 +21,8 @@ import uuid class IdpProvider(ProviderBase): def __init__(self, *pargs): - super(IdpProvider, self).__init__('openidc', 'openidc', *pargs) + super(IdpProvider, self).__init__('openidc', 'OpenID Connect', + 'openidc', *pargs) self.mapping = InfoMapping() self.keyset = None self.admin = None @@ -202,6 +203,26 @@ Provides OpenID Connect authentication infrastructure. """ 'http://openid.net/specs/connect/1.0/issuer' ) + def get_client_display_name(self, clientid): + return self.datastore.getClient(clientid)['client_name'] + + def consent_to_display(self, consentdata): + d = [] + + if len(consentdata['scopes']) > 0: + scopes = [] + for dummy_n, e in self.extensions.available().items(): + data = e.get_display_data(consentdata['scopes']) + if len(data) > 0: + scopes.append(e.get_display_name()) + d.append('Scopes: %s' % ', '.join(sorted(scopes))) + + if len(consentdata['claims']) > 0: + d.append('Claims: %s' % ', '.join([self.mapping.display_name(x) for + x in consentdata['claims']])) + + return d + class Installer(ProviderInstaller): diff --git a/ipsilon/providers/openidp.py b/ipsilon/providers/openidp.py index 5981663..3016656 100644 --- a/ipsilon/providers/openidp.py +++ b/ipsilon/providers/openidp.py @@ -16,7 +16,7 @@ from openid.server.server import Server class IdpProvider(ProviderBase): def __init__(self, *pargs): - super(IdpProvider, self).__init__('openid', 'openid', *pargs) + super(IdpProvider, self).__init__('openid', 'OpenID', 'openid', *pargs) self.mapping = InfoMapping() self.page = None self.datastore = None @@ -135,6 +135,12 @@ Provides OpenID 2.0 authentication infrastructure. """ self.init_idp() self.extensions.enable(self._config['enabled extensions'].get_value()) + def get_client_display_name(self, clientid): + return clientid + + def consent_to_display(self, consentdata): + return [] + class Installer(ProviderInstaller): diff --git a/ipsilon/providers/personaidp.py b/ipsilon/providers/personaidp.py index 54d614e..f44f701 100644 --- a/ipsilon/providers/personaidp.py +++ b/ipsilon/providers/personaidp.py @@ -17,7 +17,8 @@ import os class IdpProvider(ProviderBase): def __init__(self, *pargs): - super(IdpProvider, self).__init__('persona', 'persona', *pargs) + super(IdpProvider, self).__init__('persona', 'Persona', 'persona', + *pargs) self.mapping = InfoMapping() self.page = None self.basepath = None @@ -72,6 +73,12 @@ Provides Persona authentication infrastructure. """ super(IdpProvider, self).on_enable() self.init_idp() + def get_client_display_name(self, clientid): + return clientid + + def consent_to_display(self, consentdata): + return [] + class Installer(ProviderInstaller): diff --git a/ipsilon/providers/saml2idp.py b/ipsilon/providers/saml2idp.py index 5322582..bc0c548 100644 --- a/ipsilon/providers/saml2idp.py +++ b/ipsilon/providers/saml2idp.py @@ -217,7 +217,7 @@ class SAML2(ProviderPageBase): class IdpProvider(ProviderBase): def __init__(self, *pargs): - super(IdpProvider, self).__init__('saml2', 'saml2', *pargs) + super(IdpProvider, self).__init__('saml2', 'SAML 2.0', 'saml2', *pargs) self.admin = None self.rest = None self.page = None @@ -456,6 +456,12 @@ Provides SAML 2.0 authentication infrastructure. """ self.debug('Sending initial logout request to %s' % logout.msgUrl) raise cherrypy.HTTPRedirect(logout.msgUrl) + def get_client_display_name(self, clientid): + return clientid + + def consent_to_display(self, consentdata): + return [] + class IdpMetadataGenerator(object): diff --git a/ipsilon/root.py b/ipsilon/root.py index 2df2236..f2b3b2b 100644 --- a/ipsilon/root.py +++ b/ipsilon/root.py @@ -14,6 +14,7 @@ from ipsilon.admin.providers import ProviderPlugins from ipsilon.admin.authz import AuthzPlugins from ipsilon.rest.common import Rest from ipsilon.rest.providers import RestProviderPlugins +from ipsilon.user.common import UserPortal from ipsilon.authz.common import Authz import cherrypy @@ -52,6 +53,7 @@ class Root(Page): self.admin = Admin(self._site, 'admin') self.rest = Rest(self._site, 'rest') self.stack = LoginStack(self._site, self.admin) + self.portal = UserPortal(self._site, 'portal') LoginPlugins(self._site, self.stack) InfoPlugins(self._site, self.stack) AuthzPlugins(self._site, self.stack) diff --git a/ipsilon/user/__init__.py b/ipsilon/user/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ipsilon/user/__init__.py diff --git a/ipsilon/user/common.py b/ipsilon/user/common.py new file mode 100644 index 0000000..82cc4b7 --- /dev/null +++ b/ipsilon/user/common.py @@ -0,0 +1,62 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +from ipsilon.util.page import Page +from ipsilon.util.user import UserSession +import cherrypy + + +class UserPortalPage(Page): + def __init__(self, *args, **kwargs): + super(UserPortalPage, self).__init__(*args, **kwargs) + self.auth_protect = True + + +class UserPortalConsent(UserPortalPage): + def __init__(self, site, parent, mount): + super(UserPortalConsent, self).__init__(site) + self._master = parent + self.title = 'User portal consent' + self.url = '%s/%s' % (parent.url, 'consent') + self.menu = [self] + + def revoke(self, provider, clientid): + us = UserSession() + user = us.get_user() + user.revoke_consent(provider, clientid) + raise cherrypy.HTTPRedirect(self._master.url) + revoke.public_function = True + + +class UserPortal(UserPortalPage): + def __init__(self, site, mount): + super(UserPortal, self).__init__(site) + self.title = 'User portal' + self.url = '%s/%s' % (self.basepath, mount) + self.menu = [self] + self.consent = UserPortalConsent(site, self, 'consent') + + def root(self, *args, **kwargs): + us = UserSession() + user = us.get_user() + consents = user.list_consents() + + for consent in consents: + provname = consent['provider'] + provider = self._site['provider_config'].available.get(provname, + None) + + if provider is not None: + consent['providerdn'] = provider.get_display_name() + consent['clientdn'] = provider.\ + get_client_display_name(consent['client']) + attrs = provider.consent_to_display(consent['attrs']) + else: + self.debug('Consent relates to unknown provider %s' % provname) + attrs = [] + consent['attrs'] = attrs + + return self._template('user/index.html', + title='', + baseurl=self.url, + menu=self.menu, + consents=consents) diff --git a/templates/admin/providers.html b/templates/admin/providers.html index a0ca765..9f0e134 100644 --- a/templates/admin/providers.html +++ b/templates/admin/providers.html @@ -37,7 +37,7 @@ {%- endif %}
-

{{ p }}

+

{{ available[p].get_display_name() }}

diff --git a/templates/admin/providers/openidc.html b/templates/admin/providers/openidc.html index c027c62..72d7297 100644 --- a/templates/admin/providers/openidc.html +++ b/templates/admin/providers/openidc.html @@ -14,7 +14,7 @@ {{ cid }}
- {{ cid }} + {{ clients[cid]['client_name'] }} Delete
diff --git a/templates/index.html b/templates/index.html index 5215b3b..62e36f2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -31,6 +31,10 @@ diff --git a/templates/master-admin.html b/templates/master-admin.html index 849c81f..9885036 100644 --- a/templates/master-admin.html +++ b/templates/master-admin.html @@ -51,6 +51,10 @@ diff --git a/templates/master-portal.html b/templates/master-portal.html new file mode 100644 index 0000000..cb41d2e --- /dev/null +++ b/templates/master-portal.html @@ -0,0 +1,55 @@ + + + + {{ title }} + + + + + + + + + {% block main %} + {% endblock %} + + + + + + + + diff --git a/templates/user/index.html b/templates/user/index.html new file mode 100644 index 0000000..a235a22 --- /dev/null +++ b/templates/user/index.html @@ -0,0 +1,48 @@ +{% extends "master-portal.html" %} +{% block main %} +
+
+
+

Granted Consent

+
+
+

Provider

+
+
+

Client

+
+
+

Consent Data

+
+
+
+
+{%- for item in consents %} +
+
+

{{ item.providerdn }}

+
+
+

{{ item.clientdn }}

+
+
+{%- for attr in item.attrs %} +

{{ attr }}

+{%- endfor %} +
+
+ Revoke +
+
+{%- endfor %} +
+
+ +
+{% endblock %} From de2260929d8b0564c621d9c35623d01821139c8d Mon Sep 17 00:00:00 2001 From: Howard Johnson Date: Oct 22 2016 23:16:29 +0000 Subject: [PATCH 5/5] Add test case for openidc consent Signed-off-by: Howard Johnson Signed-off-by: Patrick Uiterwijk --- diff --git a/tests/helpers/http.py b/tests/helpers/http.py index d17de7e..210ecfe 100755 --- a/tests/helpers/http.py +++ b/tests/helpers/http.py @@ -268,7 +268,8 @@ class HttpSessions(object): return ['post', url, {'data': params}] - def fetch_page(self, idp, target_url, follow_redirect=True, krb=False): + def fetch_page(self, idp, target_url, follow_redirect=True, krb=False, + require_consent=None): """ Fetch a page and parse the response code to determine what to do next. @@ -276,10 +277,15 @@ class HttpSessions(object): The login process consists of redirections (302/303) and potentially an unauthorized (401). For the case of unauthorized try the page returned in case of fallback authentication. + + require_consent indicates whether consent should or should not be asked + or if that's not in this test. None means not tested, False means must + not be asked, True means must be asked. """ url = target_url action = 'get' args = {} + seen_consent = False while True: r = self.access(action, url, krb=krb, **args) @@ -317,12 +323,14 @@ class HttpSessions(object): try: (action, url, args) = self.handle_openid_consent_form(page) + seen_consent = True continue except WrongPage: pass try: (action, url, args) = self.handle_openid_form(page) + seen_consent = True continue except WrongPage: pass @@ -334,6 +342,13 @@ class HttpSessions(object): pass # Either we got what we wanted, or we have to stop anyway + if (not seen_consent) and require_consent: + raise ValueError('IDP did not present consent page, but ' + 'consent is required.') + elif seen_consent and (require_consent is False): + raise ValueError('IDP presented consent page, but ' + 'consent is disallowed.') + return page else: raise ValueError("Unhandled status (%d) on url %s" % ( @@ -574,6 +589,31 @@ class HttpSessions(object): if client_id in r.text: raise ValueError('Client was not gone after deletion') + def revoke_oidc_consent(self, idp): + """ + Revoke user's consent for all OpenIDC clients. + """ + idpsrv = self.servers[idp] + idpuri = idpsrv['baseuri'] + + url = '%s%s/portal' % (idpuri, self.get_idp_uri(idp)) + headers = {'referer': url} + r = idpsrv['session'].get(url, headers=headers) + if r.status_code != 200: + ValueError('Failed to load user portal [%s]' % repr(r)) + page = PageTree(r) + + revbtns = page.all_values('//a[starts-with(@id, "revoke-")]') + + for btn in revbtns: + url = '%s%s' % (idpuri, btn.get('href')) + headers = {'referer': url} + headers['content-type'] = 'application/x-www-form-urlencoded' + + r = idpsrv['session'].get(url, headers=headers) + if btn.get('id') in r.text: + raise ValueError('Client was not gone after revoke') + def fetch_rest_page(self, idpname, uri): """ idpname - the name of the IDP to fetch the page from diff --git a/tests/openidc.py b/tests/openidc.py index a7c350d..87a3381 100755 --- a/tests/openidc.py +++ b/tests/openidc.py @@ -245,7 +245,8 @@ if __name__ == '__main__': print "openidc: Access first SP Protected Area ...", try: - page = sess.fetch_page(idpname, 'https://127.0.0.11:45081/sp/') + page = sess.fetch_page(idpname, 'https://127.0.0.11:45081/sp/', + require_consent=True) h = hashlib.sha256() h.update('127.0.0.11') h.update(user) @@ -262,6 +263,37 @@ if __name__ == '__main__': sys.exit(1) print " SUCCESS" + print "openidc: Log back in to first SP Protected Area without consent" \ + " ...", + try: + page = sess.fetch_page(idpname, + 'https://127.0.0.11:45081/sp/redirect_uri?log' + 'out=https%3A%2F%2F127.0.0.11%3A45081%2Fsp%2F', + require_consent=False) + except ValueError, e: + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "openidc: Revoking SP consent ...", + try: + page = sess.revoke_oidc_consent(idpname) + except ValueError, e: + print >> sys.stderr, "" % repr(e) + sys.exit(1) + print " SUCCESS" + + print "openidc: Log back in to first SP Protected Area with consent ...", + try: + page = sess.fetch_page(idpname, + 'https://127.0.0.11:45081/sp/redirect_uri?log' + 'out=https%3A%2F%2F127.0.0.11%3A45081%2Fsp%2F', + require_consent=True) + except ValueError, e: + print >> sys.stderr, " ERROR: %s" % repr(e) + sys.exit(1) + print " SUCCESS" + print "openidc: Update first SP client name ...", try: sess.update_options(