Compare commits

..

13 Commits

Author SHA1 Message Date
5fb1b8044c new release: 0.8.11-beta 2020-02-25 11:38:50 +01:00
b8daab4377 providers/saml: fix AccessRequiredView.dispatch not being called 2020-02-25 11:38:26 +01:00
c5b91bdae8 providers/saml: fix CannotHandleAssertion Error still being sent to sentry 2020-02-24 19:14:43 +01:00
39a208c55f providers/saml: fix wrong key being used for params 2020-02-24 17:48:03 +01:00
a5bfef9b6b providers/saml: fix leftover data in session, fix IdP initiated login
move can_handle calls to binding endpoints (/login/ and /login/initiate/), so that /login/authorize/ works either way, can clean up the session and audit
2020-02-24 17:34:52 +01:00
f1f4cbef9b lib/sentry: fix SentryIgnoredException not being ignored correctly 2020-02-24 17:01:31 +01:00
8388120b06 new release: 0.8.10-beta 2020-02-24 15:30:57 +01:00
2bf96828f1 root: fix logging.basicConfig being called by pyjwkest 2020-02-24 15:30:28 +01:00
22838e66fe providers/saml: fix users being able to authenticate without audit logs being created 2020-02-24 14:40:12 +01:00
484dd6de09 providers/oidc: add error template 2020-02-24 14:19:02 +01:00
b743736c26 lib/logging: fix typo 2020-02-24 14:10:58 +01:00
af91e2079b core: sort provider by pk when selection application provider 2020-02-24 14:10:51 +01:00
cad1c17f14 helm: fix inconsistent labels 2020-02-24 13:49:42 +01:00
19 changed files with 163 additions and 156 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.8.9-beta current_version = 0.8.11-beta
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)

View File

@ -16,11 +16,11 @@ jobs:
- name: Building Docker Image - name: Building Docker Image
run: docker build run: docker build
--no-cache --no-cache
-t beryju/passbook:0.8.9-beta -t beryju/passbook:0.8.11-beta
-t beryju/passbook:latest -t beryju/passbook:latest
-f Dockerfile . -f Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook:0.8.9-beta run: docker push beryju/passbook:0.8.11-beta
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook:latest run: docker push beryju/passbook:latest
build-gatekeeper: build-gatekeeper:
@ -37,11 +37,11 @@ jobs:
cd gatekeeper cd gatekeeper
docker build \ docker build \
--no-cache \ --no-cache \
-t beryju/passbook-gatekeeper:0.8.9-beta \ -t beryju/passbook-gatekeeper:0.8.11-beta \
-t beryju/passbook-gatekeeper:latest \ -t beryju/passbook-gatekeeper:latest \
-f Dockerfile . -f Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-gatekeeper:0.8.9-beta run: docker push beryju/passbook-gatekeeper:0.8.11-beta
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-gatekeeper:latest run: docker push beryju/passbook-gatekeeper:latest
build-static: build-static:
@ -66,11 +66,11 @@ jobs:
run: docker build run: docker build
--no-cache --no-cache
--network=$(docker network ls | grep github | awk '{print $1}') --network=$(docker network ls | grep github | awk '{print $1}')
-t beryju/passbook-static:0.8.9-beta -t beryju/passbook-static:0.8.11-beta
-t beryju/passbook-static:latest -t beryju/passbook-static:latest
-f static.Dockerfile . -f static.Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-static:0.8.9-beta run: docker push beryju/passbook-static:0.8.11-beta
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-static:latest run: docker push beryju/passbook-static:latest
test-release: test-release:

View File

@ -1,6 +1,6 @@
apiVersion: v1 apiVersion: v1
appVersion: "0.8.9-beta" appVersion: "0.8.11-beta"
description: A Helm chart for passbook. description: A Helm chart for passbook.
name: passbook name: passbook
version: "0.8.9-beta" version: "0.8.11-beta"
icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png

View File

