From cf9eea4dc63a96c844e71d902f831ba2b6ed1dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Tue, 10 Jan 2017 10:52:40 +0100 Subject: [PATCH] general: allow marking form as required a given authentication context (#13177) --- tests/test_admin_pages.py | 33 +++++++++++++++++++++++++++++++++ tests/test_api.py | 5 +++++ tests/test_form_pages.py | 37 +++++++++++++++++++++++++++++++++++-- tests/test_formdef_import.py | 8 ++++++++ wcs/admin/forms.py | 9 +++++++++ wcs/api.py | 2 ++ wcs/backoffice/submission.py | 3 +++ wcs/formdef.py | 19 +++++++++++++++++++ wcs/forms/root.py | 28 +++++++++++++++++++++++++++- wcs/qommon/publisher.py | 28 ++++++++++++++++++++++++++++ wcs/qommon/sessions.py | 7 +++++++ 11 files changed, 176 insertions(+), 3 deletions(-) diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py index 69c66ffc..b31ec653 100644 --- a/tests/test_admin_pages.py +++ b/tests/test_admin_pages.py @@ -477,6 +477,39 @@ def test_form_workflow_remapping(pub): assert data_class.get(formdata1.id).status == 'wf-finished' assert data_class.get(formdata2.id).status == 'draft' +def test_form_submitter_roles(pub): + create_superuser(pub) + role = create_role() + + FormDef.wipe() + formdef = FormDef() + formdef.name = 'form title' + formdef.fields = [] + formdef.store() + + app = login(get_app(pub)) + resp = app.get('/backoffice/forms/1/') + resp = resp.click(href=re.compile('^roles$')) + resp.form['roles$element0'] = 'logged-users' + assert not 'required_authentication_contexts' in resp.body + resp = resp.form.submit() + assert FormDef.get(formdef.id).roles == ['logged-users'] + + # add auth contexts support + if not pub.site_options.has_section('options'): + pub.site_options.add_section('options') + pub.site_options.set('options', 'auth-contexts', 'fedict') + with open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w') as fd: + pub.site_options.write(fd) + + app = login(get_app(pub)) + resp = app.get('/backoffice/forms/1/') + resp = resp.click(href=re.compile('^roles$')) + assert 'required_authentication_contexts' in resp.body + resp.form['required_authentication_contexts$element0'].checked = True + resp = resp.form.submit() + assert FormDef.get(formdef.id).required_authentication_contexts == ['fedict'] + def test_form_workflow_role(pub): create_superuser(pub) role = create_role() diff --git a/tests/test_api.py b/tests/test_api.py index d90b5b0b..0718fa70 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -335,6 +335,11 @@ def test_limited_formdef_list(pub, local_user): assert resp.json[0]['authentication_required'] assert resp.json == resp2.json == resp3.json == resp4.json + formdef.required_authentication_contexts = ['fedict'] + formdef.store() + resp = get_app(pub).get('/api/formdefs/') + assert resp.json[0]['required_authentication_contexts'] == ['fedict'] + def test_formdef_list_redirection(pub): FormDef.wipe() formdef = FormDef() diff --git a/tests/test_form_pages.py b/tests/test_form_pages.py index 226cde89..95765838 100644 --- a/tests/test_form_pages.py +++ b/tests/test_form_pages.py @@ -325,10 +325,10 @@ def test_form_access(pub): login(get_app(pub), username='foo', password='foo').get('/test/', status=200) # check special "logged users" role - formdef.roles = [logged_users_role()] + formdef.roles = [logged_users_role().id] formdef.store() user = create_user(pub) - login(get_app(pub), username='foo', password='foo').get('/test/', status=403) + login(get_app(pub), username='foo', password='foo').get('/test/', status=200) resp = get_app(pub).get('/test/', status=302) # redirect to login # check "receiver" can also access the formdef @@ -341,6 +341,39 @@ def test_form_access(pub): user.store() login(get_app(pub), username='foo', password='foo').get('/test/', status=200) +def test_form_access_auth_context(pub): + user = create_user(pub) + + if not pub.site_options.has_section('options'): + pub.site_options.add_section('options') + pub.site_options.set('options', 'auth-contexts', 'fedict') + with open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w') as fd: + pub.site_options.write(fd) + + formdef = create_formdef() + get_app(pub).get('/test/', status=200) + + formdef.required_authentication_contexts = ['fedict'] + formdef.roles = [logged_users_role().id] + formdef.store() + + # an unlogged user will get a redirect to login + resp = get_app(pub).get('/test/', status=302) + assert '/login' in resp.location + + # a user logged in with a simple username/password tuple will get a page + # to relogin with a stronger auth + app = login(get_app(pub), username='foo', password='foo') + resp = app.get('/test/') + assert 'You need a stronger authentication level to fill this form.' in resp.body + + for session in pub.session_manager.values(): + session.saml_authn_context = 'urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI' + session.store() + resp = app.get('/test/') + assert 'You need a stronger authentication level to fill this form.' not in resp.body + assert resp.form + def test_form_submit(pub): formdef = create_formdef() formdef.data_class().wipe() diff --git a/tests/test_formdef_import.py b/tests/test_formdef_import.py index e66cfb5d..0e5cb0d0 100644 --- a/tests/test_formdef_import.py +++ b/tests/test_formdef_import.py @@ -408,3 +408,11 @@ def test_backoffice_submission_roles(): formdef.backoffice_submission_roles = [role.id] fd2 = assert_xml_import_export_works(formdef, include_id=True) assert fd2.backoffice_submission_roles == formdef.backoffice_submission_roles + +def test_required_authentication_contexts(): + formdef = FormDef() + formdef.name = 'foo' + formdef.fields = [] + formdef.required_authentication_contexts = ['fedict'] + fd2 = assert_xml_import_export_works(formdef, include_id=True) + assert fd2.required_authentication_contexts == formdef.required_authentication_contexts diff --git a/wcs/admin/forms.py b/wcs/admin/forms.py index 0e925ff9..c89f9c6a 100644 --- a/wcs/admin/forms.py +++ b/wcs/admin/forms.py @@ -613,6 +613,12 @@ class FormDefPage(Directory): 'render_br': False, 'options': options }) + auth_contexts = get_publisher().get_supported_authentication_contexts() + if attribute == 'roles' and auth_contexts: + form.add(CheckboxesWidget, 'required_authentication_contexts', + title=_('Required authentication contexts'), + value=self.formdef.required_authentication_contexts, + options=auth_contexts.items()) form.add_submit('submit', _('Submit')) form.add_submit('cancel', _('Cancel')) if form.get_widget('cancel').parse(): @@ -630,6 +636,9 @@ class FormDefPage(Directory): else: roles = form.get_widget('roles').parse() or [] setattr(self.formdef, attribute, [x for x in roles if x]) + if form.get_widget('required_authentication_contexts'): + self.formdef.required_authentication_contexts = form.get_widget( + 'required_authentication_contexts').parse() self.formdef.store() return redirect('.') diff --git a/wcs/api.py b/wcs/api.py index a07fd077..cc004d47 100644 --- a/wcs/api.py +++ b/wcs/api.py @@ -291,6 +291,8 @@ class ApiFormdefsDirectory(Directory): 'description': formdef.description or '', 'keywords': formdef.keywords_list, 'authentication_required': authentication_required} + if formdef.required_authentication_contexts: + formdict['required_authentication_contexts'] = formdef.required_authentication_contexts formdict['redirection'] = bool(formdef.is_disabled() and formdef.disabled_redirection) diff --git a/wcs/backoffice/submission.py b/wcs/backoffice/submission.py index 51980cba..ba140b63 100644 --- a/wcs/backoffice/submission.py +++ b/wcs/backoffice/submission.py @@ -85,6 +85,9 @@ class FormFillPage(PublicFormFillPage): def html_top(self, *args, **kwargs): return html_top('submission', *args, **kwargs) + def check_authentication_context(self): + pass + def check_role(self): if self.edit_mode: return True diff --git a/wcs/formdef.py b/wcs/formdef.py index 89f287b8..a273ddbb 100644 --- a/wcs/formdef.py +++ b/wcs/formdef.py @@ -73,6 +73,7 @@ class FormDef(StorableObject): workflow_options = None workflow_roles = None roles = None + required_authentication_contexts = None backoffice_submission_roles = None discussion = False confirmation = True @@ -566,6 +567,9 @@ class FormDef(StorableObject): if self.workflow_options: root['options'] = self.workflow_options + if self.required_authentication_contexts: + root['required_authentication_contexts'] = self.required_authentication_contexts[:] + return json.dumps(root, indent=indent, cls=misc.JSONEncoder) @classmethod @@ -650,6 +654,10 @@ class FormDef(StorableObject): if value.get('geolocations'): formdef.geolocations = value.get('geolocations') + if value.get('required_authentication_contexts'): + formdef.required_authentication_contexts = [str(x) for x in + value.get('required_authentication_contexts')] + return formdef def export_to_xml(self, include_id=False): @@ -759,6 +767,11 @@ class FormDef(StorableObject): element.attrib['key'] = geoloc_key element.text = unicode(geoloc_label, charset) + if self.required_authentication_contexts: + element = ET.SubElement(root, 'required_authentication_contexts') + for auth_context in self.required_authentication_contexts: + ET.SubElement(element, 'method').text = unicode(auth_context) + return root @classmethod @@ -946,6 +959,12 @@ class FormDef(StorableObject): geoloc_value = child.text.encode(charset) formdef.geolocations[geoloc_key] = geoloc_value + if tree.find('required_authentication_contexts') is not None: + node = tree.find('required_authentication_contexts') + formdef.required_authentication_contexts = [] + for child in node.getchildren(): + formdef.required_authentication_contexts.append(str(child.text)) + return formdef def get_detailed_email_form(self, formdata, url): diff --git a/wcs/forms/root.py b/wcs/forms/root.py index 799522d5..dc6e604b 100644 --- a/wcs/forms/root.py +++ b/wcs/forms/root.py @@ -459,7 +459,10 @@ class FormPage(Directory): (tracking code and steps).''' r = TemplateIO(html=True) r += htmltext('
') - if self.formdef.enable_tracking_codes: + if self.formdef.enable_tracking_codes and data: + # display tracking code box if they are enabled and there's some + # data (e.g. the user is not on a insufficient authenticiation + # context page) r += self.tracking_code_box(data, magictoken) r += self.step(step_no, page_no, log_detail, data=data) r += htmltext('
') @@ -524,8 +527,31 @@ class FormPage(Directory): def create_view_form(self, *args, **kwargs): return self.formdef.create_view_form(*args, **kwargs) + def check_authentication_context(self): + if not self.formdef.required_authentication_contexts: + return + if get_session().get_authentication_context() in self.formdef.required_authentication_contexts: + return + + self.html_top(self.formdef.name) + r = TemplateIO(html=True) + r += self.form_side(step_no=0, page_no=0) + auth_contexts = get_publisher().get_supported_authentication_contexts() + r += htmltext('
') + r += htmltext('

%s

') % _('You need a stronger authentication level to fill this form.') + r += htmltext('
') + root_url = get_publisher().get_root_url() + for auth_context in self.formdef.required_authentication_contexts: + r += htmltext('

%s

') % ( + root_url, _('Login with %s') % auth_contexts[auth_context]) + return r.getvalue() + def _q_index(self, log_detail=None): self.check_role() + authentication_context_check_result = self.check_authentication_context() + if authentication_context_check_result: + return authentication_context_check_result + if self.check_disabled(): return redirect(self.check_disabled()) diff --git a/wcs/qommon/publisher.py b/wcs/qommon/publisher.py index 6fb9c766..b16f59ed 100644 --- a/wcs/qommon/publisher.py +++ b/wcs/qommon/publisher.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program; if not, see . +import collections import cPickle import ConfigParser import imp @@ -972,6 +973,33 @@ class QommonPublisher(Publisher, object): default_position = '50.84;4.36' return default_position + def get_supported_authentication_contexts(self): + contexts = collections.OrderedDict() + labels = { + 'fedict': _('Belgian eID'), + 'franceconnect': _('FranceConnect'), + } + if self.get_site_option('auth-contexts'): + for context in self.get_site_option('auth-contexts').split(','): + context = context.strip() + contexts[context] = labels[context] + return contexts + + def get_authentication_saml_contexts(self, context): + return { + 'fedict': [ + # custom context, provided by authentic fedict plugin: + 'urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI', + # native fedict contexts: + 'urn:be:fedict:iam:fas:citizen:eid', + 'urn:be:fedict:iam:fas:citizen:token', + 'urn:be:fedict:iam:fas:enterprise:eid', + 'urn:be:fedict:iam:fas:enterprise:token'], + 'franceconnect': [ + 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', + ] + }[context] + def get_substitution_variables(self): import misc d = { diff --git a/wcs/qommon/sessions.py b/wcs/qommon/sessions.py index 50a7e0c6..a7fe9bec 100644 --- a/wcs/qommon/sessions.py +++ b/wcs/qommon/sessions.py @@ -171,6 +171,13 @@ class Session(QommonSession, CaptchaSession, StorableObject): def get_user_object(self): return self.get_user() + def get_authentication_context(self): + for context in get_publisher().get_supported_authentication_contexts(): + contexts = get_publisher().get_authentication_saml_contexts(context) + if self.saml_authn_context in contexts: + return context + return None + def add_tempfile(self, upload): from wcs.qommon.form import PicklableUpload token = randbytes(8) -- 2.11.0