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] [bumpversion]
current_version = 0.7.8-beta current_version = 0.7.17-beta
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)

View File

@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: actions/setup-python@v1 - uses: actions/setup-python@v1
with: with:
python-version: '3.7' python-version: '3.8'
- uses: actions/cache@v1 - uses: actions/cache@v1
with: with:
path: ~/.local/share/virtualenvs/ path: ~/.local/share/virtualenvs/
@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: actions/setup-python@v1 - uses: actions/setup-python@v1
with: with:
python-version: '3.7' python-version: '3.8'
- uses: actions/cache@v1 - uses: actions/cache@v1
with: with:
path: ~/.local/share/virtualenvs/ path: ~/.local/share/virtualenvs/
@ -48,7 +48,7 @@ jobs:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: actions/setup-python@v1 - uses: actions/setup-python@v1
with: with:
python-version: '3.7' python-version: '3.8'
- uses: actions/cache@v1 - uses: actions/cache@v1
with: with:
path: ~/.local/share/virtualenvs/ path: ~/.local/share/virtualenvs/
@ -56,7 +56,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pipenv- ${{ runner.os }}-pipenv-
- name: Install dependencies - 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 - name: Lint with prospector
run: pipenv run prospector run: pipenv run prospector
bandit: bandit:
@ -65,7 +65,7 @@ jobs:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: actions/setup-python@v1 - uses: actions/setup-python@v1
with: with:
python-version: '3.7' python-version: '3.8'
- uses: actions/cache@v1 - uses: actions/cache@v1
with: with:
path: ~/.local/share/virtualenvs/ path: ~/.local/share/virtualenvs/
@ -100,7 +100,7 @@ jobs:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: actions/setup-python@v1 - uses: actions/setup-python@v1
with: with:
python-version: '3.7' python-version: '3.8'
- uses: actions/cache@v1 - uses: actions/cache@v1
with: with:
path: ~/.local/share/virtualenvs/ path: ~/.local/share/virtualenvs/
@ -134,7 +134,7 @@ jobs:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: actions/setup-python@v1 - uses: actions/setup-python@v1
with: with:
python-version: '3.7' python-version: '3.8'
- uses: actions/cache@v1 - uses: actions/cache@v1
with: with:
path: ~/.local/share/virtualenvs/ path: ~/.local/share/virtualenvs/

View File

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

View File

@ -3,7 +3,7 @@ on:
tags: tags:
- 'version/*' - 'version/*'
name: Create Release from Tag name: passbook-version-tag
jobs: jobs:
build: build:
@ -15,9 +15,9 @@ jobs:
run: | run: |
export PASSBOOK_DOMAIN=localhost export PASSBOOK_DOMAIN=localhost
docker-compose pull docker-compose pull
docker build docker build \
--no-cache --no-cache \
-t beryju/passbook:latest -t beryju/passbook:latest \
-f Dockerfile . -f Dockerfile .
docker-compose up --no-start docker-compose up --no-start
docker-compose start postgresql redis docker-compose start postgresql redis
@ -31,6 +31,13 @@ jobs:
helm dependency update helm/ helm dependency update helm/
helm package helm/ helm package helm/
mv passbook-*.tgz passbook-chart.tgz 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 - name: Create Release
id: create_release id: create_release
uses: actions/create-release@v1.0.0 uses: actions/create-release@v1.0.0
@ -38,10 +45,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: ${{ github.ref }} tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }} release_name: Release ${{ steps.get_version.outputs.result }}
draft: false draft: false
prerelease: false prerelease: false
- name: Create Release from Tag - name: Upload packaged Helm Chart
id: upload-release-asset id: upload-release-asset
uses: actions/upload-release-asset@v1.0.1 uses: actions/upload-release-asset@v1.0.1
env: 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 /app/
COPY ./Pipfile.lock /app/ COPY ./Pipfile.lock /app/
@ -9,7 +9,7 @@ RUN pip install pipenv && \
pipenv lock -r > requirements.txt && \ pipenv lock -r > requirements.txt && \
pipenv lock -rd > requirements-dev.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.txt /app/
COPY --from=locker /app/requirements-dev.txt /app/ COPY --from=locker /app/requirements-dev.txt /app/

View File

@ -40,9 +40,10 @@ signxml = "*"
structlog = "*" structlog = "*"
swagger-spec-validator = "*" swagger-spec-validator = "*"
urllib3 = {extras = ["secure"],version = "*"} urllib3 = {extras = ["secure"],version = "*"}
jinja2 = "*"
[requires] [requires]
python_version = "3.7" python_version = "3.8"
[dev-packages] [dev-packages]
autopep8 = "*" autopep8 = "*"
@ -51,7 +52,6 @@ bumpversion = "*"
colorama = "*" colorama = "*"
coverage = "*" coverage = "*"
django-debug-toolbar = "*" django-debug-toolbar = "*"
prospector = "*"
pylint = "*" pylint = "*"
pylint-django = "*" pylint-django = "*"
unittest-xml-reporting = "*" unittest-xml-reporting = "*"

742
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -7,4 +7,4 @@ threads = 2
enable-threads = true enable-threads = true
uid = passbook uid = passbook
gid = passbook gid = passbook
disable-logging=True disable-logging = True

View File

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

View File

@ -4,9 +4,8 @@
From https://about.gitlab.com/what-is-gitlab/ 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. 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 ## Preparation

View File

@ -4,9 +4,8 @@
From https://goharbor.io 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. 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 ## Preparation

View File

@ -4,10 +4,9 @@
From https://rancher.com/products/rancher From https://rancher.com/products/rancher
``` !!! note ""
An Enterprise Platform for Managing Kubernetes Everywhere 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. 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 ## Preparation

View File

@ -4,13 +4,12 @@
From https://sentry.io From https://sentry.io
``` !!! note ""
Sentry provides self-hosted and cloud-based error monitoring that helps all software Sentry provides self-hosted and cloud-based error monitoring that helps all software
teams discover, triage, and prioritize errors in real-time. teams discover, triage, and prioritize errors in real-time.
One million developers at over fifty thousand companies already ship One million developers at over fifty thousand companies already ship
better software faster with Sentry. Wont you join them? better software faster with Sentry. Wont you join them?
```
## Preparation ## 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 apiVersion: v1
appVersion: "0.7.8-beta" appVersion: "0.7.17-beta"
description: A Helm chart for passbook. description: A Helm chart for passbook.
name: 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 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 name: {{ include "passbook.fullname" . }}-secret-key
key: monitoring_username key: monitoring_username
port: http port: http
path: /metrics/
interval: 10s interval: 10s
selector: selector:
matchLabels: matchLabels:

View File

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

View File

@ -1,5 +1,5 @@
site_name: passbook Docs 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" copyright: "Copyright &copy; 2019 - 2020 BeryJu.org"
nav: nav:
@ -19,6 +19,9 @@ nav:
- Rancher: integrations/services/rancher/index.md - Rancher: integrations/services/rancher/index.md
- Harbor: integrations/services/harbor/index.md - Harbor: integrations/services/harbor/index.md
- Sentry: integrations/services/sentry/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_name: "BeryJu.org/passbook"
repo_url: https://github.com/BeryJu/passbook repo_url: https://github.com/BeryJu/passbook
@ -29,3 +32,4 @@ theme:
markdown_extensions: markdown_extensions:
- toc: - toc:
permalink: "¶" permalink: "¶"
- admonition

View File

@ -1,2 +1,2 @@
"""passbook""" """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 utils %}
{% load i18n %} {% load i18n %}

View File

@ -20,6 +20,7 @@
<link rel="stylesheet" href="{% static 'codemirror/lib/codemirror.css' %}"> <link rel="stylesheet" href="{% static 'codemirror/lib/codemirror.css' %}">
<link rel="stylesheet" href="{% static 'codemirror/theme/monokai.css' %}"> <link rel="stylesheet" href="{% static 'codemirror/theme/monokai.css' %}">
<script src="{% static 'codemirror/mode/yaml/yaml.js' %}"></script> <script src="{% static 'codemirror/mode/yaml/yaml.js' %}"></script>
<script src="{% static 'codemirror/mode/jinja2/jinja2.js' %}"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -29,21 +30,33 @@
<div class=""> <div class="">
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% include 'partials/form.html' with form=form %} {% include 'partials/form.html' with form=form %}
{% block beneath_form %}
{% endblock %}
<a class="btn btn-default" href="{% back %}">{% trans "Cancel" %}</a> <a class="btn btn-default" href="{% back %}">{% trans "Cancel" %}</a>
<input type="submit" class="btn btn-primary" value="{% block action %}{% endblock %}" /> <input type="submit" class="btn btn-primary" value="{% block action %}{% endblock %}" />
</form> </form>
</div> </div>
{% block beneath_form %}
{% endblock %}
<script> <script>
let attributes = document.getElementsByName('attributes'); const attributes = document.getElementsByName('attributes');
if (attributes.length > 0) { 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', mode: 'yaml',
theme: 'monokai', theme: 'monokai',
lineNumbers: true, 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> </script>
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

@ -66,6 +66,9 @@ class PropertyMappingCreateView(
if x.__name__ == property_mapping_type if x.__name__ == property_mapping_type
) )
kwargs["type"] = model._meta.verbose_name 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 return kwargs
def get_form_class(self): def get_form_class(self):
@ -92,6 +95,13 @@ class PropertyMappingUpdateView(
success_url = reverse_lazy("passbook_admin:property-mappings") success_url = reverse_lazy("passbook_admin:property-mappings")
success_message = _("Successfully updated Property Mapping") 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): def get_form_class(self):
form_class_path = self.get_object().form form_class_path = self.get_object().form
form_class = path_to_class(form_class_path) form_class = path_to_class(form_class_path)

View File

@ -1,14 +1,14 @@
"""passbook audit models""" """passbook audit models"""
from enum import Enum from enum import Enum
from uuid import UUID
from inspect import getmodule, stack 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.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.translation import gettext as _ 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 datetime import timedelta
from random import SystemRandom from random import SystemRandom
from time import sleep from time import sleep
from typing import Optional from typing import Any, Optional
from uuid import uuid4 from uuid import uuid4
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.http import HttpRequest
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.timezone import now 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 guardian.mixins import GuardianUserMixin
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from jinja2.nativetypes import NativeEnvironment
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from structlog import get_logger from structlog import get_logger
from passbook.core.exceptions import PropertyMappingExpressionException
from passbook.core.signals import password_changed from passbook.core.signals import password_changed
from passbook.lib.models import CreatedUpdatedModel, UUIDModel from passbook.lib.models import CreatedUpdatedModel, UUIDModel
from passbook.policies.exceptions import PolicyException from passbook.policies.exceptions import PolicyException
from passbook.policies.struct import PolicyRequest, PolicyResult from passbook.policies.struct import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()
NATIVE_ENVIRONMENT = NativeEnvironment()
def default_nonce_duration(): def default_nonce_duration():
@ -28,7 +35,7 @@ def default_nonce_duration():
return now() + timedelta(hours=4) return now() + timedelta(hours=4)
class Group(UUIDModel): class Group(ExportModelOperationsMixin("group"), UUIDModel):
"""Custom Group model which supports a basic hierarchy""" """Custom Group model which supports a basic hierarchy"""
name = models.CharField(_("name"), max_length=80) name = models.CharField(_("name"), max_length=80)
@ -49,7 +56,7 @@ class Group(UUIDModel):
unique_together = (("name", "parent",),) 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""" """Custom User model to allow easier adding o f user-based settings"""
uuid = models.UUIDField(default=uuid4, editable=False) uuid = models.UUIDField(default=uuid4, editable=False)
@ -72,7 +79,7 @@ class User(GuardianUserMixin, AbstractUser):
permissions = (("reset_user_password", "Reset Password"),) 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""" """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
property_mappings = models.ManyToManyField( property_mappings = models.ManyToManyField(
@ -107,7 +114,7 @@ class UserSettings:
self.view_name = view_name self.view_name = view_name
class Factor(PolicyModel): class Factor(ExportModelOperationsMixin("factor"), PolicyModel):
"""Authentication factor, multiple instances of the same Factor can be used""" """Authentication factor, multiple instances of the same Factor can be used"""
name = models.TextField() name = models.TextField()
@ -128,7 +135,7 @@ class Factor(PolicyModel):
return f"Factor {self.slug}" return f"Factor {self.slug}"
class Application(PolicyModel): class Application(ExportModelOperationsMixin("application"), PolicyModel):
"""Every Application which uses passbook for authentication/identification/authorization """Every Application which uses passbook for authentication/identification/authorization
needs an Application record. Other authentication types can subclass this Model to needs an Application record. Other authentication types can subclass this Model to
add custom fields and other properties""" add custom fields and other properties"""
@ -154,7 +161,7 @@ class Application(PolicyModel):
return self.name return self.name
class Source(PolicyModel): class Source(ExportModelOperationsMixin("source"), PolicyModel):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
name = models.TextField() name = models.TextField()
@ -199,7 +206,7 @@ class UserSourceConnection(CreatedUpdatedModel):
unique_together = (("user", "source"),) 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 """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.""" other types to add other fields, more logic, etc."""
@ -241,7 +248,7 @@ class DebugPolicy(Policy):
verbose_name_plural = _("Debug Policies") verbose_name_plural = _("Debug Policies")
class Invitation(UUIDModel): class Invitation(ExportModelOperationsMixin("invitation"), UUIDModel):
"""Single-use invitation link""" """Single-use invitation link"""
created_by = models.ForeignKey("User", on_delete=models.CASCADE) created_by = models.ForeignKey("User", on_delete=models.CASCADE)
@ -266,7 +273,7 @@ class Invitation(UUIDModel):
verbose_name_plural = _("Invitations") verbose_name_plural = _("Invitations")
class Nonce(UUIDModel): class Nonce(ExportModelOperationsMixin("nonce"), UUIDModel):
"""One-time link for password resets/sign-up-confirmations""" """One-time link for password resets/sign-up-confirmations"""
expires = models.DateTimeField(default=default_nonce_duration) 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.""" """User-defined key -> x mapping which can be used by providers to expose extra data."""
name = models.TextField() name = models.TextField()
expression = models.TextField()
form = "" form = ""
objects = InheritanceManager() 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): def __str__(self):
return f"Property Mapping {self.name}" return f"Property Mapping {self.name}"

View File

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

View File

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

View File

@ -1,15 +1,17 @@
"""passbook multi-factor authentication engine""" """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 import login
from django.contrib.auth.mixins import UserPassesTestMixin 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.utils.http import urlencode
from django.views.generic import View from django.views.generic import View
from structlog import get_logger from structlog import get_logger
from passbook.core.models import Factor, User from passbook.core.models import Factor, User
from passbook.core.views.utils import PermissionDeniedView 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.reflection import class_to_path, path_to_class
from passbook.lib.utils.urls import is_url_absolute from passbook.lib.utils.urls import is_url_absolute
from passbook.policies.engine import PolicyEngine 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""" """Wrapper to redirect whilst keeping GET Parameters"""
target = reverse(view) target = reverse(view)
if get_query_set: if get_query_set:
target += "?" + urlencode(get_query_set) target += "?" + urlencode(get_query_set.items())
return redirect(target) return redirect(target)
@ -44,10 +46,26 @@ class AuthenticationView(UserPassesTestMixin, View):
current_factor: Factor current_factor: Factor
# Allow only not authenticated users to login # 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 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 # Function from UserPassesTestMixin
if NEXT_ARG_NAME in self.request.GET: if NEXT_ARG_NAME in self.request.GET:
return redirect(self.request.GET.get(NEXT_ARG_NAME)) 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:overview", self.request.GET)
return _redirect_with_qs("passbook_core:auth-login", 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""" """Loading pending factors from Database or load from session variable"""
# Write pending factors to session # Write pending factors to session
if AuthenticationView.SESSION_PENDING_FACTORS in self.request.session: if AuthenticationView.SESSION_PENDING_FACTORS in self.request.session:
@ -67,6 +85,7 @@ class AuthenticationView(UserPassesTestMixin, View):
) )
pending_factors = [] pending_factors = []
for factor in _all_factors: for factor in _all_factors:
factor: Factor
LOGGER.debug( LOGGER.debug(
"Checking if factor applies to user", "Checking if factor applies to user",
factor=factor, factor=factor,
@ -81,10 +100,13 @@ class AuthenticationView(UserPassesTestMixin, View):
LOGGER.debug("Factor applies", factor=factor, user=self.pending_user) LOGGER.debug("Factor applies", factor=factor, user=self.pending_user)
return pending_factors 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) # Check if user passes test (i.e. SESSION_PENDING_USER is set)
user_test_result = self.get_test_func()() user_test_result = self.get_test_func()()
if not user_test_result: 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() return self.handle_no_permission()
# Extract pending user from session (only remember uid) # Extract pending user from session (only remember uid)
self.pending_user = get_object_or_404( self.pending_user = get_object_or_404(
@ -117,7 +139,7 @@ class AuthenticationView(UserPassesTestMixin, View):
self._current_factor_class.request = request self._current_factor_class.request = request
return super().dispatch(request, *args, **kwargs) 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""" """pass get request to current factor"""
LOGGER.debug( LOGGER.debug(
"Passing GET", "Passing GET",
@ -125,7 +147,7 @@ class AuthenticationView(UserPassesTestMixin, View):
) )
return self._current_factor_class.get(request, *args, **kwargs) 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""" """pass post request to current factor"""
LOGGER.debug( LOGGER.debug(
"Passing POST", "Passing POST",
@ -133,7 +155,7 @@ class AuthenticationView(UserPassesTestMixin, View):
) )
return self._current_factor_class.post(request, *args, **kwargs) return self._current_factor_class.post(request, *args, **kwargs)
def user_ok(self): def user_ok(self) -> HttpResponse:
"""Redirect to next Factor""" """Redirect to next Factor"""
LOGGER.debug( LOGGER.debug(
"Factor passed", "Factor passed",
@ -160,14 +182,14 @@ class AuthenticationView(UserPassesTestMixin, View):
LOGGER.debug("User passed all factors, logging in", user=self.pending_user) LOGGER.debug("User passed all factors, logging in", user=self.pending_user)
return self._user_passed() return self._user_passed()
def user_invalid(self): def user_invalid(self) -> HttpResponse:
"""Show error message, user cannot login. """Show error message, user cannot login.
This should only be shown if user authenticated successfully, but is disabled/locked/etc""" This should only be shown if user authenticated successfully, but is disabled/locked/etc"""
LOGGER.debug("User invalid") LOGGER.debug("User invalid")
self.cleanup() self.cleanup()
return _redirect_with_qs("passbook_core:auth-denied", self.request.GET) 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""" """User Successfully passed all factors"""
backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND] backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND]
login(self.request, self.pending_user, backend=backend) login(self.request, self.pending_user, backend=backend)

View File

@ -8,7 +8,6 @@ from urllib.parse import urlparse
import yaml import yaml
from django.conf import ImproperlyConfigured from django.conf import ImproperlyConfigured
from django.utils.autoreload import autoreload_started
from structlog import get_logger from structlog import get_logger
SEARCH_PATHS = ["passbook/lib/default.yml", "/etc/passbook/config.yml", "",] + glob( SEARCH_PATHS = ["passbook/lib/default.yml", "/etc/passbook/config.yml", "",] + glob(
@ -142,12 +141,3 @@ class ConfigLoader:
CONFIG = 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 urllib.parse import urlencode
from django import template from django import template
from django.template import Context
from django.apps import apps from django.apps import apps
from django.db.models import Model from django.db.models import Model
from django.template import Context
from django.utils.html import escape from django.utils.html import escape
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _

View File

@ -1,5 +1,6 @@
"""passbook helper views""" """passbook helper views"""
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.views.generic import CreateView from django.views.generic import CreateView
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
@ -20,6 +21,10 @@ class CreateAssignPermView(CreateView):
self.object._meta.app_label, self.object._meta.app_label,
self.object._meta.model_name, self.object._meta.model_name,
) )
print(full_permission)
assign_perm(full_permission, self.request.user, self.object) assign_perm(full_permission, self.request.user, self.object)
return response 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""" """passbook policy engine"""
from multiprocessing import Pipe from multiprocessing import Pipe, set_start_method
from multiprocessing.connection import Connection from multiprocessing.connection import Connection
from typing import List, Optional, Tuple 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 from passbook.policies.struct import PolicyRequest, PolicyResult
LOGGER = get_logger() 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: class PolicyProcessInfo:
@ -36,13 +39,15 @@ class PolicyEngine:
policies: List[Policy] = [] policies: List[Policy] = []
request: PolicyRequest request: PolicyRequest
__processes: List[PolicyProcessInfo] = [] __cached_policies: List[PolicyResult]
__processes: List[PolicyProcessInfo]
def __init__(self, policies, user: User, request: HttpRequest = None): def __init__(self, policies, user: User, request: HttpRequest = None):
self.policies = policies self.policies = policies
self.request = PolicyRequest(user) self.request = PolicyRequest(user)
if request: if request:
self.request.http_request = request self.request.http_request = request
self.__cached_policies = []
self.__processes = [] self.__processes = []
def _select_subclasses(self) -> List[Policy]: def _select_subclasses(self) -> List[Policy]:
@ -55,21 +60,20 @@ class PolicyEngine:
def build(self) -> "PolicyEngine": def build(self) -> "PolicyEngine":
"""Build task group""" """Build task group"""
cached_policies = []
for policy in self._select_subclasses(): for policy in self._select_subclasses():
cached_policy = cache.get(cache_key(policy, self.request.user), None) cached_policy = cache.get(cache_key(policy, self.request.user), None)
if cached_policy and self.use_cache: if cached_policy and self.use_cache:
LOGGER.debug("Taking result from cache", policy=policy) LOGGER.debug("Taking result from cache", policy=policy)
cached_policies.append(cached_policy) self.__cached_policies.append(cached_policy)
else: continue
LOGGER.debug("Evaluating policy", policy=policy) LOGGER.debug("Evaluating policy", policy=policy)
our_end, task_end = Pipe(False) our_end, task_end = Pipe(False)
task = PolicyProcess(policy, self.request, task_end) task = PolicyProcess(policy, self.request, task_end)
LOGGER.debug("Starting Process", policy=policy) LOGGER.debug("Starting Process", policy=policy)
task.start() task.start()
self.__processes.append( self.__processes.append(
PolicyProcessInfo(process=task, connection=our_end, policy=policy) PolicyProcessInfo(process=task, connection=our_end, policy=policy)
) )
# If all policies are cached, we have an empty list here. # If all policies are cached, we have an empty list here.
for proc_info in self.__processes: for proc_info in self.__processes:
proc_info.process.join(proc_info.policy.timeout) proc_info.process.join(proc_info.policy.timeout)
@ -82,13 +86,14 @@ class PolicyEngine:
def result(self) -> Tuple[bool, List[str]]: def result(self) -> Tuple[bool, List[str]]:
"""Get policy-checking result""" """Get policy-checking result"""
messages: List[str] = [] messages: List[str] = []
for proc_info in self.__processes: process_results: List[PolicyResult] = [
LOGGER.debug( x.result for x in self.__processes if x.result
"Result", policy=proc_info.policy, passing=proc_info.result.passing ]
) for result in process_results + self.__cached_policies:
if proc_info.result.messages: LOGGER.debug("result", passing=result.passing)
messages += proc_info.result.messages if result.messages:
if not proc_info.result.passing: messages += result.messages
if not result.passing:
return False, messages return False, messages
return True, 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""" """passbook Application Security Gateway Forms"""
from django import forms from django import forms
from oauth2_provider.generators import generate_client_id, generate_client_secret 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 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() client_id=generate_client_id(), client_secret=generate_client_secret()
) )
self.instance.client.name = self.instance.name 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 = [ self.instance.client.redirect_uris = [
f"http://{self.instance.host}/oauth2/callback", f"http://{self.instance.external_host}/oauth2/callback",
f"https://{self.instance.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.scope = ["openid", "email"]
self.instance.client.save() self.instance.client.save()
@ -27,8 +32,9 @@ class ApplicationGatewayProviderForm(forms.ModelForm):
class Meta: class Meta:
model = ApplicationGatewayProvider model = ApplicationGatewayProvider
fields = ["name", "host"] fields = ["name", "internal_host", "external_host"]
widgets = { widgets = {
"name": forms.TextInput(), "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.""" """This provider uses oauth2_proxy with the OIDC Provider."""
name = models.TextField() name = models.TextField()
host = models.TextField() internal_host = models.TextField()
external_host = models.TextField()
client = models.ForeignKey(Client, on_delete=models.CASCADE) client = models.ForeignKey(Client, on_delete=models.CASCADE)

View File

@ -40,10 +40,10 @@ services:
environment: environment:
OAUTH2_PROXY_CLIENT_ID: {{ provider.client.client_id }} OAUTH2_PROXY_CLIENT_ID: {{ provider.client.client_id }}
OAUTH2_PROXY_CLIENT_SECRET: {{ provider.client.client_secret }} OAUTH2_PROXY_CLIENT_SECRET: {{ provider.client.client_secret }}
OAUTH2_PROXY_REDIRECT_URL: https://{{ provider.host }}/oauth2/callback OAUTH2_PROXY_REDIRECT_URL: https://{{ provider.external_host }}/oauth2/callback
OAUTH2_PROXY_OIDC_ISSUER_URL: https://{{ request.META.host }}/application/oidc OAUTH2_PROXY_OIDC_ISSUER_URL: https://{{ request.META.HTTP_HOST }}/application/oidc
OAUTH2_PROXY_COOKIE_SECRET: {{ cookie_secret }} OAUTH2_PROXY_COOKIE_SECRET: {{ cookie_secret }}
OAUTH2_PROXY_UPSTREAM: http://{{ provider.host }}</textarea> OAUTH2_PROXY_UPSTREAMS: http://{{ provider.internal_host }}</textarea>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">{% trans 'Close' %}</button> <button type="button" class="btn btn-primary" data-dismiss="modal">{% trans 'Close' %}</button>

View File

@ -1,21 +1,38 @@
"""OIDC Permission checking""" """OIDC Permission checking"""
from typing import Optional
from django.contrib import messages from django.contrib import messages
from django.db.models.deletion import Collector
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from oidc_provider.models import Client
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import Event, EventAction 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 from passbook.policies.engine import PolicyEngine
LOGGER = get_logger() LOGGER = get_logger()
def check_permissions(request, user, client): def check_permissions(
request: HttpRequest, user: User, client: Client
) -> Optional[HttpResponse]:
"""Check permissions, used for """Check permissions, used for
https://django-oidc-provider.readthedocs.io/en/latest/ https://django-oidc-provider.readthedocs.io/en/latest/
sections/settings.html#oidc-after-userlogin-hook""" sections/settings.html#oidc-after-userlogin-hook"""
try: 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: except Application.DoesNotExist:
return redirect("passbook_providers_oauth:oauth2-permission-denied") return redirect("passbook_providers_oauth:oauth2-permission-denied")
LOGGER.debug( LOGGER.debug(

View File

@ -14,12 +14,16 @@ class SAMLProviderSerializer(ModelSerializer):
fields = [ fields = [
"pk", "pk",
"name", "name",
"property_mappings", "processor_path",
"acs_url", "acs_url",
"audience", "audience",
"processor_path",
"issuer", "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",
"signing_cert", "signing_cert",
"signing_key", "signing_key",
@ -39,7 +43,7 @@ class SAMLPropertyMappingSerializer(ModelSerializer):
class Meta: class Meta:
model = SAMLPropertyMapping model = SAMLPropertyMapping
fields = ["pk", "name", "saml_name", "friendly_name", "values"] fields = ["pk", "name", "saml_name", "friendly_name", "expression"]
class SAMLPropertyMappingViewSet(ModelViewSet): 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): class CannotHandleAssertion(Exception):
"""This processor does not handle this assertion.""" """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.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from passbook.lib.fields import DynamicArrayField
from passbook.providers.saml.models import ( from passbook.providers.saml.models import (
SAMLPropertyMapping, SAMLPropertyMapping,
SAMLProvider, SAMLProvider,
get_provider_choices, get_provider_choices,
) )
from passbook.providers.saml.utils import CertificateBuilder from passbook.providers.saml.utils.cert import CertificateBuilder
class SAMLProviderForm(forms.ModelForm): class SAMLProviderForm(forms.ModelForm):
@ -32,24 +31,27 @@ class SAMLProviderForm(forms.ModelForm):
model = SAMLProvider model = SAMLProvider
fields = [ fields = [
"name", "name",
"property_mappings", "processor_path",
"acs_url", "acs_url",
"audience", "audience",
"processor_path",
"issuer", "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",
"signing_cert", "signing_cert",
"signing_key", "signing_key",
] ]
labels = {
"acs_url": "ACS URL",
"signing_cert": "Singing Certificate",
}
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"audience": forms.TextInput(), "audience": forms.TextInput(),
"issuer": 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), "property_mappings": FilteredSelectMultiple(_("Property Mappings"), False),
} }
@ -57,16 +59,14 @@ class SAMLProviderForm(forms.ModelForm):
class SAMLPropertyMappingForm(forms.ModelForm): class SAMLPropertyMappingForm(forms.ModelForm):
"""SAML Property Mapping form""" """SAML Property Mapping form"""
template_name = "saml/idp/property_mapping_form.html"
class Meta: class Meta:
model = SAMLPropertyMapping model = SAMLPropertyMapping
fields = ["name", "saml_name", "friendly_name", "values"] fields = ["name", "saml_name", "friendly_name", "expression"]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"saml_name": forms.TextInput(), "saml_name": forms.TextInput(),
"friendly_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""" """passbook saml_idp Models"""
from django.contrib.postgres.fields import ArrayField
from django.db import models from django.db import models
from django.shortcuts import reverse 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 structlog import get_logger
from passbook.core.models import PropertyMapping, Provider from passbook.core.models import PropertyMapping, Provider
from passbook.lib.utils.reflection import class_to_path, path_to_class 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() LOGGER = get_logger()
@ -16,13 +16,62 @@ class SAMLProvider(Provider):
"""Model to save information about a Remote SAML Endpoint""" """Model to save information about a Remote SAML Endpoint"""
name = models.TextField() name = models.TextField()
acs_url = models.URLField()
audience = models.TextField(default="")
processor_path = models.CharField(max_length=255, choices=[]) processor_path = models.CharField(max_length=255, choices=[])
acs_url = models.URLField(verbose_name=_("ACS URL"))
audience = models.TextField(default="")
issuer = models.TextField() 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 = models.BooleanField(default=True)
signing_cert = models.TextField() signing_cert = models.TextField(verbose_name=_("Singing Certificate"))
signing_key = models.TextField() signing_key = models.TextField()
form = "passbook.providers.saml.forms.SAMLProviderForm" form = "passbook.providers.saml.forms.SAMLProviderForm"
@ -33,7 +82,7 @@ class SAMLProvider(Provider):
self._meta.get_field("processor_path").choices = get_provider_choices() self._meta.get_field("processor_path").choices = get_provider_choices()
@property @property
def processor(self): def processor(self) -> Processor:
"""Return selected processor as instance""" """Return selected processor as instance"""
if not self._processor: if not self._processor:
try: try:
@ -44,7 +93,7 @@ class SAMLProvider(Provider):
return self._processor return self._processor
def __str__(self): def __str__(self):
return "SAML Provider %s" % self.name return f"SAML Provider {self.name}"
def link_download_metadata(self): def link_download_metadata(self):
"""Get link to download XML metadata for admin interface""" """Get link to download XML metadata for admin interface"""
@ -57,6 +106,16 @@ class SAMLProvider(Provider):
except Provider.application.RelatedObjectDoesNotExist: except Provider.application.RelatedObjectDoesNotExist:
return None 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: class Meta:
verbose_name = _("SAML Provider") verbose_name = _("SAML Provider")
@ -66,14 +125,13 @@ class SAMLProvider(Provider):
class SAMLPropertyMapping(PropertyMapping): class SAMLPropertyMapping(PropertyMapping):
"""SAML Property mapping, allowing Name/FriendlyName mapping to a list of strings""" """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) friendly_name = models.TextField(default=None, blank=True, null=True)
values = ArrayField(models.TextField())
form = "passbook.providers.saml.forms.SAMLPropertyMappingForm" form = "passbook.providers.saml.forms.SAMLPropertyMappingForm"
def __str__(self): def __str__(self):
return "SAML Property Mapping %s" % self.saml_name return f"SAML Property Mapping {self.saml_name}"
class Meta: 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""" """Generic Processor"""
from passbook.providers.saml.base import Processor from passbook.providers.saml.processors.base import Processor
class GenericProcessor(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""" """Salesforce Processor"""
from passbook.providers.saml.base import Processor from passbook.providers.saml.processors.generic import GenericProcessor
from passbook.providers.saml.xml_render import get_assertion_xml 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.""" """SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor."""
def _determine_audience(self):
self._audience = "IAMShowcase"
def _format_assertion(self): def _format_assertion(self):
super()._format_assertion()
self._assertion_xml = get_assertion_xml( self._assertion_xml = get_assertion_xml(
"saml/xml/assertions/salesforce.xml", self._assertion_params, signed=True "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 %} {% load i18n %}
{% block content %}
{% trans "You have successfully logged out of the Identity Provider." %} {% block card %}
<p>
{% trans "You have successfully logged out of the Identity Provider." %}
</p>
{% endblock %} {% endblock %}

View File

@ -11,15 +11,15 @@
<header class="login-pf-header"> <header class="login-pf-header">
<h1>{% trans 'Authorize Application' %}</h1> <h1>{% trans 'Authorize Application' %}</h1>
</header> </header>
<form method="POST" action="{{ acs_url }}"> <form method="POST" action="{{ saml_params.acs_url }}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="ACSUrl" value="{{ acs_url }}"> <input type="hidden" name="ACSUrl" value="{{ saml_params.acs_url }}">
<input type="hidden" name="RelayState" value="{{ relay_state }}" /> <input type="hidden" name="RelayState" value="{{ saml_params.relay_state }}" />
<input type="hidden" name="SAMLResponse" value="{{ saml_response }}" /> <input type="hidden" name="SAMLResponse" value="{{ saml_params.saml_response }}" />
<div class="login-group"> <div class="login-group">
<h3> <h3>
{% blocktrans with remote=remote.application.name %} {% blocktrans with provider=provider.application.name %}
You're about to sign into {{ remote }} You're about to sign into {{ provider }}
{% endblocktrans %} {% endblocktrans %}
</h3> </h3>
<p> <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 from passbook.providers.saml import views
urlpatterns = [ urlpatterns = [
path( # This view is used to initiate a Login-flow from the IDP
"<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login"
),
path( path(
"<slug:application>/login/initiate/", "<slug:application>/login/initiate/",
views.InitiateLoginView.as_view(), views.InitiateLoginView.as_view(),
name="saml-login-initiate", 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( path(
"<slug:application>/login/process/", "<slug:application>/login/process/",
views.LoginProcessView.as_view(), 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""" """Create self-signed certificates"""
import base64
import datetime import datetime
import uuid import uuid
import zlib
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
@ -11,24 +9,6 @@ from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID 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: class CertificateBuilder:
"""Build self-signed certificates""" """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 structlog import get_logger
from passbook.lib.utils.template import render_to_string 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: if TYPE_CHECKING:
from passbook.providers.saml.models import SAMLProvider from passbook.providers.saml.models import SAMLProvider
@ -60,7 +63,6 @@ def get_assertion_xml(template, parameters, signed=False):
_get_attribute_statement(params) _get_attribute_statement(params)
unsigned = render_to_string(template, params) unsigned = render_to_string(template, params)
# LOGGER.debug('Unsigned: %s', unsigned)
if not signed: if not signed:
return unsigned 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) raw_response = render_to_string("saml/xml/response.xml", params)
# LOGGER.debug('Unsigned: %s', unsigned)
if not saml_provider.signing: if not saml_provider.signing:
return raw_response return raw_response
signature_xml = get_signature_xml() signature_xml = get_signature_xml()
params["RESPONSE_SIGNATURE"] = signature_xml params["RESPONSE_SIGNATURE"] = signature_xml
# LOGGER.debug("Raw response: %s", raw_response)
signed = sign_with_signxml( signed = sign_with_signxml(raw_response, saml_provider, reference_uri=assertion_id,)
saml_provider.signing_key,
raw_response,
saml_provider.signing_cert,
reference_uri=assertion_id,
)
return signed return signed

View File

@ -1,4 +1,6 @@
"""Signing code goes here.""" """Signing code goes here."""
from typing import TYPE_CHECKING
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from lxml import etree # nosec from lxml import etree # nosec
@ -7,25 +9,34 @@ from structlog import get_logger
from passbook.lib.utils.template import render_to_string from passbook.lib.utils.template import render_to_string
if TYPE_CHECKING:
from passbook.providers.saml.models import SAMLProvider
LOGGER = get_logger() 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""" """Sign Data with signxml"""
key = serialization.load_pem_private_key( 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, password=None,
backend=default_backend(), backend=default_backend(),
) )
# defused XML is not used here because it messes up XML namespaces # defused XML is not used here because it messes up XML namespaces
# Data is trusted, so lxml is ok # Data is trusted, so lxml is ok
root = etree.fromstring(data) # nosec root = etree.fromstring(data) # nosec
signer = XMLSigner(c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#") signer = XMLSigner(
signed = signer.sign(root, key=key, cert=[cert], reference_uri=reference_uri) c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#",
XMLVerifier().verify(signed, x509_cert=cert) 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 return etree.tostring(signed).decode("utf-8") # nosec
def get_signature_xml(): def get_signature_xml() -> str:
"""Returns XML Signature for subject.""" """Returns XML Signature for subject."""
return render_to_string("saml/xml/signature.xml", {}) return render_to_string("saml/xml/signature.xml", {})

View File

@ -1,12 +1,15 @@
"""passbook SAML IDP Views""" """passbook SAML IDP Views"""
from typing import Optional
from django.contrib.auth import logout from django.contrib.auth import logout
from django.contrib.auth.mixins import AccessMixin from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import URLValidator 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.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.datastructures import MultiValueDictKeyError from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.html import mark_safe
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views import View from django.views import View
from django.views.decorators.csrf import csrf_exempt 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.core.models import Application
from passbook.lib.mixins import CSRFExemptMixin from passbook.lib.mixins import CSRFExemptMixin
from passbook.lib.utils.template import render_to_string from passbook.lib.utils.template import render_to_string
from passbook.lib.views import bad_request_message
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
from passbook.providers.saml import exceptions from passbook.providers.saml import exceptions
from passbook.providers.saml.models import SAMLProvider from passbook.providers.saml.models import SAMLProvider
from passbook.providers.saml.processors.types import SAMLResponseParams
LOGGER = get_logger() LOGGER = get_logger()
URL_VALIDATOR = URLValidator(schemes=("http", "https")) URL_VALIDATOR = URLValidator(schemes=("http", "https"))
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): class AccessRequiredView(AccessMixin, View):
"""Mixin class for Views using a provider instance""" """Mixin class for Views using a provider instance"""
_provider = None _provider: Optional[SAMLProvider] = None
@property @property
def provider(self): def provider(self) -> SAMLProvider:
"""Get provider instance""" """Get provider instance"""
if not self._provider: if not self._provider:
application = get_object_or_404( application = get_object_or_404(
@ -59,15 +45,18 @@ class AccessRequiredView(AccessMixin, View):
self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id) self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id)
return self._provider return self._provider
def _has_access(self): def _has_access(self) -> bool:
"""Check if user has access to application""" """Check if user has access to application"""
LOGGER.debug(
"_has_access", user=self.request.user, app=self.provider.application
)
policy_engine = PolicyEngine( policy_engine = PolicyEngine(
self.provider.application.policies.all(), self.request.user, self.request self.provider.application.policies.all(), self.request.user, self.request
) )
policy_engine.build() policy_engine.build()
return policy_engine.passing return policy_engine.passing
def dispatch(self, request, *args, **kwargs): def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
if not request.user.is_authenticated: if not request.user.is_authenticated:
return self.handle_no_permission() return self.handle_no_permission()
if not self._has_access(): if not self._has_access():
@ -87,17 +76,17 @@ class LoginBeginView(AccessRequiredView):
stores it in the session prior to enforcing login.""" stores it in the session prior to enforcing login."""
@method_decorator(csrf_exempt) @method_decorator(csrf_exempt)
def dispatch(self, request, application): def dispatch(self, request: HttpRequest, application: str) -> HttpResponse:
if request.method == "POST": if request.method == "POST":
source = request.POST source = request.POST
else: else:
source = request.GET source = request.GET
# Store these values now, because Django's login cycle won't preserve them.
# Store these values now, because Django's login cycle won't preserve them.
try: try:
request.session["SAMLRequest"] = source["SAMLRequest"] request.session["SAMLRequest"] = source["SAMLRequest"]
except (KeyError, MultiValueDictKeyError): 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", "") request.session["RelayState"] = source.get("RelayState", "")
return redirect( 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): class LoginProcessView(AccessRequiredView):
"""Processor-based login continuation. """Processor-based login continuation.
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider.""" Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
# pylint: disable=unused-argument def handle_redirect(
def get(self, request, application): self, params: SAMLResponseParams, skipped_authorization: bool
) -> HttpResponse:
"""Handle direct redirect to SP"""
# Log Application Authorization
Event.new(
EventAction.AUTHORIZE_APPLICATION,
authorized_application=self.provider.application,
skipped_authorization=skipped_authorization,
).from_http(self.request)
return render(
self.request,
"saml/idp/autosubmit_form.html",
{
"url": params.acs_url,
"attrs": {
"SAMLResponse": params.saml_response,
"RelayState": params.relay_state,
},
},
)
def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle get request, i.e. render form""" """Handle get request, i.e. render form"""
LOGGER.debug("SAMLLoginProcessView", request=request, method="get") # User access gets checked in dispatch
# Check if user has access
if self.provider.application.skip_authorization: # Otherwise we generate the IdP initiated session
ctx = self.provider.processor.generate_response()
# 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"],
)
try: try:
full_res = _generate_response(request, self.provider) # application.skip_authorization is set so we directly redirect the user
return full_res 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: 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 # pylint: disable=unused-argument
def post(self, request, application): def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Handle post request, return back to ACS""" """Handle post request, return back to ACS"""
LOGGER.debug("SAMLLoginProcessView", request=request, method="post") # User access gets checked in dispatch
# Check if user has access
if request.POST.get("ACSUrl", None): # we get here when skip_authorization is False, and after the user accepted
# User accepted request # the authorization form
Event.new( self.provider.processor.can_handle(request)
EventAction.AUTHORIZE_APPLICATION, saml_params = self.provider.processor.generate_response()
authorized_application=self.provider.application, return self.handle_redirect(saml_params, True)
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)
class LogoutView(CSRFExemptMixin, AccessRequiredView): class LogoutView(CSRFExemptMixin, AccessRequiredView):
@ -183,7 +182,7 @@ class LogoutView(CSRFExemptMixin, AccessRequiredView):
though it's technically not SAML 2.0).""" though it's technically not SAML 2.0)."""
# pylint: disable=unused-argument # pylint: disable=unused-argument
def get(self, request, application): def get(self, request: HttpRequest, application: str) -> HttpResponse:
"""Perform logout""" """Perform logout"""
logout(request) logout(request)
@ -204,7 +203,7 @@ class SLOLogout(CSRFExemptMixin, AccessRequiredView):
logs out the user and returns a standard logged-out page.""" logs out the user and returns a standard logged-out page."""
# pylint: disable=unused-argument # pylint: disable=unused-argument
def post(self, request, application): def post(self, request: HttpRequest, application: str) -> HttpResponse:
"""Perform logout""" """Perform logout"""
request.session["SAMLRequest"] = request.POST["SAMLRequest"] request.session["SAMLRequest"] = request.POST["SAMLRequest"]
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process(). # TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
@ -219,22 +218,23 @@ class SLOLogout(CSRFExemptMixin, AccessRequiredView):
class DescriptorDownloadView(AccessRequiredView): class DescriptorDownloadView(AccessRequiredView):
"""Replies with the XML Metadata IDSSODescriptor.""" """Replies with the XML Metadata IDSSODescriptor."""
def get(self, request, application): @staticmethod
"""Replies with the XML Metadata IDSSODescriptor.""" def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
entity_id = self.provider.issuer """Return rendered XML Metadata"""
entity_id = provider.issuer
slo_url = request.build_absolute_uri( slo_url = request.build_absolute_uri(
reverse( reverse(
"passbook_providers_saml:saml-logout", "passbook_providers_saml:saml-logout",
kwargs={"application": application}, kwargs={"application": provider.application},
) )
) )
sso_url = request.build_absolute_uri( sso_url = request.build_absolute_uri(
reverse( reverse(
"passbook_providers_saml:saml-login", "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", "" "\n", ""
) )
ctx = { ctx = {
@ -243,7 +243,12 @@ class DescriptorDownloadView(AccessRequiredView):
"slo_url": slo_url, "slo_url": slo_url,
"sso_url": sso_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 = HttpResponse(metadata, content_type="application/xml")
response["Content-Disposition"] = ( response["Content-Disposition"] = (
'attachment; filename="' '%s_passbook_meta.xml"' % self.provider.name 'attachment; filename="' '%s_passbook_meta.xml"' % self.provider.name
@ -254,9 +259,46 @@ class DescriptorDownloadView(AccessRequiredView):
class InitiateLoginView(AccessRequiredView): class InitiateLoginView(AccessRequiredView):
"""IdP-initiated Login""" """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 # 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.""" """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 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 base64 import b64encode
from django.conf import settings 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.views import View
from django_prometheus.exports import ExportToDjangoView from django_prometheus.exports import ExportToDjangoView
@ -13,11 +13,13 @@ class MetricsView(View):
def get(self, request: HttpRequest) -> HttpResponse: def get(self, request: HttpRequest) -> HttpResponse:
"""Check for HTTP-Basic auth""" """Check for HTTP-Basic auth"""
auth_header = request.META.get("HTTP_AUTHORIZATION", "") 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}" credentials = f"monitor:{settings.SECRET_KEY}"
expected = b64encode(str.encode(credentials)).decode() expected = b64encode(str.encode(credentials)).decode()
if auth_type != "Basic" or credentials != expected: if auth_type != "Basic" or given_credentials != expected:
raise Http404 response = HttpResponse(status=401)
response["WWW-Authenticate"] = 'Basic realm="passbook-monitoring"'
return response
return ExportToDjangoView(request) return ExportToDjangoView(request)

View File

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

View File

@ -35,7 +35,7 @@ for _passbook_app in get_apps():
urlpatterns += [ urlpatterns += [
# Administration # Administration
path("administration/django/", admin.site.urls), path("administration/django/", admin.site.urls),
path("metrics", MetricsView.as_view(), name="metrics"), path("metrics/", MetricsView.as_view(), name="metrics"),
] ]
if settings.DEBUG: if settings.DEBUG:

View File

@ -62,7 +62,8 @@ class WSGILogger:
if environ.get("QUERY_STRING") != "": if environ.get("QUERY_STRING") != "":
query_string = f"?{environ.get('QUERY_STRING')}" query_string = f"?{environ.get('QUERY_STRING')}"
self.logger.info( self.logger.info(
f"{environ.get('PATH_INFO', '')}{query_string}", "request",
path=f"{environ.get('PATH_INFO', '')}{query_string}",
host=host, host=host,
method=environ.get("REQUEST_METHOD", ""), method=environ.get("REQUEST_METHOD", ""),
protocol=environ.get("SERVER_PROTOCOL", ""), protocol=environ.get("SERVER_PROTOCOL", ""),

View File

@ -35,7 +35,7 @@ class LDAPPropertyMappingSerializer(ModelSerializer):
class Meta: class Meta:
model = LDAPPropertyMapping model = LDAPPropertyMapping
fields = ["pk", "name", "ldap_property", "object_field"] fields = ["pk", "name", "expression", "object_field"]
class LDAPSourceViewSet(ModelViewSet): class LDAPSourceViewSet(ModelViewSet):

View File

@ -5,8 +5,9 @@ import ldap3
import ldap3.core.exceptions import ldap3.core.exceptions
from structlog import get_logger from structlog import get_logger
from passbook.core.exceptions import PropertyMappingExpressionException
from passbook.core.models import Group, User 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() LOGGER = get_logger()
@ -154,7 +155,14 @@ class Connector:
) -> Dict[str, Dict[Any, Any]]: ) -> Dict[str, Dict[Any, Any]]:
properties = {"attributes": {}} properties = {"attributes": {}}
for mapping in self._source.property_mappings.all().select_subclasses(): 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: if self._source.object_uniqueness_field in attributes:
properties["attributes"]["ldap_uniq"] = attributes.get( properties["attributes"]["ldap_uniq"] = attributes.get(
self._source.object_uniqueness_field self._source.object_uniqueness_field

View File

@ -45,23 +45,17 @@ class LDAPSourceForm(forms.ModelForm):
"policies": FilteredSelectMultiple(_("policies"), False), "policies": FilteredSelectMultiple(_("policies"), False),
"property_mappings": FilteredSelectMultiple(_("Property Mappings"), 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): class LDAPPropertyMappingForm(forms.ModelForm):
"""LDAP Property Mapping form""" """LDAP Property Mapping form"""
template_name = "ldap/property_mapping_form.html"
class Meta: class Meta:
model = LDAPPropertyMapping model = LDAPPropertyMapping
fields = ["name", "ldap_property", "object_field"] fields = ["name", "object_field", "expression"]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"ldap_property": forms.TextInput(), "ldap_property": forms.TextInput(),

View File

@ -13,8 +13,9 @@ def create_default_ad_property_mappings(apps: Apps, schema_editor):
"sAMAccountName": "username", "sAMAccountName": "username",
"mail": "email", "mail": "email",
} }
db_alias = schema_editor.connection.alias
for ldap_property, object_field in mapping.items(): 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, ldap_property=ldap_property,
object_field=object_field, object_field=object_field,
defaults={ 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.core.validators import URLValidator
from django.db import models 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 from passbook.core.models import Group, PropertyMapping, Source
@ -10,17 +10,22 @@ from passbook.core.models import Group, PropertyMapping, Source
class LDAPSource(Source): class LDAPSource(Source):
"""LDAP Authentication source""" """LDAP Authentication source"""
server_uri = models.TextField(validators=[URLValidator(schemes=["ldap", "ldaps"])]) server_uri = models.TextField(
bind_cn = models.TextField() validators=[URLValidator(schemes=["ldap", "ldaps"])],
verbose_name=_("Server URI"),
)
bind_cn = models.TextField(verbose_name=_("Bind CN"))
bind_password = models.TextField() 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( 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( 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( user_object_filter = models.TextField(
@ -54,13 +59,12 @@ class LDAPSource(Source):
class LDAPPropertyMapping(PropertyMapping): class LDAPPropertyMapping(PropertyMapping):
"""Map LDAP Property to User or Group object""" """Map LDAP Property to User or Group object"""
ldap_property = models.TextField()
object_field = models.TextField() object_field = models.TextField()
form = "passbook.sources.ldap.forms.LDAPPropertyMappingForm" form = "passbook.sources.ldap.forms.LDAPPropertyMappingForm"
def __str__(self): 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: 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()), "provider_type": forms.Select(choices=MANAGER.get_name_tuple()),
"policies": FilteredSelectMultiple(_("policies"), False), "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): 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.db import models
from django.urls import reverse, reverse_lazy 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.core.models import Source, UserSettings, UserSourceConnection
from passbook.sources.oauth.clients import get_client from passbook.sources.oauth.clients import get_client
@ -12,10 +12,16 @@ class OAuthSource(Source):
"""Configuration for OAuth provider.""" """Configuration for OAuth provider."""
provider_type = models.CharField(max_length=255) provider_type = models.CharField(max_length=255)
request_token_url = models.CharField(blank=True, max_length=255) request_token_url = models.CharField(
authorization_url = models.CharField(max_length=255) blank=True, max_length=255, verbose_name=_("Request Token URL")
access_token_url = models.CharField(max_length=255) )
profile_url = models.CharField(max_length=255) 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_key = models.TextField()
consumer_secret = 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 django.utils.translation import gettext as _
from passbook.admin.forms.source import SOURCE_FORM_FIELDS 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 from passbook.sources.saml.models import SAMLSource
@ -28,11 +28,6 @@ class SAMLSourceForm(forms.ModelForm):
"auto_logout", "auto_logout",
"signing_cert", "signing_cert",
] ]
labels = {
"entity_id": "Entity ID",
"idp_url": "IDP URL",
"idp_logout_url": "IDP Logout URL",
}
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
"policies": FilteredSelectMultiple(_("policies"), False), "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""" """saml sp models"""
from django.db import models from django.db import models
from django.urls import reverse_lazy 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 from passbook.core.models import Source
@ -9,9 +9,11 @@ from passbook.core.models import Source
class SAMLSource(Source): class SAMLSource(Source):
"""SAML2 Source""" """SAML2 Source"""
entity_id = models.TextField(blank=True, default=None) entity_id = models.TextField(blank=True, default=None, verbose_name=_("Entity ID"))
idp_url = models.URLField() idp_url = models.URLField(verbose_name=_("IDP URL"))
idp_logout_url = models.URLField(default=None, blank=True, null=True) idp_logout_url = models.URLField(
default=None, blank=True, null=True, verbose_name=_("IDP Logout URL")
)
auto_logout = models.BooleanField(default=False) auto_logout = models.BooleanField(default=False)
signing_cert = models.TextField() 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 import View
from django.views.decorators.csrf import csrf_exempt 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 get_random_id, render_xml
from passbook.providers.saml.utils import nice64 from passbook.providers.saml.utils.encoding import nice64
from passbook.providers.saml.views import render_xml from passbook.providers.saml.utils.time import get_time_string
from passbook.sources.saml.models import SAMLSource from passbook.sources.saml.models import SAMLSource
from passbook.sources.saml.utils import ( from passbook.sources.saml.utils import (
_get_user_from_response, _get_user_from_response,

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