general: allow marking form as required a given authentication context (#13177)

This commit is contained in:
Frédéric Péters 2017-01-10 10:52:40 +01:00
parent 6ac8dc1396
commit 0dc2a83e47
11 changed files with 187 additions and 4 deletions

View File

@ -477,6 +477,40 @@ 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()
resp = resp.follow()
assert FormDef.get(formdef.id).required_authentication_contexts == ['fedict']
def test_form_workflow_role(pub):
create_superuser(pub)
role = create_role()

View File

@ -334,6 +334,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()

View File

@ -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()

View File

@ -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

View File

@ -430,7 +430,7 @@ class FormDefPage(Directory):
wf_role_label, role_label)
r += add_option_line('roles', _('User Roles'),
self._get_roles_label('roles'))
self._get_roles_label_and_auth_context('roles'))
r += add_option_line('backoffice-submission-roles',
_('Backoffice Submission Role'),
self._get_roles_label('backoffice_submission_roles'))
@ -525,6 +525,15 @@ class FormDefPage(Directory):
value = C_('roles|None')
return value
def _get_roles_label_and_auth_context(self, attribute):
value = self._get_roles_label(attribute)
if self.formdef.required_authentication_contexts:
auth_contexts = get_publisher().get_supported_authentication_contexts()
value += ' (%s)' % ', '.join([auth_contexts.get(x)
for x in self.formdef.required_authentication_contexts
if auth_contexts.get(x)])
return value
def get_sidebar(self):
r = TemplateIO(html=True)
r += htmltext('<ul id="sidebar-actions">')
@ -615,6 +624,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():
@ -632,6 +647,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('.')

View File

@ -289,6 +289,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)

View File

@ -84,6 +84,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

View File

@ -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):

View File

@ -459,7 +459,10 @@ class FormPage(Directory):
(tracking code and steps).'''
r = TemplateIO(html=True)
r += htmltext('<div id="side">')
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('</div> <!-- #side -->')
@ -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('<div class="errornotice">')
r += htmltext('<p>%s</p>') % _('You need a stronger authentication level to fill this form.')
r += htmltext('</div>')
root_url = get_publisher().get_root_url()
for auth_context in self.formdef.required_authentication_contexts:
r += htmltext('<p><a class="button" href="%slogin/?forceAuthn=true">%s</a></p>') % (
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())

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/licenses/>.
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 = {

View File

@ -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)