Projet

Général

Profil

0001-keep-authentication-context-fixes-21908.patch

Benjamin Dauvergne, 10 août 2018 12:43

Télécharger (23,6 ko)

Voir les différences:

Subject: [PATCH] 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.
 src/authentic2_auth_fc/auth_frontends.py      |  23 ++-
 .../authentic2_auth_fc/connecting.html        |  23 ---
 .../templates/authentic2_auth_fc/login.html   |  47 ++++-
 .../authentic2_auth_fc/registration.html      |  24 +++
 src/authentic2_auth_fc/views.py               |  37 ++--
 tests/conftest.py                             |  29 +++
 tests/test_auth_fc.py                         | 176 +++++++++++++++++-
 7 files changed, 311 insertions(+), 48 deletions(-)
 delete mode 100644 src/authentic2_auth_fc/templates/authentic2_auth_fc/connecting.html
 create mode 100644 src/authentic2_auth_fc/templates/authentic2_auth_fc/registration.html
src/authentic2_auth_fc/auth_frontends.py
2 2
from django.template.loader import render_to_string
3 3
from django.shortcuts import render
4 4

  
5
from authentic2 import app_settings as a2_app_settings
5
from authentic2 import app_settings as a2_app_settings, utils as a2_utils
6 6

  
7 7
from . import app_settings
8 8

  
......
17 17
    def id(self):
18 18
        return 'fc'
19 19

  
20
    @property
21
    def popup(self):
22
        return False
23

  
20 24
    def login(self, request, *args, **kwargs):
21 25
        if 'nofc' in request.GET:
22 26
            return
23 27
        context = kwargs.pop('context', {}).copy()
24
        context['about_url'] = app_settings.about_url
28
        params = {}
29
        if self.popup:
30
            params['popup'] = ''
31
        context.update({
32
            'popup': self.popup,
33
            'login_url': a2_utils.make_url('fc-login-or-link', keep_params=True, params=params, request=request),
34
            'registration_url': a2_utils.make_url('fc-registration', keep_params=True, request=request),
35
            'about_url': app_settings.about_url,
36
        })
25 37
        if 'fc_user_info' in request.session:
26 38
            context['fc_user_info'] = request.session['fc_user_info']
27 39
        return render(request, 'authentic2_auth_fc/login.html', context)
......
34 46

  
35 47
        context = kwargs.pop('context', {}).copy()
36 48
        context.update({
37
            'popup': True,
49
            'popup': self.popup,
38 50
            'unlink': unlink,
39 51
            'about_url': app_settings.about_url
40 52
        })
......
46 58

  
47 59
        context = kwargs.get('context', {}).copy()
48 60
        context.update({
61
            'login_url': a2_utils.make_url('fc-login-or-link', keep_params=True, params={'registration': ''}, request=request),
62
            'popup': self.popup,
49 63
            'about_url': app_settings.about_url,
50
            'registration': True,
51 64
        })
52
        return render(request, 'authentic2_auth_fc/login.html', context)
65
        return render(request, 'authentic2_auth_fc/registration.html', context)
src/authentic2_auth_fc/templates/authentic2_auth_fc/connecting.html
1
{% load staticfiles %}
2
{% load i18n %}
3

  
4
{% if 'nofc' not in request.GET %}
5
<link rel="stylesheet" type="text/css" href="{% static 'authentic2_auth_fc/css/fc.css' %}">
6
<div id="fc-button-wrapper">
7
    <div id="fc-button">
8
        {% if not fc_user_info %}
9
        <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>
10
        {% else %}
11
            <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 %}">
12
        <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>
13
        {% endif %}
14
    </div>
15
    {% block fc-explanation %}
16
    <p><a href="{{ about_url }}" target="_blank">{% trans "What is FranceConnect?" %}</a></p>
17
    <p>{% blocktrans %}
18
    FranceConnect is the solution proposed by the French state to streamline
19
    logging in online services. You can use to connect to your account.
20
    {% endblocktrans %}</p>
21
    {% endblock %}
22
</div>
23
{% endif %}
src/authentic2_auth_fc/templates/authentic2_auth_fc/login.html
1
{% include "authentic2_auth_fc/connecting.html" %}
1
{% load staticfiles %}
2
{% load i18n %}
3

  
4
<link rel="stylesheet" type="text/css" href="{% static 'authentic2_auth_fc/css/fc.css' %}">
5
<div id="fc-button-wrapper">
6
<div id="fc-button">
7
    {% if not fc_user_info %}