@ -18,7 +18,7 @@ spec:
labels: labels:
app.kubernetes.io/name: {{ include "passbook.name" . }} app.kubernetes.io/name: {{ include "passbook.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/instance: {{ .Release.Name }}
passbook.io/component: web k8s.passbook.io/component: web
spec: spec:
volumes: volumes:
- name: config-volume - name: config-volume

View File

@ -18,4 +18,4 @@ spec:
selector: selector:
app.kubernetes.io/name: {{ include "passbook.name" . }} app.kubernetes.io/name: {{ include "passbook.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/instance: {{ .Release.Name }}
passbook.io/component: web k8s.passbook.io/component: web

View File

@ -18,7 +18,7 @@ spec:
labels: labels:
app.kubernetes.io/name: {{ include "passbook.name" . }} app.kubernetes.io/name: {{ include "passbook.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/instance: {{ .Release.Name }}
passbook.io/component: worker k8s.passbook.io/component: worker
spec: spec:
volumes: volumes:
- name: config-volume - name: config-volume

View File

@ -2,7 +2,7 @@
# This is a YAML-formatted file. # This is a YAML-formatted file.
# Declare variables to be passed into your templates. # Declare variables to be passed into your templates.
image: image:
tag: 0.8.9-beta tag: 0.8.11-beta
nameOverride: "" nameOverride: ""

View File

@ -1,2 +1,2 @@
"""passbook""" """passbook"""
__version__ = "0.8.9-beta" __version__ = "0.8.11-beta"

View File

@ -137,6 +137,7 @@ class Event(UUIDModel):
action=self.action, action=self.action,
context=self.context, context=self.context,
client_ip=self.client_ip, client_ip=self.client_ip,
user=self.user,
) )
return super().save(*args, **kwargs) return super().save(*args, **kwargs)

View File

@ -10,7 +10,8 @@ class ApplicationForm(forms.ModelForm):
"""Application Form""" """Application Form"""
provider = forms.ModelChoiceField( provider = forms.ModelChoiceField(
queryset=Provider.objects.all().select_subclasses(), required=False queryset=Provider.objects.all().order_by("pk").select_subclasses(),
required=False,
) )
class Meta: class Meta:

View File

@ -231,7 +231,6 @@ class PasswordResetView(View):
login(request, nonce.user) login(request, nonce.user)
nonce.delete() nonce.delete()
messages.success( messages.success(
request, request, _(("Temporarily authenticated, please change your password")),
_(("Temporarily authenticated with Nonce, " "please change your password")),
) )
return redirect("passbook_core:user-change-password") return redirect("passbook_core:user-change-password")

View File

@ -5,5 +5,5 @@ from os import getpid
# pylint: disable=unused-argument # pylint: disable=unused-argument
def add_process_id(logger, method_name, event_dict): def add_process_id(logger, method_name, event_dict):
"""Add the current process ID""" """Add the current process ID"""
event_dict["pdi"] = getpid() event_dict["pid"] = getpid()
return event_dict return event_dict

View File

@ -12,7 +12,7 @@ LOGGER = get_logger()
class SentryIgnoredException(Exception): class SentryIgnoredException(Exception):
"""Base Class for all errors that are supressed, and not sent to sentry.""" """Base Class for all errors that are suppressed, and not sent to sentry."""
def before_send(event, hint): def before_send(event, hint):

View File

@ -0,0 +1,18 @@
{% extends 'login/base.html' %}
{% load static %}
{% load i18n %}
{% load utils %}
{% block card_title %}
{% trans error %}
{% endblock %}
{% block card %}
<form>
<h3>{% trans description %}</h3>
{% if 'back' in request.GET %}
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
{% endif %}
</form>
{% endblock %}

View File

@ -184,7 +184,7 @@ class Processor:
try: try:
self._extract_saml_request() self._extract_saml_request()
except KeyError: except KeyError:
raise CannotHandleAssertion(f"Couldn't find SAML request in user session:") raise CannotHandleAssertion(f"Couldn't find SAML request in user session")
try: try:
self._decode_and_parse_request() self._decode_and_parse_request()

View File

@ -4,11 +4,8 @@
{% load i18n %} {% load i18n %}
{% block card %} {% block card %}
<form method="POST" class="pf-c-form" action="{{ saml_params.acs_url }}"> <form method="POST" class="pf-c-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="ACSUrl" value="{{ saml_params.acs_url }}">
<input type="hidden" name="RelayState" value="{{ saml_params.relay_state }}" />
<input type="hidden" name="SAMLResponse" value="{{ saml_params.saml_response }}" />
<div class="pf-c-form__group"> <div class="pf-c-form__group">
<h3> <h3>
{% blocktrans with provider=provider.application.name %} {% blocktrans with provider=provider.application.name %}

View File

@ -16,9 +16,9 @@ urlpatterns = [
"<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login" "<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login"
), ),
path( path(
"<slug:application>/login/process/", "<slug:application>/login/authorize/",
views.LoginProcessView.as_view(), views.AuthorizeView.as_view(),
name="saml-login-process", name="saml-login-authorize",
), ),
path("<slug:application>/logout/", views.LogoutView.as_view(), name="saml-logout"), path("<slug:application>/logout/", views.LogoutView.as_view(), name="saml-logout"),
path( path(

View File

@ -21,12 +21,16 @@ from passbook.core.models import Application, Provider
from passbook.lib.utils.template import render_to_string from passbook.lib.utils.template import render_to_string
from passbook.lib.views import bad_request_message from passbook.lib.views import bad_request_message
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
from passbook.providers.saml import exceptions from passbook.providers.saml.exceptions import CannotHandleAssertion
from passbook.providers.saml.models import SAMLProvider from passbook.providers.saml.models import SAMLProvider
from passbook.providers.saml.processors.types import SAMLResponseParams from passbook.providers.saml.processors.types import SAMLResponseParams
LOGGER = get_logger() LOGGER = get_logger()
URL_VALIDATOR = URLValidator(schemes=("http", "https")) URL_VALIDATOR = URLValidator(schemes=("http", "https"))
SESSION_KEY_SAML_REQUEST = "SAMLRequest"
SESSION_KEY_SAML_RESPONSE = "SAMLResponse"
SESSION_KEY_RELAY_STATE = "RelayState"
SESSION_KEY_PARAMS = "SAMLParams"
class AccessRequiredView(AccessMixin, View): class AccessRequiredView(AccessMixin, View):
@ -50,14 +54,18 @@ class AccessRequiredView(AccessMixin, View):
def _has_access(self) -> bool: def _has_access(self) -> bool:
"""Check if user has access to application""" """Check if user has access to application"""
LOGGER.debug(
"_has_access", user=self.request.user, app=self.provider.application
)
policy_engine = PolicyEngine( policy_engine = PolicyEngine(
self.provider.application.policies.all(), self.request.user, self.request self.provider.application.policies.all(), self.request.user, self.request
) )
policy_engine.build() policy_engine.build()
return policy_engine.passing passing = policy_engine.passing
LOGGER.debug(
"saml_has_access",
user=self.request.user,
app=self.provider.application,
passing=passing,
)
return passing
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
if not request.user.is_authenticated: if not request.user.is_authenticated:
@ -75,80 +83,29 @@ class LoginBeginView(AccessRequiredView):
"""Receives a SAML 2.0 AuthnRequest from a Service Provider and """Receives a SAML 2.0 AuthnRequest from a Service Provider and
stores it in the session prior to enforcing login.""" stores it in the session prior to enforcing login."""
@method_decorator(csrf_exempt) def handler(self, source, application: str) -> HttpResponse:
def dispatch(self, request: HttpRequest, application: str) -> HttpResponse: """Handle SAML Request whether its a POST or a Redirect binding"""
if request.method == "POST":
source = request.POST
else:
source = request.GET
# Store these values now, because Django's login cycle won't preserve them. # Store these values now, because Django's login cycle won't preserve them.
try: try:
request.session["SAMLRequest"] = source["SAMLRequest"] self.request.session[SESSION_KEY_SAML_REQUEST] = source[
SESSION_KEY_SAML_REQUEST
]
except (KeyError, MultiValueDictKeyError): except (KeyError, MultiValueDictKeyError):
return bad_request_message(request, "The SAML request payload is missing.") return bad_request_message(
self.request, "The SAML request payload is missing."
request.session["RelayState"] = source.get("RelayState", "")
return redirect(
reverse(
"passbook_providers_saml:saml-login-process",
kwargs={"application": application},
) )
self.request.session[SESSION_KEY_RELAY_STATE] = source.get(
SESSION_KEY_RELAY_STATE, ""
) )
class LoginProcessView(AccessRequiredView):
"""Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
def handle_redirect(
self, params: SAMLResponseParams, skipped_authorization: bool
) -> HttpResponse:
"""Handle direct redirect to SP"""
# Log Application Authorization
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=skipped_authorization,
).from_http(self.request)
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": params.acs_url,
"attrs": {
"SAMLResponse": params.saml_response,
"RelayState": params.relay_state,
},
},
)
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle get request, i.e. render form"""
# User access gets checked in dispatch
# Otherwise we generate the IdP initiated session
try: try:
# application.skip_authorization is set so we directly redirect the user self.provider.processor.can_handle(self.request)
if self.provider.application.skip_authorization:
return self.post(request, application)
self.provider.processor.init_deep_link(request)
params = self.provider.processor.generate_response() params = self.provider.processor.generate_response()
self.request.session[SESSION_KEY_PARAMS] = params
return render( except CannotHandleAssertion as exc:
request, LOGGER.info(exc)
"saml/idp/login.html", did_you_mean_link = self.request.build_absolute_uri(
{
"saml_params": params,
"provider": self.provider,
"title": "Authorize Application",
},
)
except exceptions.CannotHandleAssertion as exc:
LOGGER.error(exc)
did_you_mean_link = request.build_absolute_uri(
reverse( reverse(
"passbook_providers_saml:saml-login-initiate", "passbook_providers_saml:saml-login-initiate",
kwargs={"application": application}, kwargs={"application": application},
@ -158,19 +115,97 @@ class LoginProcessView(AccessRequiredView):
f" Did you mean to go <a href='{did_you_mean_link}'>here</a>?" f" Did you mean to go <a href='{did_you_mean_link}'>here</a>?"
) )
return bad_request_message( return bad_request_message(
request, mark_safe(str(exc) + did_you_mean_message) self.request, mark_safe(str(exc) + did_you_mean_message)
) )
return redirect(
reverse(
"passbook_providers_saml:saml-login-authorize",
kwargs={"application": application},
)
)
@method_decorator(csrf_exempt)
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle REDIRECT bindings"""
return self.handler(request.GET, application)
@method_decorator(csrf_exempt)
def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle POST Bindings"""
return self.handler(request.POST, application)
class InitiateLoginView(AccessRequiredView):
"""IdP-initiated Login"""
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
self.provider.processor.is_idp_initiated = True
self.provider.processor.init_deep_link(request)
params = self.provider.processor.generate_response()
request.session[SESSION_KEY_PARAMS] = params
return redirect(
reverse(
"passbook_providers_saml:saml-login-authorize",
kwargs={"application": application},
)
)
class AuthorizeView(AccessRequiredView):
"""Ask the user for authorization to continue to the SP.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle get request, i.e. render form"""
# User access gets checked in dispatch
# Otherwise we generate the IdP initiated session
try:
# application.skip_authorization is set so we directly redirect the user
if self.provider.application.skip_authorization:
LOGGER.debug("skipping authz", application=self.provider.application)
return self.post(request, application)
return render(
request,
"saml/idp/login.html",
{"provider": self.provider, "title": "Authorize Application",},
)
except KeyError:
return bad_request_message(request, "Missing SAML Payload")
# pylint: disable=unused-argument # pylint: disable=unused-argument
def post(self, request: HttpRequest, application: str) -> HttpResponse: def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle post request, return back to ACS""" """Handle post request, return back to ACS"""
# User access gets checked in dispatch # User access gets checked in dispatch
# we get here when skip_authorization is False, and after the user accepted # we get here when skip_authorization is True, and after the user accepted
# the authorization form # the authorization form
self.provider.processor.can_handle(request) # Log Application Authorization
saml_params = self.provider.processor.generate_response() Event.new(
return self.handle_redirect(saml_params, True) EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=self.provider.application.skip_authorization,
).from_http(self.request)
self.request.session.pop(SESSION_KEY_SAML_REQUEST, None)
self.request.session.pop(SESSION_KEY_SAML_RESPONSE, None)
self.request.session.pop(SESSION_KEY_RELAY_STATE, None)
response: SAMLResponseParams = self.request.session.pop(SESSION_KEY_PARAMS)
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": response.acs_url,
"attrs": {
"ACSUrl": response.acs_url,
SESSION_KEY_SAML_RESPONSE: response.saml_response,
SESSION_KEY_RELAY_STATE: response.relay_state,
},
},
)
@method_decorator(csrf_exempt, name="dispatch") @method_decorator(csrf_exempt, name="dispatch")
@ -204,7 +239,9 @@ class SLOLogout(AccessRequiredView):
# pylint: disable=unused-argument # pylint: disable=unused-argument
def post(self, request: HttpRequest, application: str) -> HttpResponse: def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Perform logout""" """Perform logout"""
request.session["SAMLRequest"] = request.POST["SAMLRequest"] request.session[SESSION_KEY_SAML_REQUEST] = request.POST[
SESSION_KEY_SAML_REQUEST
]
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process(). # TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
# TODO: Modify the base processor to handle logouts? # TODO: Modify the base processor to handle logouts?
# TODO: Combine this with login_process(), since they are so very similar? # TODO: Combine this with login_process(), since they are so very similar?
@ -259,54 +296,7 @@ class DescriptorDownloadView(AccessRequiredView):
) )
else: else:
response = HttpResponse(metadata, content_type="application/xml") response = HttpResponse(metadata, content_type="application/xml")
response["Content-Disposition"] = ( response[
'attachment; filename="' '%s_passbook_meta.xml"' % self.provider.name "Content-Disposition"
) ] = f'attachment; filename="{self.provider.name}_passbook_meta.xml"'
return response return response
class InitiateLoginView(AccessRequiredView):
"""IdP-initiated Login"""
def handle_redirect(
self, params: SAMLResponseParams, skipped_authorization: bool
) -> HttpResponse:
"""Handle direct redirect to SP"""
# Log Application Authorization
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=skipped_authorization,
).from_http(self.request)
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": params.acs_url,
"attrs": {
"SAMLResponse": params.saml_response,
"RelayState": params.relay_state,
},
},
)
# pylint: disable=unused-argument
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
self.provider.processor.is_idp_initiated = True
self.provider.processor.init_deep_link(request)
params = self.provider.processor.generate_response()
# IdP-initiated Login Flow
if self.provider.application.skip_authorization:
return self.handle_redirect(params, True)
return render(
request,
"saml/idp/login.html",
{
"saml_params": params,
"provider": self.provider,
"title": "Authorize Application",
},
)

View File

@ -317,7 +317,7 @@ LOGGING = {
}, },
"handlers": { "handlers": {
"console": { "console": {
"level": DEBUG, "level": "DEBUG",
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "colored" if DEBUG else "plain", "formatter": "colored" if DEBUG else "plain",
}, },
@ -325,6 +325,7 @@ LOGGING = {
"loggers": {}, "loggers": {},
} }
_LOGGING_HANDLER_MAP = { _LOGGING_HANDLER_MAP = {
"": "DEBUG",
"passbook": "DEBUG", "passbook": "DEBUG",
"django": "WARNING", "django": "WARNING",
"celery": "WARNING", "celery": "WARNING",
@ -337,7 +338,7 @@ for handler_name, level in _LOGGING_HANDLER_MAP.items():
LOGGING["loggers"][handler_name] = { LOGGING["loggers"][handler_name] = {
"handlers": ["console"], "handlers": ["console"],
"level": level, "level": level,
"propagate": True, "propagate": False,
} }
TEST = False TEST = False