Skip to content
Snippets Groups Projects
Commit 58599815 authored by Art's avatar Art :lizard:
Browse files

Add a lot of logging out stuff.

  - SLO/SLS is implemented... and disabled by default.
  - Optional intermediate view when logging out.
  - Improved README.
  - Lots of minor improvements.
parent 2eee72aa
No related branches found
No related tags found
No related merge requests found
......@@ -2,84 +2,110 @@
#### Minimal Intro:
- [SSO](https://lmddgtfy.net/?q=SSO): Single Sign On
- SP: Service Provider (your client)
- IdP: Identity Provider (server, Shibboleth)
- Metadata/Meta: an XML that describes SP or IdP (or another entity)
- SAML/SAML2: It's just another XML-based enterprise-grade standard that will make you cry blood
- SLO: Single Log Out
- SP: Service Provider (your web app)
- IdP: Identity Provider (server with some dreadful software like Shibboleth)
- Metadata/Meta: an XML that describes SP or IdP (or some other entity)
- SAML/SAML2: It's just another XML-based enterprise-grade standard that will make you cry blood and shit bricks
#### Necessary stuff
#### Necessary Stuff
- Binary dependencies: `sudo apt install libxml2-dev libxslt1-dev xmlsec1 libxmlsec1-dev pkg-config`
- Python dependencies: see `requirements.txt` or `setup.py`
- Add the app into `INSTALLED_APPS`
- Include the app's `urls.py` into the project `urls.py` `urlpatterns`, preferably without a prefix
#### Development setup
#### Development Setup
- If you don't need a fully functional SSO, you don't need to configure anything at all. Use the dev view: `localhost:8000/dev/` (might change depending on your urlconf and settings).
- If you need a fully functional SSO during development, here's an example:
- Simply use `localhost:8000/dev/` _(might change depending on your urlconf)_ for all the logging-in-out-users-groups stuff
- To make your life easier, add the following to your project settings:
```python
LOGIN_URL = urls.reverse_lazy("sso-dev")
```
- If you want to debug `ssoauth` or need a fully functional SSO during development for some other reason, an example is below. For additional info reference production setup and `ssoauth/app_settings/defaults.py`. If you also want a working SLO during development you will need SSL for your localhost, `nginx` will be your best friend.
```python
""" settings/dev.py """
import os, socket
from django import urls
import socket
import os
IDP_META_URL = "https://idp-test.it.hs-hannover.de/idp/shibboleth"
IDP_LOGOUT_URL = "https://idp-test.it.hs-hannover.de/idp/profile/Logout"
SP_KEY = "{project_settings}/cert/sp.key"
SP_CERT = "{project_settings}/cert/sp.pem"
SP_FORCE_ENTITY_ID = "dev-auto-id-{0}-{1}".format(socket.gethostname(), os.path.dirname(os.path.dirname(__file__)))
SP_HOST = "localhost"
SP_PORT = 8000
SP_SSL = False
SP_FORCE_ENTITY_ID = "dev-id-{0}-{1}".format(socket.gethostname(), os.path.dirname(os.path.dirname(__file__))) # too many localhosts around
LOGIN_URL = urls.reverse_lazy("sso-dev")
```
#### Groups
To receive groups over SSO you need a mapping. You can manage group mapping with `group_mapping` management command. Example:
#### Regarding Logging Out
After logging out locally, user will be redirected to one of the following (with this priority):
- `?next=` GET value (Note: instead of `next` use use `django.contrib.auth.REDIRECT_FIELD_NAME`)
- `LOGOUT_REDIRECT_URL` (vanilla django setting), recommended values are:
- `LOGOUT_REDIRECT_URL = None` for the default behavior
- `LOGOUT_REDIRECT_URL = urls.reverse_lazy("sso-logout-idp")` if you want to instantly initiate IDP log out, possibly triggering SLO
- `LOGOUT_REDIRECT_URL = urls.reverse_lazy("sso-logged-out-locally")`
- Default: redirect to the view that asks users what to do next. Don't forget to override template `ssoauth/logged_out_locally.html`
**SLO**: SLO/SLS is disabled by default. If you want to try your luck with you should add to your project settings `SP_SLS_ENABLED = True`
Currently only IdP-initiated SLO is supported by this app. The only supported binding type is HTTP-Redirect due to the limitations of the underlying library used.
For SLO with HTTP-Redirect to work, the SLS page must be included as `<iframe>`. Your server and/or browser might restrict such behavior. Start with setting `SP_SLS_X_FRAME_OPTIONS` (see the `ssoauth` default settings file).
If you have `nginx` serving pages to users, you might need to configure `x-frame-options` for the SLS view (Only the SLS view, nowhere else!). Additionally you might need to configure CSP on the web server on the IdP side. Anyways it will most likely be a lot of [fun](https://duckduckgo.com/?q=dwarf+fortress+fun) for you.
#### Groups and Permissions
To receive groups over SSO you need a group mapping (and of course a properly configured IdP). You can manage group mapping with `group_mapping` management command. Example:
group_mapping add myproject_superusers "CN=MyProjectSuperusers,OU=Foo,OU=Bar,DC=fh-h,DC=de"
*Groups are not mapped automatically. Because automatic mapping can pose security risks. Imagine auto-mapping that expects group with name "Superusers"; an intruder could create a new group with this name under any path they own and/or create an alias/reference and receive superuser permissions in your project.*
*Groups are not mapped automatically. The reason is that automatic mapping can pose security risks. Imagine auto-mapping that expects group with name "Superusers"; an intruder could create new group with this name under any path they own and/or create an alias/reference and receive superuser permissions in your project.*
#### Production setup
#### Production Settings
_(Disclaimer: this example might be incomplete. Reference the `ssoauth` default settings file.)_
```python
""" settings/prod.py """
from django import urls
SP_HOST = "141.71.foo.bar" # your SP host or IP address
SP_HOST = "foobar.it.hs-hannover.de" # FQDN of your SP
IDP_META_URL = "https://idp.hs-hannover.de/idp/shibboleth" # production
IDP_LOGOUT_URL = "https://idp.it.hs-hannover.de/idp/profile/Logout" # web page for IdP logout (might initiate SLO)
LOGIN_URL = urls.reverse_lazy("sso-login")
LOGIN_URL = urls.reverse_lazy("sso-login") # django setting
```
You will also need to configure the SSO
#### SSO configuration
#### Certs
- create a key pair:
- if you don't need your cert to be signed you can use `openssl req -new -x509 -days 3650 -nodes -out sp.pem -keyout sp.key`
- create `cert` directory:
- inside of project `settings` directory if it's a package
- next to project `settings.py` file if it's a module
- `./cert/sp.key` put the private key here
- `./cert/sp.pem` put the certificate here, signing is optional
- configure the IdP:
- if you prefer your keys somewhere else, set `SP_KEY` and `SP_CERT` settings
#### Add an SP to an IdP
- Ask somebody who knows more. Seriously.
- Try on the test IdP before you brick the production!
- Grab your meta
- Run your project.
- Find meta of your SP (relative path `/saml2/meta` or view name `sso-saml2-meta`)
- Find meta of your SP (relative path `/saml2/meta`)
- Use Ctrl+U ("view source") to get the actual XML, otherwise your browser could mess it up
- configure the IdP:
- IdP:
- `SSH` to the IdP and locate the Shibboleth directory, most likely `/opt/shibboleth-idp/`
- put your meta into a new file in `./metadata/` and give it a nice verbose name
- edit `./conf/metadata-providers.xml`
......
......@@ -16,7 +16,6 @@ for setting_name in [k for k in globals().keys() if k.isupper()]:
pass # not set
# template for OneLogin toolkit settings
_SET_ON_RUNTIME = None and "will be set on runtime"
......@@ -51,6 +50,7 @@ ONELOGIN_SETTINGS_TEMPLATE = {
"binding": onelogin_settings.OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT,
},
"singleLogoutService": {
# note: as for now, SP-initiated SLO is not supported by this app
"url": _SET_ON_RUNTIME or str(),
"binding": onelogin_settings.OneLogin_Saml2_Constants.BINDING_HTTP_REDIRECT,
}
......
import os
from django import urls
"""
......@@ -18,6 +19,7 @@ SP_PORT = 443
SP_SSL = True
IDP_META_URL = None # e.g. "https://idp-test.hs-hannover.de/idp/shibboleth"
IDP_LOGOUT_URL = None # e.g. "https://idp-test.it.hs-hannover.de/idp/profile/Logout"
SP_KEY = "{project_settings}/cert/sp.key"
SP_CERT = "{project_settings}/cert/sp.pem"
......@@ -26,6 +28,8 @@ SSO_REQUIRED_IN_DEBUG = False
SSO_REQUIRED_IN_PRODUCTION = False # disabled because of e.g. collectstatic on the static server
SSO_REQUIRED_OUTSIDE_MANAGE_PY = True # enabled to ensure that production (that uses WSGI) has SSO
SP_SLS_ENABLED = False # single log out creates too many problems, so it is disabled for now
SP_SLS_X_FRAME_OPTIONS = None # in case you encounter problems with SLS view not allowed inside of an iframe, e.g. "ALLOW-FROM idp-test.it.hs-hannover.de idp.hs-hannover.de"
"""
Settings you might want to change on development (don't change them for production):
......
......@@ -78,11 +78,22 @@ def old_settings(app_configs, **kwargs):
return errors
@register(Tags.compatibility)
def sp_host_is_not_localhost(app_configs, **kwargs):
errors = list()
if app_settings.SP_HOST:
if app_settings.SP_HOST.lower() in ("localhost", "127.0.0.1", "::1",):
if app_settings.SSO_REQUIRED:
errors.append(Error("SP_HOST should not be set to localhost: it breaks SLO, causes entityID collisions and all sorts of nasty stuff."))
else:
if not app_settings.SP_FORCE_ENTITY_ID:
errors.append(Error("If your SP_HOST is localhost, take care to set a unique SP_FORCE_ENTITY_ID"))
return errors
@register(Tags.urls)
def auth_urls_configured(app_configs, **kwargs):
errors = list()
if not app_settings.SSO_REQUIRED:
return errors
def _url_from_setting(setting_name):
# get url based on django setting name, handles most of cases of something going wrong
......@@ -94,6 +105,13 @@ def auth_urls_configured(app_configs, **kwargs):
errors.append(Warning("Failed to resolve an URL for {s}. {ec}: {e}".format(ec=e.__class__.__name__, e=str(e),s=setting_name), obj=conf.settings))
return None
# # default landing and logout pages
# # probably this part is not longer required because how the logout process works
# for setting_name in ("LOGIN_REDIRECT_URL", "LOGOUT_REDIRECT_URL",):
# if not _url_from_setting(setting_name):
# errors.append(Warning("{setting_name} is not found or invalid.".format(**locals()), obj=conf.settings,))
if app_settings.SSO_REQUIRED:
# login
login_url_needed = urls.reverse("sso-login")
login_url_current = _url_from_setting("LOGIN_URL")
......@@ -103,10 +121,6 @@ def auth_urls_configured(app_configs, **kwargs):
"LOGIN_URL = urls.reverse_lazy(\"sso-login\")".format(**locals()),
obj=conf.settings,
))
# default landing and logout pages
for setting_name in ("LOGIN_REDIRECT_URL", "LOGOUT_REDIRECT_URL",):
if not _url_from_setting(setting_name):
errors.append(Warning("{setting_name} is not found or invalid.".format(**locals()), obj=conf.settings,))
return errors
......
......@@ -76,25 +76,28 @@ def get_idp_runtime_info(meta_url):
def create_onelogin_settings(template=app_settings.ONELOGIN_SETTINGS_TEMPLATE):
""" This function is intended to be run only once, on app startup. Raises exceptions. """
# get the template
template = copy(template)
settings = copy(template)
# prepare some values
protocol = "https" if app_settings.SP_SSL else "http"
assert app_settings.SP_HOST, "SP_HOST is not set." # before OneLogin toolkit chokes with "sp_acs_url_invalid,sp_sls_url_invalid"
host_full = "{protocol}://{host}{port_suffix}".format(
host=app_settings.SP_HOST,
protocol="https" if app_settings.SP_SSL else "http",
port_suffix="" if app_settings.SP_PORT in [80, 443] else ":{0}".format(app_settings.SP_PORT)
host = app_settings.SP_HOST
host_full = "{protocol}://{host}{port_suffix}".format(**locals())
)
# IDP settings
assert app_settings.IDP_META_URL, "IDP_META_URL is not specified" # before get_idp_runtime_info starts logging errors
assert app_settings.IDP_META_URL, "IDP_META_URL is not set" # before get_idp_runtime_info starts logging errors
idp_info = get_idp_runtime_info(app_settings.IDP_META_URL)
template["idp"]["x509certMulti"]["signing"] = idp_info["certificates"]["signing"]
template["idp"]["x509certMulti"]["encryption"] = idp_info["certificates"]["encryption"]
template["idp"]["singleSignOnService"]["url"] = idp_info["bindings"]["sso_redirect"]
template["idp"]["singleLogoutService"]["url"] = idp_info["bindings"]["slo_redirect"]
settings["idp"]["x509certMulti"]["signing"] = idp_info["certificates"]["signing"]
settings["idp"]["x509certMulti"]["encryption"] = idp_info["certificates"]["encryption"]
settings["idp"]["singleSignOnService"]["url"] = idp_info["bindings"]["sso_redirect"]
settings["idp"]["singleLogoutService"]["url"] = idp_info["bindings"]["slo_redirect"]
# SP settings
template["sp"]["entityId"] = app_settings.SP_FORCE_ENTITY_ID or (host_full + urls.reverse("sso-saml2-meta"))
template["sp"]["assertionConsumerService"]["url"] = host_full + urls.reverse("sso-saml2-acs")
template["sp"]["singleLogoutService"]["url"] = host_full + urls.reverse("sso-saml2-sls")
template["sp"]["x509cert"] = read_key(app_settings.SP_CERT)
template["sp"]["privateKey"] = read_key(app_settings.SP_KEY)
settings["sp"]["entityId"] = app_settings.SP_FORCE_ENTITY_ID or (host_full + urls.reverse("sso-saml2-meta"))
settings["sp"]["assertionConsumerService"]["url"] = host_full + urls.reverse("sso-saml2-acs")
if app_settings.SP_SLS_ENABLED:
settings["sp"]["singleLogoutService"]["url"] = host_full + urls.reverse("sso-saml2-sls")
settings["sp"]["x509cert"] = read_key(app_settings.SP_CERT)
settings["sp"]["privateKey"] = read_key(app_settings.SP_KEY)
# done
return OneLogin_Saml2_Settings(settings=template, sp_validation_only=True)
return OneLogin_Saml2_Settings(settings=settings, sp_validation_only=True)
......@@ -45,7 +45,7 @@
<div class="column {% if not sso_configured %}disabled{% endif %}">
<h4 class="title">Production Actions</h4>
<a class="button button-outline button-narrow" href="{% url "sso-login" %}?next={% url "sso-dev" %}">Log In</a>
<a class="button button-outline button-narrow" href="{% url "sso-logout" %}?next={% url "sso-dev" %}">Log Out</a>
<a class="button button-outline button-narrow" href="{% url "sso-logout" %}">Log Out</a>
<a class="button button-outline button-narrow" href="{% url "sso-saml2-meta" %}" target="_blank">Meta</a>
<p><i>{% if sso_configured %}These actions are used in production{% else %}Your SSO settings are a potato{% endif %}</i></p>
</div>
......
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Logged Out</title>
</head>
<body>
<h2>Logged Out</h2>
<hr/>
<p>You have successfully logged out of <b><code>{{ THIS_SITE }}</code></b>.</p>
<hr/>
<p>
However, you will still be able to automatically log in to most of our services using Single Sign On.
</p>
<ul>
<li><a href="{{ IDP_LOGOUT_URL }}">Click here</a> if you would like to log out of the Single Sign On service.</li>
<li>When you close your browser you will be logged out of all our services that use Single Sign On.</li>
</ul>
</body>
</html>
......@@ -4,6 +4,8 @@ from . import views
urlpatterns = (
url(r"^login/?$", views.LogInView.as_view(), name="sso-login"),
url(r"^logout/?$", views.LogOutView.as_view(), name="sso-logout"),
url(r"^logout/message/?$", views.LoggedOutLocallyView.as_view(), name="sso-logged-out-locally"),
url(r"^logout/idp/?$", views.IdpLogoutRedirectView.as_view(), name="sso-logout-idp"),
url(r"^saml2/acs/?$", views.ACSAuthNView.as_view(), name="sso-saml2-acs"),
url(r"^saml2/sls/?$", views.SLSView.as_view(), name="sso-saml2-sls"),
url(r"^saml2/meta(?:data)?/?$", views.MetadataView.as_view(), name="sso-saml2-meta"),
......
from django.views.generic import View, FormView
from django.views.generic import View, FormView, TemplateView, RedirectView
from django import http
from django import urls
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.clickjacking import xframe_options_exempt
from django.contrib import auth as contrib_auth
from django.contrib.auth import models as contrib_auth_models
from django.contrib.auth import REDIRECT_FIELD_NAME
......@@ -10,6 +11,7 @@ from django import conf
from django.core import exceptions
from django import forms
from django.views.decorators.cache import never_cache
from django.contrib.sites.shortcuts import get_current_site
from . import logger
from . import app_settings
from . import auth_utils
......@@ -71,16 +73,48 @@ class LogInView(SAMLMixin, View):
return str(next_url)
class LogOutView(View):
class LogOutView(RedirectView):
"""
Logs the user out locally.
Redirects the user somewhere depending on the project settings.
"""
def get(self, request, *args, **kwargs):
contrib_auth.logout(request)
return http.HttpResponseRedirect(self.get_next_url(request))
def get_redirect_url(self, *args, **kwargs):
# log the user out first, no matter SLO or not
contrib_auth.logout(self.request)
# decide where to redirect the user
url_next = self.request.GET.get(REDIRECT_FIELD_NAME, None)
settings = conf.settings.LOGOUT_REDIRECT_URL
default = urls.reverse_lazy("sso-logged-out-locally")
return url_next or settings or default
@staticmethod
def get_next_url(request):
next_url = request.GET.get(REDIRECT_FIELD_NAME, None) or conf.settings.LOGOUT_REDIRECT_URL or "/"
return str(next_url)
class LoggedOutLocallyView(TemplateView):
"""
If you use it, you should better override the default template. See: https://docs.djangoproject.com/en/1.11/howto/overriding-templates/
"""
template_name = "ssoauth/logged_out_locally.html"
def dispatch(self, request, *args, **kwargs):
if self.request.user.is_authenticated:
# fool-proof
raise exceptions.PermissionDenied("This user is logged in!")
else:
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["IDP_LOGOUT_URL"] = app_settings.IDP_LOGOUT_URL
context["THIS_SITE"] = get_current_site(self.request)
return context
class IdpLogoutRedirectView(RedirectView):
def get_redirect_url(self, *args, **kwargs):
if app_settings.IDP_LOGOUT_URL:
return http.HttpResponseRedirect(redirect_to=app_settings.IDP_LOGOUT_URL)
else:
raise exceptions.ImproperlyConfigured("IDP_LOGOUT_URL is not set.")
@method_decorator(never_cache, "dispatch")
......@@ -149,14 +183,46 @@ class ACSAuthNView(SAMLMixin, View):
@method_decorator(never_cache, "dispatch")
@method_decorator(csrf_exempt, "dispatch")
@method_decorator(xframe_options_exempt, "dispatch")
class SLSView(SAMLMixin, View):
"""
SLS (Single Logout Service) binding.
When this view is opened by a user (usually inside of a frame) this user must be unconditionally logged off.
"""
def get(self):
return None
def dispatch(self, request, *args, **kwargs):
if app_settings.SP_SLS_ENABLED:
return super().dispatch(request, *args, **kwargs)
else:
logger.warning("SLS is disabled.")
raise exceptions.PermissionDenied()
def get(self, *args, **kwargs):
def logout_callback():
nonlocal self
logger.warning("Logging out the user: {0}".format(self.request.user))
contrib_auth.logout(self.request)
# self.request.session.flush()
errors = list()
try:
auth = self.get_onelogin_auth(self.request)
response_url = auth.process_slo(delete_session_cb=logout_callback)
errors.extend(auth.get_errors())
if response_url:
response = http.HttpResponseRedirect(response_url)
else:
raise AssertionError("Did not get a response URL.") # OneLogin toolkit fails to raise an exception
except Exception as e:
logger.error("SLO failed. {e.__class__.__name__}: {e}".format(e=e))
errors.append("{e.__class__.__name__}: {e}".format(e=e))
response = http.HttpResponseBadRequest(content="Bad SLS request.")
for error in errors:
logger.error(error)
if app_settings.SP_SLS_X_FRAME_OPTIONS:
response["X-Frame-Options"] = app_settings.SP_SLS_X_FRAME_OPTIONS
return response
class MetadataView(SAMLMixin, View):
......@@ -184,7 +250,7 @@ class DevView(FormView):
form_class = DevForm
def __init__(self, *args, **kwargs):
if not conf.settings.DEBUG and False:
if not conf.settings.DEBUG:
raise exceptions.PermissionDenied()
else:
super().__init__(*args, **kwargs)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment