authentic/tests/auth_fc/test_auth_fc.py

538 lines
21 KiB
Python

# -*- coding: utf-8 -*-
# authentic2 - authentic2 authentication for FranceConnect
# Copyright (C) 2020 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
import json
import re
import urllib.parse
import mock
import pytest
import requests
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.utils.timezone import now
from authentic2.a2_rbac.utils import get_default_ou
from authentic2.apps.journal.models import Event
from authentic2.custom_user.models import DeletedUser
from authentic2.models import Attribute, Service
from authentic2_auth_fc import models
from authentic2_auth_fc.backends import FcBackend
from authentic2_auth_fc.utils import requests_retry_session
from ..utils import get_link_from_mail, login
User = get_user_model()
def path(url):
return urllib.parse.urlparse(url).path
def test_fc_url_on_login(app, franceconnect):
url = reverse('fc-login-or-link')
response = app.get(url, status=302)
assert response.location.startswith('https://fcp.integ01')
assert 'fc-state' in app.cookies
def test_retry_authorization_if_state_is_lost(settings, app, franceconnect, hooks):
response = app.get('/fc/callback/?next=/idp/&service=default%20portail', status=302)
# clear fc-state cookie
app.cookiejar.clear()
response = franceconnect.handle_authorization(app, response.location, status=302)
assert response.location.startswith('https://fcp.integ01')
def test_login_with_condition(settings, app, franceconnect):
# open the page first time so session cookie can be set
response = app.get('/login/')
assert 'fc-button' in response
# make sure FC block is first
assert response.text.index('div id="fc-button"') < response.text.index('name="login-password-submit"')
settings.AUTH_FRONTENDS_KWARGS = {'fc': {'show_condition': 'remote_addr==\'0.0.0.0\''}}
response = app.get('/login/')
assert 'fc-button' not in response
def test_login_autorun(settings, app, franceconnect):
# hide password block
settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': 'remote_addr==\'0.0.0.0\''}}
response = app.get('/login/')
assert response.location == reverse('fc-login-or-link')
def test_create(settings, app, franceconnect, hooks):
# test direct creation
response = app.get('/login/?service=portail&next=/idp/')
response = response.click(href='callback')
assert User.objects.count() == 0
assert Event.objects.which_references(Service.objects.get()).count() == 0
response = franceconnect.handle_authorization(app, response.location, status=302)
assert 'fc-state' not in app.cookies
assert User.objects.count() == 1
# check login for service=portail was registered
assert Event.objects.which_references(Service.objects.get()).count() == 1
user = User.objects.get()
assert user.verified_attributes.first_name == 'Ÿuñe'
assert user.verified_attributes.last_name == 'Frédérique'
assert path(response.location) == '/idp/'
assert hooks.event[1]['kwargs']['name'] == 'login'
assert hooks.event[1]['kwargs']['service'] == 'portail'
# we must be connected
assert app.session['_auth_user_id']
assert app.session.get_expire_at_browser_close()
assert models.FcAccount.objects.count() == 1
# test unlink cancel case
response = app.get('/accounts/')
response = response.click('Delete link')
assert len(response.pyquery('[name=cancel][formnovalidate]')) == 1
response = response.form.submit(name='cancel')
response = response.follow()
# test unlink submit case
response = app.get('/accounts/')
response = response.click('Delete link')
response.form.set('new_password1', 'ikKL1234')
response.form.set('new_password2', 'ikKL1234')
response = response.form.submit(name='unlink')
assert models.FcAccount.objects.count() == 0
response = franceconnect.handle_logout(app, response.location)
assert path(response.location) == '/accounts/'
response = response.follow()
assert 'The link with the FranceConnect account has been deleted' in response
def test_create_expired(settings, app, franceconnect, hooks):
# test direct creation failure on an expired id_token
franceconnect.exp = now() - datetime.timedelta(seconds=30)
response = app.get('/login/?service=portail&next=/idp/')
response = response.click(href='callback')
assert User.objects.count() == 0
response = franceconnect.handle_authorization(app, response.location, status=302)
assert User.objects.count() == 0
def test_login_email_is_unique(settings, app, franceconnect, caplog):
settings.A2_EMAIL_IS_UNIQUE = True
user = User(email='john.doe@example.com', first_name='John', last_name='Doe', ou=get_default_ou())
user.set_password('toto')
user.save()
franceconnect.user_info['email'] = user.email
assert User.objects.count() == 1
franceconnect.login_with_fc_fixed_params(app)
assert User.objects.count() == 1
assert app.session['_auth_user_id'] == str(user.pk)
def test_link_after_login_with_password(app, franceconnect, simple_user):
assert models.FcAccount.objects.count() == 0
response = login(app, simple_user, path='/accounts/')
response = response.click(href='/fc/callback/')
franceconnect.callback_params = {'next': '/accounts/'}
response = franceconnect.handle_authorization(app, response.location, status=302)
assert models.FcAccount.objects.count() == 1
response = response.follow()
assert response.pyquery('.fc').text() == 'Linked FranceConnect accounts\nŸuñe Frédérique Delete link'
def test_unlink_after_login_with_password(app, franceconnect, simple_user):
models.FcAccount.objects.create(user=simple_user, user_info='{}')
response = login(app, simple_user, path='/accounts/')
response = response.click('Delete link')
assert 'new_password1' not in response.form.fields
response = response.form.submit(name='unlink').follow()
assert 'The link with the FranceConnect account has been deleted' in response.text
# no logout from FC since we are not logged to it
assert response.request.path == '/accounts/'
def test_unlink_after_login_with_fc(app, franceconnect, simple_user):
models.FcAccount.objects.create(user=simple_user, sub=franceconnect.sub, user_info='{}')
response = franceconnect.login_with_fc(app, path='/accounts/')
response = response.click('Delete link')
response.form.set('new_password1', 'ikKL1234')
response.form.set('new_password2', 'ikKL1234')
response = response.form.submit(name='unlink')
assert models.FcAccount.objects.count() == 0
response = franceconnect.handle_logout(app, response.location)
assert path(response.location) == '/accounts/'
response = response.follow()
assert 'The link with the FranceConnect account has been deleted' in response
def test_login_email_is_unique_and_already_linked(settings, app, franceconnect, caplog):
settings.A2_EMAIL_IS_UNIQUE = True
# setup an already linked user account
user = User.objects.create(email='john.doe@example.com', first_name='John', last_name='Doe')
models.FcAccount.objects.create(user=user, sub='4567', token='xxx', user_info='{}')
response = app.get('/login/?service=portail&next=/idp/')
response = response.click(href='callback')
response = franceconnect.handle_authorization(app, response.location, status=302)
assert models.FcAccount.objects.count() == 1
assert 'is already used' in app.cookies['messages']
assert '_auth_user_id' not in app.session
def test_requests_proxies_support(settings, app):
session = requests_retry_session()
assert session.proxies == {}
other_session = requests.Session()
other_session.proxies = {'http': 'http://example.net'}
session = requests_retry_session(session=other_session)
assert session is other_session
assert session.proxies == {'http': 'http://example.net'}
settings.REQUESTS_PROXIES = {'https': 'http://pubproxy.com/api/proxy'}
session = requests_retry_session()
assert session.proxies == {'https': 'http://pubproxy.com/api/proxy'}
with mock.patch('authentic2_auth_fc.utils.requests.Session.send') as mocked_send:
mocked_send.return_value = mock.Mock(status_code=200, content='whatever')
session.get('https://example.net/')
assert mocked_send.call_args[1]['proxies'] == {'https': 'http://pubproxy.com/api/proxy'}
def test_no_password_with_fc_account_can_reset_password(app, db, mailoutbox):
user = User(email='john.doe@example.com')
user.set_unusable_password()
user.save()
# No FC account, forbidden to set a password
response = app.get('/login/')
response = response.click('Reset it!').maybe_follow()
response.form['email'] = user.email
assert len(mailoutbox) == 0
response = response.form.submit()
assert len(mailoutbox) == 1
url = get_link_from_mail(mailoutbox[0])
response = app.get(url).follow().follow()
assert '_auth_user_id' not in app.session
assert 'not possible to reset' in response
# With FC account, can set a password
models.FcAccount.objects.create(user=user, sub='xxx', token='aaa')
response = app.get('/login/')
response = response.click('Reset it!').maybe_follow()
response.form['email'] = user.email
assert len(mailoutbox) == 1
response = response.form.submit()
assert len(mailoutbox) == 2
url = get_link_from_mail(mailoutbox[1])
response = app.get(url, status=200)
response.form.set('new_password1', 'ikKL1234')
response.form.set('new_password2', 'ikKL1234')
response = response.form.submit().follow()
assert '_auth_user_id' in app.session
def test_login_with_missing_required_attributes(settings, app, franceconnect):
Attribute.objects.create(label='Title', name='title', required=True, kind='title')
Attribute.objects.create(label='Phone', name='phone', required=True, kind='phone_number')
assert User.objects.count() == 0
assert models.FcAccount.objects.count() == 0
franceconnect.user_info['phone'] = '0102030405'
settings.A2_FC_USER_INFO_MAPPINGS = {'phone': {'ref': 'phone'}}
response = app.get('/login/?service=portail&next=/idp/')
response = response.click(href='callback')
response = franceconnect.handle_authorization(app, response.location)
assert path(response.location) == '/accounts/edit/'
assert User.objects.count() == 1
assert models.FcAccount.objects.count() == 1
assert 'The following fields are mandatory for account creation: Title' in app.cookies['messages']
def test_can_change_password(settings, app, franceconnect):
user = User.objects.create(email='john.doe@example.com')
models.FcAccount.objects.create(user=user, sub=franceconnect.sub)
response = franceconnect.login_with_fc(app, path='/accounts/')
assert len(response.pyquery('[href*="password/change"]')) == 0
response = response.click('Logout')
response = franceconnect.handle_logout(app, response.location).follow()
assert '_auth_user_id' not in app.session
# Login with password
user.username = 'test'
user.set_password('test')
user.save()
response = login(app, user, path='/accounts/')
assert len(response.pyquery('[href*="password/change"]')) > 0
response = response.click('Logout').follow()
# Relogin with FC
response = franceconnect.login_with_fc(app, path='/accounts/')
assert len(response.pyquery('[href*="password/change"]')) == 0
# Unlink
response = response.click('Delete link')
response.form.set('new_password1', 'ikKL1234')
response.form.set('new_password2', 'ikKL1234')
response = response.form.submit(name='unlink')
assert models.FcAccount.objects.count() == 0
response = franceconnect.handle_logout(app, response.location)
assert path(response.location) == '/accounts/'
response = response.follow()
assert 'The link with the FranceConnect account has been deleted' in response
assert len(response.pyquery('[href*="password/change"]')) > 0
def test_invalid_next_url(app, franceconnect):
assert app.get('/fc/callback/?code=coin&state=JJJ72QQQ').location == '/'
def test_manager_user_sidebar(app, superuser, simple_user):
login(app, superuser, '/manage/')
response = app.get('/manage/users/%s/' % simple_user.id)
assert 'FranceConnect' not in response
fc_account = models.FcAccount(user=simple_user)
fc_account.save()
response = app.get('/manage/users/%s/' % simple_user.id)
assert 'FranceConnect' in response
def test_user_info_incomplete(settings, app, franceconnect):
franceconnect.user_info = {}
franceconnect.login_with_fc_fixed_params(app)
user = User.objects.get()
assert app.session['_auth_user_id'] == str(user.pk)
fc_account = models.FcAccount.objects.get(user=user)
assert fc_account.sub == franceconnect.sub
assert fc_account.get_user_info() == {'sub': franceconnect.sub}
def test_user_info_incomplete_already_linked(settings, app, franceconnect, simple_user):
user = User.objects.create()
models.FcAccount.objects.create(user=user, sub=franceconnect.sub)
franceconnect.user_info = {}
franceconnect.callback_params = {'next': '/accounts/'}
response = login(app, simple_user, path='/accounts/')
response = response.click(href='callback')
response = franceconnect.handle_authorization(app, response.location, status=302)
assert 'FranceConnect account is already' in app.cookies['messages']
def test_save_account_on_delete_user(db):
user = User.objects.create()
models.FcAccount.objects.create(user=user, sub='1234')
models.FcAccount.objects.create(user=user, sub='4567', order=1)
user.delete()
assert models.FcAccount.objects.count() == 0
deleted_user = DeletedUser.objects.get()
assert deleted_user.old_data.get('fc_accounts') == [
{
'sub': '1234',
},
{
'sub': '4567',
},
]
def test_create_missing_email(settings, app, franceconnect, hooks):
del franceconnect.user_info['email']
response = app.get('/login/?service=portail&next=/idp/')
response = response.click(href='callback')
response = franceconnect.handle_authorization(app, response.location, status=302)
assert User.objects.count() == 1
response = app.get('/accounts/', status=200)
def test_multiple_accounts_with_same_email(settings, app, franceconnect):
ou = get_default_ou()
ou.email_is_unique = True
ou.save()
User.objects.create(email=franceconnect.user_info['email'], ou=ou)
User.objects.create(email=franceconnect.user_info['email'], ou=ou)
response = franceconnect.login_with_fc(app, path='/accounts/')
response = response.follow()
assert 'is already used by another' in response
def test_sub_with_order_0_is_used(app, db, rf):
usera = User.objects.create(username='a')
userb = User.objects.create(username='b')
models.FcAccount.objects.create(user=usera, sub='1234', order=1)
models.FcAccount.objects.create(user=userb, sub='1234', order=0)
assert FcBackend().authenticate(rf.get('/'), sub='1234', token={}, user_info={}) == userb
def test_inactive_raise_permission_denied(app, db, rf):
usera = User.objects.create(is_active=False, username='a')
models.FcAccount.objects.create(user=usera, sub='1234')
with pytest.raises(PermissionDenied):
FcBackend().authenticate(rf.get('/'), sub='1234', token={}, user_info={})
def test_order_1_is_returned(app, db, rf):
usera = User.objects.create(username='a')
models.FcAccount.objects.create(user=usera, sub='1234', order=1)
assert FcBackend().authenticate(rf.get('/'), sub='1234', token={}, user_info={}) == usera
def test_resolve_authorization_code_http_400(app, franceconnect, caplog):
franceconnect.token_endpoint_response = {
'status_code': 400,
'content': json.dumps({'error': 'invalid_request'}),
}
response = franceconnect.login_with_fc(app, path='/accounts/').follow()
assert re.match(r'WARNING.*token request failed.*invalid_request', caplog.text)
assert 'invalid_request' in response
def test_resolve_authorization_code_http_400_error_description(app, franceconnect, caplog):
franceconnect.token_endpoint_response = {
'status_code': 400,
'content': json.dumps({'error': 'invalid_request', 'error_description': 'Requête invalide'}),
}
response = franceconnect.login_with_fc(app, path='/accounts/').follow()
assert re.match(r'WARNING.*token request failed.*invalid_request', caplog.text)
assert 'invalid_request' not in response
assert 'Requête invalide' in response
def test_resolve_authorization_code_not_json(app, franceconnect, caplog):
franceconnect.token_endpoint_response = 'not json'
franceconnect.login_with_fc(app, path='/accounts/').follow()
assert re.match(r'WARNING.*resolve_authorization_code.*not JSON.*not json', caplog.text)
def test_get_user_info_http_400(app, franceconnect, caplog):
franceconnect.user_info_endpoint_response = {
'status_code': 400,
'content': json.dumps({'error': 'invalid_request'}),
}
franceconnect.login_with_fc(app, path='/accounts/').follow()
assert re.match(r'WARNING.*get_user_info.*is not 200.*status_code=400.*invalid_request', caplog.text)
def test_get_user_info_http_400_text_content(app, franceconnect, caplog):
franceconnect.user_info_endpoint_response = {
'status_code': 400,
'content': 'coin',
}
franceconnect.login_with_fc(app, path='/accounts/').follow()
assert re.match(r'WARNING.*get_user_info.*is not 200.*status_code=400.*coin', caplog.text)
def test_get_user_info_not_json(app, franceconnect, caplog):
franceconnect.user_info_endpoint_response = {
'status_code': 200,
'content': 'coin',
}
franceconnect.login_with_fc(app, path='/accounts/').follow()
assert re.match(r'WARNING.*get_user_info.*not JSON.*coin', caplog.text)
def test_fc_is_down(app, franceconnect, freezer, caplog):
franceconnect.token_endpoint_response = {'status_code': 500, 'content': 'Internal server error'}
# first error -> warning
response = franceconnect.login_with_fc(app, path='/accounts/').follow()
assert len(caplog.records) == 1
assert caplog.records[-1].levelname == 'WARNING'
assert 'Unable to connect to FranceConnect' in response
# second error, four minutes later -> warning
freezer.move_to(datetime.timedelta(seconds=+240))
response = franceconnect.login_with_fc(app, path='/accounts/').follow()
assert len(caplog.records) == 2
assert caplog.records[-1].levelname == 'WARNING'
assert 'Unable to connect to FranceConnect' in response
# after 5 minutes an error is logged
freezer.move_to(datetime.timedelta(seconds=+240))
response = franceconnect.login_with_fc(app, path='/accounts/').follow()
assert len(caplog.records) == 4
assert caplog.records[-1].levelname == 'ERROR'
assert 'Unable to connect to FranceConnect' in response
# but only every 5 minutes
freezer.move_to(datetime.timedelta(seconds=+60))
response = franceconnect.login_with_fc(app, path='/accounts/').follow()
assert len(caplog.records) == 5
assert caplog.records[-1].levelname == 'WARNING'
assert 'Unable to connect to FranceConnect' in response
# a success clear the down flag
franceconnect.token_endpoint_response = None
response = franceconnect.login_with_fc(app, path='/accounts/')
assert app.session['_auth_user_id']
app.session.flush()
assert len(caplog.records) == 7
# such that 5 minutes later only a warning is emitted
freezer.move_to(datetime.timedelta(seconds=310))
franceconnect.token_endpoint_response = {'status_code': 500, 'content': 'Internal server error'}
response = franceconnect.login_with_fc(app, path='/accounts/').follow()
assert len(caplog.records) == 8
assert caplog.records[-1].levelname == 'WARNING'
assert 'Unable to connect to FranceConnect' in response
def test_authorization_error(app, franceconnect):
error = 'unauthorized'
error_description = 'Vous n\'êtes pas autorisé à vous connecter.'
response = app.get(
'/fc/callback/', params={'error': error, 'error_description': error_description, 'next': '/accounts/'}
).maybe_follow()
messages = response.pyquery('.messages').text()
assert error not in messages
assert error_description in messages
response = app.get('/fc/callback/', params={'error': error, 'next': '/accounts/'}).maybe_follow()
messages = response.pyquery('.messages').text()
assert error in messages
assert error_description not in messages