From 03ce85ac73eb8b2d6f4d9176b31db43692cb74f6 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 strong authentication level (#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 | 25 ++++++++++++++++++++++++- wcs/qommon/publisher.py | 28 ++++++++++++++++++++++++++++ wcs/qommon/sessions.py | 7 +++++++ 11 files changed, 173 insertions(+), 3 deletions(-) diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py index 69c66ffc..c5553028 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_levels' in resp.body + resp = resp.form.submit() + assert FormDef.get(formdef.id).roles == ['logged-users'] + + # add auth levels support + if not pub.site_options.has_section('options'): + pub.site_options.add_section('options') + pub.site_options.set('options', 'auth-levels', '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_levels' in resp.body + resp.form['required_authentication_levels$element0'].checked = True + resp = resp.form.submit() + assert FormDef.get(formdef.id).required_authentication_levels == ['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..43839fb6 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_levels = ['fedict'] + formdef.store() + resp = get_app(pub).get('/api/formdefs/') + assert resp.json[0]['required_authentication_levels'] == ['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..6171a75f 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_level(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-levels', '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_levels = ['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..3bf91a5c 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_levels(): + formdef = FormDef() + formdef.name = 'foo' + formdef.fields = [] + formdef.required_authentication_levels = ['fedict'] + fd2 = assert_xml_import_export_works(formdef, include_id=True) + assert fd2.required_authentication_levels == formdef.required_authentication_levels diff --git a/wcs/admin/forms.py b/wcs/admin/forms.py index 0e925ff9..c766d270 100644 --- a/wcs/admin/forms.py +++ b/wcs/admin/forms.py @@ -613,6 +613,12 @@ class FormDefPage(Directory): 'render_br': False, 'options': options }) + auth_levels = get_publisher().get_supported_authentication_levels() + if attribute == 'roles' and auth_levels: + form.add(CheckboxesWidget, 'required_authentication_levels', + title=_('Required authentication levels'), + value=self.formdef.required_authentication_levels, + options=auth_levels.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_levels'): + self.formdef.required_authentication_levels = form.get_widget( + 'required_authentication_levels').parse() self.formdef.store() return redirect('.') diff --git a/wcs/api.py b/wcs/api.py index a07fd077..a0a18036 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_levels: + formdict['required_authentication_levels'] = formdef.required_authentication_levels formdict['redirection'] = bool(formdef.is_disabled() and formdef.disabled_redirection) diff --git a/wcs/backoffice/submission.py b/wcs/backoffice/submission.py index 51980cba..c63562fb 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_level(self): + pass + def check_role(self): if self.edit_mode: return True diff --git a/wcs/formdef.py b/wcs/formdef.py index 89f287b8..49d3c6f8 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_levels = 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_levels: + root['required_authentication_levels'] = self.required_authentication_levels[:] + 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_levels'): + formdef.required_authentication_levels = [str(x) for x in + value.get('required_authentication_levels')] + 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_levels: + element = ET.SubElement(root, 'required_authentication_levels') + for auth_level in self.required_authentication_levels: + ET.SubElement(element, 'method').text = unicode(auth_level) + 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_levels') is not None: + node = tree.find('required_authentication_levels') + formdef.required_authentication_levels = [] + for child in node.getchildren(): + formdef.required_authentication_levels.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..07ed49d7 100644 --- a/wcs/forms/root.py +++ b/wcs/forms/root.py @@ -459,7 +459,7 @@ 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: r += self.tracking_code_box(data, magictoken) r += self.step(step_no, page_no, log_detail, data=data) r += htmltext('
') @@ -524,8 +524,31 @@ class FormPage(Directory): def create_view_form(self, *args, **kwargs): return self.formdef.create_view_form(*args, **kwargs) + def check_authentication_level(self): + if not self.formdef.required_authentication_levels: + return + if get_session().get_authentication_level() in self.formdef.required_authentication_levels: + return + + self.html_top(self.formdef.name) + r = TemplateIO(html=True) + r += self.form_side(step_no=0, page_no=0) + auth_levels = get_publisher().get_supported_authentication_levels() + 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_level in self.formdef.required_authentication_levels: + r += htmltext('

%s

') % ( + root_url, _('Login with %s') % auth_levels[auth_level]) + return r.getvalue() + def _q_index(self, log_detail=None): self.check_role() + authentication_level_check_result = self.check_authentication_level() + if authentication_level_check_result: + return authentication_level_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..25281ff1 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_levels(self): + levels = collections.OrderedDict() + labels = { + 'fedict': _('Belgian eID'), + 'franceconnect': _('FranceConnect'), + } + if self.get_site_option('auth-levels'): + for level in self.get_site_option('auth-levels').split(','): + level = level.strip() + levels[level] = labels[level] + return levels + + def get_authentication_level_saml_contexts(self, level): + 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', + ] + }[level] + def get_substitution_variables(self): import misc d = { diff --git a/wcs/qommon/sessions.py b/wcs/qommon/sessions.py index 50a7e0c6..4775c48e 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_level(self): + for level in get_publisher().get_supported_authentication_levels(): + contexts = get_publisher().get_authentication_level_saml_contexts(level) + if self.saml_authn_context in contexts: + return level + return None + def add_tempfile(self, upload): from wcs.qommon.form import PicklableUpload token = randbytes(8) -- 2.11.0