Compare commits
53 Commits
version/0.
...
version/0.
Author | SHA1 | Date | |
---|---|---|---|
04a5428148 | |||
73b173b92a | |||
7cbf20a71c | |||
7a98e6d92b | |||
49e915f98b | |||
3aa2f1e892 | |||
bc4b7ef44d | |||
9400b01a55 | |||
e57da71dcf | |||
7268afaaf9 | |||
205183445c | |||
a08bdfdbcd | |||
e6c47fee26 | |||
a5629c5155 | |||
41689fe3ce | |||
8e84208e2c | |||
32a48fa07a | |||
773a9c0692 | |||
8808e3afe0 | |||
ecea85f8ca | |||
5dfa141e35 | |||
447e81d0b8 | |||
e138076e1d | |||
721d133dc3 | |||
75b687ecbe | |||
bdd1863177 | |||
e5b85e8e6a | |||
d7481c9de7 | |||
571373866e | |||
e36d7928e4 | |||
2be026dd44 | |||
d5b9de3569 | |||
e22620b0ec | |||
ba74a3213d | |||
d9ecb7070d | |||
fc4a46bd9c | |||
78301b7bab | |||
7bf7bde856 | |||
9bdff14403 | |||
f124314eab | |||
684e4ffdcf | |||
d9ff5c69c8 | |||
8142e3df45 | |||
73920899de | |||
13666965a7 | |||
86f16e2781 | |||
2ed8e72c62 | |||
edeed18ae8 | |||
d24133d8a2 | |||
b9733e56aa | |||
cd34413914 | |||
c3a4a76d43 | |||
a59a29b256 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.7.10-beta
|
||||
current_version = 0.7.16-beta
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||
|
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: '3.7'
|
||||
python-version: '3.8'
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.local/share/virtualenvs/
|
||||
@ -31,7 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: '3.7'
|
||||
python-version: '3.8'
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.local/share/virtualenvs/
|
||||
@ -48,7 +48,7 @@ jobs:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: '3.7'
|
||||
python-version: '3.8'
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.local/share/virtualenvs/
|
||||
@ -56,7 +56,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pipenv-
|
||||
- name: Install dependencies
|
||||
run: pip install -U pip pipenv && pipenv install --dev
|
||||
run: pip install -U pip pipenv && pipenv install --dev && pipenv install --dev prospector --skip-lock
|
||||
- name: Lint with prospector
|
||||
run: pipenv run prospector
|
||||
bandit:
|
||||
@ -65,7 +65,7 @@ jobs:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: '3.7'
|
||||
python-version: '3.8'
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.local/share/virtualenvs/
|
||||
@ -100,7 +100,7 @@ jobs:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: '3.7'
|
||||
python-version: '3.8'
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.local/share/virtualenvs/
|
||||
@ -134,7 +134,7 @@ jobs:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: '3.7'
|
||||
python-version: '3.8'
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.local/share/virtualenvs/
|
||||
|
29
.github/workflows/release.yml
vendored
29
.github/workflows/release.yml
vendored
@ -16,13 +16,34 @@ jobs:
|
||||
- name: Building Docker Image
|
||||
run: docker build
|
||||
--no-cache
|
||||
-t beryju/passbook:0.7.10-beta
|
||||
-t beryju/passbook:0.7.16-beta
|
||||
-t beryju/passbook:latest
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook:0.7.10-beta
|
||||
run: docker push beryju/passbook:0.7.16-beta
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook:latest
|
||||
build-gatekeeper:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Docker Login Registry
|
||||
env:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
|
||||
- name: Building Docker Image
|
||||
run: |
|
||||
cd gatekeeper
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/passbook-gatekeeper:0.7.16-beta \
|
||||
-t beryju/passbook-gatekeeper:latest \
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-gatekeeper:0.7.16-beta
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-gatekeeper:latest
|
||||
build-static:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
@ -45,11 +66,11 @@ jobs:
|
||||
run: docker build
|
||||
--no-cache
|
||||
--network=$(docker network ls | grep github | awk '{print $1}')
|
||||
-t beryju/passbook-static:0.7.10-beta
|
||||
-t beryju/passbook-static:0.7.16-beta
|
||||
-t beryju/passbook-static:latest
|
||||
-f static.Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-static:0.7.10-beta
|
||||
run: docker push beryju/passbook-static:0.7.16-beta
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-static:latest
|
||||
test-release:
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM python:3.7-slim-buster as locker
|
||||
FROM python:3.8-slim-buster as locker
|
||||
|
||||
COPY ./Pipfile /app/
|
||||
COPY ./Pipfile.lock /app/
|
||||
@ -9,7 +9,7 @@ RUN pip install pipenv && \
|
||||
pipenv lock -r > requirements.txt && \
|
||||
pipenv lock -rd > requirements-dev.txt
|
||||
|
||||
FROM python:3.7-slim-buster
|
||||
FROM python:3.8-slim-buster
|
||||
|
||||
COPY --from=locker /app/requirements.txt /app/
|
||||
COPY --from=locker /app/requirements-dev.txt /app/
|
||||
|
4
Pipfile
4
Pipfile
@ -40,9 +40,10 @@ signxml = "*"
|
||||
structlog = "*"
|
||||
swagger-spec-validator = "*"
|
||||
urllib3 = {extras = ["secure"],version = "*"}
|
||||
jinja2 = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
python_version = "3.8"
|
||||
|
||||
[dev-packages]
|
||||
autopep8 = "*"
|
||||
@ -51,7 +52,6 @@ bumpversion = "*"
|
||||
colorama = "*"
|
||||
coverage = "*"
|
||||
django-debug-toolbar = "*"
|
||||
prospector = "*"
|
||||
pylint = "*"
|
||||
pylint-django = "*"
|
||||
unittest-xml-reporting = "*"
|
||||
|
742
Pipfile.lock
generated
742
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,7 @@
|
||||
# passbook
|
||||
|
||||

