From 0aa3d0628ebef30de89f0f8226a88cd0360b9fa8 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Fri, 28 Jan 2022 00:31:04 +0100 Subject: [PATCH] misc: maintain home url, service and ou (#61199) Home service is defined on SSO requests. Home URL is the OU home URL or the default homepage url, but on account's pages if you pass a ?next= to an /accounts/ or /register/ view and this URL can be linked ton an existing service, the home service is set and the URL is take as the home URL. --- src/authentic2/authenticators.py | 10 +- src/authentic2/constants.py | 1 - src/authentic2/context_processors.py | 17 +++ src/authentic2/idp/saml/saml2_endpoints.py | 9 +- src/authentic2/journal.py | 4 +- src/authentic2/middleware.py | 14 --- src/authentic2/models.py | 3 + src/authentic2/settings.py | 2 +- src/authentic2/templates/authentic2/base.html | 4 +- src/authentic2/utils/misc.py | 33 +++--- src/authentic2/utils/service.py | 109 ++++++++++-------- src/authentic2/views.py | 57 +++++---- src/authentic2_auth_fc/views.py | 24 ++-- src/authentic2_idp_cas/views.py | 3 + src/authentic2_idp_oidc/views.py | 8 +- tests/auth_fc/conftest.py | 8 +- tests/auth_fc/test_auth_fc.py | 20 ++-- tests/idp_oidc/test_misc.py | 20 ++-- tests/test_context_processors.py | 57 +++++++++ tests/test_idp_saml2.py | 3 +- tests/test_login.py | 34 +++--- tests/test_template.py | 24 ---- tests/utils.py | 18 +++ 23 files changed, 277 insertions(+), 205 deletions(-) create mode 100644 tests/test_context_processors.py diff --git a/src/authentic2/authenticators.py b/src/authentic2/authenticators.py index 07173f73..df32d293 100644 --- a/src/authentic2/authenticators.py +++ b/src/authentic2/authenticators.py @@ -29,7 +29,7 @@ from . import app_settings, views from .forms import authentication as authentication_forms from .utils import misc as utils_misc from .utils.evaluate import evaluate_condition -from .utils.service import get_service_from_request +from .utils.service import get_service from .utils.views import csrf_token_check logger = logging.getLogger(__name__) @@ -88,7 +88,8 @@ class LoginPasswordAuthenticator(BaseAuthenticator): return [] return OU.objects.filter(pk__in=service_ou_ids) - def get_preferred_ous(self, request, service): + def get_preferred_ous(self, request): + service = get_service(request) preferred_ous_cookie = utils_misc.get_remember_cookie(request, 'preferred-ous') preferred_ous = [] if preferred_ous_cookie: @@ -102,7 +103,6 @@ class LoginPasswordAuthenticator(BaseAuthenticator): return preferred_ous def login(self, request, *args, **kwargs): - service = get_service_from_request(request) context = kwargs.get('context', {}) is_post = request.method == 'POST' and self.submit_name in request.POST data = request.POST if is_post else None @@ -112,7 +112,7 @@ class LoginPasswordAuthenticator(BaseAuthenticator): # Special handling when the form contains an OU selector if app_settings.A2_LOGIN_FORM_OU_SELECTOR: - preferred_ous = self.get_preferred_ous(request, service) + preferred_ous = self.get_preferred_ous(request) if preferred_ous: initial['ou'] = preferred_ous[0] @@ -135,7 +135,7 @@ class LoginPasswordAuthenticator(BaseAuthenticator): if form.cleaned_data.get('remember_me'): request.session['remember_me'] = True request.session.set_expiry(app_settings.A2_USER_REMEMBER_ME) - response = utils_misc.login(request, form.get_user(), how, service=service) + response = utils_misc.login(request, form.get_user(), how) if 'ou' in form.fields: utils_misc.prepend_remember_cookie( request, response, 'preferred-ous', form.cleaned_data['ou'].pk diff --git a/src/authentic2/constants.py b/src/authentic2/constants.py index f32c226c..c0bd0d34 100644 --- a/src/authentic2/constants.py +++ b/src/authentic2/constants.py @@ -20,5 +20,4 @@ CANCEL_FIELD_NAME = 'cancel' AUTHENTICATION_EVENTS_SESSION_KEY = 'authentication-events' SWITCH_USER_SESSION_KEY = '_switch_user' LAST_LOGIN_SESSION_KEY = '_last_login' -SERVICE_FIELD_NAME = 'service' NEXT_URL_SIGNATURE = 'next-signature' diff --git a/src/authentic2/context_processors.py b/src/authentic2/context_processors.py index d821bf55..a977af05 100644 --- a/src/authentic2/context_processors.py +++ b/src/authentic2/context_processors.py @@ -20,6 +20,7 @@ from pkg_resources import get_distribution from . import app_settings, constants from .models import Service from .utils import misc as utils_misc +from .utils.service import get_service class UserFederations: @@ -69,3 +70,19 @@ def a2_processor(request): except Service.DoesNotExist: pass return variables + + +def home(request): + ctx = {} + service = get_service(request) + if service: + ctx['home_service'] = service + if service.ou: + ctx['home_ou'] = service.ou + if request.session.get('home_url'): + ctx['home_url'] = request.session['home_url'] + elif service and service.ou and service.ou.home_url: + ctx['home_url'] = service.ou.home_url + else: + ctx['home_url'] = app_settings.A2_HOMEPAGE_URL or settings.LOGIN_REDIRECT_URL + return ctx diff --git a/src/authentic2/idp/saml/saml2_endpoints.py b/src/authentic2/idp/saml/saml2_endpoints.py index 08dacc95..12b6ed6b 100644 --- a/src/authentic2/idp/saml/saml2_endpoints.py +++ b/src/authentic2/idp/saml/saml2_endpoints.py @@ -113,6 +113,7 @@ from authentic2.utils import misc as utils_misc from authentic2.utils.misc import datetime_to_xs_datetime, find_authentication_event from authentic2.utils.misc import get_backends as get_idp_backends from authentic2.utils.misc import login_require, make_url +from authentic2.utils.service import set_service from authentic2.utils.view_decorators import check_view_restriction, enable_view_restriction from . import app_settings @@ -582,6 +583,7 @@ def sso(request): }, ) else: + set_service(request, provider_loaded) policy = get_sp_options_policy(provider_loaded) if not policy: return error_page(request, _('sso: No SP policy defined'), logger=logger, warning=True) @@ -628,7 +630,7 @@ def sso(request): return sso_after_process_request(request, login, nid_format=nid_format) -def need_login(request, login, nid_format, service): +def need_login(request, login, nid_format): """Redirect to the login page with a nonce parameter to verify later that the login form was submitted """ @@ -640,7 +642,6 @@ def need_login(request, login, nid_format, service): request, next_url=next_url, params={NONCE_FIELD_NAME: nonce}, - service=service, login_hint=get_login_hints_extension(login), ) @@ -789,7 +790,7 @@ def sso_after_process_request( if not passive and (user.is_anonymous or (force_authn and not did_auth)): logger.debug('login required') - return need_login(request, login, nid_format, service) + return need_login(request, login, nid_format) # No user is authenticated and passive is True, deny request if passive and user.is_anonymous: @@ -1296,6 +1297,7 @@ def slo_soap(request): except ObjectDoesNotExist: logger.warning('provider %r unknown', logout.remoteProviderId) return return_logout_error(request, logout, AUTHENTIC_STATUS_CODE_UNAUTHORIZED) + set_service(request, provider) policy = get_sp_options_policy(provider) if not policy: logger.warning('No policy found for %s', logout.remoteProviderId) @@ -1385,6 +1387,7 @@ def slo(request): except ObjectDoesNotExist: logger.debug('provider %r unknown', logout.remoteProviderId) return return_logout_error(request, logout, AUTHENTIC_STATUS_CODE_UNAUTHORIZED) + set_service(request, provider) policy = get_sp_options_policy(provider) if not policy: logger.debug('No policy found for %s', logout.remoteProviderId) diff --git a/src/authentic2/journal.py b/src/authentic2/journal.py index b0172d52..b6eb707d 100644 --- a/src/authentic2/journal.py +++ b/src/authentic2/journal.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from authentic2.apps.journal.journal import Journal as BaseJournal -from authentic2.utils.service import get_service_from_request +from authentic2.utils.service import get_service class Journal(BaseJournal): @@ -25,7 +25,7 @@ class Journal(BaseJournal): @property def service(self): - return self._service or (get_service_from_request(self.request) if self.request else None) + return self._service or get_service(self.request) if self.request else None def massage_kwargs(self, record_parameters, kwargs): if 'service' not in kwargs and 'service' in record_parameters: diff --git a/src/authentic2/middleware.py b/src/authentic2/middleware.py index 6be6eb5b..f0e95520 100644 --- a/src/authentic2/middleware.py +++ b/src/authentic2/middleware.py @@ -28,12 +28,10 @@ from django.conf import settings from django.contrib import messages from django.db.models import Model from django.utils.deprecation import MiddlewareMixin -from django.utils.functional import SimpleLazyObject from django.utils.translation import ugettext as _ from . import app_settings, plugins from .utils import misc as utils_misc -from .utils.service import get_service_from_request, get_service_from_session class CollectIPMiddleware(MiddlewareMixin): @@ -263,18 +261,6 @@ class CookieTestMiddleware(MiddlewareMixin): return response -class SaveServiceInSessionMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - service = get_service_from_request(request) - if service: - request.session['service_pk'] = service.pk - request.service = SimpleLazyObject(lambda: get_service_from_session(request)) - return self.get_response(request) - - def journal_middleware(get_response): from . import journal diff --git a/src/authentic2/models.py b/src/authentic2/models.py index c7dddb49..3a31a1c6 100644 --- a/src/authentic2/models.py +++ b/src/authentic2/models.py @@ -451,6 +451,9 @@ class Service(models.Model): def get_absolute_url(self): return reverse('a2-manager-service', kwargs={'service_pk': self.pk}) + def get_base_urls(self): + return [] + Service._meta.natural_key = [['slug', 'ou']] diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index a05511d9..c4b1619e 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -81,6 +81,7 @@ TEMPLATES = [ 'django.contrib.messages.context_processors.messages', 'django.template.context_processors.static', 'authentic2.context_processors.a2_processor', + 'authentic2.context_processors.home', ], }, }, @@ -96,7 +97,6 @@ MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.middleware.http.ConditionalGetMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', - 'authentic2.middleware.SaveServiceInSessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', diff --git a/src/authentic2/templates/authentic2/base.html b/src/authentic2/templates/authentic2/base.html index 4c0269fc..bd56e803 100644 --- a/src/authentic2/templates/authentic2/base.html +++ b/src/authentic2/templates/authentic2/base.html @@ -12,7 +12,9 @@ {% endblock %} {% block bodyargs %} - data-service-slug="{{ service.slug }}" data-service-name="{{ service.name }}" + {% if home_url %}data-home-url="{{ home_url }}"{% endif %} + {% if home_service %}data-home-service-slug="{{ home_service.slug }}" data-home-service-name="{{ home_service.name }}"{% endif %} + {% if home_ou %}data-home-ou-slug="{{ home_ou.slug }}" data-home-ou-name="{{ home_ou.name }}"{% endif %} {% endblock %} {% block extrascripts %} diff --git a/src/authentic2/utils/misc.py b/src/authentic2/utils/misc.py index 8cd6e871..0db3b3da 100644 --- a/src/authentic2/utils/misc.py +++ b/src/authentic2/utils/misc.py @@ -50,7 +50,6 @@ from django.utils.translation import ungettext from authentic2.saml.saml2utils import filter_attribute_private_key, filter_element_private_key from .. import app_settings, constants, crypto, plugins -from .service import set_service_ref class CleanLogMessage(logging.Filter): @@ -455,15 +454,13 @@ def last_authentication_event(request=None, session=None): return None -def login(request, user, how, service=None, service_slug=None, nonce=None, record=True, **kwargs): +def login(request, user, how, nonce=None, record=True, **kwargs): """Login a user model, record the authentication event and redirect to next URL or settings.LOGIN_REDIRECT_URL.""" from .. import hooks + from .service import get_service from .views import check_cookie_works - if service: - assert service_slug is None - service_slug = service.slug check_cookie_works(request) last_login = user.last_login auth_login(request, user) @@ -472,23 +469,21 @@ def login(request, user, how, service=None, service_slug=None, nonce=None, recor if constants.LAST_LOGIN_SESSION_KEY not in request.session: request.session[constants.LAST_LOGIN_SESSION_KEY] = localize(to_current_timezone(last_login), True) record_authentication_event(request, how, nonce=nonce) - hooks.call_hooks('event', name='login', user=user, how=how, service=service_slug) + hooks.call_hooks('event', name='login', user=user, how=how, service=get_service(request)) # prevent logint-hint to influence next use of the login page if 'login-hint' in request.session: del request.session['login-hint'] if record: - request.journal.record('user.login', how=how, service=service) + request.journal.record('user.login', how=how) return continue_to_next_url(request, **kwargs) -def login_require(request, next_url=None, login_url='auth_login', service=None, login_hint=(), **kwargs): +def login_require(request, next_url=None, login_url='auth_login', login_hint=(), **kwargs): '''Require a login and come back to current URL''' next_url = next_url or request.get_full_path() params = kwargs.setdefault('params', {}) params[REDIRECT_FIELD_NAME] = next_url - if service: - set_service_ref(params, service) if login_hint: request.session['login-hint'] = list(login_hint) elif 'login-hint' in request.session: @@ -735,14 +730,12 @@ def get_fk_model(model, fieldname): return field.related_model -def get_registration_url(request, service=None): +def get_registration_url(request): next_url = select_next_url(request, settings.LOGIN_REDIRECT_URL) next_url = make_url( next_url, request=request, keep_params=True, include=(constants.NONCE_FIELD_NAME,), resolve=False ) params = {REDIRECT_FIELD_NAME: next_url} - if service: - set_service_ref(params, service) return make_url('registration_register', params=params) @@ -1041,9 +1034,17 @@ def get_next_url(params, field_name=None): return next_url -def select_next_url(request, default, field_name=None, include_post=False, replace=None): +EMPTY = object() + + +def select_next_url(request, default=EMPTY, field_name=None, include_post=False, replace=None): '''Select the first valid next URL''' # pylint: disable=consider-using-ternary + if default is EMPTY: + if request.user.is_authenticated and request.user.ou and request.user.ou.home_url: + default = request.user.ou.home_url + else: + default = settings.LOGIN_REDIRECT_URL next_url = (include_post and get_next_url(request.POST, field_name=field_name)) or get_next_url( request.GET, field_name=field_name ) @@ -1143,7 +1144,7 @@ def same_origin(url1, url2): return True -def simulate_authentication(request, user, method, backend=None, service=None, record=False, **kwargs): +def simulate_authentication(request, user, method, backend=None, record=False, **kwargs): """Simulate a normal login by eventually forcing a backend attribute on the user instance""" if not getattr(user, 'backend', None) and not backend: @@ -1151,7 +1152,7 @@ def simulate_authentication(request, user, method, backend=None, service=None, r if backend: user = copy.deepcopy(user) user.backend = backend - return login(request, user, method, service=service, record=record, **kwargs) + return login(request, user, method, record=record, **kwargs) def get_manager_login_url(): diff --git a/src/authentic2/utils/service.py b/src/authentic2/utils/service.py index 5bfdd805..c20304b0 100644 --- a/src/authentic2/utils/service.py +++ b/src/authentic2/utils/service.py @@ -14,64 +14,71 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from authentic2.constants import SERVICE_FIELD_NAME +import urllib.parse +from django.apps import apps -def service_ref(service): - if service.ou: - return '%s %s' % (service.ou.slug, service.slug) - else: - return service.slug +from authentic2.decorators import GlobalCache +from authentic2.utils.misc import same_origin -def get_service_from_ref(ref): +@GlobalCache(timeout=60) +def _base_urls_map(): from authentic2.models import Service - splitted = ref.split(' ') - - try: - ou_slug, service_slug = splitted - except ValueError: - pass - else: - return Service.objects.filter(ou__slug=ou_slug, slug=service_slug).first() - - try: - (service_slug,) = splitted - except ValueError: - return None - - service = Service.objects.filter(ou__isnull=True, slug=service_slug).first() - if service: - return service - try: - return Service.objects.get(slug=service_slug) - except (Service.DoesNotExist, Service.MultipleObjectsReturned): - return None - + base_urls_map = {} + for service in Service.objects.select_related().select_subclasses(): + for url in service.get_base_urls(): + base_urls_map[url] = (type(service), service.pk) + return base_urls_map -def get_service_from_request(request): - service_ref = request.GET.get(SERVICE_FIELD_NAME) - if service_ref and '\x00' not in service_ref: - return get_service_from_ref(service_ref) - return None +def clean_service(request): + request.session.pop('sevice_type', None) + request.session.pop('sevice_pk', None) -def get_service_from_session(request): - session = getattr(request, 'session', None) - if session and 'service_pk' in session: - from authentic2.models import Service - return Service.objects.get(pk=session['service_pk']) - return None - - -def get_service_from_token(params): - ref = params.get(SERVICE_FIELD_NAME) - if not ref: - return None - return get_service_from_ref(ref) - - -def set_service_ref(params, service): - params[SERVICE_FIELD_NAME] = service_ref(service) +def _set_session_service(session, service): + if 'home_url' in session: + del session['home_url'] + if service: + session['service_type'] = [type(service)._meta.app_label, type(service)._meta.model_name] + session['service_pk'] = service.pk + + +def set_service(request, service): + # do not set service on non document fetch (