8
        <a href="{{ login_url }}"
9
           title="{% trans 'Log in with FranceConnect' %}"
10
           class="button connexion{% if popup %} js-oauth-popup{% endif %}">
11
            <div>
12
                <img src="{% static "authentic2_auth_fc/img/FC-connect-button.svg" %}"></img>
13
            </div>
14
        </a>
15
    {% else %}
16
        <a href="{{ registration_url }}"
17
           title="{% trans 'Create your account with FranceConnect' %}"
18
           class="button connexion">
19
            <div>
20
            {% trans "Create your account with FranceConnect" %}
21
            <br/>
22
            <br/>
23
            <span class="certified">
24
                {{ fc_user_info.given_name }} {{ fc_user_info.family_name }}
25
                {% if fc_user_info.email %}
26
                <br/>
27
                {{ fc_user_info.email }}
28
                {% endif %}
29
            </span>
30
            <br/>
31
            <br/>
32
            <img src="{% static 'authentic2_auth_fc/img/FC-register-button.svg' %}"></img>
33
            </div>
34
        </a>
35
    {% endif %}
36
</div>
37
{% block fc-explanation %}
38
    <p>
39
    <a href="{{ about_url }}" target="_blank">{% trans "What is FranceConnect?" %}</a>
40
    </p>
41
    <p>{% blocktrans %}
42
    FranceConnect is the solution proposed by the French state to streamline
43
    logging in online services. You can use to connect to your account.
44
    {% endblocktrans %}</p>
45
{% endblock %}
46
</div>
src/authentic2_auth_fc/templates/authentic2_auth_fc/registration.html
1
{% load staticfiles %}
2
{% load i18n %}
3

  
4
<link rel="stylesheet" type="text/css" href="{% static 'authentic2_auth_fc/css/fc.css' %}">
5
<div id="fc-button-wrapper">
6
<div id="fc-button">
7
    <a href="{{ login_url }}"
8
       title="{% trans 'Register with FranceConnect' %}"
9
       class="button connexion{% if popup %} js-oauth-popup{% endif %}">
10
        <div>
11
            <img src="{% static "authentic2_auth_fc/img/FC-register-button.svg" %}"></img>
12
        </div>
13
    </a>
14
</div>
15
{% block fc-explanation %}
16
    <p>
17
    <a href="{{ about_url }}" target="_blank">{% trans "What is FranceConnect?" %}</a>
18
    </p>
19
    <p>{% blocktrans %}
20
    FranceConnect is the solution proposed by the French state to streamline
21
    logging in online services. You can use to connect to your account.
22
    {% endblocktrans %}</p>
23
{% endblock %}
24
</div>
src/authentic2_auth_fc/views.py
143 143
    def get_in_popup(self):
144 144
        return self.in_popup
145 145

  
146
    def redirect_to(self, request, *args, **kwargs):
146
    def redirect_to(self, request):
147 147
        if request.method == 'POST':
148 148
            redirect_to = request.POST.get(self.redirect_field_name,
149 149
                                           request.GET.get(self.redirect_field_name, ''))
......
162 162
                      {'redirect_to': next_url})
163 163

  
164 164
    def simple_redirect(self, request, next_url, *args, **kwargs):
165
        return HttpResponseRedirect(next_url)
165
        return a2_utils.redirect(request, next_url, *args, **kwargs)
166 166

  
167 167
    def redirect(self, request, *args, **kwargs):
168 168
        next_url = kwargs.pop('next_url', None)
......
175 175
            return self.simple_redirect(request, next_url, *args, **kwargs)
176 176

  
177 177
    def redirect_and_come_back(self, request, next_url, *args, **kwargs):
178
        old_next_url = self.redirect_to(request, *args, **kwargs)
179
        here = '{0}?{1}'.format(
180
            request.path, urlencode({REDIRECT_FIELD_NAME: old_next_url}))
181
        there = '{0}{2}{1}'.format(
182
            next_url, urlencode({REDIRECT_FIELD_NAME: here}),
183
            '&' if '?' in next_url else '?')
178
        old_next_url = self.redirect_to(request)
179
        here = a2_utils.make_url(request.path, params={REDIRECT_FIELD_NAME: old_next_url})
180
        here = a2_utils.make_url(here, **kwargs)
181
        there = a2_utils.make_url(next_url, params={REDIRECT_FIELD_NAME: here})
