Revert "*: providers and sources -> channels, PolicyModel to PolicyBindingModel that uses custom M2M through"
This reverts commit 7ed3ceb960
.
This commit is contained in:
0
passbook/providers/__init__.py
Normal file
0
passbook/providers/__init__.py
Normal file
0
passbook/providers/app_gw/__init__.py
Normal file
0
passbook/providers/app_gw/__init__.py
Normal file
45
passbook/providers/app_gw/api.py
Normal file
45
passbook/providers/app_gw/api.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""ApplicationGatewayProvider API Views"""
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
from oidc_provider.models import Client
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.providers.app_gw.models import ApplicationGatewayProvider
|
||||
from passbook.providers.oidc.api import OpenIDProviderSerializer
|
||||
|
||||
|
||||
class ApplicationGatewayProviderSerializer(ModelSerializer):
|
||||
"""ApplicationGatewayProvider Serializer"""
|
||||
|
||||
client = OpenIDProviderSerializer()
|
||||
|
||||
def create(self, validated_data):
|
||||
instance = super().create(validated_data)
|
||||
instance.client = Client.objects.create(
|
||||
client_id=generate_client_id(), client_secret=generate_client_secret()
|
||||
)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
self.instance.client.name = self.instance.name
|
||||
self.instance.client.redirect_uris = [
|
||||
f"http://{self.instance.host}/oauth2/callback",
|
||||
f"https://{self.instance.host}/oauth2/callback",
|
||||
]
|
||||
self.instance.client.scope = ["openid", "email"]
|
||||
self.instance.client.save()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = ApplicationGatewayProvider
|
||||
fields = ["pk", "name", "internal_host", "external_host", "client"]
|
||||
read_only_fields = ["client"]
|
||||
|
||||
|
||||
class ApplicationGatewayProviderViewSet(ModelViewSet):
|
||||
"""ApplicationGatewayProvider Viewset"""
|
||||
|
||||
queryset = ApplicationGatewayProvider.objects.all()
|
||||
serializer_class = ApplicationGatewayProviderSerializer
|
11
passbook/providers/app_gw/apps.py
Normal file
11
passbook/providers/app_gw/apps.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""passbook Application Security Gateway app"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookApplicationApplicationGatewayConfig(AppConfig):
|
||||
"""passbook app_gw app"""
|
||||
|
||||
name = "passbook.providers.app_gw"
|
||||
label = "passbook_providers_app_gw"
|
||||
verbose_name = "passbook Providers.Application Security Gateway"
|
||||
mountpoint = "application/gateway/"
|
40
passbook/providers/app_gw/forms.py
Normal file
40
passbook/providers/app_gw/forms.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""passbook Application Security Gateway Forms"""
|
||||
from django import forms
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
from oidc_provider.models import Client, ResponseType
|
||||
|
||||
from passbook.providers.app_gw.models import ApplicationGatewayProvider
|
||||
|
||||
|
||||
class ApplicationGatewayProviderForm(forms.ModelForm):
|
||||
"""Security Gateway Provider form"""
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.instance.pk:
|
||||
# New instance, so we create a new OIDC client with random keys
|
||||
self.instance.client = Client.objects.create(
|
||||
client_id=generate_client_id(), client_secret=generate_client_secret()
|
||||
)
|
||||
self.instance.client.name = self.instance.name
|
||||
self.instance.client.response_types.set(
|
||||
[ResponseType.objects.get_by_natural_key("code")]
|
||||
)
|
||||
self.instance.client.redirect_uris = [
|
||||
f"http://{self.instance.external_host}/oauth2/callback",
|
||||
f"https://{self.instance.external_host}/oauth2/callback",
|
||||
f"http://{self.instance.internal_host}/oauth2/callback",
|
||||
f"https://{self.instance.internal_host}/oauth2/callback",
|
||||
]
|
||||
self.instance.client.scope = ["openid", "email"]
|
||||
self.instance.client.save()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = ApplicationGatewayProvider
|
||||
fields = ["name", "internal_host", "external_host"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"internal_host": forms.TextInput(),
|
||||
"external_host": forms.TextInput(),
|
||||
}
|
99
passbook/providers/app_gw/migrations/0001_initial.py
Normal file
99
passbook/providers/app_gw/migrations/0001_initial.py
Normal file
@ -0,0 +1,99 @@
|
||||
# Generated by Django 2.2.6 on 2019-10-07 14:07
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ApplicationGatewayProvider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.Provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"server_name",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(), size=None
|
||||
),
|
||||
),
|
||||
(
|
||||
"upstream",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(), size=None
|
||||
),
|
||||
),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
(
|
||||
"authentication_header",
|
||||
models.TextField(blank=True, default="X-Remote-User"),
|
||||
),
|
||||
(
|
||||
"default_content_type",
|
||||
models.TextField(default="application/octet-stream"),
|
||||
),
|
||||
("upstream_ssl_verification", models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Application Gateway Provider",
|
||||
"verbose_name_plural": "Application Gateway Providers",
|
||||
},
|
||||
bases=("passbook_core.provider",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RewriteRule",
|
||||
fields=[
|
||||
(
|
||||
"propertymapping_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.PropertyMapping",
|
||||
),
|
||||
),
|
||||
("match", models.TextField()),
|
||||
("halt", models.BooleanField(default=False)),
|
||||
("replacement", models.TextField()),
|
||||
(
|
||||
"redirect",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("internal", "Internal"),
|
||||
(301, "Moved Permanently"),
|
||||
(302, "Found"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"conditions",
|
||||
models.ManyToManyField(blank=True, to="passbook_core.Policy"),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Rewrite Rule",
|
||||
"verbose_name_plural": "Rewrite Rules",
|
||||
},
|
||||
bases=("passbook_core.propertymapping",),
|
||||
),
|
||||
]
|
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.7 on 2019-11-11 17:03
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0005_merge_20191025_2022"),
|
||||
("passbook_providers_app_gw", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="rewriterule", name="conditions",),
|
||||
migrations.RemoveField(model_name="rewriterule", name="propertymapping_ptr",),
|
||||
migrations.DeleteModel(name="ApplicationGatewayProvider",),
|
||||
migrations.DeleteModel(name="RewriteRule",),
|
||||
]
|
@ -0,0 +1,48 @@
|
||||
# Generated by Django 2.2.7 on 2019-11-11 17:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0005_merge_20191025_2022"),
|
||||
("oidc_provider", "0026_client_multiple_response_types"),
|
||||
("passbook_providers_app_gw", "0002_auto_20191111_1703"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ApplicationGatewayProvider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.Provider",
|
||||
),
|
||||
),
|
||||
("name", models.TextField()),
|
||||
("host", models.TextField()),
|
||||
(
|
||||
"client",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="oidc_provider.Client",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Application Gateway Provider",
|
||||
"verbose_name_plural": "Application Gateway Providers",
|
||||
},
|
||||
bases=("passbook_core.provider",),
|
||||
),
|
||||
]
|
@ -0,0 +1,24 @@
|
||||
# Generated by Django 2.2.9 on 2020-01-02 15:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_app_gw", "0003_applicationgatewayprovider"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="applicationgatewayprovider",
|
||||
old_name="host",
|
||||
new_name="external_host",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="applicationgatewayprovider",
|
||||
name="internal_host",
|
||||
field=models.TextField(default=""),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
0
passbook/providers/app_gw/migrations/__init__.py
Normal file
0
passbook/providers/app_gw/migrations/__init__.py
Normal file
44
passbook/providers/app_gw/models.py
Normal file
44
passbook/providers/app_gw/models.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""passbook app_gw models"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
from typing import Optional
|
||||
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from oidc_provider.models import Client
|
||||
|
||||
from passbook import __version__
|
||||
from passbook.core.models import Provider
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
|
||||
class ApplicationGatewayProvider(Provider):
|
||||
"""This provider uses oauth2_proxy with the OIDC Provider."""
|
||||
|
||||
name = models.TextField()
|
||||
internal_host = models.TextField()
|
||||
external_host = models.TextField()
|
||||
|
||||
client = models.ForeignKey(Client, on_delete=models.CASCADE)
|
||||
|
||||
form = "passbook.providers.app_gw.forms.ApplicationGatewayProviderForm"
|
||||
|
||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||
cookie_secret = "".join(
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits)
|
||||
for _ in range(50)
|
||||
)
|
||||
return render_to_string(
|
||||
"app_gw/setup_modal.html",
|
||||
{"provider": self, "cookie_secret": cookie_secret, "version": __version__},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"Application Gateway {self.name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Application Gateway Provider")
|
||||
verbose_name_plural = _("Application Gateway Providers")
|
0
passbook/providers/app_gw/provider/__init__.py
Normal file
0
passbook/providers/app_gw/provider/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
version: "3.5"
|
||||
|
||||
services:
|
||||
passbook_gatekeeper:
|
||||
container_name: gatekeeper
|
||||
image: beryju/passbook-gatekeeper:{{ version }}
|
||||
ports:
|
||||
- 4180:4180
|
||||
environment:
|
||||
OAUTH2_PROXY_CLIENT_ID: {{ provider.client.client_id }}
|
||||
OAUTH2_PROXY_CLIENT_SECRET: {{ provider.client.client_secret }}
|
||||
OAUTH2_PROXY_REDIRECT_URL: https://{{ provider.external_host }}/oauth2/callback
|
||||
OAUTH2_PROXY_OIDC_ISSUER_URL: https://{{ request.META.HTTP_HOST }}/application/oidc
|
||||
OAUTH2_PROXY_COOKIE_SECRET: {{ cookie_secret }}
|
||||
OAUTH2_PROXY_UPSTREAMS: http://{{ provider.internal_host }}
|
64
passbook/providers/app_gw/templates/app_gw/k8s-manifest.yaml
Normal file
64
passbook/providers/app_gw/templates/app_gw/k8s-manifest.yaml
Normal file
@ -0,0 +1,64 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
k8s-app: passbook-gatekeeper
|
||||
name: passbook-gatekeeper
|
||||
namespace: kube-system
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
k8s-app: passbook-gatekeeper
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
k8s-app: passbook-gatekeeper
|
||||
spec:
|
||||
containers:
|
||||
- args:
|
||||
- --upstream=file:///dev/null
|
||||
env:
|
||||
- name: OAUTH2_PROXY_CLIENT_ID
|
||||
value: {{ provider.client.client_id }}
|
||||
- name: OAUTH2_PROXY_CLIENT_SECRET
|
||||
value: {{ provider.client.client_secret }}
|
||||
- name: OAUTH2_PROXY_COOKIE_SECRET
|
||||
value: {{ cookie_secret }}
|
||||
image: beryju/passbook-gatekeeper:{{ version }}
|
||||
imagePullPolicy: Always
|
||||
name: passbook-gatekeeper
|
||||
ports:
|
||||
- containerPort: 4180
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
k8s-app: passbook-gatekeeper
|
||||
name: passbook-gatekeeper
|
||||
namespace: kube-system
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
port: 4180
|
||||
protocol: TCP
|
||||
targetPort: 4180
|
||||
selector:
|
||||
k8s-app: passbook-gatekeeper
|
||||
---
|
||||
apiVersion: extensions/v1beta1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: passbook-gatekeeper
|
||||
namespace: kube-system
|
||||
spec:
|
||||
rules:
|
||||
- host: {{ provider.external_host }}
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
serviceName: passbook-gatekeeper
|
||||
servicePort: 4180
|
||||
path: /oauth2
|
57
passbook/providers/app_gw/templates/app_gw/setup_modal.html
Normal file
57
passbook/providers/app_gw/templates/app_gw/setup_modal.html
Normal file
@ -0,0 +1,57 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
<div class="pf-c-dropdown">
|
||||
<button class="pf-c-button pf-m-tertiary pf-c-dropdown__toggle" type="button">
|
||||
<span class="pf-c-dropdown__toggle-text">{% trans 'Setup with...' %}</span>
|
||||
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="pf-c-dropdown__menu" hidden>
|
||||
<li>
|
||||
<button class="pf-c-dropdown__menu-item" data-target="modal" data-modal="docker-compose-{{ provider.pk }}">{% trans 'docker-compose' %}</button>
|
||||
</li>
|
||||
<li>
|
||||
<button class="pf-c-dropdown__menu-item" data-target="modal" data-modal="k8s-{{ provider.pk }}">{% trans 'Kubernetes' %}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="pf-c-backdrop" id="docker-compose-{{ provider.pk }}" hidden>
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
||||
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
<h1 class="pf-c-title pf-m-2xl">{% trans 'Setup with docker-compose' %}</h1>
|
||||
<div class="pf-c-modal-box__body">
|
||||
{% trans 'Add the following snippet to your docker-compose file.' %}
|
||||
<textarea class="codemirror" readonly data-cm-mode="yaml">{% include 'app_gw/docker-compose.yml' %}</textarea>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf-c-backdrop" id="k8s-{{ provider.pk }}" hidden>
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
||||
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
<h1 class="pf-c-title pf-m-2xl">{% trans 'Setup with Kubernetes' %}</h1>
|
||||
<div class="pf-c-modal-box__body">
|
||||
<p>{% trans 'Download the manifest to create the Gatekeeper deployment and service:' %}</p>
|
||||
<a href="{% url 'passbook_providers_app_gw:k8s-manifest' provider=provider.pk %}">{% trans 'Here' %}</a>
|
||||
<p>{% trans 'Afterwards, add the following annotations to the Ingress you want to secure:' %}</p>
|
||||
<textarea class="codemirror" readonly data-cm-mode="yaml">
|
||||
nginx.ingress.kubernetes.io/auth-url: "https://{{ provider.external_host }}/oauth2/auth"
|
||||
nginx.ingress.kubernetes.io/auth-signin: "https://{{ provider.external_host }}/oauth2/start?rd=$escaped_request_uri"
|
||||
</textarea>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
10
passbook/providers/app_gw/urls.py
Normal file
10
passbook/providers/app_gw/urls.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""passbook app_gw urls"""
|
||||
from django.urls import path
|
||||
|
||||
from passbook.providers.app_gw.views import K8sManifestView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"<int:provider>/k8s-manifest/", K8sManifestView.as_view(), name="k8s-manifest"
|
||||
),
|
||||
]
|
40
passbook/providers/app_gw/views.py
Normal file
40
passbook/providers/app_gw/views.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""passbook app_gw views"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook import __version__
|
||||
from passbook.providers.app_gw.models import ApplicationGatewayProvider
|
||||
|
||||
ORIGINAL_URL = "HTTP_X_ORIGINAL_URL"
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def get_cookie_secret():
|
||||
"""Generate random 50-character string for cookie-secret"""
|
||||
return "".join(
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(50)
|
||||
)
|
||||
|
||||
|
||||
class K8sManifestView(LoginRequiredMixin, View):
|
||||
"""Generate K8s Deployment and SVC for gatekeeper"""
|
||||
|
||||
def get(self, request: HttpRequest, provider: int) -> HttpResponse:
|
||||
"""Render deployment template"""
|
||||
provider = get_object_or_404(ApplicationGatewayProvider, pk=provider)
|
||||
return render(
|
||||
request,
|
||||
"app_gw/k8s-manifest.yaml",
|
||||
{
|
||||
"provider": provider,
|
||||
"cookie_secret": get_cookie_secret(),
|
||||
"version": __version__,
|
||||
},
|
||||
content_type="text/yaml",
|
||||
)
|
0
passbook/providers/oauth/__init__.py
Normal file
0
passbook/providers/oauth/__init__.py
Normal file
29
passbook/providers/oauth/api.py
Normal file
29
passbook/providers/oauth/api.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""OAuth2Provider API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.providers.oauth.models import OAuth2Provider
|
||||
|
||||
|
||||
class OAuth2ProviderSerializer(ModelSerializer):
|
||||
"""OAuth2Provider Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OAuth2Provider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"redirect_uris",
|
||||
"client_type",
|
||||
"authorization_grant_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
]
|
||||
|
||||
|
||||
class OAuth2ProviderViewSet(ModelViewSet):
|
||||
"""OAuth2Provider Viewset"""
|
||||
|
||||
queryset = OAuth2Provider.objects.all()
|
||||
serializer_class = OAuth2ProviderSerializer
|
12
passbook/providers/oauth/apps.py
Normal file
12
passbook/providers/oauth/apps.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""passbook auth oauth provider app config"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookProviderOAuthConfig(AppConfig):
|
||||
"""passbook auth oauth provider app config"""
|
||||
|
||||
name = "passbook.providers.oauth"
|
||||
label = "passbook_providers_oauth"
|
||||
verbose_name = "passbook Providers.OAuth"
|
||||
mountpoint = ""
|
21
passbook/providers/oauth/forms.py
Normal file
21
passbook/providers/oauth/forms.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""passbook OAuth2 Provider Forms"""
|
||||
|
||||
from django import forms
|
||||
|
||||
from passbook.providers.oauth.models import OAuth2Provider
|
||||
|
||||
|
||||
class OAuth2ProviderForm(forms.ModelForm):
|
||||
"""OAuth2 Provider form"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OAuth2Provider
|
||||
fields = [
|
||||
"name",
|
||||
"redirect_uris",
|
||||
"client_type",
|
||||
"authorization_grant_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
]
|
104
passbook/providers/oauth/migrations/0001_initial.py
Normal file
104
passbook/providers/oauth/migrations/0001_initial.py
Normal file
@ -0,0 +1,104 @@
|
||||
# Generated by Django 2.2.6 on 2019-10-07 14:07
|
||||
|
||||
import django.db.models.deletion
|
||||
import oauth2_provider.generators
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
run_before = [
|
||||
("oauth2_provider", "0001_initial"),
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("passbook_core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="OAuth2Provider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.Provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_id",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
default=oauth2_provider.generators.generate_client_id,
|
||||
max_length=100,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"redirect_uris",
|
||||
models.TextField(
|
||||
blank=True, help_text="Allowed URIs list, space separated"
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("confidential", "Confidential"),
|
||||
("public", "Public"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_grant_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("authorization-code", "Authorization code"),
|
||||
("implicit", "Implicit"),
|
||||
("password", "Resource owner password-based"),
|
||||
("client-credentials", "Client credentials"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_secret",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
default=oauth2_provider.generators.generate_client_secret,
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(blank=True, max_length=255)),
|
||||
("skip_authorization", models.BooleanField(default=False)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="passbook_providers_oauth_oauth2provider",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "OAuth2 Provider",
|
||||
"verbose_name_plural": "OAuth2 Providers",
|
||||
},
|
||||
bases=("passbook_core.provider", models.Model),
|
||||
),
|
||||
]
|
0
passbook/providers/oauth/migrations/__init__.py
Normal file
0
passbook/providers/oauth/migrations/__init__.py
Normal file
43
passbook/providers/oauth/models.py
Normal file
43
passbook/providers/oauth/models.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""Oauth2 provider product extension"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from oauth2_provider.models import AbstractApplication
|
||||
|
||||
from passbook.core.models import Provider
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
|
||||
class OAuth2Provider(Provider, AbstractApplication):
|
||||
"""Associate an OAuth2 Application with a Product"""
|
||||
|
||||
form = "passbook.providers.oauth.forms.OAuth2ProviderForm"
|
||||
|
||||
def __str__(self):
|
||||
return f"OAuth2 Provider {self.name}"
|
||||
|
||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||
return render_to_string(
|
||||
"oauth2_provider/setup_url_modal.html",
|
||||
{
|
||||
"provider": self,
|
||||
"authorize_url": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth:oauth2-authorize")
|
||||
),
|
||||
"token_url": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth:token")
|
||||
),
|
||||
"userinfo_url": request.build_absolute_uri(
|
||||
reverse("passbook_api:openid")
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OAuth2 Provider")
|
||||
verbose_name_plural = _("OAuth2 Providers")
|
32
passbook/providers/oauth/settings.py
Normal file
32
passbook/providers/oauth/settings.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""passbook OAuth_Provider"""
|
||||
from django.conf import settings
|
||||
|
||||
CORS_ORIGIN_ALLOW_ALL = settings.DEBUG
|
||||
|
||||
REQUEST_APPROVAL_PROMPT = "auto"
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"oauth2_provider",
|
||||
"corsheaders",
|
||||
]
|
||||
MIDDLEWARE = [
|
||||
"oauth2_provider.middleware.OAuth2TokenMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
]
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"oauth2_provider.backends.OAuth2Backend",
|
||||
]
|
||||
|
||||
OAUTH2_PROVIDER_APPLICATION_MODEL = "passbook_providers_oauth.OAuth2Provider"
|
||||
|
||||
OAUTH2_PROVIDER = {
|
||||
# this is the list of available scopes
|
||||
"SCOPES": {
|
||||
"openid": "Access OpenID Userinfo",
|
||||
"openid:userinfo": "Access OpenID Userinfo",
|
||||
# 'write': 'Write scope',
|
||||
# 'groups': 'Access to your groups',
|
||||
"user:email": "GitHub Compatibility: User E-Mail",
|
||||
"read:org": "GitHub Compatibility: User Groups",
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
{% extends "login/base.html" %}
|
||||
|
||||
{% load passbook_utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block card_title %}
|
||||
{% trans 'Authorize Application' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% csrf_token %}
|
||||
{% if not error %}
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{% if field.is_hidden %}
|
||||
{{ field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="pf-c-form__group">
|
||||
<p class="subtitle">
|
||||
{% blocktrans with remote=application.name %}
|
||||
You're about to sign into {{ remote }}.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>{% trans "Application requires following permissions" %}</p>
|
||||
<ul class="pf-c-list">
|
||||
{% for scope in scopes_descriptions %}
|
||||
<li>{{ scope }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{{ form.errors }}
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
{% blocktrans with user=user %}
|
||||
You are logged in as {{ user }}. Not you?
|
||||
{% endblocktrans %}
|
||||
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Logout' %}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<input type="submit" class="pf-c-button pf-m-primary" name="allow" value="{% trans 'Continue' %}">
|
||||
<a href="{% back %}" class="pf-c-button pf-m-secondary">{% trans "Cancel" %}</a>
|
||||
</div>
|
||||
<div class="pf-c-form__group" style="display: none;" id="loading">
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="login-group">
|
||||
<p class="subtitle">
|
||||
{% blocktrans with err=error.error %}Error: {{ err }}{% endblocktrans %}
|
||||
</p>
|
||||
<p>{{ error.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.querySelector("form").addEventListener("submit", (e) => {
|
||||
document.getElementById("loading").removeAttribute("style");
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
@ -0,0 +1 @@
|
||||
{% extends "base/skeleton.html" %}
|
@ -0,0 +1,38 @@
|
||||
{% load i18n %}
|
||||
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="oauth-{{ provider.pk }}">{% trans 'View Setup URLs' %}</button>
|
||||
|
||||
<div class="pf-c-backdrop" id="oauth-{{ provider.pk }}" hidden>
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
||||
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Setup URLs' %}</h1>
|
||||
<div class="pf-c-modal-box__body" id="modal-description">
|
||||
<form class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Authorize URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ authorize_url }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Token URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ token_url }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Userinfo Endpoint' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ userinfo_url }}" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
48
passbook/providers/oauth/urls.py
Normal file
48
passbook/providers/oauth/urls.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""passbook oauth_provider urls"""
|
||||
|
||||
from django.urls import include, path
|
||||
from oauth2_provider import views
|
||||
|
||||
from passbook.providers.oauth.views import github, oauth2
|
||||
|
||||
oauth_urlpatterns = [
|
||||
# Custom OAuth 2 Authorize View
|
||||
path(
|
||||
"authorize/",
|
||||
oauth2.PassbookAuthorizationLoadingView.as_view(),
|
||||
name="oauth2-authorize",
|
||||
),
|
||||
path(
|
||||
"authorize/permission_ok/",
|
||||
oauth2.PassbookAuthorizationView.as_view(),
|
||||
name="oauth2-ok-authorize",
|
||||
),
|
||||
path(
|
||||
"authorize/permission_denied/",
|
||||
oauth2.OAuthPermissionDenied.as_view(),
|
||||
name="oauth2-permission-denied",
|
||||
),
|
||||
# OAuth API
|
||||
path("token/", views.TokenView.as_view(), name="token"),
|
||||
path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"),
|
||||
path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"),
|
||||
]
|
||||
|
||||
github_urlpatterns = [
|
||||
path(
|
||||
"login/oauth/authorize",
|
||||
oauth2.PassbookAuthorizationView.as_view(),
|
||||
name="github-authorize",
|
||||
),
|
||||
path(
|
||||
"login/oauth/access_token",
|
||||
views.TokenView.as_view(),
|
||||
name="github-access-token",
|
||||
),
|
||||
path("user", github.GitHubUserView.as_view(), name="github-user"),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(github_urlpatterns)),
|
||||
path("application/oauth/", include(oauth_urlpatterns)),
|
||||
]
|
0
passbook/providers/oauth/views/__init__.py
Normal file
0
passbook/providers/oauth/views/__init__.py
Normal file
67
passbook/providers/oauth/views/github.py
Normal file
67
passbook/providers/oauth/views/github.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""passbook pretend GitHub Views"""
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views import View
|
||||
from oauth2_provider.models import AccessToken
|
||||
|
||||
|
||||
class GitHubUserView(View):
|
||||
"""Emulate GitHub's /user API Endpoint"""
|
||||
|
||||
def verify_access_token(self):
|
||||
"""Verify access token manually since github uses /user?access_token=..."""
|
||||
token = get_object_or_404(
|
||||
AccessToken, token=self.request.GET.get("access_token", "")
|
||||
)
|
||||
return token.user
|
||||
|
||||
def get(self, request):
|
||||
"""Emulate GitHub's /user API Endpoint"""
|
||||
user = self.verify_access_token()
|
||||
return JsonResponse(
|
||||
{
|
||||
"login": user.username,
|
||||
"id": user.pk,
|
||||
"node_id": "",
|
||||
"avatar_url": "",
|
||||
"gravatar_id": "",
|
||||
"url": "",
|
||||
"html_url": "",
|
||||
"followers_url": "",
|
||||
"following_url": "",
|
||||
"gists_url": "",
|
||||
"starred_url": "",
|
||||
"subscriptions_url": "",
|
||||
"organizations_url": "",
|
||||
"repos_url": "",
|
||||
"events_url": "",
|
||||
"received_events_url": "",
|
||||
"type": "User",
|
||||
"site_admin": False,
|
||||
"name": user.name,
|
||||
"company": "",
|
||||
"blog": "",
|
||||
"location": "",
|
||||
"email": user.email,
|
||||
"hireable": False,
|
||||
"bio": "",
|
||||
"public_repos": 0,
|
||||
"public_gists": 0,
|
||||
"followers": 0,
|
||||
"following": 0,
|
||||
"created_at": user.date_joined,
|
||||
"updated_at": user.date_joined,
|
||||
"private_gists": 0,
|
||||
"total_private_repos": 0,
|
||||
"owned_private_repos": 0,
|
||||
"disk_usage": 0,
|
||||
"collaborators": 0,
|
||||
"two_factor_authentication": True,
|
||||
"plan": {
|
||||
"name": "None",
|
||||
"space": 0,
|
||||
"private_repos": 0,
|
||||
"collaborators": 0,
|
||||
},
|
||||
}
|
||||
)
|
90
passbook/providers/oauth/views/oauth2.py
Normal file
90
passbook/providers/oauth/views/oauth2.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""passbook OAuth2 Views"""
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.forms import Form
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from oauth2_provider.views.base import AuthorizationView
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.core.models import Application
|
||||
from passbook.core.views.access import AccessMixin
|
||||
from passbook.core.views.utils import LoadingView, PermissionDeniedView
|
||||
from passbook.providers.oauth.models import OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class PassbookAuthorizationLoadingView(LoginRequiredMixin, LoadingView):
|
||||
"""Show loading view for permission checks"""
|
||||
|
||||
title = _("Checking permissions...")
|
||||
|
||||
def get_url(self):
|
||||
querystring = urlencode(self.request.GET)
|
||||
return (
|
||||
reverse("passbook_providers_oauth:oauth2-ok-authorize") + "?" + querystring
|
||||
)
|
||||
|
||||
|
||||
class OAuthPermissionDenied(PermissionDeniedView):
|
||||
"""Show permission denied view"""
|
||||
|
||||
|
||||
class PassbookAuthorizationView(AccessMixin, AuthorizationView):
|
||||
"""Custom OAuth2 Authorization View which checks policies, etc"""
|
||||
|
||||
_application: Optional[Application] = None
|
||||
|
||||
def _inject_response_type(self):
|
||||
"""Inject response_type into querystring if not set"""
|
||||
LOGGER.debug("response_type not set, defaulting to 'code'")
|
||||
querystring = urlencode(self.request.GET)
|
||||
querystring += "&response_type=code"
|
||||
return redirect(
|
||||
reverse("passbook_providers_oauth:oauth2-ok-authorize") + "?" + querystring
|
||||
)
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Update OAuth2Provider's skip_authorization state"""
|
||||
# Get client_id to get provider, so we can update skip_authorization field
|
||||
client_id = request.GET.get("client_id")
|
||||
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
|
||||
try:
|
||||
application = self.provider_to_application(provider)
|
||||
except Application.DoesNotExist:
|
||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||
# Update field here so oauth-toolkit does work for us
|
||||
provider.skip_authorization = application.skip_authorization
|
||||
provider.save()
|
||||
self._application = application
|
||||
# Check permissions
|
||||
passing, policy_messages = self.user_has_access(self._application, request.user)
|
||||
if not passing:
|
||||
for policy_message in policy_messages:
|
||||
messages.error(request, policy_message)
|
||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||
# Some clients don't pass response_type, so we default to code
|
||||
if "response_type" not in request.GET:
|
||||
return self._inject_response_type()
|
||||
actual_response = AuthorizationView.dispatch(self, request, *args, **kwargs)
|
||||
if actual_response.status_code == 400:
|
||||
LOGGER.debug("Bad request", redirect_uri=request.GET.get("redirect_uri"))
|
||||
return actual_response
|
||||
|
||||
def form_valid(self, form: Form):
|
||||
# User has clicked on "Authorize"
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION, authorized_application=self._application,
|
||||
).from_http(self.request)
|
||||
LOGGER.debug(
|
||||
"User authorized Application",
|
||||
user=self.request.user,
|
||||
application=self._application,
|
||||
)
|
||||
return AuthorizationView.form_valid(self, form)
|
0
passbook/providers/oidc/__init__.py
Normal file
0
passbook/providers/oidc/__init__.py
Normal file
34
passbook/providers/oidc/api.py
Normal file
34
passbook/providers/oidc/api.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""OpenIDProvider API Views"""
|
||||
from oidc_provider.models import Client
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
# from passbook.providers.oidc.models import OpenIDProvider
|
||||
|
||||
|
||||
class OpenIDProviderSerializer(ModelSerializer):
|
||||
"""OpenIDProvider Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Client
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"client_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"response_types",
|
||||
"jwt_alg",
|
||||
"reuse_consent",
|
||||
"require_consent",
|
||||
"_redirect_uris",
|
||||
"_scope",
|
||||
]
|
||||
|
||||
|
||||
class OpenIDProviderViewSet(ModelViewSet):
|
||||
"""OpenIDProvider Viewset"""
|
||||
|
||||
queryset = Client.objects.all()
|
||||
serializer_class = OpenIDProviderSerializer
|
40
passbook/providers/oidc/apps.py
Normal file
40
passbook/providers/oidc/apps.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""passbook auth oidc provider app config"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.utils import InternalError, OperationalError, ProgrammingError
|
||||
from django.urls import include, path
|
||||
from structlog import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class PassbookProviderOIDCConfig(AppConfig):
|
||||
"""passbook auth oidc provider app config"""
|
||||
|
||||
name = "passbook.providers.oidc"
|
||||
label = "passbook_providers_oidc"
|
||||
verbose_name = "passbook Providers.OIDC"
|
||||
|
||||
def ready(self):
|
||||
try:
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from oidc_provider.models import RSAKey
|
||||
|
||||
if not RSAKey.objects.exists():
|
||||
key = RSA.generate(2048)
|
||||
rsakey = RSAKey(key=key.exportKey("PEM").decode("utf8"))
|
||||
rsakey.save()
|
||||
LOGGER.info("Created key")
|
||||
except (OperationalError, ProgrammingError, InternalError):
|
||||
pass
|
||||
from passbook.root import urls
|
||||
|
||||
urls.urlpatterns.append(
|
||||
path(
|
||||
"application/oidc/",
|
||||
include("oidc_provider.urls", namespace="oidc_provider"),
|
||||
),
|
||||
)
|
||||
|
||||
import_module("passbook.providers.oidc.signals")
|
64
passbook/providers/oidc/auth.py
Normal file
64
passbook/providers/oidc/auth.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""OIDC Permission checking"""
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models.deletion import Collector
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from oidc_provider.models import Client
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.core.models import Application, Provider, User
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def client_related_provider(client: Client) -> Optional[Provider]:
|
||||
"""Lookup related Application from Client"""
|
||||
# because oidc_provider is also used by app_gw, we can't be
|
||||
# sure an OpenIDPRovider instance exists. hence we look through all related models
|
||||
# and choose the one that inherits from Provider, which is guaranteed to
|
||||
# have the application property
|
||||
collector = Collector(using="default")
|
||||
collector.collect([client])
|
||||
for _, related in collector.data.items():
|
||||
related_object = next(iter(related))
|
||||
if isinstance(related_object, Provider):
|
||||
return related_object
|
||||
return None
|
||||
|
||||
|
||||
def check_permissions(
|
||||
request: HttpRequest, user: User, client: Client
|
||||
) -> Optional[HttpResponse]:
|
||||
"""Check permissions, used for
|
||||
https://django-oidc-provider.readthedocs.io/en/latest/
|
||||
sections/settings.html#oidc-after-userlogin-hook"""
|
||||
provider = client_related_provider(client)
|
||||
if not provider:
|
||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||
try:
|
||||
application = provider.application
|
||||
except Application.DoesNotExist:
|
||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||
LOGGER.debug(
|
||||
"Checking permissions for application", user=user, application=application
|
||||
)
|
||||
policy_engine = PolicyEngine(application.policies.all(), user, request)
|
||||
policy_engine.build()
|
||||
|
||||
# Check permissions
|
||||
passing, policy_messages = policy_engine.result
|
||||
if not passing:
|
||||
for policy_message in policy_messages:
|
||||
messages.error(request, policy_message)
|
||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=application,
|
||||
skipped_authorization=False,
|
||||
).from_http(request)
|
||||
return None
|
14
passbook/providers/oidc/claims.py
Normal file
14
passbook/providers/oidc/claims.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""passbook oidc claim helpers"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from passbook.core.models import User
|
||||
|
||||
|
||||
def userinfo(claims: Dict[str, Any], user: User) -> Dict[str, Any]:
|
||||
"""Populate claims from userdata"""
|
||||
claims["name"] = user.name
|
||||
claims["given_name"] = user.name
|
||||
claims["family_name"] = user.name
|
||||
claims["email"] = user.email
|
||||
|
||||
return claims
|
42
passbook/providers/oidc/forms.py
Normal file
42
passbook/providers/oidc/forms.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""passbook OIDC IDP Forms"""
|
||||
|
||||
from django import forms
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
from oidc_provider.models import Client
|
||||
|
||||
from passbook.providers.oidc.models import OpenIDProvider
|
||||
|
||||
|
||||
class OIDCProviderForm(forms.ModelForm):
|
||||
"""OpenID Client form"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Correctly load data from 1:1 rel
|
||||
if "instance" in kwargs and kwargs["instance"]:
|
||||
kwargs["instance"] = kwargs["instance"].oidc_client
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["client_id"].initial = generate_client_id()
|
||||
self.fields["client_secret"].initial = generate_client_secret()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.instance.reuse_consent = False # This is managed by passbook
|
||||
self.instance.require_consent = True # This is managed by passbook
|
||||
response = super().save(*args, **kwargs)
|
||||
# Check if openidprovider class instance exists
|
||||
if not OpenIDProvider.objects.filter(oidc_client=self.instance).exists():
|
||||
OpenIDProvider.objects.create(oidc_client=self.instance)
|
||||
return response
|
||||
|
||||
class Meta:
|
||||
model = Client
|
||||
fields = [
|
||||
"name",
|
||||
"client_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"response_types",
|
||||
"jwt_alg",
|
||||
"_redirect_uris",
|
||||
"_scope",
|
||||
]
|
||||
labels = {"client_secret": "Client Secret"}
|
45
passbook/providers/oidc/migrations/0001_initial.py
Normal file
45
passbook/providers/oidc/migrations/0001_initial.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Generated by Django 2.2.6 on 2019-10-07 14:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0001_initial"),
|
||||
("oidc_provider", "0026_client_multiple_response_types"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="OpenIDProvider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.Provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"oidc_client",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="oidc_provider.Client",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "OpenID Provider",
|
||||
"verbose_name_plural": "OpenID Providers",
|
||||
},
|
||||
bases=("passbook_core.provider",),
|
||||
),
|
||||
]
|
0
passbook/providers/oidc/migrations/__init__.py
Normal file
0
passbook/providers/oidc/migrations/__init__.py
Normal file
55
passbook/providers/oidc/models.py
Normal file
55
passbook/providers/oidc/models.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""oidc models"""
|
||||
from typing import Optional
|
||||
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from oidc_provider.models import Client
|
||||
|
||||
from passbook.core.models import Provider
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
|
||||
class OpenIDProvider(Provider):
|
||||
"""Proxy model for OIDC Client"""
|
||||
|
||||
# Since oidc_provider doesn't currently support swappable models
|
||||
# (https://github.com/juanifioren/django-oidc-provider/pull/305)
|
||||
# we have a 1:1 relationship, and update oidc_client when the form is saved.
|
||||
|
||||
oidc_client = models.OneToOneField(Client, on_delete=models.CASCADE)
|
||||
|
||||
form = "passbook.providers.oidc.forms.OIDCProviderForm"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Name property for UI"""
|
||||
return self.oidc_client.name
|
||||
|
||||
def __str__(self):
|
||||
return "OpenID Connect Provider %s" % self.oidc_client.__str__()
|
||||
|
||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||
return render_to_string(
|
||||
"oidc_provider/setup_url_modal.html",
|
||||
{
|
||||
"provider": self,
|
||||
"authorize": request.build_absolute_uri(
|
||||
reverse("oidc_provider:authorize")
|
||||
),
|
||||
"token": request.build_absolute_uri(reverse("oidc_provider:token")),
|
||||
"userinfo": request.build_absolute_uri(
|
||||
reverse("oidc_provider:userinfo")
|
||||
),
|
||||
"provider_info": request.build_absolute_uri(
|
||||
reverse("oidc_provider:provider-info")
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OpenID Provider")
|
||||
verbose_name_plural = _("OpenID Providers")
|
9
passbook/providers/oidc/settings.py
Normal file
9
passbook/providers/oidc/settings.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""passbook OIDC Provider"""
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"oidc_provider",
|
||||
]
|
||||
|
||||
OIDC_AFTER_USERLOGIN_HOOK = "passbook.providers.oidc.auth.check_permissions"
|
||||
OIDC_IDTOKEN_INCLUDE_CLAIMS = True
|
||||
OIDC_USERINFO = "passbook.providers.oidc.claims.userinfo"
|
15
passbook/providers/oidc/signals.py
Normal file
15
passbook/providers/oidc/signals.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""OIDC Provider signals"""
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from passbook.core.models import Application
|
||||
from passbook.providers.oidc.models import OpenIDProvider
|
||||
|
||||
|
||||
@receiver(post_save, sender=Application)
|
||||
# pylint: disable=unused-argument
|
||||
def on_application_save(sender, instance: Application, **_):
|
||||
"""Synchronize application's skip_authorization with oidc_client's require_consent"""
|
||||
if isinstance(instance.provider, OpenIDProvider):
|
||||
instance.provider.oidc_client.require_consent = not instance.skip_authorization
|
||||
instance.provider.oidc_client.save()
|
@ -0,0 +1,74 @@
|
||||
{% extends "login/base.html" %}
|
||||
|
||||
{% load passbook_utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block card_title %}
|
||||
{% trans 'Authorize Application' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% csrf_token %}
|
||||
{% if not error %}
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{% if field.is_hidden %}
|
||||
{{ field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="pf-c-form__group">
|
||||
<p class="subtitle">
|
||||
{% blocktrans with remote=client.name %}
|
||||
You're about to sign into {{ remote }}.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>{% trans "Application requires following permissions" %}</p>
|
||||
<ul class="pf-c-list">
|
||||
{% for scope in scopes %}
|
||||
<li>{{ scope.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{{ hidden_inputs }}
|
||||
{{ form.errors }}
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
{% blocktrans with user=user %}
|
||||
You are logged in as {{ user }}. Not you?
|
||||
{% endblocktrans %}
|
||||
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Logout' %}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<input type="submit" class="pf-c-button pf-m-primary" name="allow" value="{% trans 'Continue' %}">
|
||||
<a href="{% back %}" class="pf-c-button pf-m-secondary">{% trans "Cancel" %}</a>
|
||||
</div>
|
||||
<div class="pf-c-form__group" style="display: none;" id="loading">
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="login-group">
|
||||
<p class="subtitle">
|
||||
{% blocktrans with err=error.error %}Error: {{ err }}{% endblocktrans %}
|
||||
</p>
|
||||
<p>{{ error.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.querySelector("form").addEventListener("submit", (e) => {
|
||||
document.getElementById("loading").removeAttribute("style");
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
18
passbook/providers/oidc/templates/oidc_provider/error.html
Normal file
18
passbook/providers/oidc/templates/oidc_provider/error.html
Normal file
@ -0,0 +1,18 @@
|
||||
{% extends 'login/base.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load passbook_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 %}
|
@ -0,0 +1,46 @@
|
||||
{% load i18n %}
|
||||
|
||||
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="oidc-{{ provider.pk }}">{% trans 'View Setup URLs' %}</button>
|
||||
|
||||
<div class="pf-c-backdrop" id="oidc-{{ provider.pk }}" hidden>
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
||||
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Setup URLs' %}</h1>
|
||||
<div class="pf-c-modal-box__body" id="modal-description">
|
||||
<form class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Authorize URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ authorize }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Token URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ token }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Userinfo Endpoint' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ userinfo }}" />
|
||||
</div>
|
||||
<hr>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ provider_info }}" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
0
passbook/providers/saml/__init__.py
Normal file
0
passbook/providers/saml/__init__.py
Normal file
52
passbook/providers/saml/api.py
Normal file
52
passbook/providers/saml/api.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""SAMLProvider API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
|
||||
|
||||
class SAMLProviderSerializer(ModelSerializer):
|
||||
"""SAMLProvider Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = SAMLProvider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"processor_path",
|
||||
"acs_url",
|
||||
"audience",
|
||||
"issuer",
|
||||
"assertion_valid_not_before",
|
||||
"assertion_valid_not_on_or_after",
|
||||
"session_valid_not_on_or_after",
|
||||
"property_mappings",
|
||||
"digest_algorithm",
|
||||
"signature_algorithm",
|
||||
"signing_kp",
|
||||
"require_signing",
|
||||
]
|
||||
|
||||
|
||||
class SAMLProviderViewSet(ModelViewSet):
|
||||
"""SAMLProvider Viewset"""
|
||||
|
||||
queryset = SAMLProvider.objects.all()
|
||||
serializer_class = SAMLProviderSerializer
|
||||
|
||||
|
||||
class SAMLPropertyMappingSerializer(ModelSerializer):
|
||||
"""SAMLPropertyMapping Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = SAMLPropertyMapping
|
||||
fields = ["pk", "name", "saml_name", "friendly_name", "expression"]
|
||||
|
||||
|
||||
class SAMLPropertyMappingViewSet(ModelViewSet):
|
||||
"""SAMLPropertyMapping Viewset"""
|
||||
|
||||
queryset = SAMLPropertyMapping.objects.all()
|
||||
serializer_class = SAMLPropertyMappingSerializer
|
26
passbook/providers/saml/apps.py
Normal file
26
passbook/providers/saml/apps.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""passbook mod saml_idp app config"""
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from structlog import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class PassbookProviderSAMLConfig(AppConfig):
|
||||
"""passbook saml_idp app config"""
|
||||
|
||||
name = "passbook.providers.saml"
|
||||
label = "passbook_providers_saml"
|
||||
verbose_name = "passbook Providers.SAML"
|
||||
mountpoint = "application/saml/"
|
||||
|
||||
def ready(self):
|
||||
"""Load source_types from config file"""
|
||||
for source_type in settings.PASSBOOK_PROVIDERS_SAML_PROCESSORS:
|
||||
try:
|
||||
import_module(source_type)
|
||||
LOGGER.info("Loaded SAML Processor", processor_class=source_type)
|
||||
except ImportError as exc:
|
||||
LOGGER.debug(exc)
|
6
passbook/providers/saml/exceptions.py
Normal file
6
passbook/providers/saml/exceptions.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""passbook SAML IDP Exceptions"""
|
||||
from passbook.lib.sentry import SentryIgnoredException
|
||||
|
||||
|
||||
class CannotHandleAssertion(SentryIgnoredException):
|
||||
"""This processor does not handle this assertion."""
|
63
passbook/providers/saml/forms.py
Normal file
63
passbook/providers/saml/forms.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""passbook SAML IDP Forms"""
|
||||
|
||||
from django import forms
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.providers.saml.models import (
|
||||
SAMLPropertyMapping,
|
||||
SAMLProvider,
|
||||
get_provider_choices,
|
||||
)
|
||||
|
||||
|
||||
class SAMLProviderForm(forms.ModelForm):
|
||||
"""SAML Provider form"""
|
||||
|
||||
processor_path = forms.ChoiceField(
|
||||
choices=get_provider_choices(), label="Processor"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = SAMLProvider
|
||||
fields = [
|
||||
"name",
|
||||
"processor_path",
|
||||
"acs_url",
|
||||
"audience",
|
||||
"issuer",
|
||||
"assertion_valid_not_before",
|
||||
"assertion_valid_not_on_or_after",
|
||||
"session_valid_not_on_or_after",
|
||||
"property_mappings",
|
||||
"digest_algorithm",
|
||||
"require_signing",
|
||||
"signature_algorithm",
|
||||
"signing_kp",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"audience": forms.TextInput(),
|
||||
"issuer": forms.TextInput(),
|
||||
"assertion_valid_not_before": forms.TextInput(),
|
||||
"assertion_valid_not_on_or_after": forms.TextInput(),
|
||||
"session_valid_not_on_or_after": forms.TextInput(),
|
||||
"property_mappings": FilteredSelectMultiple(_("Property Mappings"), False),
|
||||
}
|
||||
|
||||
|
||||
class SAMLPropertyMappingForm(forms.ModelForm):
|
||||
"""SAML Property Mapping form"""
|
||||
|
||||
template_name = "saml/idp/property_mapping_form.html"
|
||||
|
||||
class Meta:
|
||||
|
||||
model = SAMLPropertyMapping
|
||||
fields = ["name", "saml_name", "friendly_name", "expression"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"saml_name": forms.TextInput(),
|
||||
"friendly_name": forms.TextInput(),
|
||||
}
|
79
passbook/providers/saml/migrations/0001_initial.py
Normal file
79
passbook/providers/saml/migrations/0001_initial.py
Normal file
@ -0,0 +1,79 @@
|
||||
# Generated by Django 2.2.6 on 2019-10-07 14:07
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SAMLPropertyMapping",
|
||||
fields=[
|
||||
(
|
||||
"propertymapping_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.PropertyMapping",
|
||||
),
|
||||
),
|
||||
("saml_name", models.TextField()),
|
||||
(
|
||||
"friendly_name",
|
||||
models.TextField(blank=True, default=None, null=True),
|
||||
),
|
||||
(
|
||||
"values",
|
||||
django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(), size=None
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SAML Property Mapping",
|
||||
"verbose_name_plural": "SAML Property Mappings",
|
||||
},
|
||||
bases=("passbook_core.propertymapping",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SAMLProvider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.Provider",
|
||||
),
|
||||
),
|
||||
("name", models.TextField()),
|
||||
("acs_url", models.URLField()),
|
||||
("audience", models.TextField(default="")),
|
||||
("processor_path", models.CharField(max_length=255)),
|
||||
("issuer", models.TextField()),
|
||||
("assertion_valid_for", models.IntegerField(default=86400)),
|
||||
("signing", models.BooleanField(default=True)),
|
||||
("signing_cert", models.TextField()),
|
||||
("signing_key", models.TextField()),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SAML Provider",
|
||||
"verbose_name_plural": "SAML Providers",
|
||||
},
|
||||
bases=("passbook_core.provider",),
|
||||
),
|
||||
]
|
@ -0,0 +1,61 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-14 13:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import passbook.providers.saml.utils.time
|
||||
|
||||
|
||||
def migrate_valid_for(apps, schema_editor):
|
||||
"""Migrate from single number standing for minutes to 'minutes=3'"""
|
||||
SAMLProvider = apps.get_model("passbook_providers_saml", "SAMLProvider")
|
||||
db_alias = schema_editor.connection.alias
|
||||
for provider in SAMLProvider.objects.using(db_alias).all():
|
||||
provider.assertion_valid_not_on_or_after = (
|
||||
f"minutes={provider.assertion_valid_for}"
|
||||
)
|
||||
provider.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_saml", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="assertion_valid_not_before",
|
||||
field=models.TextField(
|
||||
default="minutes=5",
|
||||
help_text="Assertion valid not before current time - this value (Format: hours=1;minutes=2;seconds=3).",
|
||||
validators=[
|
||||
passbook.providers.saml.utils.time.timedelta_string_validator
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="assertion_valid_not_on_or_after",
|
||||
field=models.TextField(
|
||||
default="minutes=5",
|
||||
help_text="Assertion not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
|
||||
validators=[
|
||||
passbook.providers.saml.utils.time.timedelta_string_validator
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_valid_for),
|
||||
migrations.RemoveField(model_name="samlprovider", name="assertion_valid_for",),
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="session_valid_not_on_or_after",
|
||||
field=models.TextField(
|
||||
default="minutes=86400",
|
||||
help_text="Session not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
|
||||
validators=[
|
||||
passbook.providers.saml.utils.time.timedelta_string_validator
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,38 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-16 11:09
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_saml", "0002_auto_20200214_1354"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="samlpropertymapping",
|
||||
name="saml_name",
|
||||
field=models.TextField(verbose_name="SAML Name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="samlpropertymapping",
|
||||
name="values",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(),
|
||||
help_text="This string can contain string substitutions delimited by {}. The following Variables are available: user, request",
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="samlprovider",
|
||||
name="acs_url",
|
||||
field=models.URLField(verbose_name="ACS URL"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="samlprovider",
|
||||
name="signing_cert",
|
||||
field=models.TextField(verbose_name="Singing Certificate"),
|
||||
),
|
||||
]
|
@ -0,0 +1,41 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-17 15:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_saml", "0003_auto_20200216_1109"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="digest_algorithm",
|
||||
field=models.CharField(
|
||||
choices=[("sha1", "SHA1"), ("sha256", "SHA256")],
|
||||
default="sha256",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="signature_algorithm",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("rsa-sha1", "RSA-SHA1"),
|
||||
("rsa-sha256", "RSA-SHA256"),
|
||||
("ecdsa-sha256", "ECDSA-SHA256"),
|
||||
("dsa-sha1", "DSA-SHA1"),
|
||||
],
|
||||
default="rsa-sha256",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="samlprovider",
|
||||
name="processor_path",
|
||||
field=models.CharField(choices=[], max_length=255),
|
||||
),
|
||||
]
|
@ -0,0 +1,76 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-17 16:15
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def cleanup_old_autogenerated(apps, schema_editor):
|
||||
SAMLPropertyMapping = apps.get_model(
|
||||
"passbook_providers_saml", "SAMLPropertyMapping"
|
||||
)
|
||||
db_alias = schema_editor.connection.alias
|
||||
SAMLPropertyMapping.objects.using(db_alias).filter(
|
||||
name__startswith="Autogenerated"
|
||||
).delete()
|
||||
|
||||
|
||||
def create_default_property_mappings(apps, schema_editor):
|
||||
"""Create default SAML Property Mappings"""
|
||||
SAMLPropertyMapping = apps.get_model(
|
||||
"passbook_providers_saml", "SAMLPropertyMapping"
|
||||
)
|
||||
db_alias = schema_editor.connection.alias
|
||||
defaults = [
|
||||
{
|
||||
"FriendlyName": "eduPersonPrincipalName",
|
||||
"Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
|
||||
"Expression": "{{ user.email }}",
|
||||
},
|
||||
{
|
||||
"FriendlyName": "cn",
|
||||
"Name": "urn:oid:2.5.4.3",
|
||||
"Expression": "{{ user.name }}",
|
||||
},
|
||||
{
|
||||
"FriendlyName": "mail",
|
||||
"Name": "urn:oid:0.9.2342.19200300.100.1.3",
|
||||
"Expression": "{{ user.email }}",
|
||||
},
|
||||
{
|
||||
"FriendlyName": "displayName",
|
||||
"Name": "urn:oid:2.16.840.1.113730.3.1.241",
|
||||
"Expression": "{{ user.username }}",
|
||||
},
|
||||
{
|
||||
"FriendlyName": "uid",
|
||||
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
|
||||
"Expression": "{{ user.pk }}",
|
||||
},
|
||||
{
|
||||
"FriendlyName": "member-of",
|
||||
"Name": "member-of",
|
||||
"Expression": "[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]",
|
||||
},
|
||||
]
|
||||
for default in defaults:
|
||||
SAMLPropertyMapping.objects.using(db_alias).get_or_create(
|
||||
saml_name=default["Name"],
|
||||
friendly_name=default["FriendlyName"],
|
||||
expression=default["Expression"],
|
||||
defaults={
|
||||
"name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_saml", "0004_auto_20200217_1526"),
|
||||
("passbook_core", "0007_auto_20200217_1934"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(cleanup_old_autogenerated),
|
||||
migrations.RemoveField(model_name="samlpropertymapping", name="values",),
|
||||
migrations.RunPython(create_default_property_mappings),
|
||||
]
|
@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-17 20:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import passbook.providers.saml.utils.time
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_saml", "0005_remove_samlpropertymapping_values"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="samlprovider",
|
||||
name="assertion_valid_not_before",
|
||||
field=models.TextField(
|
||||
default="minutes=-5",
|
||||
help_text="Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).",
|
||||
validators=[
|
||||
passbook.providers.saml.utils.time.timedelta_string_validator
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.0.3 on 2020-03-03 21:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_crypto", "0001_initial"),
|
||||
("passbook_providers_saml", "0006_auto_20200217_2031"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(model_name="samlprovider", name="signing",),
|
||||
migrations.RemoveField(model_name="samlprovider", name="signing_cert",),
|
||||
migrations.RemoveField(model_name="samlprovider", name="signing_key",),
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="singing_kp",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
help_text="Singing is enabled upon selection of a Key Pair.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="passbook_crypto.CertificateKeyPair",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,16 @@
|
||||
# Generated by Django 3.0.3 on 2020-03-05 16:06
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_saml", "0007_auto_20200303_2157"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="samlprovider", old_name="singing_kp", new_name="signing_kp",
|
||||
),
|
||||
]
|
@ -0,0 +1,40 @@
|
||||
# Generated by Django 3.0.3 on 2020-05-06 15:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_crypto", "0001_initial"),
|
||||
("passbook_providers_saml", "0008_auto_20200305_1606"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="require_signing",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Require Requests to be signed by an X509 Certificate. Must match the Certificate selected in `Singing Keypair`.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="samlprovider",
|
||||
name="issuer",
|
||||
field=models.TextField(help_text="Also known as EntityID"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="samlprovider",
|
||||
name="signing_kp",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
help_text="Singing is enabled upon selection of a Key Pair.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="passbook_crypto.CertificateKeyPair",
|
||||
verbose_name="Signing Keypair",
|
||||
),
|
||||
),
|
||||
]
|
0
passbook/providers/saml/migrations/__init__.py
Normal file
0
passbook/providers/saml/migrations/__init__.py
Normal file
168
passbook/providers/saml/models.py
Normal file
168
passbook/providers/saml/models.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""passbook saml_idp Models"""
|
||||
from typing import Optional
|
||||
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import PropertyMapping, Provider
|
||||
from passbook.crypto.models import CertificateKeyPair
|
||||
from passbook.lib.utils.reflection import class_to_path, path_to_class
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.providers.saml.processors.base import Processor
|
||||
from passbook.providers.saml.utils.time import timedelta_string_validator
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class SAMLProvider(Provider):
|
||||
"""Model to save information about a Remote SAML Endpoint"""
|
||||
|
||||
name = models.TextField()
|
||||
processor_path = models.CharField(max_length=255, choices=[])
|
||||
|
||||
acs_url = models.URLField(verbose_name=_("ACS URL"))
|
||||
audience = models.TextField(default="")
|
||||
issuer = models.TextField(help_text=_("Also known as EntityID"))
|
||||
|
||||
assertion_valid_not_before = models.TextField(
|
||||
default="minutes=-5",
|
||||
validators=[timedelta_string_validator],
|
||||
help_text=_(
|
||||
(
|
||||
"Assertion valid not before current time + this value "
|
||||
"(Format: hours=-1;minutes=-2;seconds=-3)."
|
||||
)
|
||||
),
|
||||
)
|
||||
assertion_valid_not_on_or_after = models.TextField(
|
||||
default="minutes=5",
|
||||
validators=[timedelta_string_validator],
|
||||
help_text=_(
|
||||
(
|
||||
"Assertion not valid on or after current time + this value "
|
||||
"(Format: hours=1;minutes=2;seconds=3)."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
session_valid_not_on_or_after = models.TextField(
|
||||
default="minutes=86400",
|
||||
validators=[timedelta_string_validator],
|
||||
help_text=_(
|
||||
(
|
||||
"Session not valid on or after current time + this value "
|
||||
"(Format: hours=1;minutes=2;seconds=3)."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
digest_algorithm = models.CharField(
|
||||
max_length=50,
|
||||
choices=(("sha1", _("SHA1")), ("sha256", _("SHA256")),),
|
||||
default="sha256",
|
||||
)
|
||||
signature_algorithm = models.CharField(
|
||||
max_length=50,
|
||||
choices=(
|
||||
("rsa-sha1", _("RSA-SHA1")),
|
||||
("rsa-sha256", _("RSA-SHA256")),
|
||||
("ecdsa-sha256", _("ECDSA-SHA256")),
|
||||
("dsa-sha1", _("DSA-SHA1")),
|
||||
),
|
||||
default="rsa-sha256",
|
||||
)
|
||||
|
||||
signing_kp = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
default=None,
|
||||
null=True,
|
||||
help_text=_("Singing is enabled upon selection of a Key Pair."),
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("Signing Keypair"),
|
||||
)
|
||||
|
||||
require_signing = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Require Requests to be signed by an X509 Certificate. "
|
||||
"Must match the Certificate selected in `Singing Keypair`."
|
||||
),
|
||||
)
|
||||
|
||||
form = "passbook.providers.saml.forms.SAMLProviderForm"
|
||||
_processor = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._meta.get_field("processor_path").choices = get_provider_choices()
|
||||
|
||||
@property
|
||||
def processor(self) -> Optional[Processor]:
|
||||
"""Return selected processor as instance"""
|
||||
if not self._processor:
|
||||
try:
|
||||
self._processor = path_to_class(self.processor_path)(self)
|
||||
except ImportError as exc:
|
||||
LOGGER.warning(exc)
|
||||
self._processor = None
|
||||
return self._processor
|
||||
|
||||
def __str__(self):
|
||||
return f"SAML Provider {self.name}"
|
||||
|
||||
def link_download_metadata(self):
|
||||
"""Get link to download XML metadata for admin interface"""
|
||||
try:
|
||||
# pylint: disable=no-member
|
||||
return reverse(
|
||||
"passbook_providers_saml:saml-metadata",
|
||||
kwargs={"application": self.application.slug},
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
def html_metadata_view(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with to view Metadata without downloading it"""
|
||||
from passbook.providers.saml.views import DescriptorDownloadView
|
||||
|
||||
try:
|
||||
# pylint: disable=no-member
|
||||
metadata = DescriptorDownloadView.get_metadata(request, self)
|
||||
return render_to_string(
|
||||
"saml/idp/admin_metadata_modal.html",
|
||||
{"provider": self, "metadata": metadata,},
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("SAML Provider")
|
||||
verbose_name_plural = _("SAML Providers")
|
||||
|
||||
|
||||
class SAMLPropertyMapping(PropertyMapping):
|
||||
"""SAML Property mapping, allowing Name/FriendlyName mapping to a list of strings"""
|
||||
|
||||
saml_name = models.TextField(verbose_name="SAML Name")
|
||||
friendly_name = models.TextField(default=None, blank=True, null=True)
|
||||
|
||||
form = "passbook.providers.saml.forms.SAMLPropertyMappingForm"
|
||||
|
||||
def __str__(self):
|
||||
return f"SAML Property Mapping {self.saml_name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("SAML Property Mapping")
|
||||
verbose_name_plural = _("SAML Property Mappings")
|
||||
|
||||
|
||||
def get_provider_choices():
|
||||
"""Return tuple of class_path, class name of all providers."""
|
||||
return [
|
||||
(class_to_path(x), x.__name__) for x in getattr(Processor, "__subclasses__")()
|
||||
]
|
0
passbook/providers/saml/processors/__init__.py
Normal file
0
passbook/providers/saml/processors/__init__.py
Normal file
239
passbook/providers/saml/processors/base.py
Normal file
239
passbook/providers/saml/processors/base.py
Normal file
@ -0,0 +1,239 @@
|
||||
"""Basic SAML Processor"""
|
||||
from typing import TYPE_CHECKING, Dict, List, Union
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from defusedxml import ElementTree
|
||||
from django.http import HttpRequest
|
||||
from signxml import XMLVerifier
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.exceptions import PropertyMappingExpressionException
|
||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||
from passbook.providers.saml.processors.types import SAMLResponseParams
|
||||
from passbook.providers.saml.utils import get_random_id
|
||||
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate, nice64
|
||||
from passbook.providers.saml.utils.time import get_time_string, timedelta_from_string
|
||||
from passbook.providers.saml.utils.xml_render import get_assertion_xml, get_response_xml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class Processor:
|
||||
"""Base SAML 2.0 AuthnRequest to Response Processor.
|
||||
Sub-classes should provide Service Provider-specific functionality."""
|
||||
|
||||
is_idp_initiated = False
|
||||
|
||||
_remote: "SAMLProvider"
|
||||
_http_request: HttpRequest
|
||||
|
||||
_assertion_xml: str
|
||||
_response_xml: str
|
||||
_saml_response: str
|
||||
|
||||
_relay_state: str
|
||||
_saml_request: str
|
||||
|
||||
_assertion_params: Dict[str, Union[str, List[Dict[str, str]]]]
|
||||
_request_params: Dict[str, str]
|
||||
_response_params: Dict[str, str]
|
||||
|
||||
@property
|
||||
def subject_format(self) -> str:
|
||||
"""Get subject Format"""
|
||||
return "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
||||
|
||||
def __init__(self, remote: "SAMLProvider"):
|
||||
self.name = remote.name
|
||||
self._remote = remote
|
||||
self._logger = get_logger()
|
||||
|
||||
def _build_assertion(self):
|
||||
"""Builds _assertion_params."""
|
||||
self._assertion_params = {
|
||||
"ASSERTION_ID": get_random_id(),
|
||||
"ASSERTION_SIGNATURE": "", # it's unsigned
|
||||
"AUDIENCE": self._remote.audience,
|
||||
"AUTH_INSTANT": get_time_string(),
|
||||
"ISSUE_INSTANT": get_time_string(),
|
||||
"NOT_BEFORE": get_time_string(
|
||||
timedelta_from_string(self._remote.assertion_valid_not_before)
|
||||
),
|
||||
"NOT_ON_OR_AFTER": get_time_string(
|
||||
timedelta_from_string(self._remote.assertion_valid_not_on_or_after)
|
||||
),
|
||||
"SESSION_INDEX": self._http_request.session.session_key,
|
||||
"SESSION_NOT_ON_OR_AFTER": get_time_string(
|
||||
timedelta_from_string(self._remote.session_valid_not_on_or_after)
|
||||
),
|
||||
"SP_NAME_QUALIFIER": self._remote.audience,
|
||||
"SUBJECT": self._http_request.user.email,
|
||||
"SUBJECT_FORMAT": self.subject_format,
|
||||
"ISSUER": self._remote.issuer,
|
||||
}
|
||||
self._assertion_params.update(self._request_params)
|
||||
|
||||
def _build_response(self):
|
||||
"""Builds _response_params."""
|
||||
self._response_params = {
|
||||
"ASSERTION": self._assertion_xml,
|
||||
"ISSUE_INSTANT": get_time_string(),
|
||||
"RESPONSE_ID": get_random_id(),
|
||||
"RESPONSE_SIGNATURE": "", # initially unsigned
|
||||
"ISSUER": self._remote.issuer,
|
||||
}
|
||||
self._response_params.update(self._request_params)
|
||||
|
||||
def _encode_response(self):
|
||||
"""Encodes _response_xml to _encoded_xml."""
|
||||
self._saml_response = nice64(str.encode(self._response_xml))
|
||||
|
||||
def _extract_saml_request(self):
|
||||
"""Retrieves the _saml_request AuthnRequest from the _http_request."""
|
||||
self._saml_request = self._http_request.session["SAMLRequest"]
|
||||
self._relay_state = self._http_request.session["RelayState"]
|
||||
|
||||
def _format_assertion(self):
|
||||
"""Formats _assertion_params as _assertion_xml."""
|
||||
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
|
||||
attributes = []
|
||||
from passbook.providers.saml.models import SAMLPropertyMapping
|
||||
|
||||
for mapping in self._remote.property_mappings.all().select_subclasses():
|
||||
if not isinstance(mapping, SAMLPropertyMapping):
|
||||
continue
|
||||
try:
|
||||
mapping: SAMLPropertyMapping
|
||||
value = mapping.evaluate(
|
||||
user=self._http_request.user,
|
||||
request=self._http_request,
|
||||
provider=self._remote,
|
||||
)
|
||||
mapping_payload = {
|
||||
"Name": mapping.saml_name,
|
||||
"FriendlyName": mapping.friendly_name,
|
||||
}
|
||||
# Normal values and arrays need different dict keys as they are handeled
|
||||
# differently in the template
|
||||
if isinstance(value, list):
|
||||
mapping_payload["ValueArray"] = value
|
||||
else:
|
||||
mapping_payload["Value"] = value
|
||||
attributes.append(mapping_payload)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
self._logger.warning(exc)
|
||||
continue
|
||||
self._assertion_params["ATTRIBUTES"] = attributes
|
||||
self._assertion_xml = get_assertion_xml(
|
||||
"saml/xml/assertions/generic.xml", self._assertion_params, signed=True
|
||||
)
|
||||
|
||||
def _format_response(self):
|
||||
"""Formats _response_params as _response_xml."""
|
||||
assertion_id = self._assertion_params["ASSERTION_ID"]
|
||||
self._response_xml = get_response_xml(
|
||||
self._response_params, saml_provider=self._remote, assertion_id=assertion_id
|
||||
)
|
||||
|
||||
def _get_saml_response_params(self) -> SAMLResponseParams:
|
||||
"""Returns a dictionary of parameters for the response template."""
|
||||
return SAMLResponseParams(
|
||||
acs_url=self._request_params["ACS_URL"],
|
||||
saml_response=self._saml_response,
|
||||
relay_state=self._relay_state,
|
||||
)
|
||||
|
||||
def _decode_and_parse_request(self):
|
||||
"""Parses various parameters from _request_xml into _request_params."""
|
||||
decoded_xml = decode_base64_and_inflate(self._saml_request)
|
||||
|
||||
if self._remote.require_signing and self._remote.signing_kp:
|
||||
self._logger.debug("Verifying Request signature")
|
||||
try:
|
||||
XMLVerifier().verify(
|
||||
decoded_xml, x509_cert=self._remote.signing_kp.certificate_data
|
||||
)
|
||||
except InvalidSignature as exc:
|
||||
raise CannotHandleAssertion("Failed to verify signature") from exc
|
||||
|
||||
root = ElementTree.fromstring(decoded_xml)
|
||||
|
||||
params = {}
|
||||
params["ACS_URL"] = root.attrib.get(
|
||||
"AssertionConsumerServiceURL", self._remote.acs_url
|
||||
)
|
||||
params["REQUEST_ID"] = root.attrib["ID"]
|
||||
params["DESTINATION"] = root.attrib.get("Destination", "")
|
||||
params["PROVIDER_NAME"] = root.attrib.get("ProviderName", "")
|
||||
self._request_params = params
|
||||
|
||||
def _validate_request(self):
|
||||
"""
|
||||
Validates the SAML request against the SP configuration of this
|
||||
processor. Sub-classes should override this and raise a
|
||||
`CannotHandleAssertion` exception if the validation fails.
|
||||
|
||||
Raises:
|
||||
CannotHandleAssertion: if the ACS URL specified in the SAML request
|
||||
doesn't match the one specified in the processor config.
|
||||
"""
|
||||
request_acs_url = self._request_params["ACS_URL"]
|
||||
|
||||
if self._remote.acs_url != request_acs_url:
|
||||
msg = (
|
||||
f"ACS URL of {request_acs_url} doesn't match Provider "
|
||||
f"ACS URL of {self._remote.acs_url}."
|
||||
)
|
||||
self._logger.info(msg)
|
||||
raise CannotHandleAssertion(msg)
|
||||
|
||||
def can_handle(self, request: HttpRequest) -> bool:
|
||||
"""Returns true if this processor can handle this request."""
|
||||
self._http_request = request
|
||||
# Read the request.
|
||||
try:
|
||||
self._extract_saml_request()
|
||||
except KeyError:
|
||||
raise CannotHandleAssertion(f"Couldn't find SAML request in user session")
|
||||
|
||||
try:
|
||||
self._decode_and_parse_request()
|
||||
except Exception as exc:
|
||||
raise CannotHandleAssertion(f"Couldn't parse SAML request: {exc}") from exc
|
||||
|
||||
self._validate_request()
|
||||
return True
|
||||
|
||||
def generate_response(self) -> SAMLResponseParams:
|
||||
"""Processes request and returns template variables suitable for a response."""
|
||||
# Build the assertion and response.
|
||||
# Only call can_handle if SP initiated Request, otherwise we have no Request
|
||||
if not self.is_idp_initiated:
|
||||
self.can_handle(self._http_request)
|
||||
|
||||
self._build_assertion()
|
||||
self._format_assertion()
|
||||
self._build_response()
|
||||
self._format_response()
|
||||
self._encode_response()
|
||||
|
||||
# Return proper template params.
|
||||
return self._get_saml_response_params()
|
||||
|
||||
def init_deep_link(self, request: HttpRequest):
|
||||
"""Initialize this Processor to make an IdP-initiated call to the SP's
|
||||
deep-linked URL."""
|
||||
self._http_request = request
|
||||
acs_url = self._remote.acs_url
|
||||
# NOTE: The following request params are made up. Some are blank,
|
||||
# because they comes over in the AuthnRequest, but we don't have an
|
||||
# AuthnRequest in this case:
|
||||
# - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
|
||||
# - ProviderName: According to the spec, this is optional.
|
||||
self._request_params = {
|
||||
"ACS_URL": acs_url,
|
||||
"DESTINATION": "",
|
||||
"PROVIDER_NAME": "",
|
||||
}
|
||||
self._relay_state = ""
|
7
passbook/providers/saml/processors/generic.py
Normal file
7
passbook/providers/saml/processors/generic.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""Generic Processor"""
|
||||
|
||||
from passbook.providers.saml.processors.base import Processor
|
||||
|
||||
|
||||
class GenericProcessor(Processor):
|
||||
"""Generic SAML2 Processor"""
|
14
passbook/providers/saml/processors/salesforce.py
Normal file
14
passbook/providers/saml/processors/salesforce.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Salesforce Processor"""
|
||||
|
||||
from passbook.providers.saml.processors.generic import GenericProcessor
|
||||
from passbook.providers.saml.utils.xml_render import get_assertion_xml
|
||||
|
||||
|
||||
class SalesForceProcessor(GenericProcessor):
|
||||
"""SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor."""
|
||||
|
||||
def _format_assertion(self):
|
||||
super()._format_assertion()
|
||||
self._assertion_xml = get_assertion_xml(
|
||||
"saml/xml/assertions/salesforce.xml", self._assertion_params, signed=True
|
||||
)
|
11
passbook/providers/saml/processors/types.py
Normal file
11
passbook/providers/saml/processors/types.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""passbook saml provider types"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SAMLResponseParams:
|
||||
"""Class to keep track of SAML Response Parameters"""
|
||||
|
||||
acs_url: str
|
||||
saml_response: str
|
||||
relay_state: str
|
6
passbook/providers/saml/settings.py
Normal file
6
passbook/providers/saml/settings.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""saml provider settings"""
|
||||
|
||||
PASSBOOK_PROVIDERS_SAML_PROCESSORS = [
|
||||
"passbook.providers.saml.processors.generic",
|
||||
"passbook.providers.saml.processors.salesforce",
|
||||
]
|
@ -0,0 +1,22 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="saml-{{ provider.pk }}">{% trans 'View Metadata' %}</button>
|
||||
|
||||
<div class="pf-c-backdrop" id="saml-{{ provider.pk }}" hidden>
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
||||
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Metadata' %}</h1>
|
||||
<div class="pf-c-modal-box__body" id="modal-description">
|
||||
<form method="post">
|
||||
<textarea class="codemirror" readonly data-cm-mode="xml">{{ metadata }}</textarea>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,33 @@
|
||||
{% extends "login/base.html" %}
|
||||
|
||||
{% load passbook_utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block card_title %}
|
||||
{% trans 'Redirecting...' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" action="{{ url }}">
|
||||
{% csrf_token %}
|
||||
{% for key, value in attrs.items %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endfor %}
|
||||
<div class="login-group">
|
||||
<p>
|
||||
{% blocktrans with user=user %}
|
||||
You are logged in as {{ user }}.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Not you?' %}</a>
|
||||
</p>
|
||||
<input class="btn btn-primary btn-block btn-lg" type="submit" value="{% trans 'Continue' %}" />
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
document.querySelector("form").submit();
|
||||
</script>
|
||||
{% endblock %}
|
@ -0,0 +1,9 @@
|
||||
{% extends "login/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block card %}
|
||||
<p>
|
||||
{% trans "You have successfully logged out of the Identity Provider." %}
|
||||
</p>
|
||||
{% endblock %}
|
26
passbook/providers/saml/templates/saml/idp/login.html
Normal file
26
passbook/providers/saml/templates/saml/idp/login.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends "login/base.html" %}
|
||||
|
||||
{% load passbook_utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% csrf_token %}
|
||||
<div class="pf-c-form__group">
|
||||
<h3>
|
||||
{% blocktrans with provider=provider.application.name %}
|
||||
You're about to sign into {{ provider }}
|
||||
{% endblocktrans %}
|
||||
</h3>
|
||||
<p>
|
||||
{% blocktrans with user=user %}
|
||||
You are logged in as {{ user }}.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Not you?' %}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<input class="pf-c-button pf-m-primary pf-m-block" type="submit" value="{% trans 'Continue' %}" />
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
@ -0,0 +1,19 @@
|
||||
{% extends "generic/form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block beneath_form %}
|
||||
<div class="pf-c-form__group ">
|
||||
<label for="" class="pf-c-form__label"></label>
|
||||
<div class="c-form__horizontal-group">
|
||||
<p>
|
||||
Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available:
|
||||
<ul class="pf-c-list">
|
||||
<li><code>user</code>: Passbook User Object (<a href="https://beryju.github.io/passbook/reference/property-mappings/user-object/">Reference</a>)</li>
|
||||
<li><code>request</code>: Django HTTP Request Object (<a href="https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects">Reference</a>) </li>
|
||||
<li><code>provider</code>: Passbook SAML Provider Object (<a href="https://github.com/BeryJu/passbook/blob/master/passbook/providers/saml/models.py#L16">Reference</a>) </li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -0,0 +1,19 @@
|
||||
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
||||
ID="{{ ASSERTION_ID }}"
|
||||
IssueInstant="{{ ISSUE_INSTANT }}"
|
||||
Version="2.0">
|
||||
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
|
||||
{% include 'saml/xml/signature.xml' %}
|
||||
{{ SUBJECT_STATEMENT }}
|
||||
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}">
|
||||
<saml:AudienceRestriction>
|
||||
<saml:Audience>{{ AUDIENCE }}</saml:Audience>
|
||||
</saml:AudienceRestriction>
|
||||
</saml:Conditions>
|
||||
<saml:AuthnStatement AuthnInstant="{{ NOT_BEFORE }}" SessionIndex="{{ ASSERTION_ID }}">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
{{ ATTRIBUTE_STATEMENT }}
|
||||
</saml:Assertion>
|
@ -0,0 +1,15 @@
|
||||
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
||||
ID="{{ ASSERTION_ID }}"
|
||||
IssueInstant="{{ ISSUE_INSTANT }}"
|
||||
Version="2.0">
|
||||
<saml:Issuer>{{ ISSUER }}</saml:Issuer>
|
||||
{% include 'saml/xml/signature.xml' %}
|
||||
{% include 'saml/xml/subject.xml' %}
|
||||
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" />
|
||||
<saml:AuthnStatement AuthnInstant="{{ AUTH_INSTANT }}">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
{{ ATTRIBUTE_STATEMENT }}
|
||||
</saml:Assertion>
|
@ -0,0 +1,19 @@
|
||||
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
||||
ID="{{ ASSERTION_ID }}"
|
||||
IssueInstant="{{ ISSUE_INSTANT }}"
|
||||
Version="2.0">
|
||||
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
|
||||
{{ ASSERTION_SIGNATURE|safe }}
|
||||
{% include 'saml/xml/subject.xml' %}
|
||||
<saml:Conditions NotBefore="{{ NOT_BEFORE }}" NotOnOrAfter="{{ NOT_ON_OR_AFTER }}">
|
||||
<saml:AudienceRestriction>
|
||||
<saml:Audience>{{ AUDIENCE }}</saml:Audience>
|
||||
</saml:AudienceRestriction>
|
||||
</saml:Conditions>
|
||||
<saml:AuthnStatement AuthnInstant="{{ AUTH_INSTANT }}">
|
||||
<saml:AuthnContext>
|
||||
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
|
||||
</saml:AuthnContext>
|
||||
</saml:AuthnStatement>
|
||||
{{ ATTRIBUTE_STATEMENT|safe }}
|
||||
</saml:Assertion>
|
14
passbook/providers/saml/templates/saml/xml/attributes.xml
Normal file
14
passbook/providers/saml/templates/saml/xml/attributes.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<saml:AttributeStatement>
|
||||
{% for attr in attributes %}
|
||||
<saml:Attribute {% if attr.FriendlyName %}FriendlyName="{{ attr.FriendlyName }}" {% endif %}Name="{{ attr.Name }}">
|
||||
{% if attr.Value %}
|
||||
<saml:AttributeValue>{{ attr.Value }}</saml:AttributeValue>
|
||||
{% endif %}
|
||||
{% if attr.ValueArray %}
|
||||
{% for value in attr.ValueArray %}
|
||||
<saml:AttributeValue>{{ value }}</saml:AttributeValue>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</saml:Attribute>
|
||||
{% endfor %}
|
||||
</saml:AttributeStatement>
|
18
passbook/providers/saml/templates/saml/xml/metadata.xml
Normal file
18
passbook/providers/saml/templates/saml/xml/metadata.xml
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0"?>
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="{{ entity_id }}">
|
||||
<md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
{% if cert_public_key %}
|
||||
<md:KeyDescriptor use="signing">
|
||||
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>{{ cert_public_key }}</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
{% endif %}
|
||||
<md:NameIDFormat>{{ subject_format }}</md:NameIDFormat>
|
||||
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ slo_url }}"/>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ sso_post_url }}"/>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_redirect_url }}"/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
14
passbook/providers/saml/templates/saml/xml/response.xml
Normal file
14
passbook/providers/saml/templates/saml/xml/response.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
||||
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
|
||||
Destination="{{ ACS_URL }}"
|
||||
ID="{{ RESPONSE_ID }}"
|
||||
{{ IN_RESPONSE_TO|safe }}
|
||||
IssueInstant="{{ ISSUE_INSTANT }}"
|
||||
Version="2.0">
|
||||
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">{{ ISSUER }}</saml:Issuer>
|
||||
{{ ASSERTION_SIGNATURE }}
|
||||
<samlp:Status>
|
||||
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
|
||||
</samlp:Status>
|
||||
{{ ASSERTION }}
|
||||
</samlp:Response>
|
1
passbook/providers/saml/templates/saml/xml/signature.xml
Normal file
1
passbook/providers/saml/templates/saml/xml/signature.xml
Normal file
@ -0,0 +1 @@
|
||||
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Id="placeholder"></ds:Signature>
|
6
passbook/providers/saml/templates/saml/xml/subject.xml
Normal file
6
passbook/providers/saml/templates/saml/xml/subject.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<saml:Subject>
|
||||
<saml:NameID Format="{{ SUBJECT_FORMAT }}">{{ SUBJECT }}</saml:NameID>
|
||||
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
|
||||
<saml:SubjectConfirmationData {{ IN_RESPONSE_TO|safe }} NotOnOrAfter="{{ NOT_ON_OR_AFTER }}" Recipient="{{ ACS_URL }}" />
|
||||
</saml:SubjectConfirmation>
|
||||
</saml:Subject>
|
0
passbook/providers/saml/tests/__init__.py
Normal file
0
passbook/providers/saml/tests/__init__.py
Normal file
30
passbook/providers/saml/tests/test_utils_time.py
Normal file
30
passbook/providers/saml/tests/test_utils_time.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Test time utils"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from passbook.providers.saml.utils.time import (
|
||||
timedelta_from_string,
|
||||
timedelta_string_validator,
|
||||
)
|
||||
|
||||
|
||||
class TestTimeUtils(TestCase):
|
||||
"""Test time-utils"""
|
||||
|
||||
def test_valid(self):
|
||||
"""Test valid expression"""
|
||||
expr = "hours=3;minutes=1"
|
||||
expected = timedelta(hours=3, minutes=1)
|
||||
self.assertEqual(timedelta_from_string(expr), expected)
|
||||
|
||||
def test_invalid(self):
|
||||
"""Test invalid expression"""
|
||||
with self.assertRaises(ValueError):
|
||||
timedelta_from_string("foo")
|
||||
|
||||
def test_validation(self):
|
||||
"""Test Django model field validator"""
|
||||
with self.assertRaises(ValidationError):
|
||||
timedelta_string_validator("foo")
|
34
passbook/providers/saml/urls.py
Normal file
34
passbook/providers/saml/urls.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""passbook SAML IDP URLs"""
|
||||
from django.urls import path
|
||||
|
||||
from passbook.providers.saml import views
|
||||
|
||||
urlpatterns = [
|
||||
# This view is used to initiate a Login-flow from the IDP
|
||||
path(
|
||||
"<slug:application>/login/initiate/",
|
||||
views.InitiateLoginView.as_view(),
|
||||
name="saml-login-initiate",
|
||||
),
|
||||
# This view is the endpoint a SP would redirect to, and saves data into the session
|
||||
# this is required as the process view which it redirects to might have to login first.
|
||||
path(
|
||||
"<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login"
|
||||
),
|
||||
path(
|
||||
"<slug:application>/login/authorize/",
|
||||
views.AuthorizeView.as_view(),
|
||||
name="saml-login-authorize",
|
||||
),
|
||||
path("<slug:application>/logout/", views.LogoutView.as_view(), name="saml-logout"),
|
||||
path(
|
||||
"<slug:application>/logout/slo/",
|
||||
views.SLOLogout.as_view(),
|
||||
name="saml-logout-slo",
|
||||
),
|
||||
path(
|
||||
"<slug:application>/metadata/",
|
||||
views.DescriptorDownloadView.as_view(),
|
||||
name="saml-metadata",
|
||||
),
|
||||
]
|
18
passbook/providers/saml/utils/__init__.py
Normal file
18
passbook/providers/saml/utils/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Small helper functions"""
|
||||
import uuid
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.template.context import Context
|
||||
|
||||
|
||||
def render_xml(request: HttpRequest, template: str, ctx: Context) -> HttpResponse:
|
||||
"""Render template with content_type application/xml"""
|
||||
return render(request, template, context=ctx, content_type="application/xml")
|
||||
|
||||
|
||||
def get_random_id() -> str:
|
||||
"""Random hex id"""
|
||||
# It is very important that these random IDs NOT start with a number.
|
||||
random_id = "_" + uuid.uuid4().hex
|
||||
return random_id
|
24
passbook/providers/saml/utils/encoding.py
Normal file
24
passbook/providers/saml/utils/encoding.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Wrappers to de/encode and de/inflate strings"""
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
|
||||
def decode_base64_and_inflate(encoded: str, encoding="utf-8") -> str:
|
||||
"""Base64 decode and ZLib decompress b64string"""
|
||||
decoded_data = base64.b64decode(encoded)
|
||||
try:
|
||||
return zlib.decompress(decoded_data, -15).decode(encoding)
|
||||
except zlib.error:
|
||||
return decoded_data.decode(encoding)
|
||||
|
||||
|
||||
def deflate_and_base64_encode(inflated: bytes, encoding="utf-8"):
|
||||
"""Base64 and ZLib Compress b64string"""
|
||||
zlibbed_str = zlib.compress(inflated)
|
||||
compressed_string = zlibbed_str[2:-4]
|
||||
return base64.b64encode(compressed_string).decode(encoding)
|
||||
|
||||
|
||||
def nice64(src):
|
||||
""" Returns src base64-encoded and formatted nicely for our XML. """
|
||||
return base64.b64encode(src).decode("utf-8").replace("\n", "")
|
48
passbook/providers/saml/utils/time.py
Normal file
48
passbook/providers/saml/utils/time.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Time utilities"""
|
||||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
ALLOWED_KEYS = (
|
||||
"days",
|
||||
"seconds",
|
||||
"microseconds",
|
||||
"milliseconds",
|
||||
"minutes",
|
||||
"hours",
|
||||
"weeks",
|
||||
)
|
||||
|
||||
|
||||
def timedelta_string_validator(value: str):
|
||||
"""Validator for Django that checks if value can be parsed with `timedelta_from_string`"""
|
||||
try:
|
||||
timedelta_from_string(value)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
_("%(value)s is not in the correct format of 'hours=3;minutes=1'."),
|
||||
params={"value": value},
|
||||
) from exc
|
||||
|
||||
|
||||
def timedelta_from_string(expr: str) -> datetime.timedelta:
|
||||
"""Convert a string with the format of 'hours=1;minute=3;seconds=5' to a
|
||||
`datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5"""
|
||||
kwargs = {}
|
||||
for duration_pair in expr.split(";"):
|
||||
key, value = duration_pair.split("=")
|
||||
if key.lower() not in ALLOWED_KEYS:
|
||||
continue
|
||||
kwargs[key.lower()] = float(value)
|
||||
return datetime.timedelta(**kwargs)
|
||||
|
||||
|
||||
def get_time_string(delta: Optional[datetime.timedelta] = None) -> str:
|
||||
"""Get Data formatted in SAML format"""
|
||||
if delta is None:
|
||||
delta = datetime.timedelta()
|
||||
now = datetime.datetime.now()
|
||||
final = now + delta
|
||||
return final.strftime("%Y-%m-%dT%H:%M:%SZ")
|
92
passbook/providers/saml/utils/xml_render.py
Normal file
92
passbook/providers/saml/utils/xml_render.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""Functions for creating XML output."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.providers.saml.utils.xml_signing import (
|
||||
get_signature_xml,
|
||||
sign_with_signxml,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def _get_attribute_statement(params):
|
||||
"""Inserts AttributeStatement, if we have any attributes.
|
||||
Modifies the params dict.
|
||||
PRE-REQ: params['SUBJECT'] has already been created (usually by a call to
|
||||
_get_subject()."""
|
||||
attributes = params.get("ATTRIBUTES", [])
|
||||
if not attributes:
|
||||
params["ATTRIBUTE_STATEMENT"] = ""
|
||||
return
|
||||
# Build complete AttributeStatement.
|
||||
params["ATTRIBUTE_STATEMENT"] = render_to_string(
|
||||
"saml/xml/attributes.xml", {"attributes": attributes}
|
||||
)
|
||||
|
||||
|
||||
def _get_in_response_to(params):
|
||||
"""Insert InResponseTo if we have a RequestID.
|
||||
Modifies the params dict."""
|
||||
# NOTE: I don't like this. We're mixing templating logic here, but the
|
||||
# current design requires this; maybe refactor using better templates, or
|
||||
# just bite the bullet and use elementtree to produce the XML; see comments
|
||||
# in xml_templates about Canonical XML.
|
||||
request_id = params.get("REQUEST_ID", None)
|
||||
if request_id:
|
||||
params["IN_RESPONSE_TO"] = 'InResponseTo="%s" ' % request_id
|
||||
else:
|
||||
params["IN_RESPONSE_TO"] = ""
|
||||
|
||||
|
||||
def _get_subject(params):
|
||||
"""Insert Subject. Modifies the params dict."""
|
||||
params["SUBJECT_STATEMENT"] = render_to_string("saml/xml/subject.xml", params)
|
||||
|
||||
|
||||
def get_assertion_xml(template, parameters, signed=False):
|
||||
"""Get XML for Assertion"""
|
||||
# Reset signature.
|
||||
params = {}
|
||||
params.update(parameters)
|
||||
params["ASSERTION_SIGNATURE"] = ""
|
||||
|
||||
_get_in_response_to(params)
|
||||
_get_subject(params) # must come before _get_attribute_statement()
|
||||
_get_attribute_statement(params)
|
||||
|
||||
unsigned = render_to_string(template, params)
|
||||
if not signed:
|
||||
return unsigned
|
||||
|
||||
# Sign it.
|
||||
signature_xml = get_signature_xml()
|
||||
params["ASSERTION_SIGNATURE"] = signature_xml
|
||||
return render_to_string(template, params)
|
||||
|
||||
|
||||
def get_response_xml(parameters, saml_provider: SAMLProvider, assertion_id=""):
|
||||
"""Returns XML for response, with signatures, if signed is True."""
|
||||
# Reset signatures.
|
||||
params = {}
|
||||
params.update(parameters)
|
||||
params["RESPONSE_SIGNATURE"] = ""
|
||||
_get_in_response_to(params)
|
||||
|
||||
raw_response = render_to_string("saml/xml/response.xml", params)
|
||||
|
||||
if not saml_provider.signing_kp:
|
||||
return raw_response
|
||||
|
||||
signature_xml = get_signature_xml()
|
||||
params["RESPONSE_SIGNATURE"] = signature_xml
|
||||
|
||||
signed = sign_with_signxml(raw_response, saml_provider, reference_uri=assertion_id,)
|
||||
return signed
|
38
passbook/providers/saml/utils/xml_signing.py
Normal file
38
passbook/providers/saml/utils/xml_signing.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""Signing code goes here."""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lxml import etree # nosec
|
||||
from signxml import XMLSigner, XMLVerifier
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def sign_with_signxml(data: str, provider: "SAMLProvider", reference_uri=None) -> str:
|
||||
"""Sign Data with signxml"""
|
||||
# defused XML is not used here because it messes up XML namespaces
|
||||
# Data is trusted, so lxml is ok
|
||||
root = etree.fromstring(data) # nosec
|
||||
signer = XMLSigner(
|
||||
c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#",
|
||||
signature_algorithm=provider.signature_algorithm,
|
||||
digest_algorithm=provider.digest_algorithm,
|
||||
)
|
||||
signed = signer.sign(
|
||||
root,
|
||||
key=provider.signing_kp.private_key,
|
||||
cert=[provider.signing_kp.certificate_data],
|
||||
reference_uri=reference_uri,
|
||||
)
|
||||
XMLVerifier().verify(signed, x509_cert=provider.signing_kp.certificate_data)
|
||||
return etree.tostring(signed).decode("utf-8") # nosec
|
||||
|
||||
|
||||
def get_signature_xml() -> str:
|
||||
"""Returns XML Signature for subject."""
|
||||
return render_to_string("saml/xml/signature.xml", {})
|
306
passbook/providers/saml/views.py
Normal file
306
passbook/providers/saml/views.py
Normal file
@ -0,0 +1,306 @@
|
||||
"""passbook SAML IDP Views"""
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.mixins import AccessMixin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render, reverse
|
||||
from django.utils.datastructures import MultiValueDictKeyError
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.html import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from signxml.util import strip_pem_header
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.core.models import Application, Provider
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.lib.views import bad_request_message
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
from passbook.providers.saml.processors.types import SAMLResponseParams
|
||||
|
||||
LOGGER = get_logger()
|
||||
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):
|
||||
"""Mixin class for Views using a provider instance"""
|
||||
|
||||
_provider: Optional[SAMLProvider] = None
|
||||
|
||||
@property
|
||||
def provider(self) -> SAMLProvider:
|
||||
"""Get provider instance"""
|
||||
if not self._provider:
|
||||
application = get_object_or_404(
|
||||
Application, slug=self.kwargs["application"]
|
||||
)
|
||||
provider: SAMLProvider = get_object_or_404(
|
||||
SAMLProvider, pk=application.provider_id
|
||||
)
|
||||
self._provider = provider
|
||||
return self._provider
|
||||
return self._provider
|
||||
|
||||
def _has_access(self) -> bool:
|
||||
"""Check if user has access to application"""
|
||||
policy_engine = PolicyEngine(
|
||||
self.provider.application.policies.all(), self.request.user, self.request
|
||||
)
|
||||
policy_engine.build()
|
||||
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:
|
||||
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"),},
|
||||
)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class LoginBeginView(AccessRequiredView):
|
||||
"""Receives a SAML 2.0 AuthnRequest from a Service Provider and
|
||||
stores it in the session prior to enforcing login."""
|
||||
|
||||
def handler(self, source, application: str) -> HttpResponse:
|
||||
"""Handle SAML Request whether its a POST or a Redirect binding"""
|
||||
# Store these values now, because Django's login cycle won't preserve them.
|
||||
try:
|
||||
self.request.session[SESSION_KEY_SAML_REQUEST] = source[
|
||||
SESSION_KEY_SAML_REQUEST
|
||||
]
|
||||
except (KeyError, MultiValueDictKeyError):
|
||||
return bad_request_message(
|
||||
self.request, "The SAML request payload is missing."
|
||||
)
|
||||
|
||||
self.request.session[SESSION_KEY_RELAY_STATE] = source.get(
|
||||
SESSION_KEY_RELAY_STATE, ""
|
||||
)
|
||||
|
||||
try:
|
||||
self.provider.processor.can_handle(self.request)
|
||||
params = self.provider.processor.generate_response()
|
||||
self.request.session[SESSION_KEY_PARAMS] = params
|
||||
except CannotHandleAssertion as exc:
|
||||
LOGGER.info(exc)
|
||||
did_you_mean_link = self.request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:saml-login-initiate",
|
||||
kwargs={"application": application},
|
||||
)
|
||||
)
|
||||
did_you_mean_message = (
|
||||
f" Did you mean to go <a href='{did_you_mean_link}'>here</a>?"
|
||||
)
|
||||
return bad_request_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 dispatch(self, *args, **kwargs):
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
@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
|
||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Handle post request, return back to ACS"""
|
||||
# User access gets checked in dispatch
|
||||
|
||||
# we get here when skip_authorization is True, and after the user accepted
|
||||
# the authorization form
|
||||
# Log Application Authorization
|
||||
Event.new(
|
||||
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")
|
||||
class LogoutView(AccessRequiredView):
|
||||
"""Allows a non-SAML 2.0 URL to log out the user and
|
||||
returns a standard logged-out page. (SalesForce and others use this method,
|
||||
though it's technically not SAML 2.0)."""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Perform logout"""
|
||||
logout(request)
|
||||
|
||||
redirect_url = request.GET.get("redirect_to", "")
|
||||
|
||||
try:
|
||||
URL_VALIDATOR(redirect_url)
|
||||
except ValidationError:
|
||||
pass
|
||||
else:
|
||||
return redirect(redirect_url)
|
||||
|
||||
return render(request, "saml/idp/logged_out.html")
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class SLOLogout(AccessRequiredView):
|
||||
"""Receives a SAML 2.0 LogoutRequest from a Service Provider,
|
||||
logs out the user and returns a standard logged-out page."""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Perform logout"""
|
||||
request.session[SESSION_KEY_SAML_REQUEST] = request.POST[
|
||||
SESSION_KEY_SAML_REQUEST
|
||||
]
|
||||
# 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):
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
|
||||
@staticmethod
|
||||
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
|
||||
"""Return rendered XML Metadata"""
|
||||
entity_id = provider.issuer
|
||||
slo_url = request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:saml-logout",
|
||||
kwargs={"application": provider.application.slug},
|
||||
)
|
||||
)
|
||||
sso_post_url = request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:saml-login",
|
||||
kwargs={"application": provider.application.slug},
|
||||
)
|
||||
)
|
||||
subject_format = provider.processor.subject_format
|
||||
ctx = {
|
||||
"entity_id": entity_id,
|
||||
"slo_url": slo_url,
|
||||
# Currently, the same endpoint accepts POST and REDIRECT
|
||||
"sso_post_url": sso_post_url,
|
||||
"sso_redirect_url": sso_post_url,
|
||||
"subject_format": subject_format,
|
||||
}
|
||||
if provider.signing_kp:
|
||||
ctx["cert_public_key"] = strip_pem_header(
|
||||
provider.signing_kp.certificate_data.replace("\r", "")
|
||||
).replace("\n", "")
|
||||
return render_to_string("saml/xml/metadata.xml", ctx)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
try:
|
||||
metadata = DescriptorDownloadView.get_metadata(request, self.provider)
|
||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||
return bad_request_message(
|
||||
request, "Provider is not assigned to an application."
|
||||
)
|
||||
else:
|
||||
response = HttpResponse(metadata, content_type="application/xml")
|
||||
response[
|
||||
"Content-Disposition"
|
||||
] = f'attachment; filename="{self.provider.name}_passbook_meta.xml"'
|
||||
return response
|
0
passbook/providers/samlv2/__init__.py
Normal file
0
passbook/providers/samlv2/__init__.py
Normal file
11
passbook/providers/samlv2/apps.py
Normal file
11
passbook/providers/samlv2/apps.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""passbook saml provider app config"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookProviderSAMLv2Config(AppConfig):
|
||||
"""passbook samlv2 provider app config"""
|
||||
|
||||
name = "passbook.providers.samlv2"
|
||||
label = "passbook_providers_samlv2"
|
||||
verbose_name = "passbook Providers.SAMLv2"
|
||||
mountpoint = "application/samlv2/"
|
0
passbook/providers/samlv2/models.py
Normal file
0
passbook/providers/samlv2/models.py
Normal file
0
passbook/providers/samlv2/saml/__init__.py
Normal file
0
passbook/providers/samlv2/saml/__init__.py
Normal file
15
passbook/providers/samlv2/saml/constants.py
Normal file
15
passbook/providers/samlv2/saml/constants.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""SAML-related constants"""
|
||||
NS_SAML_PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
|
||||
NS_SAML_ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
|
||||
NS_SIGNATURE = "http://www.w3.org/2000/09/xmldsig#"
|
||||
|
||||
REQ_KEY_REQUEST = "SAMLRequest"
|
||||
REQ_KEY_SIGNATURE = "Signature"
|
||||
|
||||
SESSION_KEY = "passbook_saml_request"
|
||||
|
||||
SAML_ATTRIB_ACS_URL = "AssertionConsumerServiceURL"
|
||||
SAML_ATTRIB_DESTINATION = "Destination"
|
||||
SAML_ATTRIB_ID = "ID"
|
||||
SAML_ATTRIB_ISSUE_INSTANT = "IssueInstant"
|
||||
SAML_ATTRIB_PROTOCOL_BINDING = "ProtocolBinding"
|
83
passbook/providers/samlv2/saml/parser.py
Normal file
83
passbook/providers/samlv2/saml/parser.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""SAML Request Parse/builder"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from defusedxml import ElementTree
|
||||
from signxml import XMLVerifier
|
||||
|
||||
from passbook.crypto.models import CertificateKeyPair
|
||||
from passbook.providers.samlv2.saml.constants import (
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
SAML_ATTRIB_ACS_URL,
|
||||
SAML_ATTRIB_DESTINATION,
|
||||
SAML_ATTRIB_ID,
|
||||
SAML_ATTRIB_ISSUE_INSTANT,
|
||||
SAML_ATTRIB_PROTOCOL_BINDING,
|
||||
)
|
||||
from passbook.providers.samlv2.saml.utils import decode_base64_and_inflate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element # nosec
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class SAMLRequest:
|
||||
"""SAML Request data class, parse raw base64-encoded data, checks signature and more"""
|
||||
|
||||
_root: "Element"
|
||||
|
||||
acs_url: str
|
||||
destination: str
|
||||
id: str
|
||||
issue_instant: str
|
||||
protocol_binding: str
|
||||
|
||||
issuer: str
|
||||
|
||||
is_signed: bool
|
||||
_detached_signature: str
|
||||
|
||||
def __init__(self):
|
||||
self.acs_url = ""
|
||||
self.destination = ""
|
||||
# pylint: disable=invalid-name
|
||||
self.id = ""
|
||||
self.issue_instant = ""
|
||||
self.protocol_binding = ""
|
||||
|
||||
@staticmethod
|
||||
def parse(raw: str, detached_signature: Optional[str] = None) -> "SAMLRequest":
|
||||
"""Prase SAML request from raw string, which can be base64 encoded and deflated.
|
||||
Optionally accepts a detached_signature, as from a REDIRECT request."""
|
||||
decoded_xml = decode_base64_and_inflate(raw)
|
||||
root = ElementTree.fromstring(decoded_xml)
|
||||
req = SAMLRequest()
|
||||
req._root = root # pylint: disable=protected-access
|
||||
# Verify the root element's tag
|
||||
_expected_tag = f"{{{NS_SAML_PROTOCOL}}}AuthnRequest"
|
||||
if root.tag != _expected_tag:
|
||||
raise ValueError(
|
||||
f"Invalid root tag, got '{root.tag}', expected '{_expected_tag}."
|
||||
)
|
||||
req.acs_url = root.attrib[SAML_ATTRIB_ACS_URL]
|
||||
req.destination = root.attrib[SAML_ATTRIB_DESTINATION]
|
||||
req.id = root.attrib[SAML_ATTRIB_ID]
|
||||
req.issue_instant = root.attrib[SAML_ATTRIB_ISSUE_INSTANT]
|
||||
req.protocol_binding = root.attrib[SAML_ATTRIB_PROTOCOL_BINDING]
|
||||
req.issuer = root.find(f"{{{NS_SAML_ASSERTION}}}Issuer").text
|
||||
# Check if this Request is signed
|
||||
if detached_signature:
|
||||
# pylint: disable=protected-access
|
||||
req._detached_signature = detached_signature
|
||||
return req
|
||||
|
||||
def verify_signature(self, keypair: CertificateKeyPair):
|
||||
"""Verify signature of SAML Request.
|
||||
Raises `cryptography.exceptions.InvalidSignature` on validaton failure."""
|
||||
verifier = XMLVerifier()
|
||||
if self._detached_signature:
|
||||
verifier.verify(
|
||||
self._detached_signature, x509_cert=keypair.certificate_data
|
||||
)
|
||||
else:
|
||||
verifier.verify(self._root, x509_cert=keypair.certificate_data)
|
5
passbook/providers/samlv2/saml/provider.py
Normal file
5
passbook/providers/samlv2/saml/provider.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""SAML Provider logic"""
|
||||
|
||||
|
||||
class SAMLProvider:
|
||||
"""SAML Provider"""
|
24
passbook/providers/samlv2/saml/utils.py
Normal file
24
passbook/providers/samlv2/saml/utils.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Wrappers to de/encode and de/inflate strings"""
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
|
||||
def decode_base64_and_inflate(encoded: str, encoding="utf-8") -> str:
|
||||
"""Base64 decode and ZLib decompress b64string"""
|
||||
decoded_data = base64.b64decode(encoded)
|
||||
try:
|
||||
return zlib.decompress(decoded_data, -15).decode(encoding)
|
||||
except zlib.error:
|
||||
return decoded_data.decode(encoding)
|
||||
|
||||
|
||||
def deflate_and_base64_encode(inflated: bytes, encoding="utf-8"):
|
||||
"""Base64 and ZLib Compress b64string"""
|
||||
zlibbed_str = zlib.compress(inflated)
|
||||
compressed_string = zlibbed_str[2:-4]
|
||||
return base64.b64encode(compressed_string).decode(encoding)
|
||||
|
||||
|
||||
def nice64(src):
|
||||
""" Returns src base64-encoded and formatted nicely for our XML. """
|
||||
return base64.b64encode(src).decode("utf-8").replace("\n", "")
|
35
passbook/providers/samlv2/urls.py
Normal file
35
passbook/providers/samlv2/urls.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""passbook samlv2 URLs"""
|
||||
from django.urls import path
|
||||
|
||||
from passbook.providers.samlv2.views import authorize, idp_initiated, slo, sso
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"<slug:app_slug>/authorize/",
|
||||
authorize.AuthorizeView.as_view(),
|
||||
name="authorize",
|
||||
),
|
||||
path(
|
||||
"<slug:app_slug>/sso/redirect/",
|
||||
sso.SAMLRedirectBindingView.as_view(),
|
||||
name="sso-redirect",
|
||||
),
|
||||
path(
|
||||
"<slug:app_slug>/sso/post/", sso.SAMLPostBindingView.as_view(), name="sso-post",
|
||||
),
|
||||
path(
|
||||
"<slug:app_slug>/slo/redirect/",
|
||||
slo.SAMLRedirectBindingView.as_view(),
|
||||
name="slo-redirect",
|
||||
),
|
||||
path(
|
||||
"<slug:app_slug>/slo/redirect/",
|
||||
slo.SAMLPostBindingView.as_view(),
|
||||
name="slo-post",
|
||||
),
|
||||
path(
|
||||
"<slug:app_slug>/initiate/",
|
||||
idp_initiated.IDPInitiatedView.as_view(),
|
||||
name="initiate",
|
||||
),
|
||||
]
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user