refactor next_url and RelayState use (fixes #10372)

The next_url parameter is  no more stored directly in the RelayState, as it
RelayState should only contain strings of no more thant 80 bytes, instead
generate an uuid as the relaystate and store the next_url value in session using
a key based on this uuid.

The implementation is generic enough to accomodate storing any other kind of
data during an SSO or SLO workflow.
This commit is contained in:
Benjamin Dauvergne 2016-03-18 15:48:29 +01:00
parent bfa84bb6ba
commit 66d1811e2f
2 changed files with 76 additions and 25 deletions

View File

@ -1,6 +1,7 @@
import logging
import requests
import lasso
import uuid
from requests.exceptions import RequestException
from django.views.generic import View
@ -8,21 +9,70 @@ from django.http import HttpResponseBadRequest, HttpResponseRedirect, HttpRespon
from django.contrib import auth
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import render, redirect, resolve_url
from django.shortcuts import render, resolve_url
from django.utils.http import urlencode
from django.contrib.auth import REDIRECT_FIELD_NAME
from . import app_settings, utils
lasso.setFlag('thin-sessions')
class LogMixin(object):
"""Initialize a module logger in new objects"""
def __init__(self, *args, **kwargs):
self.log = logging.getLogger(__name__)
super(LogMixin, self).__init__(*args, **kwargs)
class LoginView(LogMixin, View):
class ProfileMixin(object):
profile = None
def set_next_url(self, next_url):
if not next_url:
return
if not utils.is_nonnull(next_url):
self.log.warning('next parameter ignored, as it contains null characters')
return
try:
next_url.encode('ascii')
except UnicodeDecodeError:
self.log.warning('next parameter ignored, as is\'s not an ASCII string')
return
if not utils.same_origin(next_url, self.request.build_absolute_uri()):
self.log.warning('next parameter ignored as it is not of the same origin')
return
self.set_state('next_url', next_url)
def set_state(self, name, value):
assert self.profile
relay_state = self.get_relay_state(create=True)
self.request.session['mellon_%s_%s' % (name, relay_state)] = value
def get_state(self, name, default=None):
if self.profile:
relay_state = self.get_relay_state()
key = 'mellon_%s_%s' % (name, relay_state)
return self.request.session.get(key, default)
return default
def get_relay_state(self, create=False):
if self.profile and self.profile.msgRelayState:
try:
return uuid.UUID(self.profile.msgRelayState)
except ValueError:
pass
if create:
assert self.profile
self.profile.msgRelayState = str(uuid.uuid4())
return self.profile.msgRelayState
def get_next_url(self, default=None):
return self.get_state('next_url', default=default)
class LoginView(ProfileMixin, LogMixin, View):
def get_idp(self, request):
entity_id = request.POST.get('entityID') or request.GET.get('entityID')
if not entity_id:
@ -39,7 +89,7 @@ class LoginView(LogMixin, View):
return self.get(request, *args, **kwargs)
if not utils.is_nonnull(request.POST['SAMLResponse']):
return HttpResponseBadRequest('SAMLResponse contains a null character')
login = utils.create_login(request)
self.profile = login = utils.create_login(request)
idp_message = None
status_codes = []
# prevent null characters in SAMLResponse
@ -132,6 +182,7 @@ class LoginView(LogMixin, View):
def authenticate(self, request, login, attributes):
user = auth.authenticate(saml_attributes=attributes)
next_url = self.get_next_url(default=resolve_url(settings.LOGIN_REDIRECT_URL))
if user is not None:
if user.is_active:
auth.login(request, user)
@ -149,14 +200,14 @@ class LoginView(LogMixin, View):
return render(request, 'mellon/user_not_found.html', {
'saml_attributes': attributes })
request.session['lasso_session_dump'] = login.session.dump()
next_url = login.msgRelayState or resolve_url(settings.LOGIN_REDIRECT_URL)
return HttpResponseRedirect(next_url)
def continue_sso_artifact_get(self, request):
idp_message = None
status_codes = []
login = utils.create_login(request)
self.profile = login = utils.create_login(request)
try:
login.initRequest(request.META['QUERY_STRING'], lasso.HTTP_METHOD_ARTIFACT_GET)
except lasso.ProfileInvalidArtifactError:
@ -250,11 +301,11 @@ class LoginView(LogMixin, View):
return self.request_discovery_service(
request, is_passive=request.GET.get('passive') == '1')
next_url = request.GET.get('next')
next_url = request.GET.get(REDIRECT_FIELD_NAME)
idp = self.get_idp(request)
if idp is None:
return HttpResponseBadRequest('no idp found')
login = utils.create_login(request)
self.profile = login = utils.create_login(request)
self.log.debug('authenticating to %r', idp['ENTITY_ID'])
try:
login.initAuthnRequest(idp['ENTITY_ID'],
@ -275,8 +326,7 @@ class LoginView(LogMixin, View):
req_authncontext = lasso.Samlp2RequestedAuthnContext()
authn_request.requestedAuthnContext = req_authncontext
req_authncontext.authnContextClassRef = tuple(authn_classref)
if next_url and utils.is_nonnull(next_url):
login.msgRelayState = next_url
self.set_next_url(next_url)
login.buildAuthnRequestMsg()
except lasso.Error, e:
return HttpResponseBadRequest('error initializing the '
@ -287,7 +337,7 @@ class LoginView(LogMixin, View):
login = csrf_exempt(LoginView.as_view())
class LogoutView(LogMixin, View):
class LogoutView(ProfileMixin, LogMixin, View):
def get(self, request):
if 'SAMLRequest' in request.GET:
return self.idp_logout(request)
@ -298,7 +348,7 @@ class LogoutView(LogMixin, View):
def idp_logout(self, request):
'''Handle logout request emitted by the IdP'''
logout = utils.create_logout(request)
self.profile = logout = utils.create_logout(request)
try:
logout.processRequestMsg(request.META['QUERY_STRING'])
except lasso.Error, e:
@ -320,15 +370,15 @@ class LogoutView(LogMixin, View):
def sp_logout_request(self, request):
'''Launch a logout request to the identity provider'''
next_url = resolve_url(settings.LOGIN_REDIRECT_URL)
next_url = request.GET.get('next') or next_url
next_url = request.GET.get(REDIRECT_FIELD_NAME)
referer = request.META.get('HTTP_REFERER')
if not referer or utils.same_origin(referer, request.build_absolute_uri()):
if request.user.is_authenticated():
logout = None
try:
issuer = request.session.get('mellon_session', {}).get('issuer')
if issuer:
logout = utils.create_logout(request)
self.profile = logout = utils.create_logout(request)
try:
if request.session.has_key('lasso_session_dump'):
logout.setSessionFromDump(
@ -337,8 +387,6 @@ class LogoutView(LogMixin, View):
else:
self.log.error('unable to find lasso session dump')
logout.initRequest(issuer, lasso.HTTP_METHOD_REDIRECT)
if utils.is_nonnull(next_url):
logout.msgRelayState = next_url
logout.buildRequestMsg()
except lasso.Error, e:
self.log.error('unable to initiate a logout request %r', e)
@ -347,8 +395,11 @@ class LogoutView(LogMixin, View):
self.log.debug('to URL %r', logout.msgUrl)
return HttpResponseRedirect(logout.msgUrl)
finally:
auth.logout(request)
self.log.info('user %r logged out, SLO request sent',
auth.logout(request)
# set next_url after local logout, as the session is wiped by auth.logout
if logout:
self.set_next_url(next_url)
self.log.info('user %r logged out, SLO request sent',
unicode(request.user))
else:
self.log.warning('logout refused referer %r is not of the '
@ -357,17 +408,14 @@ class LogoutView(LogMixin, View):
def sp_logout_response(self, request):
'''Launch a logout request to the identity provider'''
next_url = resolve_url(settings.LOGIN_REDIRECT_URL)
if 'SAMLResponse' not in request.GET:
return HttpResponseRedirect(next_url)
logout = utils.create_logout(request)
self.profile = logout = utils.create_logout(request)
try:
logout.processResponseMsg(request.META['QUERY_STRING'])
except lasso.Error, e:
self.log.error('unable to process a logout response %r', e)
if logout.msgRelayState and utils.same_origin(logout.msgRelayState, request.build_absolute_uri()):
return redirect(logout.msgRelayState)
return redirect(next_url)
return HttpResponseRedirect(resolve_url(settings.LOGIN_REDIRECT_URL))
next_url = self.get_next_url(default=resolve_url(settings.LOGIN_REDIRECT_URL))
return HttpResponseRedirect(next_url)
logout = LogoutView.as_view()

View File

@ -15,6 +15,9 @@ from xml_utils import assert_xml_constraints
from utils import error_500, html_response
pytestmark = pytest.mark.django_db
def test_null_character_on_samlresponse_post(app):
app.post(reverse('mellon_login'), {'SAMLResponse': '\x00'}, status=400)