keep authentication context (fixes #21908)

- simplify and reorganize login templates,
- URL are not built inside templates anymore,
- we have now 3 different templates:
  - login.html for the login page
  - registration.html for the registration page
  - linking.html for the account page
- using feature from #25623, authentication_method is kept by the
  registration view.
- the service slug is correctly threaded between every views.
- explanations about FranceConnect are now done in a common template
  "explanation.html".
- restore popup mode, use it through setting A2_FC_POPUP=True, it works
  for:
  - login and login with registration (workflow for login with
    registration is a bit complicated),
  - registration,
  - and linking (linking your existing to FC through the "My account"
   page)
  unlinking is not handled with a popup.
This commit is contained in:
Benjamin Dauvergne 2018-08-09 15:37:19 +02:00
parent c21a16108a
commit 2e5ac496b8
12 changed files with 391 additions and 60 deletions

View File

@ -108,6 +108,10 @@ class AppSettings(object):
def scopes(self):
return self._setting('SCOPES', [])
@property
def popup(self):
return self._setting('POPUP', False)
import sys

View File

@ -2,7 +2,7 @@ from django.utils.translation import gettext_noop
from django.template.loader import render_to_string
from django.shortcuts import render
from authentic2 import app_settings as a2_app_settings
from authentic2 import app_settings as a2_app_settings, utils as a2_utils
from . import app_settings
@ -17,14 +17,39 @@ class FcFrontend(object):
def id(self):
return 'fc'
@property
def popup(self):
return app_settings.popup
def login(self, request, *args, **kwargs):
if 'nofc' in request.GET:
return
fc_user_info = request.session.get('fc_user_info')
context = kwargs.pop('context', {}).copy()
context['about_url'] = app_settings.about_url
if 'fc_user_info' in request.session:
context['fc_user_info'] = request.session['fc_user_info']
return render(request, 'authentic2_auth_fc/login.html', context)
params = {}
if self.popup:
params['popup'] = ''
context.update({
'popup': self.popup,
'about_url': app_settings.about_url,
'fc_user_info': fc_user_info,
})
if fc_user_info:
context.update({
'registration_url': a2_utils.make_url('fc-registration',
keep_params=True,
params=params,
request=request),
'fc_user_info': fc_user_info,
})
template = 'authentic2_auth_fc/login_registration.html'
else:
context['login_url'] = a2_utils.make_url('fc-login-or-link',
keep_params=True,
params=params,
request=request)
template = 'authentic2_auth_fc/login.html'
return render(request, template, context)
def profile(self, request, *args, **kwargs):
# We prevent unlinking if the user has no usable password and can't change it
@ -32,11 +57,21 @@ class FcFrontend(object):
# and unlinking would make the account unreachable.
unlink = request.user.has_usable_password() or a2_app_settings.A2_REGISTRATION_CAN_CHANGE_PASSWORD
account_path = a2_utils.reverse('account_management')
params = {
'next': account_path,
}
if self.popup:
params['popup'] = ''
link_url = a2_utils.make_url('fc-login-or-link',
params=params)
context = kwargs.pop('context', {}).copy()
context.update({
'popup': True,
'popup': self.popup,
'unlink': unlink,
'about_url': app_settings.about_url
'about_url': app_settings.about_url,
'link_url': link_url,
})
return render_to_string('authentic2_auth_fc/linking.html', context, request=request)
@ -45,8 +80,16 @@ class FcFrontend(object):
return []
context = kwargs.get('context', {}).copy()
params = {
'registration': '',
}
if self.popup:
params['popup'] = ''
context.update({
'login_url': a2_utils.make_url('fc-login-or-link',
keep_params=True, params=params,
request=request),
'popup': self.popup,
'about_url': app_settings.about_url,
'registration': True,
})
return render(request, 'authentic2_auth_fc/login.html', context)
return render(request, 'authentic2_auth_fc/registration.html', context)

View File

@ -0,0 +1,30 @@
/* Open FranceConnect in popup */
(function(undef) {
function PopupCenter(url, title, w, h) {
// Fixes dual-screen position Most browsers Firefox
var dualScreenLeft = window.screenLeft != undefined ? window.screenLeft : window.screenX;
var dualScreenTop = window.screenTop != undefined ? window.screenTop : window.screenY;
var width = window.innerWidth ? window.innerWidth : document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width;
var height = window.innerHeight ? window.innerHeight : document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height;
var left = ((width / 2) - (w / 2)) + dualScreenLeft;
var top = ((height / 2) - (h / 2)) + dualScreenTop;
var newWindow = window.open(url, title, 'location=0,status=0,menubar=0,toolbar=0,scrollbars=yes, width=' + w + ', height=' + h + ', top=' + top + ', left=' + left);
// Puts focus on the newWindow
if (window.focus) {
newWindow.focus();
}
}
var tags = document.getElementsByClassName('js-fc-popup');
for (var i = 0; i < tags.length; i++) {
var tag = tags[i];
tag.onclick = function (ev) {
PopupCenter(this.href, 'Authentification FranceConnect', 700, 500);
return false;
};
}
})();

View File

@ -1,23 +0,0 @@
{% load staticfiles %}
{% load i18n %}
{% if 'nofc' not in request.GET %}
<link rel="stylesheet" type="text/css" href="{% static 'authentic2_auth_fc/css/fc.css' %}">
<div id="fc-button-wrapper">
<div id="fc-button">
{% if not fc_user_info %}
<a href="{% url 'fc-login-or-link' %}{% if request.GET.next or popup or registration %}?{% endif %}{% if next %}{{ next }}{% else %}{% if request.GET.next %}{{ request.GET.urlencode }}{% endif %}{% endif %}{% if popup %}&popup=1{% endif %}{% if registration %}&registration{% endif %}" title="{% trans 'Log in with FranceConnect' %}" class="button connexion{% if popup %} js-oauth-popup{% endif %}"><div><img src="{% if registration %}{% static "authentic2_auth_fc/img/FC-register-button.svg" %}{% else %}{% static "authentic2_auth_fc/img/FC-connect-button.svg" %}{% endif %}"></img></div></a>
{% else %}
<a class="button" href="{% url 'fc-registration' %}{% if request.GET.next or popup %}?{% endif %}{% if request.GET.next %}{{ request.GET.urlencode }}{% endif %}{% if popup %}&popup=1{% endif %}" title="{% trans 'Create your account with FranceConnect' %}" class="connexion{% if popup %} js-oauth-popup{% endif %}">
<div>{% trans "Create your account with FranceConnect" %}<br/><br/><span class="certified">{{ fc_user_info.given_name }} {{ fc_user_info.family_name }}{% if fc_user_info.email %}<br/>{{ fc_user_info.email }}{% endif %}</span><br/><br/><img src="{% static 'authentic2_auth_fc/img/FC-register-button.svg' %}"></img></div></a>
{% endif %}
</div>
{% block fc-explanation %}
<p><a href="{{ about_url }}" target="_blank">{% trans "What is FranceConnect?" %}</a></p>
<p>{% blocktrans %}
FranceConnect is the solution proposed by the French state to streamline
logging in online services. You can use to connect to your account.
{% endblocktrans %}</p>
{% endblock %}
</div>
{% endif %}

View File

@ -0,0 +1,10 @@
{% load i18n %}
{% block fc-explanation %}
<p>
<a href="{{ about_url }}" target="_blank">{% trans "What is FranceConnect?" %}</a>
</p>
<p>{% blocktrans %}
FranceConnect is the solution proposed by the French state to streamline
logging in online services. You can use to connect to your account.
{% endblocktrans %}</p>
{% endblock %}

View File

@ -11,13 +11,13 @@
{% trans "Linked FranceConnect accounts" %}
</p>
<ul class="fond">
<li class="picto utilisateur"><p class="lien">{{ user.fc_accounts.all.0 }}{% if unlink %} <a href="{% url 'fc-unlink' %}">{% trans 'Delete link'%}</a>{% endif %}</p></li>
<li class="picto utilisateur"><p class="lien">{{ user.fc_accounts.all.0 }}{% if unlink %} <a href="{% url 'fc-unlink' %}">{% trans 'Delete link'%}</a>{% endif %}</p></li>
</ul>
{% else %}
<p>
<div id="fc-button-wrapper">
<div id="fc-button">
<a href="{% url 'fc-login-or-link' %}?next={% url 'account_management' %}" title="{% trans 'Link with a FranceConnect account' %}" class="button connexion"><div>{% trans "Link with a FranceConnect account" %}<img src="{% static 'authentic2_auth_fc/img/FCboutons-10.svg' %}"></img></div></a>
<a href="{{ link_url }}" title="{% trans 'Link with a FranceConnect account' %}" class="button connexion{% if popup %} js-fc-popup{% endif %}"><div>{% trans "Link with a FranceConnect account" %}<img src="{% static 'authentic2_auth_fc/img/FCboutons-10.svg' %}"></img></div></a>
</div>
</div>
</p>
@ -26,3 +26,4 @@
</div>
<p><a href="{{ about_url }}" target="_blank">{% trans "What is FranceConnect?" %}</a></p>
</div>
{% if popup %}<script src="{% static 'authentic2_auth_fc/js/fc.js' %}" type="text/javascript"></script>{% endif %}

View File

@ -1 +1,16 @@
{% include "authentic2_auth_fc/connecting.html" %}
{% load staticfiles %}
{% load i18n %}
<link rel="stylesheet" type="text/css" href="{% static 'authentic2_auth_fc/css/fc.css' %}">
<div id="fc-button-wrapper">
<div id="fc-button">
<a href="{{ login_url }}"
title="{% trans 'Log in with FranceConnect' %}"
class="button connexion{% if popup %} js-fc-popup{% endif %}">
<div>
<img src="{% static "authentic2_auth_fc/img/FC-connect-button.svg" %}"></img>
</div>
</a>
</div>
{% include "authentic2_auth_fc/explanation.html" %}
{% if popup %}<script src="{% static 'authentic2_auth_fc/js/fc.js' %}" type="text/javascript"></script>{% endif %}

View File

@ -0,0 +1,28 @@
{% load staticfiles %}
{% load i18n %}
<link rel="stylesheet" type="text/css" href="{% static 'authentic2_auth_fc/css/fc.css' %}">
<div id="fc-button-wrapper">
<div id="fc-button">
<a href="{{ registration_url }}"
title="{% trans 'Create your account with FranceConnect' %}"
class="button connexion{% if popup %} js-fc-popup{% endif %}">
<div>
{% trans "Create your account with FranceConnect" %}
<br/>
<br/>
<span class="certified">
{{ fc_user_info.given_name }} {{ fc_user_info.family_name }}
{% if fc_user_info.email %}
<br/>
{{ fc_user_info.email }}
{% endif %}
</span>
<br/>
<br/>
<img src="{% static 'authentic2_auth_fc/img/FC-register-button.svg' %}"></img>
</div>
</a>
</div>
{% include "authentic2_auth_fc/explanation.html" %}
{% if popup %}<script src="{% static 'authentic2_auth_fc/js/fc.js' %}" type="text/javascript"></script>{% endif %}

View File

@ -0,0 +1,16 @@
{% load staticfiles %}
{% load i18n %}
<link rel="stylesheet" type="text/css" href="{% static 'authentic2_auth_fc/css/fc.css' %}">
<div id="fc-button-wrapper">
<div id="fc-button">
<a href="{{ login_url }}"
title="{% trans 'Register with FranceConnect' %}"
class="button connexion{% if popup %} js-fc-popup{% endif %}">
<div>
<img src="{% static "authentic2_auth_fc/img/FC-register-button.svg" %}"></img>
</div>
</a>
</div>
{% include "authentic2_auth_fc/explanation.html" %}
{% if popup %}<script src="{% static 'authentic2_auth_fc/js/fc.js' %}" type="text/javascript"></script>{% endif %}

View File

@ -143,7 +143,7 @@ class FcOAuthSessionViewMixin(LoggerMixin):
def get_in_popup(self):
return self.in_popup
def redirect_to(self, request, *args, **kwargs):
def redirect_to(self, request):
if request.method == 'POST':
redirect_to = request.POST.get(self.redirect_field_name,
request.GET.get(self.redirect_field_name, ''))
@ -162,7 +162,7 @@ class FcOAuthSessionViewMixin(LoggerMixin):
{'redirect_to': next_url})
def simple_redirect(self, request, next_url, *args, **kwargs):
return HttpResponseRedirect(next_url)
return a2_utils.redirect(request, next_url, *args, **kwargs)
def redirect(self, request, *args, **kwargs):
next_url = kwargs.pop('next_url', None)
@ -175,12 +175,10 @@ class FcOAuthSessionViewMixin(LoggerMixin):
return self.simple_redirect(request, next_url, *args, **kwargs)
def redirect_and_come_back(self, request, next_url, *args, **kwargs):
old_next_url = self.redirect_to(request, *args, **kwargs)
here = '{0}?{1}'.format(
request.path, urlencode({REDIRECT_FIELD_NAME: old_next_url}))
there = '{0}{2}{1}'.format(
next_url, urlencode({REDIRECT_FIELD_NAME: here}),
'&' if '?' in next_url else '?')
old_next_url = self.redirect_to(request)
here = a2_utils.make_url(request.path, params={REDIRECT_FIELD_NAME: old_next_url})
here = a2_utils.make_url(here, **kwargs)
there = a2_utils.make_url(next_url, params={REDIRECT_FIELD_NAME: here})
return self.redirect(request, next_url=there, *args, **kwargs)
def get_scopes(self):
@ -421,19 +419,27 @@ class LoginOrLinkView(PopupViewMixin, FcOAuthSessionViewMixin, View):
self.logger.info('logged in using fc sub %s', self.sub)
return self.redirect(request)
else:
params = {}
if self.service_slug:
params[constants.SERVICE_FIELD_NAME] = self.service_slug
if registration:
return self.redirect_and_come_back(request, reverse('fc-registration'))
return self.redirect_and_come_back(request,
a2_utils.make_url('fc-registration',
params=params),
params=params)
else:
messages.info(request, _('If you already have an account, please log in, else '
'create your account.'))
if app_settings.show_button_quick_account_creation:
return self.redirect_and_come_back(request, settings.LOGIN_URL)
else:
return self.redirect_and_come_back(request,
'{0}?nofc=1'.format(settings.LOGIN_URL))
login_params = params.copy()
if not app_settings.show_button_quick_account_creation:
login_params['nofc'] = 1
login_url = a2_utils.make_url(settings.LOGIN_URL, params=login_params)
return self.redirect_and_come_back(request, login_url, params=params)
class RegistrationView(LoggerMixin, View):
class RegistrationView(PopupViewMixin, LoggerMixin, View):
def get(self, request, *args, **kwargs):
data = utils.get_mapped_attributes_flat(request)
data['no_password'] = True
@ -447,11 +453,15 @@ class RegistrationView(LoggerMixin, View):
# Prevent errors when redirect_to does not contain fc-login-or-link view
parsed_redirect_to = urlparse.urlparse(redirect_to)
if parsed_redirect_to.path != reverse('fc-login-or-link'):
redirect_to = '%s?%s=%s' % (
reverse('fc-login-or-link'),
REDIRECT_FIELD_NAME,
urllib.quote(redirect_to))
if parsed_redirect_to.path == reverse('fc-login-or-link'):
redirect_to = urlparse.parse_qs(parsed_redirect_to.query) \
.get(REDIRECT_FIELD_NAME, [a2_utils.make_url('auth_homepage')])[0]
params = {
REDIRECT_FIELD_NAME: redirect_to,
}
if self.get_in_popup():
params['popup'] = ''
redirect_to = a2_utils.make_url('fc-login-or-link', params=params)
if not 'email' in data:
data[REDIRECT_FIELD_NAME] = redirect_to
messages.warning(request,
@ -460,6 +470,9 @@ class RegistrationView(LoggerMixin, View):
signing.dumps(data)))
data['valid_email'] = False
data['franceconnect'] = True
data['authentication_method'] = 'france-connect'
if constants.SERVICE_FIELD_NAME in request.GET:
data[constants.SERVICE_FIELD_NAME] = request.GET[constants.SERVICE_FIELD_NAME]
activation_url = a2_utils.build_activation_url(request,
next_url=redirect_to,
**data)