184 182
        return self.redirect(request, next_url=there, *args, **kwargs)
185 183

  
186 184
    def get_scopes(self):
......
421 419
            self.logger.info('logged in using fc sub %s', self.sub)
422 420
            return self.redirect(request)
423 421
        else:
422
            params = {}
423
            if self.service_slug:
424
                params[constants.SERVICE_FIELD_NAME] = self.service_slug
424 425
            if registration:
425
                return self.redirect_and_come_back(request, reverse('fc-registration'))
426
                return self.redirect_and_come_back(request,
427
                                                   a2_utils.make_url('fc-registration',
428
                                                                     params=params),
429
                                                   params=params)
426 430
            else:
427 431
                messages.info(request, _('If you already have an account, please log in, else '
428 432
                                         'create your account.'))
429
                if app_settings.show_button_quick_account_creation:
430
                    return self.redirect_and_come_back(request, settings.LOGIN_URL)
431
                else:
432
                    return self.redirect_and_come_back(request,
433
                                                       '{0}?nofc=1'.format(settings.LOGIN_URL))
433

  
434
                login_params = params.copy()
435
                if not app_settings.show_button_quick_account_creation:
436
                    login_params['nofc'] = 1
437

  
438
                login_url = a2_utils.make_url(settings.LOGIN_URL, params=login_params)
439
                return self.redirect_and_come_back(request, login_url, params=params)
434 440

  
435 441

  
436 442
class RegistrationView(LoggerMixin, View):
......
460 466
                                                             signing.dumps(data)))
461 467
        data['valid_email'] = False
462 468
        data['franceconnect'] = True
469
        data['authentication_method'] = 'france-connect'
470
        if constants.SERVICE_FIELD_NAME in request.GET:
471
            data[constants.SERVICE_FIELD_NAME] = request.GET[constants.SERVICE_FIELD_NAME]
463 472
        activation_url = a2_utils.build_activation_url(request,
464 473
                                                       next_url=redirect_to,
465 474
                                                       **data)
tests/conftest.py
84 84
def user_cartman(db, ou_southpark):
85 85
    return create_user(username='ecartman', first_name='eric', last_name='cartman',
86 86
                       email='ecartman@southpark.org', ou=ou_southpark, federation=CARTMAN_FC_INFO)
87

  
88

  
89
class AllHook(object):
90
    def __init__(self):
91
        self.calls = {}
92
        from authentic2 import hooks
93
        hooks.get_hooks.cache.clear()
94

  
95
    def __call__(self, hook_name, *args, **kwargs):
96
        calls = self.calls.setdefault(hook_name, [])
97
        calls.append({'args': args, 'kwargs': kwargs})
98

  
99
    def __getattr__(self, name):
100
        return self.calls.get(name, [])
101

  
102
    def clear(self):
103
        self.calls = {}
104

  
105

  
106
@pytest.fixture
107
def hooks(settings):
108
    if hasattr(settings, 'A2_HOOKS'):
109
        hooks = settings.A2_HOOKS
110
    else:
111
        hooks = settings.A2_HOOKS = {}
112
    hook = hooks['__all__'] = AllHook()
113
    yield hook
114
    hook.clear()
115
    del settings.A2_HOOKS['__all__']
tests/test_auth_fc.py
64 64

  
65 65
@pytest.mark.parametrize('exp', [timestamp_from_datetime(now() + datetime.timedelta(seconds=1000)),
66 66
                                 timestamp_from_datetime(now() - datetime.timedelta(seconds=1000))])
67
def test_login(app, fc_settings, caplog, exp):
68
    callback = reverse('fc-login-or-link')
69
    response = app.get(callback, status=302)
67
def test_login_simple(app, fc_settings, caplog, hooks, exp):
68
    response = app.get('/login/?service=portail&next=/idp/')
69
    response = response.click(href='callback')
70 70
    location = response['Location']
71 71
    state = check_authorization_url(location)
72 72

  
......
101 101
            'given_name': u'Ÿuñe',
102 102
        })
103 103

  
104
    callback = reverse('fc-login-or-link')
104 105
    with httmock.HTTMock(access_token_response, user_info_response):
105
        response = app.get(callback + '?code=zzz&state=%s' % state, status=302)
106
        response = app.get(callback + '?service=portail&next=/idp/&code=zzz&state=%s' % state, status=302)
106 107
    assert User.objects.count() == 0
107 108
    fc_settings.A2_FC_CREATE = True
