Projet

Général

Profil

0001-views-use-a-view-to-show-all-error-messages-56354.patch

Benjamin Dauvergne, 12 janvier 2022 23:24

Télécharger (25,6 ko)

Voir les différences:

Subject: [PATCH 1/2] views: use a view to show all error messages (#56354)

 .../mellon/authentication_failed.html         |  20 +-
 mellon/views.py                               | 227 +++++++++---------
 tests/test_sso_slo.py                         |  22 +-
 tests/test_views.py                           |  16 +-
 4 files changed, 155 insertions(+), 130 deletions(-)
mellon/templates/mellon/authentication_failed.html
12 12
  <h2 class="mellon-message-header">{% trans "Authentication failed" %}</h2>
13 13
  <p class="mellon-message-body">
14 14
    {% blocktrans %}The authentication has failed.{% endblocktrans %}
15
    {% if reason %}<p class="mellon-reason">{% trans "Reason" %}&nbsp;: {{ reason }}</p>{% endif %}
15
    {% for reason in reasons %}
16
      <p class="mellon-reason">{% if loop.first %}{% trans "Reason" %}&nbsp;: {% endif %}{{ reason }}</p>
17
    {% endfor %}
16 18
  </p>
17 19
  <p class="mellon-message-continue">
18 20
    <a class="mellon-link" href="{{ next_url }}">{% trans "Continue" %}</a>
19 21
  </p>
20 22
  {% if debug %}
21
  <!-- DEBUG INFO:
22
       Issuer: {{ issuer }}
23
       Message: {{ status_message }}
24
       Status codes: {{ status_codes|join:", " }}
25
       Relaystate: {{ relaystate }}
26
  -->
23
  <p>{% trans "Information shown because DEBUG is True" %}</p>
24
  <pre>
25
Issuer: {{ issuer }}
26
Message: {{ status_message }}
27
Status codes: {{ status_codes|join:", " }}
28
Relaystate: {{ relaystate }}
29
{% for key, value in debug_details.items %}
30
{{key}}: {{value|pprint}}
31
{% endfor %}
32
  </pre>
27 33
  {% endif %}
28 34
</div>
29 35
{% endblock %}
mellon/views.py
22 22
from io import StringIO
23 23
from xml.sax.saxutils import escape
24 24

  
25
import django.http
26 25
import lasso
27 26
import requests
28 27
from django.conf import settings
......
57 56
User = get_user_model()
58 57

  
59 58

  
60
class HttpResponseBadRequest(django.http.HttpResponseBadRequest):
61
    def __init__(self, *args, **kwargs):
62
        kwargs['content_type'] = kwargs.get('content_type', 'text/plain')
63
        super().__init__(*args, **kwargs)
64
        self['X-Content-Type-Options'] = 'nosniff'
65

  
66

  
67 59
class LogMixin:
68 60
    """Initialize a module logger in new objects"""
69 61

  
......
93 85
class ProfileMixin:
94 86
    profile = None
95 87

  
88
    def dispatch(self, request, *args, **kwargs):
89
        try:
90
            return super().dispatch(request, *args, **kwargs)
91
        # handle common errors
92
        except utils.CreateServerError:
93
            return self.failure(
94
                request,
95
                reason=_(
96
                    'Unable to initialize a SAML server object, the private key '
97
                    'is maybe invalid or unreadable, please check its access '
98
                    'rights and content.'
99
                ),
100
            )
101

  
96 102
    def set_next_url(self, next_url):
97 103
        if not check_next_url(self.request, next_url):
98 104
            return
......
124 130
    def get_next_url(self, default=None):
125 131
        return self.get_state('next_url', default=default)
126 132

  
127
    def show_message_status_is_not_success(self, profile, prefix):
133
    @property
134
    def template_base(self):
135
        return self.kwargs.get('template_base', 'base.html')
136

  
137
    def render(self, request, template_names, context, status=None):
138
        context['template_base'] = self.template_base
139
        if 'context_hook' in self.kwargs:
140
            self.kwargs['context_hook'](context)
141
        return render(request, template_names, context, status=status)
142

  
143
    def failure_status_is_not_success(self, request, profile, prefix):
128 144
        status_codes, idp_message = utils.get_status_codes_and_message(profile)
129 145
        args = ['%s: status is not success codes: %r', prefix, status_codes]
130 146
        if idp_message:
131 147
            args[0] += ' message: %s'
132 148
            args.append(idp_message)
133 149
        self.log.warning(*args)
150
        return self.failure(
151
            request,
152
            reasons=[
153
                _('%s refused by identity provider') % prefix,
154
                idp_message,
155
                _('Status codes: %s') % ', '.join([str(code) for code in status_codes]),
156
            ],
157
            status_codes=status_codes,
158
        )
134 159

  
135
    def dispatch(self, request, *args, **kwargs):
136
        try:
137
            return super().dispatch(request, *args, **kwargs)
138
        except utils.CreateServerError:
139
            return self.failure(
140
                request,
141
                reason=_(
142
                    'Unable to initialize a SAML server object, the private key '
143
                    'is maybe invalid or unreadable, please check its access '
144
                    'rights and content.'
145
                ),
146
            )
147

  
148
    def failure(self, request, reason='', status_codes=()):
160
    def failure(self, request, reason='', reasons=None, status_codes=(), exception=None, debug_details=None):
149 161
        '''show error message to user after a login failure'''
150
        login = self.profile
151
        idp = utils.get_idp(login and login.remoteProviderId)
152
        if not idp and login:
153
            self.log.warning('entity id %r is unknown', login.remoteProviderId)
154
            return HttpResponseBadRequest('entity id %r is unknown' % login.remoteProviderId)
162
        reasons = reasons or []
163
        reasons.append(reason)
164
        profile = self.profile
165
        idp = utils.get_idp(profile.remoteProviderId) if profile else {}
166
        if not idp and profile and not isinstance(exception, lasso.ServerProviderNotFoundError):
167
            self.log.warning('entity id %r is unknown', profile.remoteProviderId)
168
            reasons.insert(
169
                0,
170
                _('Other error: %s')
171
                % (_('the received message EntityID (%r) is unknown.') % profile.remoteProviderId),
172
            )
173
        if exception and isinstance(
174
            exception,
175
            (
176
                lasso.DsError,
177
                lasso.ProfileCannotVerifySignatureError,
178
                lasso.LoginInvalidSignatureError,
179
                lasso.LoginInvalidAssertionSignatureError,
180
            ),
181
        ):
182
            reasons.append(
183
                _(
184
                    'There was a problem with a signature. It usually means '
185
                    'that signature key has changed or is unknown. '
186
                    'The metadata of the identity provider may be out of date.'
187
                )
188
            )
189
            if 'METADATA_URL' not in idp:
190
                reasons.append(
191
                    _(
192
                        'Possible solution: you currently do not use METADATA_URL '
193
                        'to reference the metadata, use it to always keep the metadata up to date.'
194
                    )
195
                )
155 196
        error_url = utils.get_setting(idp, 'ERROR_URL')
156 197
        error_redirect_after_timeout = utils.get_setting(idp, 'ERROR_REDIRECT_AFTER_TIMEOUT')
157 198
        if error_url:
......
162 203
            'mellon/authentication_failed.html',
163 204
            {
164 205
                'debug': settings.DEBUG,
165
                'reason': reason,
206
                'reasons': reasons,
166 207
                'status_codes': status_codes,
167
                'issuer': login and login.remoteProviderId,
208
                'issuer': profile.remoteProviderId if profile else None,
168 209
                'next_url': next_url,
169
                'relaystate': login and login.msgRelayState,
210
                'relaystate': profile.msgRelayState if profile else None,
170 211
                'error_redirect_after_timeout': error_redirect_after_timeout,
212
                'debug_details': debug_details or {},
171 213
            },
214
            status=400,
172 215
        )