View File

@ -84,3 +84,32 @@ def admin(db):
def user_cartman(db, ou_southpark):
return create_user(username='ecartman', first_name='eric', last_name='cartman',
email='ecartman@southpark.org', ou=ou_southpark, federation=CARTMAN_FC_INFO)
class AllHook(object):
def __init__(self):
self.calls = {}
from authentic2 import hooks
hooks.get_hooks.cache.clear()
def __call__(self, hook_name, *args, **kwargs):
calls = self.calls.setdefault(hook_name, [])
calls.append({'args': args, 'kwargs': kwargs})
def __getattr__(self, name):
return self.calls.get(name, [])
def clear(self):
self.calls = {}
@pytest.fixture
def hooks(settings):
if hasattr(settings, 'A2_HOOKS'):
hooks = settings.A2_HOOKS
else:
hooks = settings.A2_HOOKS = {}
hook = hooks['__all__'] = AllHook()
yield hook
hook.clear()
del settings.A2_HOOKS['__all__']

View File

@ -64,9 +64,9 @@ def check_authorization_url(url):
@pytest.mark.parametrize('exp', [timestamp_from_datetime(now() + datetime.timedelta(seconds=1000)),
timestamp_from_datetime(now() - datetime.timedelta(seconds=1000))])
def test_login(app, fc_settings, caplog, exp):
callback = reverse('fc-login-or-link')
response = app.get(callback, status=302)
def test_login_simple(app, fc_settings, caplog, hooks, exp):
response = app.get('/login/?service=portail&next=/idp/')
response = response.click(href='callback')
location = response['Location']
state = check_authorization_url(location)
@ -101,17 +101,21 @@ def test_login(app, fc_settings, caplog, exp):
'given_name': u'Ÿuñe',
})
callback = reverse('fc-login-or-link')
with httmock.HTTMock(access_token_response, user_info_response):
response = app.get(callback + '?code=zzz&state=%s' % state, status=302)
response = app.get(callback + '?service=portail&next=/idp/&code=zzz&state=%s' % state, status=302)
assert User.objects.count() == 0
fc_settings.A2_FC_CREATE = True
with httmock.HTTMock(access_token_response, user_info_response):
response = app.get(callback + '?code=zzz&state=%s' % state, status=302)
response = app.get(callback + '?service=portail&next=/idp/&code=zzz&state=%s' % state, status=302)
if exp < timestamp_from_datetime(now()):
assert User.objects.count() == 0
else:
assert User.objects.count() == 1
if User.objects.count():
assert response['Location'] == 'http://testserver/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 models.FcAccount.objects.count() == 1
@ -273,3 +277,164 @@ def test_password_reset(app, mailoutbox):
models.FcAccount.objects.create(user=user, sub='xxx', token='aaa')
response = app.get(url)
assert 'new_password1' in response.form.fields
def test_registration1(app, fc_settings, caplog, hooks):
exp = timestamp_from_datetime(now() + datetime.timedelta(seconds=1000))
response = app.get('/login/?service=portail&next=/idp/')
response = response.click(href="callback")
# 1. Try a login
# 2. Verify we come back to login page
# 3. Check presence of registration link
# 4. Follow it
location = response['Location']
state = check_authorization_url(location)
@httmock.urlmatch(path=r'.*/token$')
def access_token_response(url, request):
parsed = {x: y[0] for x, y in urlparse.parse_qs(request.body).items()}
assert set(parsed.keys()) == set(['code', 'client_id', 'client_secret', 'redirect_uri',
'grant_type'])
assert parsed['code'] == 'zzz'
assert parsed['client_id'] == 'xxx'
assert parsed['client_secret'] == 'yyy'
assert parsed['grant_type'] == 'authorization_code'
assert callback in parsed['redirect_uri']
id_token = {
'sub': '1234',
'aud': 'xxx',
'nonce': state,
'exp': exp,
'iss': 'https://fcp.integ01.dev-franceconnect.fr/',
'email': 'john.doe@example.com',
}
return json.dumps({
'access_token': 'uuu',
'id_token': hmac_jwt(id_token, 'yyy')
})
@httmock.urlmatch(path=r'.*userinfo$')
def user_info_response(url, request):
assert request.headers['Authorization'] == 'Bearer uuu'
return json.dumps({
'sub': '1234',
'family_name': u'Frédérique',
'given_name': u'Ÿuñe',
'email': 'john.doe@example.com',
})
callback = urlparse.parse_qs(urlparse.urlparse(location).query)['redirect_uri'][0]
with httmock.HTTMock(access_token_response, user_info_response):
response = app.get(callback + '&code=zzz&state=%s' % state, status=302)
assert User.objects.count() == 0
assert response['Location'].startswith('http://testserver/login/')
response = response.follow()
response = response.click('Create your account with FranceConnect')
location = response['Location']
location.startswith('http://testserver/accounts/activate/')
response = response.follow()
assert hooks.calls['event'][0]['kwargs']['service'] == 'portail'
# we must be connected
assert app.session['_auth_user_id']
assert response['Location'].startswith(callback)
response = response.follow()
location = response['Location']
state = check_authorization_url(location)
with httmock.HTTMock(access_token_response, user_info_response):
response = app.get(callback + '&code=zzz&state=%s' % state, status=302)
assert models.FcAccount.objects.count() == 1
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 'The link with the FranceConnect account has been deleted' in response.content
assert models.FcAccount.objects.count() == 0
continue_url = response.pyquery('a#a2-continue').attr['href']
state = urlparse.parse_qs(urlparse.urlparse(continue_url).query)['state'][0]
assert app.session['fc_states'][state]['next'] == '/accounts/'
response = app.get(reverse('fc-logout') + '?state=' + state)
assert response['Location'] == 'http://testserver/accounts/'
def test_registration2(app, fc_settings, caplog, hooks):
exp = timestamp_from_datetime(now() + datetime.timedelta(seconds=1000))
response = app.get('/login/?service=portail&next=/idp/')
response = response.click("Register")
response = response.click(href='callback')
# 1. Try a login
# 2. Verify we come back to login page
# 3. Check presence of registration link
# 4. Follow it
location = response['Location']
state = check_authorization_url(location)
@httmock.urlmatch(path=r'.*/token$')
def access_token_response(url, request):
parsed = {x: y[0] for x, y in urlparse.parse_qs(request.body).items()}
assert set(parsed.keys()) == set(['code', 'client_id', 'client_secret', 'redirect_uri',
'grant_type'])
assert parsed['code'] == 'zzz'
assert parsed['client_id'] == 'xxx'
assert parsed['client_secret'] == 'yyy'
assert parsed['grant_type'] == 'authorization_code'
assert callback in parsed['redirect_uri']
id_token = {
'sub': '1234',
'aud': 'xxx',
'nonce': state,
'exp': exp,
'iss': 'https://fcp.integ01.dev-franceconnect.fr/',
'email': 'john.doe@example.com',
}
return json.dumps({
'access_token': 'uuu',
'id_token': hmac_jwt(id_token, 'yyy')
})
@httmock.urlmatch(path=r'.*userinfo$')
def user_info_response(url, request):
assert request.headers['Authorization'] == 'Bearer uuu'
return json.dumps({
'sub': '1234',
'family_name': u'Frédérique',
'given_name': u'Ÿuñe',
'email': 'john.doe@example.com',
})
callback = urlparse.parse_qs(urlparse.urlparse(location).query)['redirect_uri'][0]
with httmock.HTTMock(access_token_response, user_info_response):
response = app.get(callback + '&code=zzz&state=%s' % state, status=302)
assert User.objects.count() == 0
assert response['Location'].startswith('http://testserver/accounts/fc/register/')
response = response.follow()
location = response['Location']
location.startswith('http://testserver/accounts/activate/')
response = response.follow()
assert hooks.calls['event'][0]['kwargs']['service'] == 'portail'
assert hooks.calls['event'][1]['kwargs']['service'] == 'portail'
# we must be connected
assert app.session['_auth_user_id']
# remove the registration parameter
callback = callback.replace('&registration=', '')
callback = callback.replace('?registration=', '?')
callback = callback.replace('?&', '?')
assert response['Location'].startswith(callback)
response = response.follow()
location = response['Location']
state = check_authorization_url(location)
with httmock.HTTMock(access_token_response, user_info_response):
response = app.get(callback + '&code=zzz&state=%s' % state, status=302)
assert models.FcAccount.objects.count() == 1
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 'The link with the FranceConnect account has been deleted' in response.content
assert models.FcAccount.objects.count() == 0
continue_url = response.pyquery('a#a2-continue').attr['href']
state = urlparse.parse_qs(urlparse.urlparse(continue_url).query)['state'][0]
assert app.session['fc_states'][state]['next'] == '/accounts/'
response = app.get(reverse('fc-logout') + '?state=' + state)
assert response['Location'] == 'http://testserver/accounts/'