108 109
    with httmock.HTTMock(access_token_response, user_info_response):
109
        response = app.get(callback + '?code=zzz&state=%s' % state, status=302)
110
        response = app.get(callback + '?service=portail&next=/idp/&code=zzz&state=%s' % state, status=302)
110 111
    if exp < timestamp_from_datetime(now()):
111 112
        assert User.objects.count() == 0
112 113
    else:
113 114
        assert User.objects.count() == 1
114 115
    if User.objects.count():
116
        assert response['Location'] == 'http://testserver/idp/'
117
        assert hooks.event[1]['kwargs']['name'] == 'login'
118
        assert hooks.event[1]['kwargs']['service'] == 'portail'
115 119
        # we must be connected
116 120
        assert app.session['_auth_user_id']
117 121
        assert models.FcAccount.objects.count() == 1
......
273 277
    models.FcAccount.objects.create(user=user, sub='xxx', token='aaa')
274 278
    response = app.get(url)
275 279
    assert 'new_password1' in response.form.fields
280

  
281

  
282
def test_registration1(app, fc_settings, caplog, hooks):
283
    exp = timestamp_from_datetime(now() + datetime.timedelta(seconds=1000))
284
    response = app.get('/login/?service=portail&next=/idp/')
285
    response = response.click(href="callback")
286
    # 1. Try a login
287
    # 2. Verify we come back to login page
288
    # 3. Check presence of registration link
289
    # 4. Follow it
290
    location = response['Location']
291
    state = check_authorization_url(location)
292

  
293
    @httmock.urlmatch(path=r'.*/token$')
294
    def access_token_response(url, request):
295
        parsed = {x: y[0] for x, y in urlparse.parse_qs(request.body).items()}
296
        assert set(parsed.keys()) == set(['code', 'client_id', 'client_secret', 'redirect_uri',
297
                                          'grant_type'])
298
        assert parsed['code'] == 'zzz'
299
        assert parsed['client_id'] == 'xxx'
300
        assert parsed['client_secret'] == 'yyy'
301
        assert parsed['grant_type'] == 'authorization_code'
302
        assert callback in parsed['redirect_uri']
303
        id_token = {
304
            'sub': '1234',
305
            'aud': 'xxx',
306
            'nonce': state,
307
            'exp': exp,
308
            'iss': 'https://fcp.integ01.dev-franceconnect.fr/',
309
            'email': 'john.doe@example.com',
310
        }
311
        return json.dumps({
312
            'access_token': 'uuu',
313
            'id_token': hmac_jwt(id_token, 'yyy')
314
        })
315

  
316
    @httmock.urlmatch(path=r'.*userinfo$')
317
    def user_info_response(url, request):
318
        assert request.headers['Authorization'] == 'Bearer uuu'
319
        return json.dumps({
320
            'sub': '1234',
321
            'family_name': u'Frédérique',
322
            'given_name': u'Ÿuñe',
323
            'email': 'john.doe@example.com',
324
        })
325

  
326
    callback = urlparse.parse_qs(urlparse.urlparse(location).query)['redirect_uri'][0]
327
    with httmock.HTTMock(access_token_response, user_info_response):
328
        response = app.get(callback + '&code=zzz&state=%s' % state, status=302)
329
    assert User.objects.count() == 0
330
    assert response['Location'].startswith('http://testserver/login/')
331
    response = response.follow()
332
    response = response.click('Create your account with FranceConnect')
333
    location = response['Location']
334
    location.startswith('http://testserver/accounts/activate/')
335
    response = response.follow()
336
    assert hooks.calls['event'][0]['kwargs']['service'] == 'portail'
337
    # we must be connected
338
    assert app.session['_auth_user_id']
339
    assert response['Location'].startswith(callback)
340
    response = response.follow()
341
    location = response['Location']
342
    state = check_authorization_url(location)
343
    with httmock.HTTMock(access_token_response, user_info_response):
344
        response = app.get(callback + '&code=zzz&state=%s' % state, status=302)
345
    assert models.FcAccount.objects.count() == 1
346
    response = app.get('/accounts/')
347
    response = response.click('Delete link')
348
    response.form.set('new_password1', 'ikKL1234')
349
    response.form.set('new_password2', 'ikKL1234')
350
    response = response.form.submit(name='unlink')
351
    assert 'The link with the FranceConnect account has been deleted' in response.content
352
    assert models.FcAccount.objects.count() == 0
