This repository has been archived on 2024-05-31. You can view files and clone it, but cannot push or open issues or pull requests.
authentik/passbook/saml_idp/views.py

236 lines
8.9 KiB
Python
Raw Normal View History

2018-11-16 08:10:35 +00:00
"""passbook SAML IDP Views"""
from logging import getLogger
2018-12-16 16:09:26 +00:00
from django.contrib.auth import logout
from django.contrib.auth.mixins import AccessMixin
2018-11-16 08:10:35 +00:00
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
2018-12-16 16:09:26 +00:00
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render, reverse
2018-11-16 08:10:35 +00:00
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator
2019-03-08 20:43:33 +00:00
from django.utils.translation import gettext as _
2018-12-16 16:09:26 +00:00
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from signxml.util import strip_pem_header
2019-03-02 23:34:34 +00:00
from passbook.audit.models import AuditEntry
from passbook.core.models import Application
2019-03-02 23:07:40 +00:00
from passbook.core.policies import PolicyEngine
from passbook.lib.config import CONFIG
2018-12-16 16:09:26 +00:00
from passbook.lib.mixins import CSRFExemptMixin
2018-11-16 10:41:14 +00:00
from passbook.lib.utils.template import render_to_string
from passbook.saml_idp import exceptions
2018-12-16 16:09:26 +00:00
from passbook.saml_idp.models import SAMLProvider
2018-11-16 08:10:35 +00:00
LOGGER = getLogger(__name__)
URL_VALIDATOR = URLValidator(schemes=('http', 'https'))
def _generate_response(request, provider: SAMLProvider):
"""Generate a SAML response using processor_instance and return it in the proper Django
2018-11-16 10:41:14 +00:00
response."""
2018-11-16 08:10:35 +00:00
try:
provider.processor.init_deep_link(request, '')
ctx = provider.processor.generate_response()
ctx['remote'] = provider
ctx['is_login'] = True
2018-11-16 08:10:35 +00:00
except exceptions.UserNotAuthorized:
return render(request, 'saml/idp/invalid_user.html')
return render(request, 'saml/idp/login.html', ctx)
def render_xml(request, template, ctx):
"""Render template with content_type application/xml"""
return render(request, template, context=ctx, content_type="application/xml")
class AccessRequiredView(AccessMixin, View):
2018-12-26 20:56:08 +00:00
"""Mixin class for Views using a provider instance"""
_provider = None
@property
def provider(self):
2018-12-26 20:56:08 +00:00
"""Get provider instance"""
if not self._provider:
application = get_object_or_404(Application, slug=self.kwargs['application'])
self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id)
return self._provider
def _has_access(self):
"""Check if user has access to application"""
policy_engine = PolicyEngine(self.provider.application.policies.all())
policy_engine.for_user(self.request.user).with_request(self.request).build()
return policy_engine.passing
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if not self._has_access():
return render(request, 'login/denied.html', {
'title': _("You don't have access to this application"),
'is_login': True
})
return super().dispatch(request, *args, **kwargs)
class LoginBeginView(AccessRequiredView):
2018-11-16 10:41:14 +00:00
"""Receives a SAML 2.0 AuthnRequest from a Service Provider and
stores it in the session prior to enforcing login."""
2018-11-16 08:10:35 +00:00
2019-02-27 10:20:52 +00:00
@method_decorator(csrf_exempt)
def dispatch(self, request, application):
2018-12-16 16:09:26 +00:00
if request.method == 'POST':
source = request.POST
else:
source = request.GET
# Store these values now, because Django's login cycle won't preserve them.
2018-11-16 08:10:35 +00:00
2018-12-16 16:09:26 +00:00
try:
request.session['SAMLRequest'] = source['SAMLRequest']
except (KeyError, MultiValueDictKeyError):
return HttpResponseBadRequest('the SAML request payload is missing')
2018-11-16 08:10:35 +00:00
2018-12-16 16:09:26 +00:00
request.session['RelayState'] = source.get('RelayState', '')
2019-03-02 23:07:40 +00:00
return redirect(reverse('passbook_saml_idp:saml-login-process', kwargs={
'application': application
2019-02-27 10:20:52 +00:00
}))
2018-11-16 08:10:35 +00:00
2018-12-16 16:09:26 +00:00
class RedirectToSPView(AccessRequiredView):
2018-11-16 10:41:14 +00:00
"""Return autosubmit form"""
2018-11-16 08:10:35 +00:00
2018-12-16 16:09:26 +00:00
def get(self, request, acs_url, saml_response, relay_state):
"""Return autosubmit form"""
return render(request, 'core/autosubmit_form.html', {
'url': acs_url,
'attrs': {
'SAMLResponse': saml_response,
'RelayState': relay_state
}
})
2018-11-16 08:10:35 +00:00
2018-12-16 16:09:26 +00:00
class LoginProcessView(AccessRequiredView):
2018-11-16 10:41:14 +00:00
"""Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
2018-12-16 16:09:26 +00:00
def get(self, request, application):
"""Handle get request, i.e. render form"""
2018-12-16 16:09:26 +00:00
LOGGER.debug("Request: %s", request)
# Check if user has access
2019-03-08 20:30:16 +00:00
if self.provider.application.skip_authorization:
ctx = self.provider.processor.generate_response()
2019-03-02 23:34:34 +00:00
# Log Application Authorization
AuditEntry.create(
action=AuditEntry.ACTION_AUTHORIZE_APPLICATION,
request=request,
app=self.provider.application.name,
skipped_authorization=True)
2018-12-16 16:09:26 +00:00
return RedirectToSPView.as_view()(
request=request,
acs_url=ctx['acs_url'],
saml_response=ctx['saml_response'],
relay_state=ctx['relay_state'])
try:
full_res = _generate_response(request, self.provider)
return full_res
except exceptions.CannotHandleAssertion as exc:
LOGGER.debug(exc)
def post(self, request, application):
"""Handle post request, return back to ACS"""
LOGGER.debug("Request: %s", request)
# Check if user has access
2019-03-08 20:30:16 +00:00
if request.POST.get('ACSUrl', None):
2018-12-16 16:09:26 +00:00
# User accepted request
2019-03-02 23:34:34 +00:00
AuditEntry.create(
action=AuditEntry.ACTION_AUTHORIZE_APPLICATION,
request=request,
app=self.provider.application.name,
skipped_authorization=False)
2018-12-16 16:09:26 +00:00
return RedirectToSPView.as_view()(
request=request,
acs_url=request.POST.get('ACSUrl'),
saml_response=request.POST.get('SAMLResponse'),
relay_state=request.POST.get('RelayState'))
try:
2018-12-26 20:56:08 +00:00
full_res = _generate_response(request, self.provider)
2018-12-16 16:09:26 +00:00
return full_res
except exceptions.CannotHandleAssertion as exc:
LOGGER.debug(exc)
class LogoutView(CSRFExemptMixin, AccessRequiredView):
2018-11-16 10:41:14 +00:00
"""Allows a non-SAML 2.0 URL to log out the user and
2018-11-16 08:10:35 +00:00
returns a standard logged-out page. (SalesForce and others use this method,
2018-11-16 10:41:14 +00:00
though it's technically not SAML 2.0)."""
2018-11-16 08:10:35 +00:00
2019-03-02 23:07:40 +00:00
def get(self, request, application):
2018-12-16 16:09:26 +00:00
"""Perform logout"""
logout(request)
2018-11-16 08:10:35 +00:00
2018-12-16 16:09:26 +00:00
redirect_url = request.GET.get('redirect_to', '')
2018-11-16 08:10:35 +00:00
2018-12-16 16:09:26 +00:00
try:
URL_VALIDATOR(redirect_url)
except ValidationError:
pass
else:
return redirect(redirect_url)
2018-11-16 08:10:35 +00:00
2018-12-16 16:09:26 +00:00
return render(request, 'saml/idp/logged_out.html')
2018-11-16 08:10:35 +00:00
2018-12-16 16:09:26 +00:00
class SLOLogout(CSRFExemptMixin, AccessRequiredView):
2018-11-16 10:41:14 +00:00
"""Receives a SAML 2.0 LogoutRequest from a Service Provider,
logs out the user and returns a standard logged-out page."""
2018-12-16 16:09:26 +00:00
2019-03-02 23:07:40 +00:00
def post(self, request, application):
2018-12-16 16:09:26 +00:00
"""Perform logout"""
request.session['SAMLRequest'] = request.POST['SAMLRequest']
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
# TODO: Modify the base processor to handle logouts?
# TODO: Combine this with login_process(), since they are so very similar?
# TODO: Format a LogoutResponse and return it to the browser.
# XXX: For now, simply log out without validating the request.
logout(request)
return render(request, 'saml/idp/logged_out.html')
class DescriptorDownloadView(AccessRequiredView):
2018-11-16 10:41:14 +00:00
"""Replies with the XML Metadata IDSSODescriptor."""
2018-12-16 16:09:26 +00:00
def get(self, request, application):
2018-12-16 16:09:26 +00:00
"""Replies with the XML Metadata IDSSODescriptor."""
entity_id = self.provider.issuer
slo_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml-logout', kwargs={
'application': application
}))
2019-03-02 23:07:40 +00:00
sso_url = request.build_absolute_uri(reverse('passbook_saml_idp:saml-login', kwargs={
2018-12-26 20:56:08 +00:00
'application': application
}))
pubkey = strip_pem_header(self.provider.signing_cert.replace('\r', '')).replace('\n', '')
2018-12-16 16:09:26 +00:00
ctx = {
'entity_id': entity_id,
'cert_public_key': pubkey,
'slo_url': slo_url,
'sso_url': sso_url
}
metadata = render_to_string('saml/xml/metadata.xml', ctx)
response = HttpResponse(metadata, content_type='application/xml')
response['Content-Disposition'] = ('attachment; filename="'
'%s_passbook_meta.xml"' % self.provider.name)
2018-12-16 16:09:26 +00:00
return response
2018-11-16 08:10:35 +00:00
class InitiateLoginView(AccessRequiredView):
2018-12-26 20:56:08 +00:00
"""IdP-initiated Login"""
2018-11-16 08:10:35 +00:00
def get(self, request, application):
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
2018-12-26 20:56:08 +00:00
self.provider.processor.init_deep_link(request, '')
return _generate_response(request, self.provider)