173 216

  
174 217

  
......
178 221
        with self.capture_logs() if self.debug_login else nullcontext():
179 222
            return super().dispatch(request, *args, **kwargs)
180 223

  
181
    @property
182
    def template_base(self):
183
        return self.kwargs.get('template_base', 'base.html')
184

  
185
    def render(self, request, template_names, context):
186
        context['template_base'] = self.template_base
187
        if 'context_hook' in self.kwargs:
188
            self.kwargs['context_hook'](context)
189
        return render(request, template_names, context)
190

  
191
    def get_idp(self, request):
192
        entity_id = request.POST.get('entityID') or request.GET.get('entityID')
224
    def get_idp(self, entity_id):
193 225
        if not entity_id:
194 226
            for idp in utils.get_idps():
195 227
                return idp
......
202 234
        '''Assertion consumer'''
203 235
        if 'SAMLart' in request.POST:
204 236
            if 'artifact' not in app_settings.ASSERTION_CONSUMER_BINDINGS:
205
                raise Http404('artifact binding is not supported')
237
                raise self.failure(request, _('Artifact binding is not supported'))
206 238
            return self.continue_sso_artifact(request, lasso.HTTP_METHOD_ARTIFACT_POST)
207 239
        if 'SAMLResponse' not in request.POST:
208 240
            if 'post' not in app_settings.ASSERTION_CONSUMER_BINDINGS:
209
                raise Http404('post binding is not supported')
241
                raise self.failure(request, _('Post binding is not supported'))
210 242
            return self.get(request, *args, **kwargs)
243
        # prevent null characters in SAMLResponse
211 244
        if not utils.is_nonnull(request.POST['SAMLResponse']):
212
            return HttpResponseBadRequest('SAMLResponse contains a null character')
245
            return self.failure(_('SAMLResponse contains a null character'))
213 246
        self.log.info('Got SAML Response', extra={'saml_response': request.POST['SAMLResponse']})
214 247
        self.profile = login = utils.create_login(request)
215
        idp_message = None
216
        status_codes = []
217
        # prevent null characters in SAMLResponse
218 248
        try:
219 249
            login.processAuthnResponseMsg(request.POST['SAMLResponse'])
220 250
            login.acceptSso()
221
        except lasso.ProfileCannotVerifySignatureError:
222
            self.log.warning(
223
                'SAML authentication failed: signature validation failed for %r', login.remoteProviderId
224
            )
225
        except lasso.ParamError:
226
            self.log.exception('lasso param error')
227 251
        except (
228 252
            lasso.LoginStatusNotSuccessError,
229 253
            lasso.ProfileStatusNotSuccessError,
230 254
            lasso.ProfileRequestDeniedError,
231 255
        ):
232
            self.show_message_status_is_not_success(login, 'SAML authentication failed')
233
        except lasso.Error as e:
234
            return HttpResponseBadRequest('error processing the authentication response: %r' % e)
235
        else:
236
            if 'RelayState' in request.POST and utils.is_nonnull(request.POST['RelayState']):
237
                login.msgRelayState = request.POST['RelayState']
238
            return self.sso_success(request, login)
239
        return self.failure(request, reason=idp_message, status_codes=status_codes)
256
            return self.failure_status_is_not_success(request, login, _('Login'))
257
        except Exception as e:
258
            return self.failure(request, _('Technical error'), exception=e)
259
        if 'RelayState' in request.POST and utils.is_nonnull(request.POST['RelayState']):
260
            login.msgRelayState = request.POST['RelayState']
261
        return self.sso_success(request, login)
240 262

  
241 263
    def get_attribute_value(self, attribute, attribute_value):
242 264
        # check attribute_value contains only text
......
369 391
        return response
370 392

  
371 393
    def continue_sso_artifact(self, request, method):
372
        idp_message = None
373
        status_codes = []
374

  
375 394
        if method == lasso.HTTP_METHOD_ARTIFACT_GET:
376 395
            message = request.META['QUERY_STRING']
377 396
            artifact = request.GET['SAMLart']
......
386 405
            login.msgRelayState = relay_state
387 406
        try:
388 407
            login.initRequest(message, method)
389
        except lasso.ProfileInvalidArtifactError:
408
        except lasso.ProfileInvalidArtifactError as e:
390 409
            self.log.warning('artifact is malformed %r', artifact)
391
            return HttpResponseBadRequest('artifact is malformed %r' % artifact)
392
        except lasso.ServerProviderNotFoundError:
410
            return self.failure(request, _('Artifact is invalid.'), exception=e)
411
        except lasso.ServerProviderNotFoundError as e:
393 412
            self.log.warning('no entity id found for artifact %s', artifact)
394
            return HttpResponseBadRequest('no entity id found for this artifact %r' % artifact)
413
            return self.failure(
414
                request,
415
                _('the received message EntityID (%r) is unknown.') % login.remoteProviderId,
416
                exception=e,
417
            )
395 418
        idp = utils.get_idp(login.remoteProviderId)
396 419
        if not idp:
397
            return HttpResponseBadRequest('entity id %r is unknown' % login.remoteProviderId)
420
            return self.failure(
421
                request,
422
                _('the received message EntityID (%r) is unknown.') % login.remoteProviderId,
423
            )
398 424
        verify_ssl_certificate = utils.get_setting(idp, 'VERIFY_SSL_CERTIFICATE')
399 425
        login.buildRequestMsg()
400 426
        try:
......
407 433
            )
408 434
        except RequestException as e:
409 435
            self.log.warning('unable to reach %r: %s', login.msgUrl, e)
410
            return self.failure(
411
                request,
412
                reason=_('IdP is temporarily down, please try again ' 'later.'),
413
                status_codes=status_codes,
414
            )
436
            return self.failure(request, reason=_('IdP is temporarily down, please try again later.'))
415 437
        if result.status_code != 200:
416 438
            self.log.warning(
417 439
                'SAML authentication failed: IdP returned %s when given artifact: %r',
418 440
                result.status_code,
419 441
                result.content,
420 442
            )
421
            return self.failure(request, reason=idp_message, status_codes=status_codes)
443
            return self.failure(request, reason=_('IdP is temporarily down, please try again later.'))
422 444

  
423 445
        self.log.info('Got SAML Artifact Response', extra={'saml_response': result.content})
424 446
        result.encoding = utils.get_xml_encoding(result.content)
......
429 451
            # artifact is invalid, idp returned no response
430 452
            self.log.warning('ArtifactResolveResponse is empty: dead artifact %r', artifact)
431 453
            return self.retry_login()
432
        except lasso.ProfileInvalidMsgError:
454
        except lasso.ProfileInvalidMsgError as e:
433 455
            self.log.warning('ArtifactResolveResponse is malformed %r', result.content[:200])
434
            if settings.DEBUG:
435
                return HttpResponseBadRequest('ArtififactResolveResponse is malformed\n%r' % result.content)
436
            else:
437
                return HttpResponseBadRequest('ArtififactResolveResponse is malformed')
438
        except lasso.ProfileCannotVerifySignatureError:
439
            self.log.warning(
440
                'SAML authentication failed: signature validation failed for %r', login.remoteProviderId
456
            return self.failure(
457
                request,
458
                _('Could not process the ArtifactResolveResponse'),
459
                exception=e,
441 460
            )
442
        except lasso.ParamError:
443
            self.log.exception('lasso param error')
444 461
        except (
445 462
            lasso.LoginStatusNotSuccessError,
446 463
            lasso.ProfileStatusNotSuccessError,
447 464
            lasso.ProfileRequestDeniedError,
448 465
        ):
449
            status = login.response.status
450
            a = status
451
            while a.statusCode:
452
                status_codes.append(a.statusCode.value)
453
                a = a.statusCode
454
            args = ['SAML authentication failed: status is not success codes: %r', status_codes]
455
            if status.statusMessage:
456
                idp_message = lasso_decode(status.statusMessage)
457
                args[0] += ' message: %r'
458
                args.append(status.statusMessage)
459
            self.log.warning(*args)
460
        except lasso.Error as e:
461
            self.log.exception('unexpected lasso error')
462
            return HttpResponseBadRequest('error processing the authentication response: %r' % e)
466
            return self.failure_status_is_not_success(request, login, _('Login'))
467
        except Exception as e:
468
            return self.failure(
469
                request,
470
                _('Technical error'),
471
                exception=e,
472
                debug_details={'ArtifactResolveResponse_content': result.content},
473
            )
463 474
        else:
464 475
            return self.sso_success(request, login)
465
        return self.failure(request, reason=idp_message, status_codes=status_codes)
466 476

  
467 477
    def request_discovery_service(self, request, is_passive=False):
468 478
        return_url = request.build_absolute_uri()
......
495 505
            return self.request_discovery_service(request, is_passive=request.GET.get('passive') == '1')
496 506

  
497 507
        next_url = check_next_url(self.request, request.GET.get(REDIRECT_FIELD_NAME))
498
        idp = self.get_idp(request)
508
        entity_id = request.POST.get('entityID') or request.GET.get('entityID')
509
        idp = self.get_idp(entity_id)
499 510
        if not idp:
500
            return HttpResponseBadRequest('no idp found')
511
            return self.failure(request, _('No idp found for "%s"') % (entity_id or ''))
501 512
        self.profile = login = utils.create_login(request)
502 513
        self.log.debug('authenticating to %r', idp['ENTITY_ID'])
503 514
        try:
......
550 561
            self.add_login_hints(idp, authn_request, request=request, next_url=next_url or '/')
551 562
            login.buildAuthnRequestMsg()
552 563
        except lasso.Error as e:
553
            return HttpResponseBadRequest('error initializing the authentication request: %r' % e)
564
            return self.failure(request, _('Could not initialize the authentication request'), exception=e)
554 565
        self.log.debug('sending authn request %r', authn_request.dump())
555 566
        self.log.debug('to url %r', login.msgUrl)
556 567
        return HttpResponseRedirect(login.msgUrl)
......
661 672
        try:
662 673
            logout.processRequestMsg(msg)
663 674
        except lasso.Error as e:
664
            return HttpResponseBadRequest('error processing logout request: %r' % e)
675
            return self.failure(request, _('Could not process the logout request'), exception=e)
665 676

  
666 677
        entity_id = force_text(logout.remoteProviderId)
667 678
        session_indexes = {force_text(sessionIndex) for sessionIndex in logout.request.sessionIndexes}
......
706 717
        try:
707 718
            logout.buildResponseMsg()
708 719
        except lasso.Error as e:
709
            return HttpResponseBadRequest('error processing logout request: %r' % e)
720
            return self.failure(request, _('Could not process the logout response'), exception=e)
710 721
        if logout.msgBody:
711 722
            return HttpResponse(force_text(logout.msgBody), content_type='text/xml')
712 723
        else:
......
759 770
        try:
760 771
            logout.processResponseMsg(request.META['QUERY_STRING'])
761 772
        except lasso.ProfileStatusNotSuccessError:
762
            self.show_message_status_is_not_success(logout, 'SAML logout failed')
773
            return self.failure_status_is_not_success(request, logout, _('Logout'))
763 774
        except lasso.LogoutPartialLogoutError:
764 775
            self.log.warning('partial logout')
765 776
        except lasso.Error as e:
tests/test_sso_slo.py
429 429
    )
430 430
    assert not relay_state
431 431
    assert url.endswith(reverse('mellon_login'))
432
    response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
432
    response = app.post(
433
        reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}, status=400
434
    )
435
    assert 'User is not allowed to login' in response
433 436
    assert (
434 437
        "status is not success codes: ['urn:oasis:names:tc:SAML:2.0:status:Responder',\
435 438
 'urn:oasis:names:tc:SAML:2.0:status:RequestDenied']"
436 439
        in caplog.text
437 440
    )
441
    assert 'User is not allowed to login' in caplog.text
438 442

  
439 443

  
440 444
@pytest.mark.urls('urls_tests_template_base')
......
444 448
    url, body, relay_state = idp.process_authn_request_redirect(
445 449
        response['Location'], auth_result=False, msg='User is not allowed to login'
446 450
    )