353
    continue_url = response.pyquery('a#a2-continue').attr['href']
354
    state = urlparse.parse_qs(urlparse.urlparse(continue_url).query)['state'][0]
355
    assert app.session['fc_states'][state]['next'] == '/accounts/'
356
    response = app.get(reverse('fc-logout') + '?state=' + state)
357
    assert response['Location'] == 'http://testserver/accounts/'
358

  
359

  
360
def test_registration2(app, fc_settings, caplog, hooks):
361
    exp = timestamp_from_datetime(now() + datetime.timedelta(seconds=1000))
362
    response = app.get('/login/?service=portail&next=/idp/')
363
    response = response.click("Register")
364
    response = response.click(href='callback')
365
    # 1. Try a login
366
    # 2. Verify we come back to login page
367
    # 3. Check presence of registration link
368
    # 4. Follow it
369
    location = response['Location']
370
    state = check_authorization_url(location)
371

  
372
    @httmock.urlmatch(path=r'.*/token$')
373
    def access_token_response(url, request):
374
        parsed = {x: y[0] for x, y in urlparse.parse_qs(request.body).items()}
375
        assert set(parsed.keys()) == set(['code', 'client_id', 'client_secret', 'redirect_uri',
376
                                          'grant_type'])
377
        assert parsed['code'] == 'zzz'
378
        assert parsed['client_id'] == 'xxx'
379
        assert parsed['client_secret'] == 'yyy'
380
        assert parsed['grant_type'] == 'authorization_code'
381
        assert callback in parsed['redirect_uri']
382
        id_token = {
383
            'sub': '1234',
384
            'aud': 'xxx',
385
            'nonce': state,
386
            'exp': exp,
387
            'iss': 'https://fcp.integ01.dev-franceconnect.fr/',
388
            'email': 'john.doe@example.com',
389
        }
390
        return json.dumps({
391
            'access_token': 'uuu',
392
            'id_token': hmac_jwt(id_token, 'yyy')
393
        })
394

  
395
    @httmock.urlmatch(path=r'.*userinfo$')
396
    def user_info_response(url, request):
397
        assert request.headers['Authorization'] == 'Bearer uuu'
398
        return json.dumps({
399
            'sub': '1234',
400
            'family_name': u'Frédérique',
401
            'given_name': u'Ÿuñe',
402
            'email': 'john.doe@example.com',
403
        })
404

  
405
    callback = urlparse.parse_qs(urlparse.urlparse(location).query)['redirect_uri'][0]
406
    with httmock.HTTMock(access_token_response, user_info_response):
407
        response = app.get(callback + '&code=zzz&state=%s' % state, status=302)
408
    assert User.objects.count() == 0
409
    assert response['Location'].startswith('http://testserver/accounts/fc/register/')
410
    response = response.follow()
411
    location = response['Location']
412
    location.startswith('http://testserver/accounts/activate/')
413
    response = response.follow()
414
    assert hooks.calls['event'][0]['kwargs']['service'] == 'portail'
415
    assert hooks.calls['event'][1]['kwargs']['service'] == 'portail'
416
    # we must be connected
417
    assert app.session['_auth_user_id']
418
    # remove the registration parameter
419
    callback = callback.replace('&registration=', '')
420
    callback = callback.replace('?registration=', '?')
421
    assert response['Location'].startswith(callback)
422
    response = response.follow()
423
    location = response['Location']
424
    state = check_authorization_url(location)
425
    with httmock.HTTMock(access_token_response, user_info_response):
426
        response = app.get(callback + '&code=zzz&state=%s' % state, status=302)
427
    assert models.FcAccount.objects.count() == 1
428
    response = app.get('/accounts/')
429
    response = response.click('Delete link')
430
    response.form.set('new_password1', 'ikKL1234')
431
    response.form.set('new_password2', 'ikKL1234')
432
    response = response.form.submit(name='unlink')
433
    assert 'The link with the FranceConnect account has been deleted' in response.content
434
    assert models.FcAccount.objects.count() == 0
435
    continue_url = response.pyquery('a#a2-continue').attr['href']
436
    state = urlparse.parse_qs(urlparse.urlparse(continue_url).query)['state'][0]
437
    assert app.session['fc_states'][state]['next'] == '/accounts/'
438
    response = app.get(reverse('fc-logout') + '?state=' + state)
439
    assert response['Location'] == 'http://testserver/accounts/'
440
    import pdb
441
    pdb.set_trace()
276
-