|
||||
|
||||
## Quick instance
|
||||
|
||||
```
|
||||
|
@ -7,4 +7,4 @@ threads = 2
|
||||
enable-threads = true
|
||||
uid = passbook
|
||||
gid = passbook
|
||||
disable-logging=True
|
||||
disable-logging = True
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM python:3.7-slim-buster as builder
|
||||
FROM python:3.8-slim-buster as builder
|
||||
|
||||
WORKDIR /mkdocs
|
||||
|
||||
|
20
docs/reference/property-mappings/user-object.md
Normal file
20
docs/reference/property-mappings/user-object.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Passbook User Object
|
||||
|
||||
The User object has the following attributes:
|
||||
|
||||
- `username`: User's Username
|
||||
- `email` User's E-Mail
|
||||
- `name` User's Display Name
|
||||
- `is_staff` Boolean field if user is staff
|
||||
- `is_active` Boolean field if user is active
|
||||
- `date_joined` Date User joined/was created
|
||||
- `password_change_date` Date Password was last changed
|
||||
- `attributes` Dynamic Attributes
|
||||
|
||||
## Examples
|
||||
|
||||
List all the User's Group Names
|
||||
|
||||
```jinja2
|
||||
[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]
|
||||
```
|
@ -1,6 +1,6 @@
|
||||
apiVersion: v1
|
||||
appVersion: "0.7.10-beta"
|
||||
appVersion: "0.7.16-beta"
|
||||
description: A Helm chart for passbook.
|
||||
name: passbook
|
||||
version: "0.7.10-beta"
|
||||
version: "0.7.16-beta"
|
||||
icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png
|
||||
|
@ -18,6 +18,7 @@ spec:
|
||||
name: {{ include "passbook.fullname" . }}-secret-key
|
||||
key: monitoring_username
|
||||
port: http
|
||||
path: /metrics/
|
||||
interval: 10s
|
||||
selector:
|
||||
matchLabels:
|
||||
|
@ -2,7 +2,7 @@
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
image:
|
||||
tag: 0.7.10-beta
|
||||
tag: 0.7.16-beta
|
||||
|
||||
nameOverride: ""
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
site_name: passbook Docs
|
||||
site_url: https://docs.passbook.beryju.org
|
||||
site_url: https://beryju.github.io/passbook
|
||||
copyright: "Copyright © 2019 - 2020 BeryJu.org"
|
||||
|
||||
nav:
|
||||
@ -19,6 +19,9 @@ nav:
|
||||
- Rancher: integrations/services/rancher/index.md
|
||||
- Harbor: integrations/services/harbor/index.md
|
||||
- Sentry: integrations/services/sentry/index.md
|
||||
- Reference:
|
||||
- Property Mappings:
|
||||
- User Object: reference/property-mappings/user-object.md
|
||||
|
||||
repo_name: "BeryJu.org/passbook"
|
||||
repo_url: https://github.com/BeryJu/passbook
|
||||
|
@ -1,2 +1,2 @@
|
||||
"""passbook"""
|
||||
__version__ = "0.7.10-beta"
|
||||
__version__ = "0.7.16-beta"
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends "generic/form.html" %}
|
||||
{% extends base_template|default:"generic/form.html" %}
|
||||
|
||||
{% load utils %}
|
||||
{% load i18n %}
|
||||
|
@ -20,6 +20,7 @@
|
||||
<link rel="stylesheet" href="{% static 'codemirror/lib/codemirror.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'codemirror/theme/monokai.css' %}">
|
||||
<script src="{% static 'codemirror/mode/yaml/yaml.js' %}"></script>
|
||||
<script src="{% static 'codemirror/mode/jinja2/jinja2.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@ -29,21 +30,33 @@
|
||||
<div class="">
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% include 'partials/form.html' with form=form %}
|
||||
{% block beneath_form %}
|
||||
{% endblock %}
|
||||
<a class="btn btn-default" href="{% back %}">{% trans "Cancel" %}</a>
|
||||
<input type="submit" class="btn btn-primary" value="{% block action %}{% endblock %}" />
|
||||
</form>
|
||||
</div>
|
||||
{% block beneath_form %}
|
||||
{% endblock %}
|
||||
<script>
|
||||
let attributes = document.getElementsByName('attributes');
|
||||
const attributes = document.getElementsByName('attributes');
|
||||
if (attributes.length > 0) {
|
||||
let myCodeMirror = CodeMirror.fromTextArea(attributes[0], {
|
||||
// https://github.com/codemirror/CodeMirror/issues/5092
|
||||
attributes[0].removeAttribute("required");
|
||||
const attributesCM = CodeMirror.fromTextArea(attributes[0], {
|
||||
mode: 'yaml',
|
||||
theme: 'monokai',
|
||||
lineNumbers: true,
|
||||
});
|
||||
}
|
||||
const expressions = document.getElementsByName('expression');
|
||||
if (expressions.length > 0) {
|
||||
// https://github.com/codemirror/CodeMirror/issues/5092
|
||||
expressions[0].removeAttribute("required");
|
||||
const expressionCM = CodeMirror.fromTextArea(expressions[0], {
|
||||
mode: 'jinja2',
|
||||
theme: 'monokai',
|
||||
lineNumbers: true,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends "generic/form.html" %}
|
||||
{% extends base_template|default:"generic/form.html" %}
|
||||
|
||||
{% load utils %}
|
||||
{% load i18n %}
|
||||
|
@ -66,6 +66,9 @@ class PropertyMappingCreateView(
|
||||
if x.__name__ == property_mapping_type
|
||||
)
|
||||
kwargs["type"] = model._meta.verbose_name
|
||||
form_cls = self.get_form_class()
|
||||
if hasattr(form_cls, "template_name"):
|
||||
kwargs["base_template"] = form_cls.template_name
|
||||
return kwargs
|
||||
|
||||
def get_form_class(self):
|
||||
@ -92,6 +95,13 @@ class PropertyMappingUpdateView(
|
||||
success_url = reverse_lazy("passbook_admin:property-mappings")
|
||||
success_message = _("Successfully updated Property Mapping")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
form_cls = self.get_form_class()
|
||||
if hasattr(form_cls, "template_name"):
|
||||
kwargs["base_template"] = form_cls.template_name
|
||||
return kwargs
|
||||
|
||||
def get_form_class(self):
|
||||
form_class_path = self.get_object().form
|
||||
form_class = path_to_class(form_class_path)
|
||||
|
@ -1,14 +1,14 @@
|
||||
"""passbook audit models"""
|
||||
from enum import Enum
|
||||
from uuid import UUID
|
||||
from inspect import getmodule, stack
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
|
19
passbook/core/migrations/0006_propertymapping_template.py
Normal file
19
passbook/core/migrations/0006_propertymapping_template.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-17 16:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0005_merge_20191025_2022"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="propertymapping",
|
||||
name="template",
|
||||
field=models.TextField(default=""),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
16
passbook/core/migrations/0007_auto_20200217_1934.py
Normal file
16
passbook/core/migrations/0007_auto_20200217_1934.py
Normal file
@ -0,0 +1,16 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-17 19:34
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0006_propertymapping_template"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="propertymapping", old_name="template", new_name="expression",
|
||||
),
|
||||
]
|
@ -2,15 +2,18 @@
|
||||
from datetime import timedelta
|
||||
from random import SystemRandom
|
||||
from time import sleep
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from jinja2.nativetypes import NativeEnvironment
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.db import models
|
||||
from django.urls import reverse_lazy
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_prometheus.models import ExportModelOperationsMixin
|
||||
from guardian.mixins import GuardianUserMixin
|
||||
from model_utils.managers import InheritanceManager
|
||||
from structlog import get_logger
|
||||
@ -21,6 +24,7 @@ from passbook.policies.exceptions import PolicyException
|
||||
from passbook.policies.struct import PolicyRequest, PolicyResult
|
||||
|
||||
LOGGER = get_logger()
|
||||
NATIVE_ENVIRONMENT = NativeEnvironment()
|
||||
|
||||
|
||||
def default_nonce_duration():
|
||||
@ -28,7 +32,7 @@ def default_nonce_duration():
|
||||
return now() + timedelta(hours=4)
|
||||
|
||||
|
||||
class Group(UUIDModel):
|
||||
class Group(ExportModelOperationsMixin("group"), UUIDModel):
|
||||
"""Custom Group model which supports a basic hierarchy"""
|
||||
|
||||
name = models.CharField(_("name"), max_length=80)
|
||||
@ -49,7 +53,7 @@ class Group(UUIDModel):
|
||||
unique_together = (("name", "parent",),)
|
||||
|
||||
|
||||
class User(GuardianUserMixin, AbstractUser):
|
||||
class User(ExportModelOperationsMixin("user"), GuardianUserMixin, AbstractUser):
|
||||
"""Custom User model to allow easier adding o f user-based settings"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False)
|
||||
@ -72,7 +76,7 @@ class User(GuardianUserMixin, AbstractUser):
|
||||
permissions = (("reset_user_password", "Reset Password"),)
|
||||
|
||||
|
||||
class Provider(models.Model):
|
||||
class Provider(ExportModelOperationsMixin("provider"), models.Model):
|
||||
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
|
||||
|
||||
property_mappings = models.ManyToManyField(
|
||||
@ -107,7 +111,7 @@ class UserSettings:
|
||||
self.view_name = view_name
|
||||
|
||||
|
||||
class Factor(PolicyModel):
|
||||
class Factor(ExportModelOperationsMixin("factor"), PolicyModel):
|
||||
"""Authentication factor, multiple instances of the same Factor can be used"""
|
||||
|
||||
name = models.TextField()
|
||||
@ -128,7 +132,7 @@ class Factor(PolicyModel):
|
||||
return f"Factor {self.slug}"
|
||||
|
||||
|
||||
class Application(PolicyModel):
|
||||
class Application(ExportModelOperationsMixin("application"), PolicyModel):
|
||||
"""Every Application which uses passbook for authentication/identification/authorization
|
||||
needs an Application record. Other authentication types can subclass this Model to
|
||||
add custom fields and other properties"""
|
||||
@ -154,7 +158,7 @@ class Application(PolicyModel):
|
||||
return self.name
|
||||
|
||||
|
||||
class Source(PolicyModel):
|
||||
class Source(ExportModelOperationsMixin("source"), PolicyModel):
|
||||
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
|
||||
|
||||
name = models.TextField()
|
||||
@ -199,7 +203,7 @@ class UserSourceConnection(CreatedUpdatedModel):
|
||||
unique_together = (("user", "source"),)
|
||||
|
||||
|
||||
class Policy(UUIDModel, CreatedUpdatedModel):
|
||||
class Policy(ExportModelOperationsMixin("policy"), UUIDModel, CreatedUpdatedModel):
|
||||
"""Policies which specify if a user is authorized to use an Application. Can be overridden by
|
||||
other types to add other fields, more logic, etc."""
|
||||
|
||||
@ -241,7 +245,7 @@ class DebugPolicy(Policy):
|
||||
verbose_name_plural = _("Debug Policies")
|
||||
|
||||
|
||||
class Invitation(UUIDModel):
|
||||
class Invitation(ExportModelOperationsMixin("invitation"), UUIDModel):
|
||||
"""Single-use invitation link"""
|
||||
|
||||
created_by = models.ForeignKey("User", on_delete=models.CASCADE)
|
||||
@ -266,7 +270,7 @@ class Invitation(UUIDModel):
|
||||
verbose_name_plural = _("Invitations")
|
||||
|
||||
|
||||
class Nonce(UUIDModel):
|
||||
class Nonce(ExportModelOperationsMixin("nonce"), UUIDModel):
|
||||
"""One-time link for password resets/sign-up-confirmations"""
|
||||
|
||||
expires = models.DateTimeField(default=default_nonce_duration)
|
||||
@ -292,10 +296,16 @@ class PropertyMapping(UUIDModel):
|
||||
"""User-defined key -> x mapping which can be used by providers to expose extra data."""
|
||||
|
||||
name = models.TextField()
|
||||
expression = models.TextField()
|
||||
|
||||
form = ""
|
||||
objects = InheritanceManager()
|
||||
|
||||
def evaluate(self, user: User, request: HttpRequest, **kwargs) -> Any:
|
||||
"""Evaluate `self.expression` using `**kwargs` as Context."""
|
||||
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
|
||||
return expression.render(user=user, request=request, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"Property Mapping {self.name}"
|
||||
|
||||
|
@ -23,7 +23,7 @@ def _redirect_with_qs(view, get_query_set=None):
|
||||
"""Wrapper to redirect whilst keeping GET Parameters"""
|
||||
target = reverse(view)
|
||||
if get_query_set:
|
||||
target += "?" + urlencode(get_query_set)
|
||||
target += "?" + urlencode(get_query_set.items())
|
||||
return redirect(target)
|
||||
|
||||
|
||||
|
@ -8,7 +8,6 @@ from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
from django.conf import ImproperlyConfigured
|
||||
from django.utils.autoreload import autoreload_started
|
||||
from structlog import get_logger
|
||||
|
||||
SEARCH_PATHS = ["passbook/lib/default.yml", "/etc/passbook/config.yml", "",] + glob(
|
||||
@ -142,12 +141,3 @@ class ConfigLoader:
|
||||
|
||||
|
||||
CONFIG = ConfigLoader()
|
||||
|
||||
|
||||
def signal_handler(sender, **_):
|
||||
"""Add all loaded config files to autoreload watcher"""
|
||||
for path in CONFIG.loaded_file:
|
||||
sender.watch_file(path)
|
||||
|
||||
|
||||
autoreload_started.connect(signal_handler)
|
||||
|
@ -3,9 +3,9 @@ from hashlib import md5
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django import template
|
||||
from django.template import Context
|
||||
from django.apps import apps
|
||||
from django.db.models import Model
|
||||
from django.template import Context
|
||||
from django.utils.html import escape
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
|
@ -20,6 +20,5 @@ class CreateAssignPermView(CreateView):
|
||||
self.object._meta.app_label,
|
||||
self.object._meta.model_name,
|
||||
)
|
||||
print(full_permission)
|
||||
assign_perm(full_permission, self.request.user, self.object)
|
||||
return response
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""passbook policy engine"""
|
||||
from multiprocessing import Pipe
|
||||
from multiprocessing import Pipe, set_start_method
|
||||
from multiprocessing.connection import Connection
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
@ -12,6 +12,9 @@ from passbook.policies.process import PolicyProcess, cache_key
|
||||
from passbook.policies.struct import PolicyRequest, PolicyResult
|
||||
|
||||
LOGGER = get_logger()
|
||||
# This is only really needed for macOS, because Python 3.8 changed the default to spawn
|
||||
# spawn causes issues with objects that aren't picklable, and also the django setup
|
||||
set_start_method("fork")
|
||||
|
||||
|
||||
class PolicyProcessInfo:
|
||||
@ -36,13 +39,15 @@ class PolicyEngine:
|
||||
policies: List[Policy] = []
|
||||
request: PolicyRequest
|
||||
|
||||
__processes: List[PolicyProcessInfo] = []
|
||||
__cached_policies: List[PolicyResult]
|
||||
__processes: List[PolicyProcessInfo]
|
||||
|
||||
def __init__(self, policies, user: User, request: HttpRequest = None):
|
||||
self.policies = policies
|
||||
self.request = PolicyRequest(user)
|
||||
if request:
|
||||
self.request.http_request = request
|
||||
self.__cached_policies = []
|
||||
self.__processes = []
|
||||
|
||||
def _select_subclasses(self) -> List[Policy]:
|
||||
@ -55,21 +60,20 @@ class PolicyEngine:
|
||||
|
||||
def build(self) -> "PolicyEngine":
|
||||
"""Build task group"""
|
||||
cached_policies = []
|
||||
for policy in self._select_subclasses():
|
||||
cached_policy = cache.get(cache_key(policy, self.request.user), None)
|
||||
if cached_policy and self.use_cache:
|
||||
LOGGER.debug("Taking result from cache", policy=policy)
|
||||
cached_policies.append(cached_policy)
|
||||
else:
|
||||
LOGGER.debug("Evaluating policy", policy=policy)
|
||||
our_end, task_end = Pipe(False)
|
||||
task = PolicyProcess(policy, self.request, task_end)
|
||||
LOGGER.debug("Starting Process", policy=policy)
|
||||
task.start()
|
||||
self.__processes.append(
|
||||
PolicyProcessInfo(process=task, connection=our_end, policy=policy)
|
||||
)
|
||||
self.__cached_policies.append(cached_policy)
|
||||
continue
|
||||
LOGGER.debug("Evaluating policy", policy=policy)
|
||||
our_end, task_end = Pipe(False)
|
||||
task = PolicyProcess(policy, self.request, task_end)
|
||||
LOGGER.debug("Starting Process", policy=policy)
|
||||
task.start()
|
||||
self.__processes.append(
|
||||
PolicyProcessInfo(process=task, connection=our_end, policy=policy)
|
||||
)
|
||||
# If all policies are cached, we have an empty list here.
|
||||
for proc_info in self.__processes:
|
||||
proc_info.process.join(proc_info.policy.timeout)
|
||||
@ -82,13 +86,14 @@ class PolicyEngine:
|
||||
def result(self) -> Tuple[bool, List[str]]:
|
||||
"""Get policy-checking result"""
|
||||
messages: List[str] = []
|
||||
for proc_info in self.__processes:
|
||||
LOGGER.debug(
|
||||
"Result", policy=proc_info.policy, passing=proc_info.result.passing
|
||||
)
|
||||
if proc_info.result.messages:
|
||||
messages += proc_info.result.messages
|
||||
if not proc_info.result.passing:
|
||||
process_results: List[PolicyResult] = [
|
||||
x.result for x in self.__processes if x.result
|
||||
]
|
||||
for result in process_results + self.__cached_policies:
|
||||
LOGGER.debug("result", passing=result.passing)
|
||||
if result.messages:
|
||||
messages += result.messages
|
||||
if not result.passing:
|
||||
return False, messages
|
||||
return True, messages
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""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
|
||||
from oidc_provider.models import Client, ResponseType
|
||||
|
||||
from passbook.providers.app_gw.models import ApplicationGatewayProvider
|
||||
|
||||
@ -16,9 +16,14 @@ class ApplicationGatewayProviderForm(forms.ModelForm):
|
||||
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.host}/oauth2/callback",
|
||||
f"https://{self.instance.host}/oauth2/callback",
|
||||
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()
|
||||
@ -27,8 +32,9 @@ class ApplicationGatewayProviderForm(forms.ModelForm):
|
||||
class Meta:
|
||||
|
||||
model = ApplicationGatewayProvider
|
||||
fields = ["name", "host"]
|
||||
fields = ["name", "internal_host", "external_host"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"host": forms.TextInput(),
|
||||
"internal_host": forms.TextInput(),
|
||||
"external_host": forms.TextInput(),
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
]
|
@ -14,7 +14,8 @@ class ApplicationGatewayProvider(Provider):
|
||||
"""This provider uses oauth2_proxy with the OIDC Provider."""
|
||||
|
||||
name = models.TextField()
|
||||
host = models.TextField()
|
||||
internal_host = models.TextField()
|
||||
external_host = models.TextField()
|
||||
|
||||
client = models.ForeignKey(Client, on_delete=models.CASCADE)
|
||||
|
||||
|
@ -40,10 +40,10 @@ services:
|
||||
environment:
|
||||
OAUTH2_PROXY_CLIENT_ID: {{ provider.client.client_id }}
|
||||
OAUTH2_PROXY_CLIENT_SECRET: {{ provider.client.client_secret }}
|
||||
OAUTH2_PROXY_REDIRECT_URL: https://{{ provider.host }}/oauth2/callback
|
||||
OAUTH2_PROXY_OIDC_ISSUER_URL: https://{{ request.META.host }}/application/oidc
|
||||
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_UPSTREAM: http://{{ provider.host }}</textarea>
|
||||
OAUTH2_PROXY_UPSTREAMS: http://{{ provider.internal_host }}</textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-dismiss="modal">{% trans 'Close' %}</button>
|
||||
|
@ -1,21 +1,38 @@
|
||||
"""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
|
||||
from passbook.core.models import Application, Provider, User
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def check_permissions(request, user, client):
|
||||
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"""
|
||||
try:
|
||||
application = client.openidprovider.application
|
||||
# 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):
|
||||
application = related_object.application
|
||||
break
|
||||
except Application.DoesNotExist:
|
||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||
LOGGER.debug(
|
||||
|
@ -14,12 +14,16 @@ class SAMLProviderSerializer(ModelSerializer):
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"property_mappings",
|
||||
"processor_path",
|
||||
"acs_url",
|
||||
"audience",
|
||||
"processor_path",
|
||||
"issuer",
|
||||
"assertion_valid_for",
|
||||
"assertion_valid_not_before",
|
||||
"assertion_valid_not_on_or_after",
|
||||
"session_valid_not_on_or_after",
|
||||
"property_mappings",
|
||||
"digest_algorithm",
|
||||
"signature_algorithm",
|
||||
"signing",
|
||||
"signing_cert",
|
||||
"signing_key",
|
||||
@ -39,7 +43,7 @@ class SAMLPropertyMappingSerializer(ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
model = SAMLPropertyMapping
|
||||
fields = ["pk", "name", "saml_name", "friendly_name", "values"]
|
||||
fields = ["pk", "name", "saml_name", "friendly_name", "expression"]
|
||||
|
||||
|
||||
class SAMLPropertyMappingViewSet(ModelViewSet):
|
||||
|
@ -1,336 +0,0 @@
|
||||
"""Basic SAML Processor"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from defusedxml import ElementTree
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.providers.saml import exceptions, utils, xml_render
|
||||
|
||||
MINUTES = 60
|
||||
HOURS = 60 * MINUTES
|
||||
|
||||
|
||||
def get_random_id():
|
||||
"""Random hex id"""
|
||||
# It is very important that these random IDs NOT start with a number.
|
||||
random_id = "_" + uuid.uuid4().hex
|
||||
return random_id
|
||||
|
||||
|
||||
def get_time_string(delta=0):
|
||||
"""Get Data formatted in SAML format"""
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + delta))
|
||||
|
||||
|
||||
# Design note: I've tried to make this easy to sub-class and override
|
||||
# just the bits you need to override. I've made use of object properties,
|
||||
# so that your sub-classes have access to all information: use wisely.
|
||||
# Formatting note: These methods are alphabetized.
|
||||
# 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
|
||||
|
||||
_audience = ""
|
||||
_assertion_params = None
|
||||
_assertion_xml = None
|
||||
_assertion_id = None
|
||||
_django_request = None
|
||||
_relay_state = None
|
||||
_request = None
|
||||
_request_id = None
|
||||
_request_xml = None
|
||||
_request_params = None
|
||||
_response_id = None
|
||||
_response_xml = None
|
||||
_response_params = None
|
||||
_saml_request = None
|
||||
_saml_response = None
|
||||
_session_index = None
|
||||
_subject = None
|
||||
_subject_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
|
||||
_system_params = {}
|
||||
|
||||
@property
|
||||
def dotted_path(self):
|
||||
"""Return a dotted path to this class"""
|
||||
return "{module}.{class_name}".format(
|
||||
module=self.__module__, class_name=self.__class__.__name__
|
||||
)
|
||||
|
||||
def __init__(self, remote):
|
||||
self.name = remote.name
|
||||
self._remote = remote
|
||||
self._logger = get_logger()
|
||||
self._system_params["ISSUER"] = self._remote.issuer
|
||||
self._logger.debug("processor configured")
|
||||
|
||||
def _build_assertion(self):
|
||||
"""Builds _assertion_params."""
|
||||
self._determine_assertion_id()
|
||||
self._determine_audience()
|
||||
self._determine_subject()
|
||||
self._determine_session_index()
|
||||
|
||||
self._assertion_params = {
|
||||
"ASSERTION_ID": self._assertion_id,
|
||||
"ASSERTION_SIGNATURE": "", # it's unsigned
|
||||
"AUDIENCE": self._audience,
|
||||
"AUTH_INSTANT": get_time_string(),
|
||||
"ISSUE_INSTANT": get_time_string(),
|
||||
"NOT_BEFORE": get_time_string(-1 * HOURS), # TODO: Make these settings.
|
||||
"NOT_ON_OR_AFTER": get_time_string(86400 * MINUTES),
|
||||
"SESSION_INDEX": self._session_index,
|
||||
"SESSION_NOT_ON_OR_AFTER": get_time_string(8 * HOURS),
|
||||
"SP_NAME_QUALIFIER": self._audience,
|
||||
"SUBJECT": self._subject,
|
||||
"SUBJECT_FORMAT": self._subject_format,
|
||||
}
|
||||
self._assertion_params.update(self._system_params)
|
||||
self._assertion_params.update(self._request_params)
|
||||
|
||||
def _build_response(self):
|
||||
"""Builds _response_params."""
|
||||
self._determine_response_id()
|
||||
self._response_params = {
|
||||
"ASSERTION": self._assertion_xml,
|
||||
"ISSUE_INSTANT": get_time_string(),
|
||||
"RESPONSE_ID": self._response_id,
|
||||
"RESPONSE_SIGNATURE": "", # initially unsigned
|
||||
}
|
||||
self._response_params.update(self._system_params)
|
||||
self._response_params.update(self._request_params)
|
||||
|
||||
def _decode_request(self):
|
||||
"""Decodes _request_xml from _saml_request."""
|
||||
|
||||
self._request_xml = utils.decode_base64_and_inflate(self._saml_request).decode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
self._logger.debug("SAML request decoded")
|
||||
|
||||
def _determine_assertion_id(self):
|
||||
"""Determines the _assertion_id."""
|
||||
self._assertion_id = get_random_id()
|
||||
|
||||
def _determine_audience(self):
|
||||
"""Determines the _audience."""
|
||||
self._audience = self._remote.audience
|
||||
self._logger.info("determined audience")
|
||||
|
||||
def _determine_response_id(self):
|
||||
"""Determines _response_id."""
|
||||
self._response_id = get_random_id()
|
||||
|
||||
def _determine_session_index(self):
|
||||
self._session_index = self._django_request.session.session_key
|
||||
|
||||
def _determine_subject(self):
|
||||
"""Determines _subject and _subject_type for Assertion Subject."""
|
||||
self._subject = self._django_request.user.email
|
||||
|
||||
def _encode_response(self):
|
||||
"""Encodes _response_xml to _encoded_xml."""
|
||||
self._saml_response = utils.nice64(str.encode(self._response_xml))
|
||||
|
||||
def _extract_saml_request(self):
|
||||
"""Retrieves the _saml_request AuthnRequest from the _django_request."""
|
||||
self._saml_request = self._django_request.session["SAMLRequest"]
|
||||
self._relay_state = self._django_request.session["RelayState"]
|
||||
|
||||
def _format_assertion(self):
|
||||
"""Formats _assertion_params as _assertion_xml."""
|
||||
# https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
|
||||
self._assertion_params["ATTRIBUTES"] = [
|
||||
{
|
||||
"FriendlyName": "eduPersonPrincipalName",
|
||||
"Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
|
||||
"Value": self._django_request.user.email,
|
||||
},
|
||||
{
|
||||
"FriendlyName": "cn",
|
||||
"Name": "urn:oid:2.5.4.3",
|
||||
"Value": self._django_request.user.name,
|
||||
},
|
||||
{
|
||||
"FriendlyName": "mail",
|
||||
"Name": "urn:oid:0.9.2342.19200300.100.1.3",
|
||||
"Value": self._django_request.user.email,
|
||||
},
|
||||
{
|
||||
"FriendlyName": "displayName",
|
||||
"Name": "urn:oid:2.16.840.1.113730.3.1.241",
|
||||
"Value": self._django_request.user.username,
|
||||
},
|
||||
{
|
||||
"FriendlyName": "uid",
|
||||
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
|
||||
"Value": self._django_request.user.pk,
|
||||
},
|
||||
]
|
||||
from passbook.providers.saml.models import SAMLPropertyMapping
|
||||
|
||||
for mapping in self._remote.property_mappings.all().select_subclasses():
|
||||
if isinstance(mapping, SAMLPropertyMapping):
|
||||
mapping_payload = {
|
||||
"Name": mapping.saml_name,
|
||||
"ValueArray": [],
|
||||
"FriendlyName": mapping.friendly_name,
|
||||
}
|
||||
for value in mapping.values:
|
||||
mapping_payload["ValueArray"].append(
|
||||
value.format(
|
||||
user=self._django_request.user, request=self._django_request
|
||||
)
|
||||
)
|
||||
self._assertion_params["ATTRIBUTES"].append(mapping_payload)
|
||||
self._assertion_xml = xml_render.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 = xml_render.get_response_xml(
|
||||
self._response_params, saml_provider=self._remote, assertion_id=assertion_id
|
||||
)
|
||||
|
||||
def _get_django_response_params(self):
|
||||
"""Returns a dictionary of parameters for the response template."""
|
||||
return {
|
||||
"acs_url": self._request_params["ACS_URL"],
|
||||
"saml_response": self._saml_response,
|
||||
"relay_state": self._relay_state,
|
||||
"autosubmit": self._remote.application.skip_authorization,
|
||||
}
|
||||
|
||||
def _parse_request(self):
|
||||
"""Parses various parameters from _request_xml into _request_params."""
|
||||
# Minimal test to verify that it's not binarily encoded still:
|
||||
if not str(self._request_xml.strip()).startswith("<"):
|
||||
raise Exception(
|
||||
"RequestXML is not valid XML; "
|
||||
"it may need to be decoded or decompressed."
|
||||
)
|
||||
|
||||
root = ElementTree.fromstring(self._request_xml)
|
||||
params = {}
|
||||
params["ACS_URL"] = root.attrib["AssertionConsumerServiceURL"]
|
||||
params["REQUEST_ID"] = root.attrib["ID"]
|
||||
params["DESTINATION"] = root.attrib.get("Destination", "")
|
||||
params["PROVIDER_NAME"] = root.attrib.get("ProviderName", "")
|
||||
self._request_params = params
|
||||
|
||||
def _reset(self, django_request, sp_config=None):
|
||||
"""Initialize (and reset) object properties, so we don't risk carrying
|
||||
over anything from the last authentication.
|
||||
If provided, use sp_config throughout; otherwise, it will be set in
|
||||
_validate_request(). """
|
||||
self._assertion_params = sp_config
|
||||
self._assertion_xml = sp_config
|
||||
self._assertion_id = sp_config
|
||||
self._django_request = django_request
|
||||
self._relay_state = sp_config
|
||||
self._request = sp_config
|
||||
self._request_id = sp_config
|
||||
self._request_xml = sp_config
|
||||
self._request_params = sp_config
|
||||
self._response_id = sp_config
|
||||
self._response_xml = sp_config
|
||||
self._response_params = sp_config
|
||||
self._saml_request = sp_config
|
||||
self._saml_response = sp_config
|
||||
self._session_index = sp_config
|
||||
self._subject = sp_config
|
||||
self._subject_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
|
||||
self._system_params = {"ISSUER": self._remote.issuer}
|
||||
|
||||
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 = "couldn't find ACS url '{}' in SAML2IDP_REMOTES " "setting.".format(
|
||||
request_acs_url
|
||||
)
|
||||
self._logger.info(msg)
|
||||
raise exceptions.CannotHandleAssertion(msg)
|
||||
|
||||
def _validate_user(self):
|
||||
"""Validates the User. Sub-classes should override this and
|
||||
throw an CannotHandleAssertion Exception if the validation does not succeed."""
|
||||
|
||||
def can_handle(self, request):
|
||||
"""Returns true if this processor can handle this request."""
|
||||
self._reset(request)
|
||||
# Read the request.
|
||||
try:
|
||||
self._extract_saml_request()
|
||||
except Exception as exc:
|
||||
msg = "can't find SAML request in user session: %s" % exc
|
||||
self._logger.info(msg)
|
||||
raise exceptions.CannotHandleAssertion(msg)
|
||||
|
||||
try:
|
||||
self._decode_request()
|
||||
except Exception as exc:
|
||||
msg = "can't decode SAML request: %s" % exc
|
||||
self._logger.info(msg)
|
||||
raise exceptions.CannotHandleAssertion(msg)
|
||||
|
||||
try:
|
||||
self._parse_request()
|
||||
except Exception as exc:
|
||||
msg = "can't parse SAML request: %s" % exc
|
||||
self._logger.info(msg)
|
||||
raise exceptions.CannotHandleAssertion(msg)
|
||||
|
||||
self._validate_request()
|
||||
return True
|
||||
|
||||
def generate_response(self):
|
||||
"""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._django_request)
|
||||
|
||||
self._validate_user()
|
||||
self._build_assertion()
|
||||
self._format_assertion()
|
||||
self._build_response()
|
||||
self._format_response()
|
||||
self._encode_response()
|
||||
|
||||
# Return proper template params.
|
||||
return self._get_django_response_params()
|
||||
|
||||
def init_deep_link(self, request, url):
|
||||
"""Initialize this Processor to make an IdP-initiated call to the SP's
|
||||
deep-linked URL."""
|
||||
self._reset(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 = url
|
@ -4,13 +4,12 @@ from django import forms
|
||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.lib.fields import DynamicArrayField
|
||||
from passbook.providers.saml.models import (
|
||||
SAMLPropertyMapping,
|
||||
SAMLProvider,
|
||||
get_provider_choices,
|
||||
)
|
||||
from passbook.providers.saml.utils import CertificateBuilder
|
||||
from passbook.providers.saml.utils.cert import CertificateBuilder
|
||||
|
||||
|
||||
class SAMLProviderForm(forms.ModelForm):
|
||||
@ -32,24 +31,27 @@ class SAMLProviderForm(forms.ModelForm):
|
||||
model = SAMLProvider
|
||||
fields = [
|
||||
"name",
|
||||
"property_mappings",
|
||||
"processor_path",
|
||||
"acs_url",
|
||||
"audience",
|
||||
"processor_path",
|
||||
"issuer",
|
||||
"assertion_valid_for",
|
||||
"assertion_valid_not_before",
|
||||
"assertion_valid_not_on_or_after",
|
||||
"session_valid_not_on_or_after",
|
||||
"property_mappings",
|
||||
"digest_algorithm",
|
||||
"signature_algorithm",
|
||||
"signing",
|
||||
"signing_cert",
|
||||
"signing_key",
|
||||
]
|
||||
labels = {
|
||||
"acs_url": "ACS URL",
|
||||
"signing_cert": "Singing Certificate",
|
||||
}
|
||||
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),
|
||||
}
|
||||
|
||||
@ -57,16 +59,14 @@ class SAMLProviderForm(forms.ModelForm):
|
||||
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", "values"]
|
||||
fields = ["name", "saml_name", "friendly_name", "expression"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"saml_name": forms.TextInput(),
|
||||
"friendly_name": forms.TextInput(),
|
||||
}
|
||||
field_classes = {"values": DynamicArrayField}
|
||||
help_texts = {
|
||||
"values": 'String substitution uses a syntax like "{variable} test}".'
|
||||
}
|
||||
|
@ -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),
|
||||
]
|
@ -1,13 +1,13 @@
|
||||
"""passbook saml_idp Models"""
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.shortcuts import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import PropertyMapping, Provider
|
||||
from passbook.lib.utils.reflection import class_to_path, path_to_class
|
||||
from passbook.providers.saml.base import Processor
|
||||
from passbook.providers.saml.processors.base import Processor
|
||||
from passbook.providers.saml.utils.time import timedelta_string_validator
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -16,13 +16,62 @@ class SAMLProvider(Provider):
|
||||
"""Model to save information about a Remote SAML Endpoint"""
|
||||
|
||||
name = models.TextField()
|
||||
acs_url = models.URLField()
|
||||
audience = models.TextField(default="")
|
||||
processor_path = models.CharField(max_length=255, choices=[])
|
||||
|
||||
acs_url = models.URLField(verbose_name=_("ACS URL"))
|
||||
audience = models.TextField(default="")
|
||||
issuer = models.TextField()
|
||||
assertion_valid_for = models.IntegerField(default=86400)
|
||||
|
||||
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 = models.BooleanField(default=True)
|
||||
signing_cert = models.TextField()
|
||||
signing_cert = models.TextField(verbose_name=_("Singing Certificate"))
|
||||
signing_key = models.TextField()
|
||||
|
||||
form = "passbook.providers.saml.forms.SAMLProviderForm"
|
||||
@ -44,7 +93,7 @@ class SAMLProvider(Provider):
|
||||
return self._processor
|
||||
|
||||
def __str__(self):
|
||||
return "SAML Provider %s" % self.name
|
||||
return f"SAML Provider {self.name}"
|
||||
|
||||
def link_download_metadata(self):
|
||||
"""Get link to download XML metadata for admin interface"""
|
||||
@ -66,14 +115,13 @@ class SAMLProvider(Provider):
|
||||
class SAMLPropertyMapping(PropertyMapping):
|
||||
"""SAML Property mapping, allowing Name/FriendlyName mapping to a list of strings"""
|
||||
|
||||
saml_name = models.TextField()
|
||||
saml_name = models.TextField(verbose_name="SAML Name")
|
||||
friendly_name = models.TextField(default=None, blank=True, null=True)
|
||||
values = ArrayField(models.TextField())
|
||||
|
||||
form = "passbook.providers.saml.forms.SAMLPropertyMappingForm"
|
||||
|
||||
def __str__(self):
|
||||
return "SAML Property Mapping %s" % self.saml_name
|
||||
return f"SAML Property Mapping {self.saml_name}"
|
||||
|
||||
class Meta:
|
||||
|
||||
|
221
passbook/providers/saml/processors/base.py
Normal file
221
passbook/providers/saml/processors/base.py
Normal file
@ -0,0 +1,221 @@
|
||||
"""Basic SAML Processor"""
|
||||
from typing import TYPE_CHECKING, Dict, List, Union
|
||||
|
||||
from defusedxml import ElementTree
|
||||
from django.http import HttpRequest
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||
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:2.0:nameid-format:persistent"
|
||||
|
||||
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 isinstance(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,
|
||||
}
|
||||
if isinstance(value, list):
|
||||
mapping_payload["ValueArray"] = value
|
||||
else:
|
||||
mapping_payload["Value"] = value
|
||||
attributes.append(mapping_payload)
|
||||
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_django_response_params(self) -> Dict[str, str]:
|
||||
"""Returns a dictionary of parameters for the response template."""
|
||||
return {
|
||||
"acs_url": self._request_params["ACS_URL"],
|
||||
"saml_response": self._saml_response,
|
||||
"relay_state": self._relay_state,
|
||||
"autosubmit": self._remote.application.skip_authorization,
|
||||
}
|
||||
|
||||
def _decode_and_parse_request(self):
|
||||
"""Parses various parameters from _request_xml into _request_params."""
|
||||
decoded_xml = decode_base64_and_inflate(self._saml_request).decode("utf-8")
|
||||
|
||||
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 Exception as exc:
|
||||
raise CannotHandleAssertion(
|
||||
f"can't find SAML request in user session: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
self._decode_and_parse_request()
|
||||
except Exception as exc:
|
||||
raise CannotHandleAssertion(f"can't parse SAML request: {exc}") from exc
|
||||
|
||||
self._validate_request()
|
||||
return True
|
||||
|
||||
def generate_response(self) -> Dict[str, str]:
|
||||
"""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_django_response_params()
|
||||
|
||||
def init_deep_link(self, request: HttpRequest, url: str):
|
||||
"""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 = url
|
@ -1,7 +1,7 @@
|
||||
"""Generic Processor"""
|
||||
|
||||
from passbook.providers.saml.base import Processor
|
||||
from passbook.providers.saml.processors.base import Processor
|
||||
|
||||
|
||||
class GenericProcessor(Processor):
|
||||
"""Generic Response Handler Processor for testing against django-saml2-sp."""
|
||||
"""Generic SAML2 Processor"""
|
||||
|
@ -1,16 +1,14 @@
|
||||
"""Salesforce Processor"""
|
||||
|
||||
from passbook.providers.saml.base import Processor
|
||||
from passbook.providers.saml.xml_render import get_assertion_xml
|
||||
from passbook.providers.saml.processors.generic import GenericProcessor
|
||||
from passbook.providers.saml.utils.xml_render import get_assertion_xml
|
||||
|
||||
|
||||
class SalesForceProcessor(Processor):
|
||||
class SalesForceProcessor(GenericProcessor):
|
||||
"""SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor."""
|
||||
|
||||
def _determine_audience(self):
|
||||
self._audience = "IAMShowcase"
|
||||
|
||||
def _format_assertion(self):
|
||||
super()._format_assertion()
|
||||
self._assertion_xml = get_assertion_xml(
|
||||
"saml/xml/assertions/salesforce.xml", self._assertion_params, signed=True
|
||||
)
|
||||
|
@ -0,0 +1,20 @@
|
||||
{% extends "generic/form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block beneath_form %}
|
||||
<div class="form-group ">
|
||||
<label class="col-sm-2 control-label" for="friendly_name-2">
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<p>
|
||||
Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available:
|
||||
<ul>
|
||||
<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
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")
|
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
|
@ -1,8 +1,6 @@
|
||||
"""Wrappers to de/encode and de/inflate strings"""
|
||||
import base64
|
||||
"""Create self-signed certificates"""
|
||||
import datetime
|
||||
import uuid
|
||||
import zlib
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
@ -11,24 +9,6 @@ from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
|
||||
def decode_base64_and_inflate(b64string):
|
||||
"""Base64 decode and ZLib decompress b64string"""
|
||||
decoded_data = base64.b64decode(b64string)
|
||||
return zlib.decompress(decoded_data, -15)
|
||||
|
||||
|
||||
def deflate_and_base64_encode(string_val):
|
||||
"""Base64 and ZLib Compress b64string"""
|
||||
zlibbed_str = zlib.compress(string_val)
|
||||
compressed_string = zlibbed_str[2:-4]
|
||||
return base64.b64encode(compressed_string)
|
||||
|
||||
|
||||
def nice64(src):
|
||||
""" Returns src base64-encoded and formatted nicely for our XML. """
|
||||
return base64.b64encode(src).decode("utf-8").replace("\n", "")
|
||||
|
||||
|
||||
class CertificateBuilder:
|
||||
"""Build self-signed certificates"""
|
||||
|
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(b64string):
|
||||
"""Base64 decode and ZLib decompress b64string"""
|
||||
decoded_data = base64.b64decode(b64string)
|
||||
try:
|
||||
return zlib.decompress(decoded_data, -15)
|
||||
except zlib.error:
|
||||
return decoded_data
|
||||
|
||||
|
||||
def deflate_and_base64_encode(string_val):
|
||||
"""Base64 and ZLib Compress b64string"""
|
||||
zlibbed_str = zlib.compress(string_val)
|
||||
compressed_string = zlibbed_str[2:-4]
|
||||
return base64.b64encode(compressed_string)
|
||||
|
||||
|
||||
def nice64(src):
|
||||
""" Returns src base64-encoded and formatted nicely for our XML. """
|
||||
return base64.b64encode(src).decode("utf-8").replace("\n", "")
|
47
passbook/providers/saml/utils/time.py
Normal file
47
passbook/providers/saml/utils/time.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""Time utilities"""
|
||||
import datetime
|
||||
|
||||
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: 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")
|
@ -6,7 +6,10 @@ from typing import TYPE_CHECKING
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.providers.saml.xml_signing import get_signature_xml, sign_with_signxml
|
||||
from passbook.providers.saml.utils.xml_signing import (
|
||||
get_signature_xml,
|
||||
sign_with_signxml,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
@ -60,7 +63,6 @@ def get_assertion_xml(template, parameters, signed=False):
|
||||
_get_attribute_statement(params)
|
||||
|
||||
unsigned = render_to_string(template, params)
|
||||
# LOGGER.debug('Unsigned: %s', unsigned)
|
||||
if not signed:
|
||||
return unsigned
|
||||
|
||||
@ -80,18 +82,11 @@ def get_response_xml(parameters, saml_provider: SAMLProvider, assertion_id=""):
|
||||
|
||||
raw_response = render_to_string("saml/xml/response.xml", params)
|
||||
|
||||
# LOGGER.debug('Unsigned: %s', unsigned)
|
||||
if not saml_provider.signing:
|
||||
return raw_response
|
||||
|
||||
signature_xml = get_signature_xml()
|
||||
params["RESPONSE_SIGNATURE"] = signature_xml
|
||||
# LOGGER.debug("Raw response: %s", raw_response)
|
||||
|
||||
signed = sign_with_signxml(
|
||||
saml_provider.signing_key,
|
||||
raw_response,
|
||||
saml_provider.signing_cert,
|
||||
reference_uri=assertion_id,
|
||||
)
|
||||
signed = sign_with_signxml(raw_response, saml_provider, reference_uri=assertion_id,)
|
||||
return signed
|
@ -1,4 +1,6 @@
|
||||
"""Signing code goes here."""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from lxml import etree # nosec
|
||||
@ -7,25 +9,34 @@ 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(private_key, data, cert, reference_uri=None):
|
||||
def sign_with_signxml(data: str, provider: "SAMLProvider", reference_uri=None) -> str:
|
||||
"""Sign Data with signxml"""
|
||||
key = serialization.load_pem_private_key(
|
||||
str.encode("\n".join([x.strip() for x in private_key.split("\n")])),
|
||||
str.encode("\n".join([x.strip() for x in provider.signing_key.split("\n")])),
|
||||
password=None,
|
||||
backend=default_backend(),
|
||||
)
|
||||
# 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#")
|
||||
signed = signer.sign(root, key=key, cert=[cert], reference_uri=reference_uri)
|
||||
XMLVerifier().verify(signed, x509_cert=cert)
|
||||
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=key, cert=[provider.signing_cert], reference_uri=reference_uri
|
||||
)
|
||||
XMLVerifier().verify(signed, x509_cert=provider.signing_cert)
|
||||
return etree.tostring(signed).decode("utf-8") # nosec
|
||||
|
||||
|
||||
def get_signature_xml():
|
||||
def get_signature_xml() -> str:
|
||||
"""Returns XML Signature for subject."""
|
||||
return render_to_string("saml/xml/signature.xml", {})
|
@ -1,9 +1,11 @@
|
||||
"""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 HttpResponse, HttpResponseBadRequest
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404, redirect, render, reverse
|
||||
from django.utils.datastructures import MultiValueDictKeyError
|
||||
from django.utils.decorators import method_decorator
|
||||
@ -25,7 +27,7 @@ LOGGER = get_logger()
|
||||
URL_VALIDATOR = URLValidator(schemes=("http", "https"))
|
||||
|
||||
|
||||
def _generate_response(request, provider: SAMLProvider):
|
||||
def _generate_response(request: HttpRequest, provider: SAMLProvider) -> HttpResponse:
|
||||
"""Generate a SAML response using processor_instance and return it in the proper Django
|
||||
response."""
|
||||
try:
|
||||
@ -39,18 +41,13 @@ def _generate_response(request, provider: SAMLProvider):
|
||||
return render(request, "saml/idp/login.html", ctx)
|
||||
|
||||
|
||||
def render_xml(request, template, ctx):
|
||||
"""Render template with content_type application/xml"""
|
||||
return render(request, template, context=ctx, content_type="application/xml")
|
||||
|
||||
|
||||
class AccessRequiredView(AccessMixin, View):
|
||||
"""Mixin class for Views using a provider instance"""
|
||||
|
||||
_provider = None
|
||||
_provider: Optional[SAMLProvider] = None
|
||||
|
||||
@property
|
||||
def provider(self):
|
||||
def provider(self) -> SAMLProvider:
|
||||
"""Get provider instance"""
|
||||
if not self._provider:
|
||||
application = get_object_or_404(
|
||||
@ -59,15 +56,18 @@ class AccessRequiredView(AccessMixin, View):
|
||||
self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id)
|
||||
return self._provider
|
||||
|
||||
def _has_access(self):
|
||||
def _has_access(self) -> bool:
|
||||
"""Check if user has access to application"""
|
||||
LOGGER.debug(
|
||||
"_has_access", user=self.request.user, app=self.provider.application
|
||||
)
|
||||
policy_engine = PolicyEngine(
|
||||
self.provider.application.policies.all(), self.request.user, self.request
|
||||
)
|
||||
policy_engine.build()
|
||||
return policy_engine.passing
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
if not self._has_access():
|
||||
@ -87,13 +87,13 @@ class LoginBeginView(AccessRequiredView):
|
||||
stores it in the session prior to enforcing login."""
|
||||
|
||||
@method_decorator(csrf_exempt)
|
||||
def dispatch(self, request, application):
|
||||
def dispatch(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
if request.method == "POST":
|
||||
source = request.POST
|
||||
else:
|
||||
source = request.GET
|
||||
# Store these values now, because Django's login cycle won't preserve them.
|
||||
|
||||
# Store these values now, because Django's login cycle won't preserve them.
|
||||
try:
|
||||
request.session["SAMLRequest"] = source["SAMLRequest"]
|
||||
except (KeyError, MultiValueDictKeyError):
|
||||
@ -111,7 +111,9 @@ class LoginBeginView(AccessRequiredView):
|
||||
class RedirectToSPView(AccessRequiredView):
|
||||
"""Return autosubmit form"""
|
||||
|
||||
def get(self, request, acs_url, saml_response, relay_state):
|
||||
def get(
|
||||
self, request: HttpRequest, acs_url: str, saml_response: str, relay_state: str
|
||||
) -> HttpResponse:
|
||||
"""Return autosubmit form"""
|
||||
return render(
|
||||
request,
|
||||
@ -128,10 +130,9 @@ class LoginProcessView(AccessRequiredView):
|
||||
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request, application):
|
||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Handle get request, i.e. render form"""
|
||||
LOGGER.debug("SAMLLoginProcessView", request=request, method="get")
|
||||
# Check if user has access
|
||||
# User access gets checked in dispatch
|
||||
if self.provider.application.skip_authorization:
|
||||
ctx = self.provider.processor.generate_response()
|
||||
# Log Application Authorization
|
||||
@ -147,16 +148,15 @@ class LoginProcessView(AccessRequiredView):
|
||||
relay_state=ctx["relay_state"],
|
||||
)
|
||||
try:
|
||||
full_res = _generate_response(request, self.provider)
|
||||
return full_res
|
||||
return _generate_response(request, self.provider)
|
||||
except exceptions.CannotHandleAssertion as exc:
|
||||
LOGGER.debug(exc)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def post(self, request, application):
|
||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Handle post request, return back to ACS"""
|
||||
LOGGER.debug("SAMLLoginProcessView", request=request, method="post")
|
||||
# Check if user has access
|
||||
# User access gets checked in dispatch
|
||||
if request.POST.get("ACSUrl", None):
|
||||
# User accepted request
|
||||
Event.new(
|
||||
@ -171,10 +171,10 @@ class LoginProcessView(AccessRequiredView):
|
||||
relay_state=request.POST.get("RelayState"),
|
||||
)
|
||||
try:
|
||||
full_res = _generate_response(request, self.provider)
|
||||
return full_res
|
||||
return _generate_response(request, self.provider)
|
||||
except exceptions.CannotHandleAssertion as exc:
|
||||
LOGGER.debug(exc)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
|
||||
class LogoutView(CSRFExemptMixin, AccessRequiredView):
|
||||
@ -183,7 +183,7 @@ class LogoutView(CSRFExemptMixin, AccessRequiredView):
|
||||
though it's technically not SAML 2.0)."""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request, application):
|
||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Perform logout"""
|
||||
logout(request)
|
||||
|
||||
@ -204,7 +204,7 @@ class SLOLogout(CSRFExemptMixin, AccessRequiredView):
|
||||
logs out the user and returns a standard logged-out page."""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def post(self, request, application):
|
||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Perform logout"""
|
||||
request.session["SAMLRequest"] = request.POST["SAMLRequest"]
|
||||
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
|
||||
@ -219,7 +219,7 @@ class SLOLogout(CSRFExemptMixin, AccessRequiredView):
|
||||
class DescriptorDownloadView(AccessRequiredView):
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
|
||||
def get(self, request, application):
|
||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
entity_id = self.provider.issuer
|
||||
slo_url = request.build_absolute_uri(
|
||||
@ -255,7 +255,7 @@ class InitiateLoginView(AccessRequiredView):
|
||||
"""IdP-initiated Login"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request, application):
|
||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
|
||||
self.provider.processor.init_deep_link(request, "")
|
||||
self.provider.processor.is_idp_initiated = True
|
||||
|
@ -2,7 +2,7 @@
|
||||
from base64 import b64encode
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views import View
|
||||
from django_prometheus.exports import ExportToDjangoView
|
||||
|
||||
@ -13,11 +13,13 @@ class MetricsView(View):
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Check for HTTP-Basic auth"""
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
auth_type, _, credentials = auth_header.partition(" ")
|
||||
auth_type, _, given_credentials = auth_header.partition(" ")
|
||||
credentials = f"monitor:{settings.SECRET_KEY}"
|
||||
expected = b64encode(str.encode(credentials)).decode()
|
||||
|
||||
if auth_type != "Basic" or credentials != expected:
|
||||
raise Http404
|
||||
if auth_type != "Basic" or given_credentials != expected:
|
||||
response = HttpResponse(status=401)
|
||||
response["WWW-Authenticate"] = 'Basic realm="passbook-monitoring"'
|
||||
return response
|
||||
|
||||
return ExportToDjangoView(request)
|
||||
|
@ -35,7 +35,7 @@ for _passbook_app in get_apps():
|
||||
urlpatterns += [
|
||||
# Administration
|
||||
path("administration/django/", admin.site.urls),
|
||||
path("metrics", MetricsView.as_view(), name="metrics"),
|
||||
path("metrics/", MetricsView.as_view(), name="metrics"),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
@ -62,7 +62,8 @@ class WSGILogger:
|
||||
if environ.get("QUERY_STRING") != "":
|
||||
query_string = f"?{environ.get('QUERY_STRING')}"
|
||||
self.logger.info(
|
||||
f"{environ.get('PATH_INFO', '')}{query_string}",
|
||||
"request",
|
||||
path=f"{environ.get('PATH_INFO', '')}{query_string}",
|
||||
host=host,
|
||||
method=environ.get("REQUEST_METHOD", ""),
|
||||
protocol=environ.get("SERVER_PROTOCOL", ""),
|
||||
|
@ -35,7 +35,7 @@ class LDAPPropertyMappingSerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = LDAPPropertyMapping
|
||||
fields = ["pk", "name", "ldap_property", "object_field"]
|
||||
fields = ["pk", "name", "expression", "object_field"]
|
||||
|
||||
|
||||
class LDAPSourceViewSet(ModelViewSet):
|
||||
|
@ -6,7 +6,7 @@ import ldap3.core.exceptions
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Group, User
|
||||
from passbook.sources.ldap.models import LDAPSource
|
||||
from passbook.sources.ldap.models import LDAPSource, LDAPPropertyMapping
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -154,7 +154,10 @@ class Connector:
|
||||
) -> Dict[str, Dict[Any, Any]]:
|
||||
properties = {"attributes": {}}
|
||||
for mapping in self._source.property_mappings.all().select_subclasses():
|
||||
properties[mapping.object_field] = attributes.get(mapping.ldap_property, "")
|
||||
mapping: LDAPPropertyMapping
|
||||
properties[mapping.object_field] = mapping.evaluate(
|
||||
user=None, request=None, ldap=attributes
|
||||
)
|
||||
if self._source.object_uniqueness_field in attributes:
|
||||
properties["attributes"]["ldap_uniq"] = attributes.get(
|
||||
self._source.object_uniqueness_field
|
||||
|
@ -45,23 +45,17 @@ class LDAPSourceForm(forms.ModelForm):
|
||||
"policies": FilteredSelectMultiple(_("policies"), False),
|
||||
"property_mappings": FilteredSelectMultiple(_("Property Mappings"), False),
|
||||
}
|
||||
labels = {
|
||||
"server_uri": _("Server URI"),
|
||||
"bind_cn": _("Bind CN"),
|
||||
"start_tls": _("Enable Start TLS"),
|
||||
"base_dn": _("Base DN"),
|
||||
"additional_user_dn": _("Addition User DN"),
|
||||
"additional_group_dn": _("Addition Group DN"),
|
||||
}
|
||||
|
||||
|
||||
class LDAPPropertyMappingForm(forms.ModelForm):
|
||||
"""LDAP Property Mapping form"""
|
||||
|
||||
template_name = "ldap/property_mapping_form.html"
|
||||
|
||||
class Meta:
|
||||
|
||||
model = LDAPPropertyMapping
|
||||
fields = ["name", "ldap_property", "object_field"]
|
||||
fields = ["name", "object_field", "expression"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"ldap_property": forms.TextInput(),
|
||||
|
@ -13,8 +13,9 @@ def create_default_ad_property_mappings(apps: Apps, schema_editor):
|
||||
"sAMAccountName": "username",
|
||||
"mail": "email",
|
||||
}
|
||||
db_alias = schema_editor.connection.alias
|
||||
for ldap_property, object_field in mapping.items():
|
||||
LDAPPropertyMapping.objects.get_or_create(
|
||||
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
|
||||
ldap_property=ldap_property,
|
||||
object_field=object_field,
|
||||
defaults={
|
||||
|
60
passbook/sources/ldap/migrations/0006_auto_20200216_1116.py
Normal file
60
passbook/sources/ldap/migrations/0006_auto_20200216_1116.py
Normal file
@ -0,0 +1,60 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-16 11:16
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_sources_ldap", "0005_auto_20191011_1059"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ldappropertymapping",
|
||||
name="ldap_property",
|
||||
field=models.TextField(verbose_name="LDAP Property"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ldapsource",
|
||||
name="additional_group_dn",
|
||||
field=models.TextField(
|
||||
help_text="Prepended to Base DN for Group-queries.",
|
||||
verbose_name="Addition Group DN",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ldapsource",
|
||||
name="additional_user_dn",
|
||||
field=models.TextField(
|
||||
help_text="Prepended to Base DN for User-queries.",
|
||||
verbose_name="Addition User DN",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ldapsource",
|
||||
name="base_dn",
|
||||
field=models.TextField(verbose_name="Base DN"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ldapsource",
|
||||
name="bind_cn",
|
||||
field=models.TextField(verbose_name="Bind CN"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ldapsource",
|
||||
name="server_uri",
|
||||
field=models.TextField(
|
||||
validators=[
|
||||
django.core.validators.URLValidator(schemes=["ldap", "ldaps"])
|
||||
],
|
||||
verbose_name="Server URI",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ldapsource",
|
||||
name="start_tls",
|
||||
field=models.BooleanField(default=False, verbose_name="Enable Start TLS"),
|
||||
),
|
||||
]
|
@ -0,0 +1,46 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-17 16:19
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def cleanup_old_autogenerated(apps, schema_editor):
|
||||
LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping")
|
||||
db_alias = schema_editor.connection.alias
|
||||
LDAPPropertyMapping.objects.using(db_alias).filter(
|
||||
name__startswith="Autogenerated"
|
||||
).delete()
|
||||
|
||||
|
||||
def create_default_ad_property_mappings(apps: Apps, schema_editor):
|
||||
LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping")
|
||||
mapping = {
|
||||
"name": "{{ ldap.name }}",
|
||||
"first_name": "{{ ldap.givenName }}",
|
||||
"last_name": "{{ ldap.sn }}",
|
||||
"username": "{{ ldap.sAMAccountName }}",
|
||||
"email": "{{ ldap.mail }}",
|
||||
}
|
||||
db_alias = schema_editor.connection.alias
|
||||
for object_field, expression in mapping.items():
|
||||
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
|
||||
expression=expression,
|
||||
object_field=object_field,
|
||||
defaults={
|
||||
"name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_sources_ldap", "0006_auto_20200216_1116"),
|
||||
("passbook_core", "0007_auto_20200217_1934"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(cleanup_old_autogenerated),
|
||||
migrations.RemoveField(model_name="ldappropertymapping", name="ldap_property",),
|
||||
migrations.RunPython(create_default_ad_property_mappings),
|
||||
]
|
@ -2,7 +2,7 @@
|
||||
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passbook.core.models import Group, PropertyMapping, Source
|
||||
|
||||
@ -10,17 +10,22 @@ from passbook.core.models import Group, PropertyMapping, Source
|
||||
class LDAPSource(Source):
|
||||
"""LDAP Authentication source"""
|
||||
|
||||
server_uri = models.TextField(validators=[URLValidator(schemes=["ldap", "ldaps"])])
|
||||
bind_cn = models.TextField()
|
||||
server_uri = models.TextField(
|
||||
validators=[URLValidator(schemes=["ldap", "ldaps"])],
|
||||
verbose_name=_("Server URI"),
|
||||
)
|
||||
bind_cn = models.TextField(verbose_name=_("Bind CN"))
|
||||
bind_password = models.TextField()
|
||||
start_tls = models.BooleanField(default=False)
|
||||
start_tls = models.BooleanField(default=False, verbose_name=_("Enable Start TLS"))
|
||||
|
||||
base_dn = models.TextField()
|
||||
base_dn = models.TextField(verbose_name=_("Base DN"))
|
||||
additional_user_dn = models.TextField(
|
||||
help_text=_("Prepended to Base DN for User-queries.")
|
||||
help_text=_("Prepended to Base DN for User-queries."),
|
||||
verbose_name=_("Addition User DN"),
|
||||
)
|
||||
additional_group_dn = models.TextField(
|
||||
help_text=_("Prepended to Base DN for Group-queries.")
|
||||
help_text=_("Prepended to Base DN for Group-queries."),
|
||||
verbose_name=_("Addition Group DN"),
|
||||
)
|
||||
|
||||
user_object_filter = models.TextField(
|
||||
@ -54,13 +59,12 @@ class LDAPSource(Source):
|
||||
class LDAPPropertyMapping(PropertyMapping):
|
||||
"""Map LDAP Property to User or Group object"""
|
||||
|
||||
ldap_property = models.TextField()
|
||||
object_field = models.TextField()
|
||||
|
||||
form = "passbook.sources.ldap.forms.LDAPPropertyMappingForm"
|
||||
|
||||
def __str__(self):
|
||||
return f"LDAP Property Mapping {self.ldap_property} -> {self.object_field}"
|
||||
return f"LDAP Property Mapping {self.expression} -> {self.object_field}"
|
||||
|
||||
class Meta:
|
||||
|
||||
|
@ -0,0 +1,18 @@
|
||||
{% extends "generic/form.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block beneath_form %}
|
||||
<div class="form-group ">
|
||||
<label class="col-sm-2 control-label" for="friendly_name-2">
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<p>
|
||||
Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available:
|
||||
<ul>
|
||||
<li><code>ldap</code>: A Dictionary of all values retrieved from LDAP.</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -38,12 +38,6 @@ class OAuthSourceForm(forms.ModelForm):
|
||||
"provider_type": forms.Select(choices=MANAGER.get_name_tuple()),
|
||||
"policies": FilteredSelectMultiple(_("policies"), False),
|
||||
}
|
||||
labels = {
|
||||
"request_token_url": _("Request Token URL"),
|
||||
"authorization_url": _("Authorization URL"),
|
||||
"access_token_url": _("Access Token URL"),
|
||||
"profile_url": _("Profile URL"),
|
||||
}
|
||||
|
||||
|
||||
class GitHubOAuthSourceForm(OAuthSourceForm):
|
||||
|
35
passbook/sources/oauth/migrations/0002_auto_20200217_1526.py
Normal file
35
passbook/sources/oauth/migrations/0002_auto_20200217_1526.py
Normal file
@ -0,0 +1,35 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-17 15:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_sources_oauth", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="oauthsource",
|
||||
name="access_token_url",
|
||||
field=models.CharField(max_length=255, verbose_name="Access Token URL"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauthsource",
|
||||
name="authorization_url",
|
||||
field=models.CharField(max_length=255, verbose_name="Authorization URL"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauthsource",
|
||||
name="profile_url",
|
||||
field=models.CharField(max_length=255, verbose_name="Profile URL"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauthsource",
|
||||
name="request_token_url",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=255, verbose_name="Request Token URL"
|
||||
),
|
||||
),
|
||||
]
|
@ -2,7 +2,7 @@
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passbook.core.models import Source, UserSettings, UserSourceConnection
|
||||
from passbook.sources.oauth.clients import get_client
|
||||
@ -12,10 +12,16 @@ class OAuthSource(Source):
|
||||
"""Configuration for OAuth provider."""
|
||||
|
||||
provider_type = models.CharField(max_length=255)
|
||||
request_token_url = models.CharField(blank=True, max_length=255)
|
||||
authorization_url = models.CharField(max_length=255)
|
||||
access_token_url = models.CharField(max_length=255)
|
||||
profile_url = models.CharField(max_length=255)
|
||||
request_token_url = models.CharField(
|
||||
blank=True, max_length=255, verbose_name=_("Request Token URL")
|
||||
)
|
||||
authorization_url = models.CharField(
|
||||
max_length=255, verbose_name=_("Authorization URL")
|
||||
)
|
||||
access_token_url = models.CharField(
|
||||
max_length=255, verbose_name=_("Access Token URL")
|
||||
)
|
||||
profile_url = models.CharField(max_length=255, verbose_name=_("Profile URL"))
|
||||
consumer_key = models.TextField()
|
||||
consumer_secret = models.TextField()
|
||||
|
||||
|
@ -5,7 +5,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
|
||||
from passbook.providers.saml.utils import CertificateBuilder
|
||||
from passbook.providers.saml.utils.cert import CertificateBuilder
|
||||
from passbook.sources.saml.models import SAMLSource
|
||||
|
||||
|
||||
@ -28,11 +28,6 @@ class SAMLSourceForm(forms.ModelForm):
|
||||
"auto_logout",
|
||||
"signing_cert",
|
||||
]
|
||||
labels = {
|
||||
"entity_id": "Entity ID",
|
||||
"idp_url": "IDP URL",
|
||||
"idp_logout_url": "IDP Logout URL",
|
||||
}
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"policies": FilteredSelectMultiple(_("policies"), False),
|
||||
|
30
passbook/sources/saml/migrations/0004_auto_20200217_1526.py
Normal file
30
passbook/sources/saml/migrations/0004_auto_20200217_1526.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-17 15:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_sources_saml", "0003_auto_20191107_1550"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="samlsource",
|
||||
name="entity_id",
|
||||
field=models.TextField(blank=True, default=None, verbose_name="Entity ID"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="samlsource",
|
||||
name="idp_logout_url",
|
||||
field=models.URLField(
|
||||
blank=True, default=None, null=True, verbose_name="IDP Logout URL"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="samlsource",
|
||||
name="idp_url",
|
||||
field=models.URLField(verbose_name="IDP URL"),
|
||||
),
|
||||
]
|
@ -1,7 +1,7 @@
|
||||
"""saml sp models"""
|
||||
from django.db import models
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passbook.core.models import Source
|
||||
|
||||
@ -9,9 +9,11 @@ from passbook.core.models import Source
|
||||
class SAMLSource(Source):
|
||||
"""SAML2 Source"""
|
||||
|
||||
entity_id = models.TextField(blank=True, default=None)
|
||||
idp_url = models.URLField()
|
||||
idp_logout_url = models.URLField(default=None, blank=True, null=True)
|
||||
entity_id = models.TextField(blank=True, default=None, verbose_name=_("Entity ID"))
|
||||
idp_url = models.URLField(verbose_name=_("IDP URL"))
|
||||
idp_logout_url = models.URLField(
|
||||
default=None, blank=True, null=True, verbose_name=_("IDP Logout URL")
|
||||
)
|
||||
auto_logout = models.BooleanField(default=False)
|
||||
signing_cert = models.TextField()
|
||||
|
||||
|
@ -9,9 +9,9 @@ from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from passbook.providers.saml.base import get_random_id, get_time_string
|
||||
from passbook.providers.saml.utils import nice64
|
||||
from passbook.providers.saml.views import render_xml
|
||||
from passbook.providers.saml.utils import get_random_id, render_xml
|
||||
from passbook.providers.saml.utils.encoding import nice64
|
||||
from passbook.providers.saml.utils.time import get_time_string
|
||||
from passbook.sources.saml.models import SAMLSource
|
||||
from passbook.sources.saml.utils import (
|
||||
_get_user_from_response,
|
||||
|
@ -2,7 +2,7 @@
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.providers.saml.xml_signing import get_signature_xml
|
||||
from passbook.providers.saml.utils.xml_signing import get_signature_xml
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM python:3.7-slim-buster as locker
|
||||
FROM python:3.8-slim-buster as locker
|
||||
|
||||
COPY ./Pipfile /app/
|
||||
COPY ./Pipfile.lock /app/
|
||||
@ -9,7 +9,7 @@ RUN pip install pipenv && \
|
||||
pipenv lock -r > requirements.txt && \
|
||||
pipenv lock -rd > requirements-dev.txt
|
||||
|
||||
FROM python:3.7-slim-buster as static-build
|
||||
FROM python:3.8-slim-buster as static-build
|
||||
|
||||
COPY --from=locker /app/requirements.txt /app/
|
||||
COPY --from=locker /app/requirements-dev.txt /app/
|
||||
@ -34,8 +34,7 @@ ENV PASSBOOK_POSTGRESQL__USER=passbook
|
||||
ENV PASSBOOK_POSTGRESQL__PASSWORD="EK-5jnKfjrGRm<77"
|
||||
RUN ./manage.py collectstatic --no-input
|
||||
|
||||
FROM docker.beryju.org/pixie/server
|
||||
FROM beryju/pixie:latest
|
||||
|
||||
COPY --from=static-build /app/static /data/static/
|
||||
COPY --from=static-build /app/static/robots.txt /data/robots.txt
|
||||
WORKDIR /data
|
||||
COPY --from=static-build /app/static /web-root/static/
|
||||
COPY --from=static-build /app/static/robots.txt /web-root/robots.txt
|
||||
|
Reference in New Issue
Block a user