Compare commits

..

69 Commits

Author SHA1 Message Date
38dfb03668 new release: 0.7.17-beta 2020-02-18 16:29:23 +01:00
e2631cec0e factors/view: show concise error message when domain is mis-configured 2020-02-18 16:29:04 +01:00
5dad853f8a docs: use note blocks instead of code blocks for product description 2020-02-18 15:34:41 +01:00
9f00843441 policies/expression: add Expression based policy 2020-02-18 15:12:50 +01:00
f31cd7dec6 core: check PropertyMapping's expression syntax before save 2020-02-18 15:12:05 +01:00
1c1afca31f providers/saml: fix linting error 2020-02-18 11:34:04 +01:00
fbd4bdef33 providers/saml: add modal to show metadata without download 2020-02-18 10:57:43 +01:00
5b22f9b6c3 providers/saml: transition to dataclass from dict, cleanup unused templates, add missing autosubmit_form 2020-02-18 10:57:30 +01:00
083e317028 lib: add helper method for 400 response with message 2020-02-18 10:13:53 +01:00
95416623b3 sources/ldap: better handle property mapping evaluation errors 2020-02-18 10:13:05 +01:00
813b2676de providers/saml: better handle PropertyMapping evaluation errors 2020-02-18 10:12:42 +01:00
aeca66a288 providers/saml: change assertion_valid_not_before default to -5 minutes 2020-02-17 21:32:23 +01:00
04a5428148 new release: 0.7.16-beta 2020-02-17 21:02:54 +01:00
73b173b92a admin: fix form missing on update pages 2020-02-17 21:02:47 +01:00
7cbf20a71c admin: fix CodeMirror field not loading correctly 2020-02-17 21:02:35 +01:00
7a98e6d92b new release: 0.7.15-beta 2020-02-17 20:45:56 +01:00
49e915f98b Merge pull request #4 from BeryJu/propertymapping-jinja
PropertyMappings using Jinja
2020-02-17 20:45:04 +01:00
3aa2f1e892 *: propertymapping template -> expression 2020-02-17 20:38:14 +01:00
bc4b7ef44d providers/saml: add custom help text for templates, add docs for User Object reference 2020-02-17 20:30:14 +01:00
9400b01a55 admin: parameterise generic from's base template 2020-02-17 20:29:41 +01:00
e57da71dcf sources/ldap: update LDAP source to use new property mappings 2020-02-17 17:55:48 +01:00
7268afaaf9 providers/saml: update to new PropertyMappings 2020-02-17 17:50:11 +01:00
205183445c admin: add support for template field and Jinja2 highlighting 2020-02-17 17:48:53 +01:00
a08bdfdbcd root: remove prospector from Pipfile as it causes lock issues, install in CI 2020-02-17 17:48:18 +01:00
e6c47fee26 core: add template field to PropertyMapping 2020-02-17 17:47:51 +01:00
a5629c5155 providers/saml: add changeable signature and digest algorithm 2020-02-17 16:28:18 +01:00
41689fe3ce sources/* add missing migrations 2020-02-17 16:27:35 +01:00
8e84208e2c new release: 0.7.14-beta 2020-02-17 15:42:14 +01:00
32a48fa07a providers/saml: more typehints 2020-02-17 15:40:49 +01:00
773a9c0692 policies/engine: fix cached policy results being ignored 2020-02-17 15:37:51 +01:00
8808e3afe0 policies/engine: set mp start method to fork to fix issues under macOS 2020-02-17 15:20:30 +01:00
ecea85f8ca lib/config: remove autoreload handler as this API is gone in django 3 2020-02-17 15:20:11 +01:00
5dfa141e35 root/wsgi: log requests with event name of request 2020-02-16 14:36:31 +01:00
447e81d0b8 providers/saml: handle uncompressed SAML AuthNRequest 2020-02-16 14:08:35 +01:00
e138076e1d sources/saml: move labels from forms to models 2020-02-16 12:34:46 +01:00
721d133dc3 sources/oauth: move labels from form to models 2020-02-16 12:34:33 +01:00
75b687ecbe sources/ldap: move labels from form to models 2020-02-16 12:30:45 +01:00
bdd1863177 providers/saml: move field labels from Form into models 2020-02-16 12:30:26 +01:00
e5b85e8e6a providers/saml: move default saml properties to DB 2020-02-16 12:29:53 +01:00
d7481c9de7 new release: 0.7.13-beta 2020-02-14 15:35:05 +01:00
571373866e providers/saml: some more cleanup, fix get_time_string when called without argument 2020-02-14 15:34:24 +01:00
e36d7928e4 providers/saml: big cleanup, simplify base processor
add New fields for
 - assertion_valid_not_before
 - assertion_valid_not_on_or_after
 - session_valid_not_on_or_after
allow flexible time durations for these fields
fall back to Provider's ACS if none is specified in AuthNRequest
2020-02-14 15:19:48 +01:00
2be026dd44 global: fix import order 2020-02-14 15:17:40 +01:00
d5b9de3569 Merge pull request #3 from BeryJu/dependabot/pip/django-2.2.10
build(deps): bump django from 2.2.9 to 2.2.10
2020-02-12 09:31:13 +01:00
e22620b0ec build(deps): bump django from 2.2.9 to 2.2.10
Bumps [django](https://github.com/django/django) from 2.2.9 to 2.2.10.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/2.2.9...2.2.10)

Signed-off-by: dependabot[bot] <support@github.com>
2020-02-12 03:20:09 +00:00
ba74a3213d *: upgrade python 3.7 to 3.8 2020-01-19 21:03:01 +01:00
d9ecb7070d core: add more prometheus metrics 2020-01-19 21:01:26 +01:00
fc4a46bd9c root: fix credential variables overwriting each other 2020-01-17 11:16:23 +01:00
78301b7bab docs: fix site_url 2020-01-17 10:55:20 +01:00
7bf7bde856 root: fix prometheus path in ServiceMonitor, return WWW-Authenticate header so basic auth is sent 2020-01-17 10:55:11 +01:00
9bdff14403 providers/app_gw: fix wrong UPSTREAM parameter 2020-01-03 09:15:07 +01:00
f124314eab new release: 0.7.12-beta 2020-01-02 20:22:44 +01:00
684e4ffdcf providers/app_gw: fix formatting 2020-01-02 20:22:36 +01:00
d9ff5c69c8 providers/app_gw: fix assignment of response_types 2020-01-02 20:20:10 +01:00
8142e3df45 providers/oidc: fix application property of wrong object being used 2020-01-02 20:19:53 +01:00
73920899de static: use current pixie image 2020-01-02 20:09:30 +01:00
13666965a7 actions: fix build over gatekeeper 2020-01-02 16:55:30 +01:00
86f16e2781 providers/oidc: fix incorrectly sorted imports 2020-01-02 16:42:52 +01:00
2ed8e72c62 new release: 0.7.11-beta 2020-01-02 16:38:11 +01:00
edeed18ae8 providers/oidc: fix error when using with app_gw 2020-01-02 16:38:01 +01:00
d24133d8a2 core: fix _redirect_with_qs appending an array to the URL 2020-01-02 16:14:56 +01:00
b9733e56aa providers/app_gw: fix passbook domain being empty 2020-01-02 16:09:17 +01:00
cd34413914 providers/app_gw: separate host field into external_ and internal_ 2020-01-02 16:09:04 +01:00
c3a4a76d43 providers/app_gw: fix Client's response_type not being set 2020-01-02 16:06:32 +01:00
a59a29b256 actions: also build gatekeeper on release 2020-01-02 15:55:39 +01:00
dce1edbe53 new release: 0.7.10-beta 2020-01-02 14:54:52 +01:00
264d43827a actions: create release based on version number, not tag name 2020-01-02 14:46:44 +01:00
6207226bdf new release: 0.7.9-beta 2020-01-02 14:09:58 +01:00
ebf33f39c9 actions: fix missing backslash for dockerbuild 2020-01-02 14:09:42 +01:00
102 changed files with 2071 additions and 1179 deletions

View File

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

View File

@ -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/

View File

@ -1,8 +1,6 @@
name: passbook-release
on:
release:
types:
- created
release
jobs:
# Build
@ -18,13 +16,34 @@ jobs:
- name: Building Docker Image
run: docker build
--no-cache
-t beryju/passbook:0.7.8-beta
-t beryju/passbook:0.7.17-beta
-t beryju/passbook:latest
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook:0.7.8-beta
run: docker push beryju/passbook:0.7.17-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.17-beta \
-t beryju/passbook-gatekeeper:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-gatekeeper:0.7.17-beta
- name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-gatekeeper:latest
build-static:
runs-on: ubuntu-latest
services:
@ -47,11 +66,11 @@ jobs:
run: docker build
--no-cache
--network=$(docker network ls | grep github | awk '{print $1}')
-t beryju/passbook-static:0.7.8-beta
-t beryju/passbook-static:0.7.17-beta
-t beryju/passbook-static:latest
-f static.Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-static:0.7.8-beta
run: docker push beryju/passbook-static:0.7.17-beta
- name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-static:latest
test-release:

View File

@ -3,7 +3,7 @@ on:
tags:
- 'version/*'
name: Create Release from Tag
name: passbook-version-tag
jobs:
build:
@ -15,9 +15,9 @@ jobs:
run: |
export PASSBOOK_DOMAIN=localhost
docker-compose pull
docker build
--no-cache
-t beryju/passbook:latest
docker build \
--no-cache \
-t beryju/passbook:latest \
-f Dockerfile .
docker-compose up --no-start
docker-compose start postgresql redis
@ -31,6 +31,13 @@ jobs:
helm dependency update helm/
helm package helm/
mv passbook-*.tgz passbook-chart.tgz
- name: Extract verison number
id: get_version
uses: actions/github-script@0.2.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
- name: Create Release
id: create_release
uses: actions/create-release@v1.0.0
@ -38,10 +45,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
release_name: Release ${{ steps.get_version.outputs.result }}
draft: false
prerelease: false
- name: Create Release from Tag
- name: Upload packaged Helm Chart
id: upload-release-asset
uses: actions/upload-release-asset@v1.0.1
env:

View File

@ -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/

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
# passbook
![](https://github.com/BeryJu/passbook/workflows/passbook-ci/badge.svg)
## Quick instance
```

View File

@ -1,4 +1,4 @@
FROM python:3.7-slim-buster as builder
FROM python:3.8-slim-buster as builder
WORKDIR /mkdocs

View File

@ -4,9 +4,8 @@
From https://about.gitlab.com/what-is-gitlab/
```
!!! note ""
GitLab is a complete DevOps platform, delivered as a single application. This makes GitLab unique and makes Concurrent DevOps possible, unlocking your organization from the constraints of a pieced together toolchain. Join us for a live Q&A to learn how GitLab can give you unmatched visibility and higher levels of efficiency in a single application across the DevOps lifecycle.
```
## Preparation

View File

@ -4,9 +4,8 @@
From https://goharbor.io
```
!!! note ""
Harbor is an open source container image registry that secures images with role-based access control, scans images for vulnerabilities, and signs images as trusted. A CNCF Incubating project, Harbor delivers compliance, performance, and interoperability to help you consistently and securely manage images across cloud native compute platforms like Kubernetes and Docker.
```
## Preparation

View File

@ -4,10 +4,9 @@
From https://rancher.com/products/rancher
```
!!! note ""
An Enterprise Platform for Managing Kubernetes Everywhere
Rancher is a platform built to address the needs of the DevOps teams deploying applications with Kubernetes, and the IT staff responsible for delivering an enterprise-critical service.
```
## Preparation

View File

@ -4,13 +4,12 @@
From https://sentry.io
```
!!! note ""
Sentry provides self-hosted and cloud-based error monitoring that helps all software
teams discover, triage, and prioritize errors in real-time.
One million developers at over fifty thousand companies already ship
better software faster with Sentry. Wont you join them?
```
## Preparation

View 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 %}]
```

View File

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

View File

@ -18,6 +18,7 @@ spec:
name: {{ include "passbook.fullname" . }}-secret-key
key: monitoring_username
port: http
path: /metrics/
interval: 10s
selector:
matchLabels:

View File

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

View File

@ -1,5 +1,5 @@
site_name: passbook Docs
site_url: https://docs.passbook.beryju.org
site_url: https://beryju.github.io/passbook
copyright: "Copyright &copy; 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
@ -29,3 +32,4 @@ theme:
markdown_extensions:
- toc:
permalink: "¶"
- admonition

View File

@ -1,2 +1,2 @@
"""passbook"""
__version__ = "0.7.8-beta"
__version__ = "0.7.17-beta"

View File

@ -1,4 +1,4 @@
{% extends "generic/form.html" %}
{% extends base_template|default:"generic/form.html" %}
{% load utils %}
{% load i18n %}

View File

@ -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 %}

View File

@ -1,4 +1,4 @@
{% extends "generic/form.html" %}
{% extends base_template|default:"generic/form.html" %}
{% load utils %}
{% load i18n %}

View File

@ -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)

View File

@ -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 _

View File

@ -0,0 +1,5 @@
"""passbook core exceptions"""
class PropertyMappingExpressionException(Exception):
"""Error when a PropertyMapping Exception expression could not be parsed or evaluated."""

View 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,
),
]

View 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",
),
]

View File

@ -2,25 +2,32 @@
from datetime import timedelta
from random import SystemRandom
from time import sleep
from typing import Optional
from typing import Any, Optional
from uuid import uuid4
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.db import models
from django.http import HttpRequest
from django.urls import reverse_lazy
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 jinja2.exceptions import TemplateSyntaxError, UndefinedError
from jinja2.nativetypes import NativeEnvironment
from model_utils.managers import InheritanceManager
from structlog import get_logger
from passbook.core.exceptions import PropertyMappingExpressionException
from passbook.core.signals import password_changed
from passbook.lib.models import CreatedUpdatedModel, UUIDModel
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 +35,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 +56,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 +79,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 +114,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 +135,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 +161,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 +206,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 +248,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 +273,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 +299,29 @@ 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."""
try:
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
except TemplateSyntaxError as exc:
raise PropertyMappingExpressionException from exc
try:
return expression.render(user=user, request=request, **kwargs)
except UndefinedError as exc:
raise PropertyMappingExpressionException from exc
def save(self, *args, **kwargs):
try:
NATIVE_ENVIRONMENT.from_string(self.expression)
except TemplateSyntaxError as exc:
raise ValidationError("Expression Syntax Error") from exc
return super().save(*args, **kwargs)
def __str__(self):
return f"Property Mapping {self.name}"

View File

@ -19,6 +19,9 @@
<h1>{% trans 'Bad Request' %}</h1>
</header>
<form>
{% if message %}
<h3>{% trans message %}</h3>
{% endif %}
{% if 'back' in request.GET %}
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
{% endif %}

View File

@ -38,9 +38,8 @@ class TestFactorAuthentication(TestCase):
def test_unauthenticated_raw(self):
"""test direct call to AuthenticationView"""
response = self.client.get(reverse("passbook_core:auth-process"))
# Response should be 302 since no pending user is set
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("passbook_core:auth-login"))
# Response should be 400 since no pending user is set
self.assertEqual(response.status_code, 400)
def test_unauthenticated_prepared(self):
"""test direct call but with pending_uesr in session"""
@ -71,9 +70,8 @@ class TestFactorAuthentication(TestCase):
"""Test with already logged in user"""
self.client.force_login(self.user)
response = self.client.get(reverse("passbook_core:auth-process"))
# Response should be 302 since no pending user is set
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("passbook_core:overview"))
# Response should be 400 since no pending user is set
self.assertEqual(response.status_code, 400)
self.client.logout()
def test_unauthenticated_post(self):

View File

@ -1,15 +1,17 @@
"""passbook multi-factor authentication engine"""
from typing import List, Tuple
from typing import List, Optional, Tuple
from django.contrib.auth import login
from django.contrib.auth.mixins import UserPassesTestMixin
from django.shortcuts import get_object_or_404, redirect, reverse
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.http import urlencode
from django.views.generic import View
from structlog import get_logger
from passbook.core.models import Factor, User
from passbook.core.views.utils import PermissionDeniedView
from passbook.lib.config import CONFIG
from passbook.lib.utils.reflection import class_to_path, path_to_class
from passbook.lib.utils.urls import is_url_absolute
from passbook.policies.engine import PolicyEngine
@ -23,7 +25,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)
@ -44,10 +46,26 @@ class AuthenticationView(UserPassesTestMixin, View):
current_factor: Factor
# Allow only not authenticated users to login
def test_func(self):
def test_func(self) -> bool:
return AuthenticationView.SESSION_PENDING_USER in self.request.session
def handle_no_permission(self):
def _check_config_domain(self) -> Optional[HttpResponse]:
"""Checks if current request's domain matches configured Domain, and
adds a warning if not."""
current_domain = self.request.get_host()
config_domain = CONFIG.y("domain")
if current_domain != config_domain:
message = (
f"Current domain of '{current_domain}' doesn't "
f"match configured domain of '{config_domain}'."
)
LOGGER.warning(message)
return render(
self.request, "error/400.html", context={"message": message}, status=400
)
return None
def handle_no_permission(self) -> HttpResponse:
# Function from UserPassesTestMixin
if NEXT_ARG_NAME in self.request.GET:
return redirect(self.request.GET.get(NEXT_ARG_NAME))
@ -55,7 +73,7 @@ class AuthenticationView(UserPassesTestMixin, View):
return _redirect_with_qs("passbook_core:overview", self.request.GET)
return _redirect_with_qs("passbook_core:auth-login", self.request.GET)
def get_pending_factors(self):
def get_pending_factors(self) -> List[Tuple[str, str]]:
"""Loading pending factors from Database or load from session variable"""
# Write pending factors to session
if AuthenticationView.SESSION_PENDING_FACTORS in self.request.session:
@ -67,6 +85,7 @@ class AuthenticationView(UserPassesTestMixin, View):
)
pending_factors = []
for factor in _all_factors:
factor: Factor
LOGGER.debug(
"Checking if factor applies to user",
factor=factor,
@ -81,10 +100,13 @@ class AuthenticationView(UserPassesTestMixin, View):
LOGGER.debug("Factor applies", factor=factor, user=self.pending_user)
return pending_factors
def dispatch(self, request, *args, **kwargs):
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
# Check if user passes test (i.e. SESSION_PENDING_USER is set)
user_test_result = self.get_test_func()()
if not user_test_result:
incorrect_domain_message = self._check_config_domain()
if incorrect_domain_message:
return incorrect_domain_message
return self.handle_no_permission()
# Extract pending user from session (only remember uid)
self.pending_user = get_object_or_404(
@ -117,7 +139,7 @@ class AuthenticationView(UserPassesTestMixin, View):
self._current_factor_class.request = request
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass get request to current factor"""
LOGGER.debug(
"Passing GET",
@ -125,7 +147,7 @@ class AuthenticationView(UserPassesTestMixin, View):
)
return self._current_factor_class.get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass post request to current factor"""
LOGGER.debug(
"Passing POST",
@ -133,7 +155,7 @@ class AuthenticationView(UserPassesTestMixin, View):
)
return self._current_factor_class.post(request, *args, **kwargs)
def user_ok(self):
def user_ok(self) -> HttpResponse:
"""Redirect to next Factor"""
LOGGER.debug(
"Factor passed",
@ -160,14 +182,14 @@ class AuthenticationView(UserPassesTestMixin, View):
LOGGER.debug("User passed all factors, logging in", user=self.pending_user)
return self._user_passed()
def user_invalid(self):
def user_invalid(self) -> HttpResponse:
"""Show error message, user cannot login.
This should only be shown if user authenticated successfully, but is disabled/locked/etc"""
LOGGER.debug("User invalid")
self.cleanup()
return _redirect_with_qs("passbook_core:auth-denied", self.request.GET)
def _user_passed(self):
def _user_passed(self) -> HttpResponse:
"""User Successfully passed all factors"""
backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND]
login(self.request, self.pending_user, backend=backend)

View File

@ -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)

View File

@ -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 _

View File

@ -1,5 +1,6 @@
"""passbook helper views"""
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.views.generic import CreateView
from guardian.shortcuts import assign_perm
@ -20,6 +21,10 @@ 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
def bad_request_message(request: HttpRequest, message: str) -> HttpResponse:
"""Return generic error page with message, with status code set to 400"""
return render(request, "error/400.html", {"message": message}, status=400)

View File

@ -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,13 +60,12 @@ 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:
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)
@ -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

View File

View File

@ -0,0 +1,5 @@
"""Passbook passbook expression policy Admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister("passbook_policies_expression")

View File

@ -0,0 +1,21 @@
"""Expression Policy API"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.policies.expression.models import ExpressionPolicy
from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS
class ExpressionPolicySerializer(ModelSerializer):
"""Group Membership Policy Serializer"""
class Meta:
model = ExpressionPolicy
fields = GENERAL_SERIALIZER_FIELDS + ["expression"]
class ExpressionPolicyViewSet(ModelViewSet):
"""Source Viewset"""
queryset = ExpressionPolicy.objects.all()
serializer_class = ExpressionPolicySerializer

View File

@ -0,0 +1,11 @@
"""Passbook policy_expression app config"""
from django.apps import AppConfig
class PassbookPolicyExpressionConfig(AppConfig):
"""Passbook policy_expression app config"""
name = "passbook.policies.expression"
label = "passbook_policies_expression"
verbose_name = "passbook Policies.Expression"

View File

@ -0,0 +1,22 @@
"""passbook Expression Policy forms"""
from django import forms
from passbook.policies.expression.models import ExpressionPolicy
from passbook.policies.forms import GENERAL_FIELDS
class ExpressionPolicyForm(forms.ModelForm):
"""ExpressionPolicy Form"""
template_name = "policy/expression/form.html"
class Meta:
model = ExpressionPolicy
fields = GENERAL_FIELDS + [
"expression",
]
widgets = {
"name": forms.TextInput(),
}

View File

@ -0,0 +1,38 @@
# Generated by Django 3.0.3 on 2020-02-18 14:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0007_auto_20200217_1934"),
]
operations = [
migrations.CreateModel(
name="ExpressionPolicy",
fields=[
(
"policy_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_core.Policy",
),
),
("expression", models.TextField()),
],
options={
"verbose_name": "Expression Policy",
"verbose_name_plural": "Expression Policies",
},
bases=("passbook_core.policy",),
),
]

View File

@ -0,0 +1,49 @@
"""passbook expression Policy Models"""
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext as _
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from jinja2.nativetypes import NativeEnvironment
from structlog import get_logger
from passbook.core.models import Policy
from passbook.policies.struct import PolicyRequest, PolicyResult
LOGGER = get_logger()
NATIVE_ENVIRONMENT = NativeEnvironment()
class ExpressionPolicy(Policy):
"""Jinja2-based Expression policy that allows Admins to write their own logic"""
expression = models.TextField()
form = "passbook.policies.expression.forms.ExpressionPolicyForm"
def passes(self, request: PolicyRequest) -> PolicyResult:
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
try:
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
except TemplateSyntaxError as exc:
return PolicyResult(False, str(exc))
try:
result = expression.render(request=request)
if isinstance(result, list) and len(result) == 2:
return PolicyResult(*result)
if result:
return PolicyResult(result)
return PolicyResult(False)
except UndefinedError as exc:
return PolicyResult(False, str(exc))
def save(self, *args, **kwargs):
try:
NATIVE_ENVIRONMENT.from_string(self.expression)
except TemplateSyntaxError as exc:
raise ValidationError("Expression Syntax Error") from exc
return super().save(*args, **kwargs)
class Meta:
verbose_name = _("Expression Policy")
verbose_name_plural = _("Expression Policies")

View File

@ -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>request.user</code>: Passbook User Object (<a href="https://beryju.github.io/passbook/reference/property-mappings/user-object/">Reference</a>)</li>
<li><code>request.http_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>request.obj</code>: Model the Policy is run against. </li>
</ul>
</p>
</div>
</div>
{% endblock %}

View File

@ -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(),
}

View File

@ -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,
),
]

View File

@ -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)

View File

@ -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>

View File

@ -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(

View File

@ -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):

View File

@ -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

View File

@ -3,7 +3,3 @@
class CannotHandleAssertion(Exception):
"""This processor does not handle this assertion."""
class UserNotAuthorized(Exception):
"""User not authorized for SAML 2.0 authentication."""

View File

@ -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}".'
}

View File

@ -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
],
),
),
]

View File

@ -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"),
),
]

View File

@ -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),
),
]

View File

@ -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),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 3.0.3 on 2020-02-17 20:31
from django.db import migrations, models
import passbook.providers.saml.utils.time
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_saml", "0005_remove_samlpropertymapping_values"),
]
operations = [
migrations.AlterField(
model_name="samlprovider",
name="assertion_valid_not_before",
field=models.TextField(
default="minutes=-5",
help_text="Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).",
validators=[
passbook.providers.saml.utils.time.timedelta_string_validator
],
),
),
]

View File

@ -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"
@ -33,7 +82,7 @@ class SAMLProvider(Provider):
self._meta.get_field("processor_path").choices = get_provider_choices()
@property
def processor(self):
def processor(self) -> Processor:
"""Return selected processor as instance"""
if not self._processor:
try:
@ -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"""
@ -57,6 +106,16 @@ class SAMLProvider(Provider):
except Provider.application.RelatedObjectDoesNotExist:
return None
def html_metadata_view(self, request):
"""return template and context modal with to view Metadata without downloading it"""
from passbook.providers.saml.views import DescriptorDownloadView
metadata = DescriptorDownloadView.get_metadata(request, self)
return (
"saml/idp/admin_metadata_modal.html",
{"provider": self, "metadata": metadata,},
)
class Meta:
verbose_name = _("SAML Provider")
@ -66,14 +125,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:

View File

@ -0,0 +1,230 @@
"""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.core.exceptions import PropertyMappingExpressionException
from passbook.providers.saml.exceptions import CannotHandleAssertion
from passbook.providers.saml.processors.types import SAMLResponseParams
from passbook.providers.saml.utils import get_random_id
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate, nice64
from passbook.providers.saml.utils.time import get_time_string, timedelta_from_string
from passbook.providers.saml.utils.xml_render import get_assertion_xml, get_response_xml
if TYPE_CHECKING:
from passbook.providers.saml.models import SAMLProvider
# pylint: disable=too-many-instance-attributes
class Processor:
"""Base SAML 2.0 AuthnRequest to Response Processor.
Sub-classes should provide Service Provider-specific functionality."""
is_idp_initiated = False
_remote: "SAMLProvider"
_http_request: HttpRequest
_assertion_xml: str
_response_xml: str
_saml_response: str
_relay_state: str
_saml_request: str
_assertion_params: Dict[str, Union[str, List[Dict[str, str]]]]
_request_params: Dict[str, str]
_response_params: Dict[str, str]
@property
def subject_format(self) -> str:
"""Get subject Format"""
return "urn:oasis:names:tc:SAML: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 not isinstance(mapping, SAMLPropertyMapping):
continue
try:
mapping: SAMLPropertyMapping
value = mapping.evaluate(
user=self._http_request.user,
request=self._http_request,
provider=self._remote,
)
mapping_payload = {
"Name": mapping.saml_name,
"FriendlyName": mapping.friendly_name,
}
# Normal values and arrays need different dict keys as they are handeled
# differently in the template
if isinstance(value, list):
mapping_payload["ValueArray"] = value
else:
mapping_payload["Value"] = value
attributes.append(mapping_payload)
except PropertyMappingExpressionException as exc:
self._logger.warning(exc)
continue
self._assertion_params["ATTRIBUTES"] = attributes
self._assertion_xml = get_assertion_xml(
"saml/xml/assertions/generic.xml", self._assertion_params, signed=True
)
def _format_response(self):
"""Formats _response_params as _response_xml."""
assertion_id = self._assertion_params["ASSERTION_ID"]
self._response_xml = get_response_xml(
self._response_params, saml_provider=self._remote, assertion_id=assertion_id
)
def _get_saml_response_params(self) -> SAMLResponseParams:
"""Returns a dictionary of parameters for the response template."""
return SAMLResponseParams(
acs_url=self._request_params["ACS_URL"],
saml_response=self._saml_response,
relay_state=self._relay_state,
)
def _decode_and_parse_request(self):
"""Parses various parameters from _request_xml into _request_params."""
decoded_xml = decode_base64_and_inflate(self._saml_request).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 KeyError 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) -> SAMLResponseParams:
"""Processes request and returns template variables suitable for a response."""
# Build the assertion and response.
# Only call can_handle if SP initiated Request, otherwise we have no Request
if not self.is_idp_initiated:
self.can_handle(self._http_request)
self._build_assertion()
self._format_assertion()
self._build_response()
self._format_response()
self._encode_response()
# Return proper template params.
return self._get_saml_response_params()
def init_deep_link(self, request: HttpRequest):
"""Initialize this Processor to make an IdP-initiated call to the SP's
deep-linked URL."""
self._http_request = request
acs_url = self._remote.acs_url
# NOTE: The following request params are made up. Some are blank,
# because they comes over in the AuthnRequest, but we don't have an
# AuthnRequest in this case:
# - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
# - ProviderName: According to the spec, this is optional.
self._request_params = {
"ACS_URL": acs_url,
"DESTINATION": "",
"PROVIDER_NAME": "",
}
self._relay_state = ""

View File

@ -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"""

View File

@ -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
)

View File

@ -0,0 +1,11 @@
"""passbook saml provider types"""
from dataclasses import dataclass
@dataclass
class SAMLResponseParams:
"""Class to keep track of SAML Response Parameters"""
acs_url: str
saml_response: str
relay_state: str

View File

@ -0,0 +1,41 @@
{% load i18n %}
{% load static %}
<script src="{% static 'codemirror/lib/codemirror.js' %}"></script>
<script src="{% static 'codemirror/addon/display/autorefresh.js' %}"></script>
<link rel="stylesheet" href="{% static 'codemirror/lib/codemirror.css' %}">
<link rel="stylesheet" href="{% static 'codemirror/theme/monokai.css' %}">
<script src="{% static 'codemirror/mode/xml/xml.js' %}"></script>
<button class="btn btn-default btn-sm" data-toggle="modal" data-target="#{{ provider.pk }}">{% trans 'View Metadata' %}</button>
<div class="modal fade" id="{{ provider.pk }}" tabindex="-1" role="dialog" aria-labelledby="{{ provider.pk }}Label" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" aria-label="Close">
<span class="pficon pficon-close"></span>
</button>
<h4 class="modal-title" id="{{ provider.pk }}Label">{% trans 'Metadata' %}</h4>
</div>
<div class="modal-body">
<form class="form-horizontal">
<textarea class="codemirror" id="{{ provider.pk }}-textarea">
{{ metadata }}
</textarea>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">{% trans 'Close' %}</button>
</div>
</div>
</div>
</div>
<script>
CodeMirror.fromTextArea(document.getElementById("{{ provider.pk }}-textarea"), {
mode: 'xml',
theme: 'monokai',
lineNumbers: false,
readOnly: true,
autoRefresh: true,
});
</script>

View File

@ -0,0 +1,39 @@
{% extends "login/base.html" %}
{% load utils %}
{% load i18n %}
{% block title %}
{% title 'Redirecting...' %}
{% endblock %}
{% block card %}
<header class="login-pf-header">
<h1>{% trans 'Redirecting...' %}</h1>
</header>
<form method="POST" action="{{ url }}">
{% csrf_token %}
{% for key, value in attrs.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endfor %}
<div class="login-group">
<h3>
{% trans "Redirecting..." %}
</h3>
<p>
{% blocktrans with user=user %}
You are logged in as {{ user }}.
{% endblocktrans %}
<a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Not you?' %}</a>
</p>
<input class="btn btn-primary btn-block btn-lg" type="submit" value="{% trans 'Continue' %}" />
</div>
</form>
{% endblock %}
{% block scripts %}
{{ block.super }}
<script>
$('form').submit();
</script>
{% endblock %}

View File

@ -1,5 +0,0 @@
{% extends "saml/idp/base.html" %}
{% load i18n %}
{% block content %}
{% trans "You have logged in, but your user account is not enabled for SAML 2.0." %}
{% endblock %}

View File

@ -1,5 +1,9 @@
{% extends "saml/idp/base.html" %}
{% extends "login/base.html" %}
{% load i18n %}
{% block content %}
{% block card %}
<p>
{% trans "You have successfully logged out of the Identity Provider." %}
</p>
{% endblock %}

View File

@ -11,15 +11,15 @@
<header class="login-pf-header">
<h1>{% trans 'Authorize Application' %}</h1>
</header>
<form method="POST" action="{{ acs_url }}">
<form method="POST" action="{{ saml_params.acs_url }}">
{% csrf_token %}
<input type="hidden" name="ACSUrl" value="{{ acs_url }}">
<input type="hidden" name="RelayState" value="{{ relay_state }}" />
<input type="hidden" name="SAMLResponse" value="{{ saml_response }}" />
<input type="hidden" name="ACSUrl" value="{{ saml_params.acs_url }}">
<input type="hidden" name="RelayState" value="{{ saml_params.relay_state }}" />
<input type="hidden" name="SAMLResponse" value="{{ saml_params.saml_response }}" />
<div class="login-group">
<h3>
{% blocktrans with remote=remote.application.name %}
You're about to sign into {{ remote }}
{% blocktrans with provider=provider.application.name %}
You're about to sign into {{ provider }}
{% endblocktrans %}
</h3>
<p>

View File

@ -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 %}

View File

@ -1,47 +0,0 @@
{% extends "_admin/module_default.html" %}
{% load i18n %}
{% load utils %}
{% block title %}
{% title "Overview" %}
{% endblock %}
{% block module_content %}
<h2><clr-icon shape="application" size="32"></clr-icon>{% trans 'SAML2 IDP' %}</h2>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h2><clr-icon shape="settings" size="32"></clr-icon>{% trans 'Settings' %}</h2>
</div>
<form role="form" method="POST">
<div class="card-block">
{% include 'partials/form.html' with form=form %}
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary">{% trans 'Update' %}</button>
</div>
</form>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h2><clr-icon shape="bank" size="32"></clr-icon>{% trans 'Metadata' %}</h2>
</div>
<div class="card-block">
<p>{% trans 'Cert Fingerprint (SHA1):' %} <pre>{{ fingerprint }}</pre></p>
<section class="form-block">
<pre lang="xml" >{{ metadata }}</pre>
</section>
</div>
<div class="card-footer">
<a href="{% url 'passbook_providers_saml:saml-metadata' %}" class="btn btn-primary"><clr-icon shape="download"></clr-icon>{% trans 'Download Metadata' %}</a>
</div>
</div>
</div>
</div>
{% endblock %}

View 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")

View File

@ -4,14 +4,17 @@ from django.urls import path
from passbook.providers.saml import views
urlpatterns = [
path(
"<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login"
),
# This view is used to initiate a Login-flow from the IDP
path(
"<slug:application>/login/initiate/",
views.InitiateLoginView.as_view(),
name="saml-login-initiate",
),
# This view is the endpoint a SP would redirect to, and saves data into the session
# this is required as the process view which it redirects to might have to login first.
path(
"<slug:application>/login/", views.LoginProcessView.as_view(), name="saml-login"
),
path(
"<slug:application>/login/process/",
views.LoginProcessView.as_view(),

View 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

View File

@ -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"""

View 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", "")

View 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")

View File

@ -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

View File

@ -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", {})

View File

@ -1,12 +1,15 @@
"""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
from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator
from django.utils.html import mark_safe
from django.utils.translation import gettext as _
from django.views import View
from django.views.decorators.csrf import csrf_exempt
@ -17,40 +20,23 @@ from passbook.audit.models import Event, EventAction
from passbook.core.models import Application
from passbook.lib.mixins import CSRFExemptMixin
from passbook.lib.utils.template import render_to_string
from passbook.lib.views import bad_request_message
from passbook.policies.engine import PolicyEngine
from passbook.providers.saml import exceptions
from passbook.providers.saml.models import SAMLProvider
from passbook.providers.saml.processors.types import SAMLResponseParams
LOGGER = get_logger()
URL_VALIDATOR = URLValidator(schemes=("http", "https"))
def _generate_response(request, provider: SAMLProvider):
"""Generate a SAML response using processor_instance and return it in the proper Django
response."""
try:
provider.processor.init_deep_link(request, "")
ctx = provider.processor.generate_response()
ctx["remote"] = provider
ctx["is_login"] = True
except exceptions.UserNotAuthorized:
return render(request, "saml/idp/invalid_user.html")
return render(request, "saml/idp/login.html", ctx)
def render_xml(request, template, ctx):
"""Render template with content_type application/xml"""
return render(request, template, context=ctx, content_type="application/xml")
class AccessRequiredView(AccessMixin, View):
"""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 +45,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,17 +76,17 @@ 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):
return HttpResponseBadRequest("the SAML request payload is missing")
return bad_request_message(request, "The SAML request payload is missing.")
request.session["RelayState"] = source.get("RelayState", "")
return redirect(
@ -108,73 +97,83 @@ class LoginBeginView(AccessRequiredView):
)
class RedirectToSPView(AccessRequiredView):
"""Return autosubmit form"""
def get(self, request, acs_url, saml_response, relay_state):
"""Return autosubmit form"""
return render(
request,
"core/autosubmit_form.html",
{
"url": acs_url,
"attrs": {"SAMLResponse": saml_response, "RelayState": relay_state},
},
)
class LoginProcessView(AccessRequiredView):
"""Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
# pylint: disable=unused-argument
def get(self, request, application):
"""Handle get request, i.e. render form"""
LOGGER.debug("SAMLLoginProcessView", request=request, method="get")
# Check if user has access
if self.provider.application.skip_authorization:
ctx = self.provider.processor.generate_response()
def handle_redirect(
self, params: SAMLResponseParams, skipped_authorization: bool
) -> HttpResponse:
"""Handle direct redirect to SP"""
# Log Application Authorization
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=True,
).from_http(request)
return RedirectToSPView.as_view()(
request=request,
acs_url=ctx["acs_url"],
saml_response=ctx["saml_response"],
relay_state=ctx["relay_state"],
skipped_authorization=skipped_authorization,
).from_http(self.request)
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": params.acs_url,
"attrs": {
"SAMLResponse": params.saml_response,
"RelayState": params.relay_state,
},
},
)
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle get request, i.e. render form"""
# User access gets checked in dispatch
# Otherwise we generate the IdP initiated session
try:
full_res = _generate_response(request, self.provider)
return full_res
# application.skip_authorization is set so we directly redirect the user
if self.provider.application.skip_authorization:
self.provider.processor.can_handle(request)
saml_params = self.provider.processor.generate_response()
return self.handle_redirect(saml_params, True)
self.provider.processor.init_deep_link(request)
params = self.provider.processor.generate_response()
return render(
request,
"saml/idp/login.html",
{
"saml_params": params,
"provider": self.provider,
# This is only needed to for the template to render correctly
"is_login": True,
},
)
except exceptions.CannotHandleAssertion as exc:
LOGGER.debug(exc)
LOGGER.error(exc)
did_you_mean_link = request.build_absolute_uri(
reverse(
"passbook_providers_saml:saml-login-initiate",
kwargs={"application": application},
)
)
did_you_mean_message = (
f" Did you mean to go <a href='{did_you_mean_link}'>here</a>?"
)
return bad_request_message(
request, mark_safe(str(exc) + did_you_mean_message)
)
# 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
if request.POST.get("ACSUrl", None):
# User accepted request
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=False,
).from_http(request)
return RedirectToSPView.as_view()(
request=request,
acs_url=request.POST.get("ACSUrl"),
saml_response=request.POST.get("SAMLResponse"),
relay_state=request.POST.get("RelayState"),
)
try:
full_res = _generate_response(request, self.provider)
return full_res
except exceptions.CannotHandleAssertion as exc:
LOGGER.debug(exc)
# User access gets checked in dispatch
# we get here when skip_authorization is False, and after the user accepted
# the authorization form
self.provider.processor.can_handle(request)
saml_params = self.provider.processor.generate_response()
return self.handle_redirect(saml_params, True)
class LogoutView(CSRFExemptMixin, AccessRequiredView):
@ -183,7 +182,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 +203,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,22 +218,23 @@ class SLOLogout(CSRFExemptMixin, AccessRequiredView):
class DescriptorDownloadView(AccessRequiredView):
"""Replies with the XML Metadata IDSSODescriptor."""
def get(self, request, application):
"""Replies with the XML Metadata IDSSODescriptor."""
entity_id = self.provider.issuer
@staticmethod
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
"""Return rendered XML Metadata"""
entity_id = provider.issuer
slo_url = request.build_absolute_uri(
reverse(
"passbook_providers_saml:saml-logout",
kwargs={"application": application},
kwargs={"application": provider.application},
)
)
sso_url = request.build_absolute_uri(
reverse(
"passbook_providers_saml:saml-login",
kwargs={"application": application},
kwargs={"application": provider.application},
)
)
pubkey = strip_pem_header(self.provider.signing_cert.replace("\r", "")).replace(
pubkey = strip_pem_header(provider.signing_cert.replace("\r", "")).replace(
"\n", ""
)
ctx = {
@ -243,7 +243,12 @@ class DescriptorDownloadView(AccessRequiredView):
"slo_url": slo_url,
"sso_url": sso_url,
}
metadata = render_to_string("saml/xml/metadata.xml", ctx)
return render_to_string("saml/xml/metadata.xml", ctx)
# pylint: disable=unused-argument
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Replies with the XML Metadata IDSSODescriptor."""
metadata = DescriptorDownloadView.get_metadata(request, self.provider)
response = HttpResponse(metadata, content_type="application/xml")
response["Content-Disposition"] = (
'attachment; filename="' '%s_passbook_meta.xml"' % self.provider.name
@ -254,9 +259,46 @@ class DescriptorDownloadView(AccessRequiredView):
class InitiateLoginView(AccessRequiredView):
"""IdP-initiated Login"""
def handle_redirect(
self, params: SAMLResponseParams, skipped_authorization: bool
) -> HttpResponse:
"""Handle direct redirect to SP"""
# Log Application Authorization
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=skipped_authorization,
).from_http(self.request)
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": params.acs_url,
"attrs": {
"SAMLResponse": params.saml_response,
"RelayState": params.relay_state,
},
},
)
# pylint: disable=unused-argument
def get(self, request, 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
return _generate_response(request, self.provider)
self.provider.processor.init_deep_link(request)
params = self.provider.processor.generate_response()
# IdP-initiated Login Flow
if self.provider.application.skip_authorization:
return self.handle_redirect(params, True)
return render(
request,
"saml/idp/login.html",
{
"saml_params": params,
"provider": self.provider,
# This is only needed to for the template to render correctly
"is_login": True,
},
)

View File

@ -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)

View File

@ -98,6 +98,7 @@ INSTALLED_APPS = [
"passbook.policies.password.apps.PassbookPoliciesPasswordConfig",
"passbook.policies.sso.apps.PassbookPoliciesSSOConfig",
"passbook.policies.webhook.apps.PassbookPoliciesWebhookConfig",
"passbook.policies.expression.apps.PassbookPolicyExpressionConfig",
]
GUARDIAN_MONKEY_PATCH = False
@ -276,7 +277,7 @@ structlog.configure_once(
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(),
structlog.processors.StackInfoRenderer(),
# structlog.processors.format_exc_info,
structlog.processors.format_exc_info,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
context_class=structlog.threadlocal.wrap_dict(dict),

View File

@ -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:

View File

@ -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", ""),

View File

@ -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):

View File

@ -5,8 +5,9 @@ import ldap3
import ldap3.core.exceptions
from structlog import get_logger
from passbook.core.exceptions import PropertyMappingExpressionException
from passbook.core.models import Group, User
from passbook.sources.ldap.models import LDAPSource
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
LOGGER = get_logger()
@ -154,7 +155,14 @@ 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
try:
properties[mapping.object_field] = mapping.evaluate(
user=None, request=None, ldap=attributes
)
except PropertyMappingExpressionException as exc:
LOGGER.warning(exc)
continue
if self._source.object_uniqueness_field in attributes:
properties["attributes"]["ldap_uniq"] = attributes.get(
self._source.object_uniqueness_field

View File

@ -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(),

View File

@ -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={

View 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"),
),
]

View File

@ -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),
]

View File

@ -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:

View File

@ -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 %}

View File

@ -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):

View 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"
),
),
]

View File

@ -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()

View File

@ -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),

View 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"),
),
]

View File

@ -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()

View File

@ -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,

View File

@ -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()

Some files were not shown because too many files have changed in this diff Show More