447
    response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
451
    response = app.post(
452
        reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}, status=400
453
    )
448 454
    assert 'Theme is ok' in response.text
449 455

  
450 456
    response = app.get(reverse('mellon_login'))
......
461 467
    url, body, relay_state = idp.process_authn_request_redirect(
462 468
        response['Location'], auth_result=False, msg='User is not allowed to login'
463 469
    )
464
    response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
470
    response = app.post(
471
        reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}, status=400
472
    )
465 473
    assert 'Theme is ok' in response.text
466 474
    assert 'HOOK' in response.text
467 475

  
......
477 485
    url, body, relay_state = idp.process_authn_request_redirect(
478 486
        response['Location'], auth_result=False, msg='User is not allowed to login'
479 487
    )
480
    response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
488
    response = app.post(
489
        reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}, status=400
490
    )
481 491
    assert 'Theme is ok' not in response.text
482 492

  
483 493

  
......
496 506
    assert 'SAMLart' in url
497 507
    acs_artifact_url = url.split('testserver', 1)[1]
498 508
    with HTTMock(idp.mock_artifact_resolver()):
499
        response = app.get(acs_artifact_url, params={'RelayState': relay_state})
509
        response = app.get(acs_artifact_url, params={'RelayState': relay_state}, status=400)
500 510
    assert (
501 511
        "status is not success codes: ['urn:oasis:names:tc:SAML:2.0:status:Responder',\
502 512
 'urn:oasis:names:tc:SAML:2.0:status:RequestDenied']"
......
601 611
    # forget the artifact
602 612
    idp.artifact = ''
603 613
    with HTTMock(idp.mock_artifact_resolver()):
604
        response = app.get(acs_artifact_url)
614
        response = app.get(acs_artifact_url, status=400)
605 615

  
606 616
    # check cookie is deleted after failed retry
607 617
    # Py3-Dj111 variation
tests/test_views.py
158 158
    private_settings.MELLON_IDENTITY_PROVIDERS = []
159 159
    response = client.get('/login/')
160 160
    assert response.status_code == 400
161
    assert b'no idp found' in response.content
161
    assert 'No idp found for' in response.content.decode()
162 162

  
163 163

  
164 164
def test_sp_initiated_login_discovery_service(private_settings, client):
......
190 190
    private_settings.MELLON_IDENTITY_PROVIDERS = []
191 191
    private_settings.MELLON_DISCOVERY_SERVICE_URL = 'https://disco'
192 192
    response = client.get('/login/?nodisco=1')
193
    assert response.status_code == 400
194
    assert b'no idp found' in response.content
193
    assert 'No idp found for' in response.content.decode()
195 194

  
196 195

  
197 196
def test_sp_initiated_login(private_settings, client):
......
254 253
    )
255 254

  
256 255

  
257
def test_malfortmed_artifact(private_settings, client, caplog):
256
def test_malfortmed_artifact(private_settings, app, caplog):
258 257
    private_settings.MELLON_IDENTITY_PROVIDERS = [
259 258
        {
260 259
            'METADATA': open('tests/metadata.xml').read(),
261 260
        }
262 261
    ]
263
    response = client.get('/login/?SAMLart=xxx', status=400)
264
    assert response['Content-Type'] == 'text/plain'
265
    assert response['X-Content-Type-Options'] == 'nosniff'
266
    assert b'artifact is malformed' in response.content
262
    response = app.get('/login/?SAMLart=xxx', status=400)
263
    assert response['Content-Type'].split(';')[0] == 'text/html'
264
    assert 'Artifact is invalid' in response
267 265
    assert 'artifact is malformed' in caplog.text
268 266

  
269 267

  
......
308 306
        fd.write('1')
309 307
    private_key.chmod(0o000)
310 308
    private_settings.MELLON_PRIVATE_KEY = str(private_key)
311
    response = app.get('/login/?next=%2Fwhatever')
309
    response = app.get('/login/?next=%2Fwhatever', status=400)
312 310
    assert 'Unable to initialize a SAML server object' in response
313
-