Compare commits
	
		
			69 Commits
		
	
	
		
			version/0.
			...
			version/0.
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 38dfb03668 | |||
| e2631cec0e | |||
| 5dad853f8a | |||
| 9f00843441 | |||
| f31cd7dec6 | |||
| 1c1afca31f | |||
| fbd4bdef33 | |||
| 5b22f9b6c3 | |||
| 083e317028 | |||
| 95416623b3 | |||
| 813b2676de | |||
| aeca66a288 | |||
| 04a5428148 | |||
| 73b173b92a | |||
| 7cbf20a71c | |||
| 7a98e6d92b | |||
| 49e915f98b | |||
| 3aa2f1e892 | |||
| bc4b7ef44d | |||
| 9400b01a55 | |||
| e57da71dcf | |||
| 7268afaaf9 | |||
| 205183445c | |||
| a08bdfdbcd | |||
| e6c47fee26 | |||
| a5629c5155 | |||
| 41689fe3ce | |||
| 8e84208e2c | |||
| 32a48fa07a | |||
| 773a9c0692 | |||
| 8808e3afe0 | |||
| ecea85f8ca | |||
| 5dfa141e35 | |||
| 447e81d0b8 | |||
| e138076e1d | |||
| 721d133dc3 | |||
| 75b687ecbe | |||
| bdd1863177 | |||
| e5b85e8e6a | |||
| d7481c9de7 | |||
| 571373866e | |||
| e36d7928e4 | |||
| 2be026dd44 | |||
| d5b9de3569 | |||
| e22620b0ec | |||
| ba74a3213d | |||
| d9ecb7070d | |||
| fc4a46bd9c | |||
| 78301b7bab | |||
| 7bf7bde856 | |||
| 9bdff14403 | |||
| f124314eab | |||
| 684e4ffdcf | |||
| d9ff5c69c8 | |||
| 8142e3df45 | |||
| 73920899de | |||
| 13666965a7 | |||
| 86f16e2781 | |||
| 2ed8e72c62 | |||
| edeed18ae8 | |||
| d24133d8a2 | |||
| b9733e56aa | |||
| cd34413914 | |||
| c3a4a76d43 | |||
| a59a29b256 | |||
| dce1edbe53 | |||
| 264d43827a | |||
| 6207226bdf | |||
| ebf33f39c9 | 
@ -1,5 +1,5 @@
 | 
			
		||||
[bumpversion]
 | 
			
		||||
current_version = 0.7.8-beta
 | 
			
		||||
current_version = 0.7.17-beta
 | 
			
		||||
tag = True
 | 
			
		||||
commit = True
 | 
			
		||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										14
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							@ -14,7 +14,7 @@ jobs:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - uses: actions/setup-python@v1
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: '3.7'
 | 
			
		||||
          python-version: '3.8'
 | 
			
		||||
      - uses: actions/cache@v1
 | 
			
		||||
        with:
 | 
			
		||||
          path: ~/.local/share/virtualenvs/
 | 
			
		||||
@ -31,7 +31,7 @@ jobs:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - uses: actions/setup-python@v1
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: '3.7'
 | 
			
		||||
          python-version: '3.8'
 | 
			
		||||
      - uses: actions/cache@v1
 | 
			
		||||
        with:
 | 
			
		||||
          path: ~/.local/share/virtualenvs/
 | 
			
		||||
@ -48,7 +48,7 @@ jobs:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - uses: actions/setup-python@v1
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: '3.7'
 | 
			
		||||
          python-version: '3.8'
 | 
			
		||||
      - uses: actions/cache@v1
 | 
			
		||||
        with:
 | 
			
		||||
          path: ~/.local/share/virtualenvs/
 | 
			
		||||
@ -56,7 +56,7 @@ jobs:
 | 
			
		||||
          restore-keys: |
 | 
			
		||||
            ${{ runner.os }}-pipenv-
 | 
			
		||||
      - name: Install dependencies
 | 
			
		||||
        run: pip install -U pip pipenv && pipenv install --dev
 | 
			
		||||
        run: pip install -U pip pipenv && pipenv install --dev && pipenv install --dev prospector --skip-lock
 | 
			
		||||
      - name: Lint with prospector
 | 
			
		||||
        run: pipenv run prospector
 | 
			
		||||
  bandit:
 | 
			
		||||
@ -65,7 +65,7 @@ jobs:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - uses: actions/setup-python@v1
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: '3.7'
 | 
			
		||||
          python-version: '3.8'
 | 
			
		||||
      - uses: actions/cache@v1
 | 
			
		||||
        with:
 | 
			
		||||
          path: ~/.local/share/virtualenvs/
 | 
			
		||||
@ -100,7 +100,7 @@ jobs:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - uses: actions/setup-python@v1
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: '3.7'
 | 
			
		||||
          python-version: '3.8'
 | 
			
		||||
      - uses: actions/cache@v1
 | 
			
		||||
        with:
 | 
			
		||||
          path: ~/.local/share/virtualenvs/
 | 
			
		||||
@ -134,7 +134,7 @@ jobs:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - uses: actions/setup-python@v1
 | 
			
		||||
        with:
 | 
			
		||||
          python-version: '3.7'
 | 
			
		||||
          python-version: '3.8'
 | 
			
		||||
      - uses: actions/cache@v1
 | 
			
		||||
        with:
 | 
			
		||||
          path: ~/.local/share/virtualenvs/
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										33
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										33
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							@ -1,8 +1,6 @@
 | 
			
		||||
name: passbook-release
 | 
			
		||||
on:
 | 
			
		||||
  release:
 | 
			
		||||
    types:
 | 
			
		||||
      - created
 | 
			
		||||
  release
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  # Build
 | 
			
		||||
@ -18,13 +16,34 @@ jobs:
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
        run: docker build
 | 
			
		||||
          --no-cache
 | 
			
		||||
          -t beryju/passbook:0.7.8-beta
 | 
			
		||||
          -t beryju/passbook:0.7.17-beta
 | 
			
		||||
          -t beryju/passbook:latest
 | 
			
		||||
          -f Dockerfile .
 | 
			
		||||
      - name: Push Docker Container to Registry (versioned)
 | 
			
		||||
        run: docker push beryju/passbook:0.7.8-beta
 | 
			
		||||
        run: docker push beryju/passbook:0.7.17-beta
 | 
			
		||||
      - name: Push Docker Container to Registry (latest)
 | 
			
		||||
        run: docker push beryju/passbook:latest
 | 
			
		||||
  build-gatekeeper:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - name: Docker Login Registry
 | 
			
		||||
        env:
 | 
			
		||||
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
        run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
        run: |
 | 
			
		||||
          cd gatekeeper
 | 
			
		||||
          docker build \
 | 
			
		||||
          --no-cache \
 | 
			
		||||
          -t beryju/passbook-gatekeeper:0.7.17-beta \
 | 
			
		||||
          -t beryju/passbook-gatekeeper:latest \
 | 
			
		||||
          -f Dockerfile .
 | 
			
		||||
      - name: Push Docker Container to Registry (versioned)
 | 
			
		||||
        run: docker push beryju/passbook-gatekeeper:0.7.17-beta
 | 
			
		||||
      - name: Push Docker Container to Registry (latest)
 | 
			
		||||
        run: docker push beryju/passbook-gatekeeper:latest
 | 
			
		||||
  build-static:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    services:
 | 
			
		||||
@ -47,11 +66,11 @@ jobs:
 | 
			
		||||
        run: docker build
 | 
			
		||||
          --no-cache
 | 
			
		||||
          --network=$(docker network ls | grep github | awk '{print $1}')
 | 
			
		||||
          -t beryju/passbook-static:0.7.8-beta
 | 
			
		||||
          -t beryju/passbook-static:0.7.17-beta
 | 
			
		||||
          -t beryju/passbook-static:latest
 | 
			
		||||
          -f static.Dockerfile .
 | 
			
		||||
      - name: Push Docker Container to Registry (versioned)
 | 
			
		||||
        run: docker push beryju/passbook-static:0.7.8-beta
 | 
			
		||||
        run: docker push beryju/passbook-static:0.7.17-beta
 | 
			
		||||
      - name: Push Docker Container to Registry (latest)
 | 
			
		||||
        run: docker push beryju/passbook-static:latest
 | 
			
		||||
  test-release:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							@ -3,7 +3,7 @@ on:
 | 
			
		||||
    tags:
 | 
			
		||||
    - 'version/*'
 | 
			
		||||
 | 
			
		||||
name: Create Release from Tag
 | 
			
		||||
name: passbook-version-tag
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  build:
 | 
			
		||||
@ -15,9 +15,9 @@ jobs:
 | 
			
		||||
        run: |
 | 
			
		||||
          export PASSBOOK_DOMAIN=localhost
 | 
			
		||||
          docker-compose pull
 | 
			
		||||
          docker build
 | 
			
		||||
            --no-cache
 | 
			
		||||
            -t beryju/passbook:latest
 | 
			
		||||
          docker build \
 | 
			
		||||
            --no-cache \
 | 
			
		||||
            -t beryju/passbook:latest \
 | 
			
		||||
            -f Dockerfile .
 | 
			
		||||
          docker-compose up --no-start
 | 
			
		||||
          docker-compose start postgresql redis
 | 
			
		||||
@ -31,6 +31,13 @@ jobs:
 | 
			
		||||
          helm dependency update helm/
 | 
			
		||||
          helm package helm/
 | 
			
		||||
          mv passbook-*.tgz passbook-chart.tgz
 | 
			
		||||
      - name: Extract verison number
 | 
			
		||||
        id: get_version
 | 
			
		||||
        uses: actions/github-script@0.2.0
 | 
			
		||||
        with:
 | 
			
		||||
          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          script: |
 | 
			
		||||
            return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
 | 
			
		||||
      - name: Create Release
 | 
			
		||||
        id: create_release
 | 
			
		||||
        uses: actions/create-release@v1.0.0
 | 
			
		||||
@ -38,10 +45,10 @@ jobs:
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
        with:
 | 
			
		||||
          tag_name: ${{ github.ref }}
 | 
			
		||||
          release_name: Release ${{ github.ref }}
 | 
			
		||||
          release_name: Release ${{ steps.get_version.outputs.result }}
 | 
			
		||||
          draft: false
 | 
			
		||||
          prerelease: false
 | 
			
		||||
      - name: Create Release from Tag
 | 
			
		||||
      - name: Upload packaged Helm Chart
 | 
			
		||||
        id: upload-release-asset
 | 
			
		||||
        uses: actions/upload-release-asset@v1.0.1
 | 
			
		||||
        env:
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
FROM python:3.7-slim-buster as locker
 | 
			
		||||
FROM python:3.8-slim-buster as locker
 | 
			
		||||
 | 
			
		||||
COPY ./Pipfile /app/
 | 
			
		||||
COPY ./Pipfile.lock /app/
 | 
			
		||||
@ -9,7 +9,7 @@ RUN pip install pipenv && \
 | 
			
		||||
    pipenv lock -r > requirements.txt && \
 | 
			
		||||
    pipenv lock -rd > requirements-dev.txt
 | 
			
		||||
 | 
			
		||||
FROM python:3.7-slim-buster
 | 
			
		||||
FROM python:3.8-slim-buster
 | 
			
		||||
 | 
			
		||||
COPY --from=locker /app/requirements.txt /app/
 | 
			
		||||
COPY --from=locker /app/requirements-dev.txt /app/
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								Pipfile
									
									
									
									
									
								
							@ -40,9 +40,10 @@ signxml = "*"
 | 
			
		||||
structlog = "*"
 | 
			
		||||
swagger-spec-validator = "*"
 | 
			
		||||
urllib3 = {extras = ["secure"],version = "*"}
 | 
			
		||||
jinja2 = "*"
 | 
			
		||||
 | 
			
		||||
[requires]
 | 
			
		||||
python_version = "3.7"
 | 
			
		||||
python_version = "3.8"
 | 
			
		||||
 | 
			
		||||
[dev-packages]
 | 
			
		||||
autopep8 = "*"
 | 
			
		||||
@ -51,7 +52,6 @@ bumpversion = "*"
 | 
			
		||||
colorama = "*"
 | 
			
		||||
coverage = "*"
 | 
			
		||||
django-debug-toolbar = "*"
 | 
			
		||||
prospector = "*"
 | 
			
		||||
pylint = "*"
 | 
			
		||||
pylint-django = "*"
 | 
			
		||||
unittest-xml-reporting = "*"
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										742
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										742
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -1,5 +1,7 @@
 | 
			
		||||
# passbook
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
## Quick instance
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
FROM python:3.7-slim-buster as builder
 | 
			
		||||
FROM python:3.8-slim-buster as builder
 | 
			
		||||
 | 
			
		||||
WORKDIR /mkdocs
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,9 +4,8 @@
 | 
			
		||||
 | 
			
		||||
From https://about.gitlab.com/what-is-gitlab/
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
!!! note ""
 | 
			
		||||
    GitLab is a complete DevOps platform, delivered as a single application. This makes GitLab unique and makes Concurrent DevOps possible, unlocking your organization from the constraints of a pieced together toolchain. Join us for a live Q&A to learn how GitLab can give you unmatched visibility and higher levels of efficiency in a single application across the DevOps lifecycle.
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Preparation
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,9 +4,8 @@
 | 
			
		||||
 | 
			
		||||
From https://goharbor.io
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
!!! note ""
 | 
			
		||||
    Harbor is an open source container image registry that secures images with role-based access control, scans images for vulnerabilities, and signs images as trusted. A CNCF Incubating project, Harbor delivers compliance, performance, and interoperability to help you consistently and securely manage images across cloud native compute platforms like Kubernetes and Docker.
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Preparation
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,10 +4,9 @@
 | 
			
		||||
 | 
			
		||||
From https://rancher.com/products/rancher
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
!!! note ""
 | 
			
		||||
    An Enterprise Platform for Managing Kubernetes Everywhere
 | 
			
		||||
    Rancher is a platform built to address the needs of the DevOps teams deploying applications with Kubernetes, and the IT staff responsible for delivering an enterprise-critical service.
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Preparation
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,13 +4,12 @@
 | 
			
		||||
 | 
			
		||||
From https://sentry.io
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
!!! note ""
 | 
			
		||||
    Sentry provides self-hosted and cloud-based error monitoring that helps all software
 | 
			
		||||
    teams discover, triage, and prioritize errors in real-time.
 | 
			
		||||
 | 
			
		||||
    One million developers at over fifty thousand companies already ship
 | 
			
		||||
    better software faster with Sentry. Won’t you join them?
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Preparation
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										20
									
								
								docs/reference/property-mappings/user-object.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								docs/reference/property-mappings/user-object.md
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
# Passbook User Object
 | 
			
		||||
 | 
			
		||||
The User object has the following attributes:
 | 
			
		||||
 | 
			
		||||
 - `username`: User's Username
 | 
			
		||||
 - `email` User's E-Mail
 | 
			
		||||
 - `name` User's Display Name
 | 
			
		||||
 - `is_staff` Boolean field if user is staff
 | 
			
		||||
 - `is_active` Boolean field if user is active
 | 
			
		||||
 - `date_joined` Date User joined/was created
 | 
			
		||||
 - `password_change_date` Date Password was last changed
 | 
			
		||||
 - `attributes` Dynamic Attributes
 | 
			
		||||
 | 
			
		||||
## Examples
 | 
			
		||||
 | 
			
		||||
List all the User's Group Names
 | 
			
		||||
 | 
			
		||||
```jinja2
 | 
			
		||||
[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]
 | 
			
		||||
```
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
apiVersion: v1
 | 
			
		||||
appVersion: "0.7.8-beta"
 | 
			
		||||
appVersion: "0.7.17-beta"
 | 
			
		||||
description: A Helm chart for passbook.
 | 
			
		||||
name: passbook
 | 
			
		||||
version: "0.7.8-beta"
 | 
			
		||||
version: "0.7.17-beta"
 | 
			
		||||
icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ spec:
 | 
			
		||||
        name: {{ include "passbook.fullname" . }}-secret-key
 | 
			
		||||
        key: monitoring_username
 | 
			
		||||
    port: http
 | 
			
		||||
    path: /metrics/
 | 
			
		||||
    interval: 10s
 | 
			
		||||
  selector:
 | 
			
		||||
    matchLabels:
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
# This is a YAML-formatted file.
 | 
			
		||||
# Declare variables to be passed into your templates.
 | 
			
		||||
image:
 | 
			
		||||
  tag: 0.7.8-beta
 | 
			
		||||
  tag: 0.7.17-beta
 | 
			
		||||
 | 
			
		||||
nameOverride: ""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
site_name: passbook Docs
 | 
			
		||||
site_url: https://docs.passbook.beryju.org
 | 
			
		||||
site_url: https://beryju.github.io/passbook
 | 
			
		||||
copyright: "Copyright © 2019 - 2020 BeryJu.org"
 | 
			
		||||
 | 
			
		||||
nav:
 | 
			
		||||
@ -19,6 +19,9 @@ nav:
 | 
			
		||||
          - Rancher: integrations/services/rancher/index.md
 | 
			
		||||
          - Harbor: integrations/services/harbor/index.md
 | 
			
		||||
          - Sentry: integrations/services/sentry/index.md
 | 
			
		||||
  - Reference:
 | 
			
		||||
      - Property Mappings:
 | 
			
		||||
          - User Object: reference/property-mappings/user-object.md
 | 
			
		||||
 | 
			
		||||
repo_name: "BeryJu.org/passbook"
 | 
			
		||||
repo_url: https://github.com/BeryJu/passbook
 | 
			
		||||
@ -29,3 +32,4 @@ theme:
 | 
			
		||||
markdown_extensions:
 | 
			
		||||
  - toc:
 | 
			
		||||
      permalink: "¶"
 | 
			
		||||
  - admonition
 | 
			
		||||
 | 
			
		||||
@ -1,2 +1,2 @@
 | 
			
		||||
"""passbook"""
 | 
			
		||||
__version__ = "0.7.8-beta"
 | 
			
		||||
__version__ = "0.7.17-beta"
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
{% extends "generic/form.html" %}
 | 
			
		||||
{% extends base_template|default:"generic/form.html" %}
 | 
			
		||||
 | 
			
		||||
{% load utils %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
@ -20,6 +20,7 @@
 | 
			
		||||
<link rel="stylesheet" href="{% static 'codemirror/lib/codemirror.css' %}">
 | 
			
		||||
<link rel="stylesheet" href="{% static 'codemirror/theme/monokai.css' %}">
 | 
			
		||||
<script src="{% static 'codemirror/mode/yaml/yaml.js' %}"></script>
 | 
			
		||||
<script src="{% static 'codemirror/mode/jinja2/jinja2.js' %}"></script>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
@ -29,21 +30,33 @@
 | 
			
		||||
  <div class="">
 | 
			
		||||
    <form action="" method="post" class="form-horizontal">
 | 
			
		||||
      {% include 'partials/form.html' with form=form %}
 | 
			
		||||
      {% block beneath_form %}
 | 
			
		||||
      {% endblock %}
 | 
			
		||||
      <a class="btn btn-default" href="{% back %}">{% trans "Cancel" %}</a>
 | 
			
		||||
      <input type="submit" class="btn btn-primary" value="{% block action %}{% endblock %}" />
 | 
			
		||||
    </form>
 | 
			
		||||
  </div>
 | 
			
		||||
  {% block beneath_form %}
 | 
			
		||||
  {% endblock %}
 | 
			
		||||
  <script>
 | 
			
		||||
    let attributes = document.getElementsByName('attributes');
 | 
			
		||||
    const attributes = document.getElementsByName('attributes');
 | 
			
		||||
    if (attributes.length > 0) {
 | 
			
		||||
      let myCodeMirror = CodeMirror.fromTextArea(attributes[0], {
 | 
			
		||||
      // https://github.com/codemirror/CodeMirror/issues/5092
 | 
			
		||||
      attributes[0].removeAttribute("required");
 | 
			
		||||
      const attributesCM = CodeMirror.fromTextArea(attributes[0], {
 | 
			
		||||
        mode: 'yaml',
 | 
			
		||||
        theme: 'monokai',
 | 
			
		||||
        lineNumbers: true,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    const expressions = document.getElementsByName('expression');
 | 
			
		||||
    if (expressions.length > 0) {
 | 
			
		||||
      // https://github.com/codemirror/CodeMirror/issues/5092
 | 
			
		||||
      expressions[0].removeAttribute("required");
 | 
			
		||||
      const expressionCM = CodeMirror.fromTextArea(expressions[0], {
 | 
			
		||||
        mode: 'jinja2',
 | 
			
		||||
        theme: 'monokai',
 | 
			
		||||
        lineNumbers: true,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  </script>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
{% extends "generic/form.html" %}
 | 
			
		||||
{% extends base_template|default:"generic/form.html" %}
 | 
			
		||||
 | 
			
		||||
{% load utils %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
@ -66,6 +66,9 @@ class PropertyMappingCreateView(
 | 
			
		||||
            if x.__name__ == property_mapping_type
 | 
			
		||||
        )
 | 
			
		||||
        kwargs["type"] = model._meta.verbose_name
 | 
			
		||||
        form_cls = self.get_form_class()
 | 
			
		||||
        if hasattr(form_cls, "template_name"):
 | 
			
		||||
            kwargs["base_template"] = form_cls.template_name
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
    def get_form_class(self):
 | 
			
		||||
@ -92,6 +95,13 @@ class PropertyMappingUpdateView(
 | 
			
		||||
    success_url = reverse_lazy("passbook_admin:property-mappings")
 | 
			
		||||
    success_message = _("Successfully updated Property Mapping")
 | 
			
		||||
 | 
			
		||||
    def get_context_data(self, **kwargs):
 | 
			
		||||
        kwargs = super().get_context_data(**kwargs)
 | 
			
		||||
        form_cls = self.get_form_class()
 | 
			
		||||
        if hasattr(form_cls, "template_name"):
 | 
			
		||||
            kwargs["base_template"] = form_cls.template_name
 | 
			
		||||
        return kwargs
 | 
			
		||||
 | 
			
		||||
    def get_form_class(self):
 | 
			
		||||
        form_class_path = self.get_object().form
 | 
			
		||||
        form_class = path_to_class(form_class_path)
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,14 @@
 | 
			
		||||
"""passbook audit models"""
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from uuid import UUID
 | 
			
		||||
from inspect import getmodule, stack
 | 
			
		||||
from typing import Optional, Dict, Any
 | 
			
		||||
from typing import Any, Dict, Optional
 | 
			
		||||
from uuid import UUID
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import AnonymousUser
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django.contrib.postgres.fields import JSONField
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.contrib.contenttypes.models import ContentType
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								passbook/core/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								passbook/core/exceptions.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
"""passbook core exceptions"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PropertyMappingExpressionException(Exception):
 | 
			
		||||
    """Error when a PropertyMapping Exception expression could not be parsed or evaluated."""
 | 
			
		||||
							
								
								
									
										19
									
								
								passbook/core/migrations/0006_propertymapping_template.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								passbook/core/migrations/0006_propertymapping_template.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
# Generated by Django 3.0.3 on 2020-02-17 16:15
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_core", "0005_merge_20191025_2022"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="propertymapping",
 | 
			
		||||
            name="template",
 | 
			
		||||
            field=models.TextField(default=""),
 | 
			
		||||
            preserve_default=False,
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										16
									
								
								passbook/core/migrations/0007_auto_20200217_1934.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								passbook/core/migrations/0007_auto_20200217_1934.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
			
		||||
# Generated by Django 3.0.3 on 2020-02-17 19:34
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_core", "0006_propertymapping_template"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name="propertymapping", old_name="template", new_name="expression",
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -2,25 +2,32 @@
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from random import SystemRandom
 | 
			
		||||
from time import sleep
 | 
			
		||||
from typing import Optional
 | 
			
		||||
from typing import Any, Optional
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.models import AbstractUser
 | 
			
		||||
from django.contrib.postgres.fields import JSONField
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.utils.timezone import now
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from django_prometheus.models import ExportModelOperationsMixin
 | 
			
		||||
from guardian.mixins import GuardianUserMixin
 | 
			
		||||
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
 | 
			
		||||
from jinja2.nativetypes import NativeEnvironment
 | 
			
		||||
from model_utils.managers import InheritanceManager
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from passbook.core.exceptions import PropertyMappingExpressionException
 | 
			
		||||
from passbook.core.signals import password_changed
 | 
			
		||||
from passbook.lib.models import CreatedUpdatedModel, UUIDModel
 | 
			
		||||
from passbook.policies.exceptions import PolicyException
 | 
			
		||||
from passbook.policies.struct import PolicyRequest, PolicyResult
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
NATIVE_ENVIRONMENT = NativeEnvironment()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def default_nonce_duration():
 | 
			
		||||
@ -28,7 +35,7 @@ def default_nonce_duration():
 | 
			
		||||
    return now() + timedelta(hours=4)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Group(UUIDModel):
 | 
			
		||||
class Group(ExportModelOperationsMixin("group"), UUIDModel):
 | 
			
		||||
    """Custom Group model which supports a basic hierarchy"""
 | 
			
		||||
 | 
			
		||||
    name = models.CharField(_("name"), max_length=80)
 | 
			
		||||
@ -49,7 +56,7 @@ class Group(UUIDModel):
 | 
			
		||||
        unique_together = (("name", "parent",),)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class User(GuardianUserMixin, AbstractUser):
 | 
			
		||||
class User(ExportModelOperationsMixin("user"), GuardianUserMixin, AbstractUser):
 | 
			
		||||
    """Custom User model to allow easier adding o f user-based settings"""
 | 
			
		||||
 | 
			
		||||
    uuid = models.UUIDField(default=uuid4, editable=False)
 | 
			
		||||
@ -72,7 +79,7 @@ class User(GuardianUserMixin, AbstractUser):
 | 
			
		||||
        permissions = (("reset_user_password", "Reset Password"),)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Provider(models.Model):
 | 
			
		||||
class Provider(ExportModelOperationsMixin("provider"), models.Model):
 | 
			
		||||
    """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
 | 
			
		||||
 | 
			
		||||
    property_mappings = models.ManyToManyField(
 | 
			
		||||
@ -107,7 +114,7 @@ class UserSettings:
 | 
			
		||||
        self.view_name = view_name
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Factor(PolicyModel):
 | 
			
		||||
class Factor(ExportModelOperationsMixin("factor"), PolicyModel):
 | 
			
		||||
    """Authentication factor, multiple instances of the same Factor can be used"""
 | 
			
		||||
 | 
			
		||||
    name = models.TextField()
 | 
			
		||||
@ -128,7 +135,7 @@ class Factor(PolicyModel):
 | 
			
		||||
        return f"Factor {self.slug}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Application(PolicyModel):
 | 
			
		||||
class Application(ExportModelOperationsMixin("application"), PolicyModel):
 | 
			
		||||
    """Every Application which uses passbook for authentication/identification/authorization
 | 
			
		||||
    needs an Application record. Other authentication types can subclass this Model to
 | 
			
		||||
    add custom fields and other properties"""
 | 
			
		||||
@ -154,7 +161,7 @@ class Application(PolicyModel):
 | 
			
		||||
        return self.name
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Source(PolicyModel):
 | 
			
		||||
class Source(ExportModelOperationsMixin("source"), PolicyModel):
 | 
			
		||||
    """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
 | 
			
		||||
 | 
			
		||||
    name = models.TextField()
 | 
			
		||||
@ -199,7 +206,7 @@ class UserSourceConnection(CreatedUpdatedModel):
 | 
			
		||||
        unique_together = (("user", "source"),)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Policy(UUIDModel, CreatedUpdatedModel):
 | 
			
		||||
class Policy(ExportModelOperationsMixin("policy"), UUIDModel, CreatedUpdatedModel):
 | 
			
		||||
    """Policies which specify if a user is authorized to use an Application. Can be overridden by
 | 
			
		||||
    other types to add other fields, more logic, etc."""
 | 
			
		||||
 | 
			
		||||
@ -241,7 +248,7 @@ class DebugPolicy(Policy):
 | 
			
		||||
        verbose_name_plural = _("Debug Policies")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Invitation(UUIDModel):
 | 
			
		||||
class Invitation(ExportModelOperationsMixin("invitation"), UUIDModel):
 | 
			
		||||
    """Single-use invitation link"""
 | 
			
		||||
 | 
			
		||||
    created_by = models.ForeignKey("User", on_delete=models.CASCADE)
 | 
			
		||||
@ -266,7 +273,7 @@ class Invitation(UUIDModel):
 | 
			
		||||
        verbose_name_plural = _("Invitations")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Nonce(UUIDModel):
 | 
			
		||||
class Nonce(ExportModelOperationsMixin("nonce"), UUIDModel):
 | 
			
		||||
    """One-time link for password resets/sign-up-confirmations"""
 | 
			
		||||
 | 
			
		||||
    expires = models.DateTimeField(default=default_nonce_duration)
 | 
			
		||||
@ -292,10 +299,29 @@ class PropertyMapping(UUIDModel):
 | 
			
		||||
    """User-defined key -> x mapping which can be used by providers to expose extra data."""
 | 
			
		||||
 | 
			
		||||
    name = models.TextField()
 | 
			
		||||
    expression = models.TextField()
 | 
			
		||||
 | 
			
		||||
    form = ""
 | 
			
		||||
    objects = InheritanceManager()
 | 
			
		||||
 | 
			
		||||
    def evaluate(self, user: User, request: HttpRequest, **kwargs) -> Any:
 | 
			
		||||
        """Evaluate `self.expression` using `**kwargs` as Context."""
 | 
			
		||||
        try:
 | 
			
		||||
            expression = NATIVE_ENVIRONMENT.from_string(self.expression)
 | 
			
		||||
        except TemplateSyntaxError as exc:
 | 
			
		||||
            raise PropertyMappingExpressionException from exc
 | 
			
		||||
        try:
 | 
			
		||||
            return expression.render(user=user, request=request, **kwargs)
 | 
			
		||||
        except UndefinedError as exc:
 | 
			
		||||
            raise PropertyMappingExpressionException from exc
 | 
			
		||||
 | 
			
		||||
    def save(self, *args, **kwargs):
 | 
			
		||||
        try:
 | 
			
		||||
            NATIVE_ENVIRONMENT.from_string(self.expression)
 | 
			
		||||
        except TemplateSyntaxError as exc:
 | 
			
		||||
            raise ValidationError("Expression Syntax Error") from exc
 | 
			
		||||
        return super().save(*args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"Property Mapping {self.name}"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -19,6 +19,9 @@
 | 
			
		||||
    <h1>{% trans 'Bad Request' %}</h1>
 | 
			
		||||
</header>
 | 
			
		||||
<form>
 | 
			
		||||
    {% if message %}
 | 
			
		||||
    <h3>{% trans message %}</h3>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    {% if 'back' in request.GET %}
 | 
			
		||||
    <a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
@ -38,9 +38,8 @@ class TestFactorAuthentication(TestCase):
 | 
			
		||||
    def test_unauthenticated_raw(self):
 | 
			
		||||
        """test direct call to AuthenticationView"""
 | 
			
		||||
        response = self.client.get(reverse("passbook_core:auth-process"))
 | 
			
		||||
        # Response should be 302 since no pending user is set
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.url, reverse("passbook_core:auth-login"))
 | 
			
		||||
        # Response should be 400 since no pending user is set
 | 
			
		||||
        self.assertEqual(response.status_code, 400)
 | 
			
		||||
 | 
			
		||||
    def test_unauthenticated_prepared(self):
 | 
			
		||||
        """test direct call but with pending_uesr in session"""
 | 
			
		||||
@ -71,9 +70,8 @@ class TestFactorAuthentication(TestCase):
 | 
			
		||||
        """Test with already logged in user"""
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        response = self.client.get(reverse("passbook_core:auth-process"))
 | 
			
		||||
        # Response should be 302 since no pending user is set
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.url, reverse("passbook_core:overview"))
 | 
			
		||||
        # Response should be 400 since no pending user is set
 | 
			
		||||
        self.assertEqual(response.status_code, 400)
 | 
			
		||||
        self.client.logout()
 | 
			
		||||
 | 
			
		||||
    def test_unauthenticated_post(self):
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,17 @@
 | 
			
		||||
"""passbook multi-factor authentication engine"""
 | 
			
		||||
from typing import List, Tuple
 | 
			
		||||
from typing import List, Optional, Tuple
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth import login
 | 
			
		||||
from django.contrib.auth.mixins import UserPassesTestMixin
 | 
			
		||||
from django.shortcuts import get_object_or_404, redirect, reverse
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.shortcuts import get_object_or_404, redirect, render, reverse
 | 
			
		||||
from django.utils.http import urlencode
 | 
			
		||||
from django.views.generic import View
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from passbook.core.models import Factor, User
 | 
			
		||||
from passbook.core.views.utils import PermissionDeniedView
 | 
			
		||||
from passbook.lib.config import CONFIG
 | 
			
		||||
from passbook.lib.utils.reflection import class_to_path, path_to_class
 | 
			
		||||
from passbook.lib.utils.urls import is_url_absolute
 | 
			
		||||
from passbook.policies.engine import PolicyEngine
 | 
			
		||||
@ -23,7 +25,7 @@ def _redirect_with_qs(view, get_query_set=None):
 | 
			
		||||
    """Wrapper to redirect whilst keeping GET Parameters"""
 | 
			
		||||
    target = reverse(view)
 | 
			
		||||
    if get_query_set:
 | 
			
		||||
        target += "?" + urlencode(get_query_set)
 | 
			
		||||
        target += "?" + urlencode(get_query_set.items())
 | 
			
		||||
    return redirect(target)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -44,10 +46,26 @@ class AuthenticationView(UserPassesTestMixin, View):
 | 
			
		||||
    current_factor: Factor
 | 
			
		||||
 | 
			
		||||
    # Allow only not authenticated users to login
 | 
			
		||||
    def test_func(self):
 | 
			
		||||
    def test_func(self) -> bool:
 | 
			
		||||
        return AuthenticationView.SESSION_PENDING_USER in self.request.session
 | 
			
		||||
 | 
			
		||||
    def handle_no_permission(self):
 | 
			
		||||
    def _check_config_domain(self) -> Optional[HttpResponse]:
 | 
			
		||||
        """Checks if current request's domain matches configured Domain, and
 | 
			
		||||
        adds a warning if not."""
 | 
			
		||||
        current_domain = self.request.get_host()
 | 
			
		||||
        config_domain = CONFIG.y("domain")
 | 
			
		||||
        if current_domain != config_domain:
 | 
			
		||||
            message = (
 | 
			
		||||
                f"Current domain of '{current_domain}' doesn't "
 | 
			
		||||
                f"match configured domain of '{config_domain}'."
 | 
			
		||||
            )
 | 
			
		||||
            LOGGER.warning(message)
 | 
			
		||||
            return render(
 | 
			
		||||
                self.request, "error/400.html", context={"message": message}, status=400
 | 
			
		||||
            )
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def handle_no_permission(self) -> HttpResponse:
 | 
			
		||||
        # Function from UserPassesTestMixin
 | 
			
		||||
        if NEXT_ARG_NAME in self.request.GET:
 | 
			
		||||
            return redirect(self.request.GET.get(NEXT_ARG_NAME))
 | 
			
		||||
@ -55,7 +73,7 @@ class AuthenticationView(UserPassesTestMixin, View):
 | 
			
		||||
            return _redirect_with_qs("passbook_core:overview", self.request.GET)
 | 
			
		||||
        return _redirect_with_qs("passbook_core:auth-login", self.request.GET)
 | 
			
		||||
 | 
			
		||||
    def get_pending_factors(self):
 | 
			
		||||
    def get_pending_factors(self) -> List[Tuple[str, str]]:
 | 
			
		||||
        """Loading pending factors from Database or load from session variable"""
 | 
			
		||||
        # Write pending factors to session
 | 
			
		||||
        if AuthenticationView.SESSION_PENDING_FACTORS in self.request.session:
 | 
			
		||||
@ -67,6 +85,7 @@ class AuthenticationView(UserPassesTestMixin, View):
 | 
			
		||||
        )
 | 
			
		||||
        pending_factors = []
 | 
			
		||||
        for factor in _all_factors:
 | 
			
		||||
            factor: Factor
 | 
			
		||||
            LOGGER.debug(
 | 
			
		||||
                "Checking if factor applies to user",
 | 
			
		||||
                factor=factor,
 | 
			
		||||
@ -81,10 +100,13 @@ class AuthenticationView(UserPassesTestMixin, View):
 | 
			
		||||
                LOGGER.debug("Factor applies", factor=factor, user=self.pending_user)
 | 
			
		||||
        return pending_factors
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
    def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        # Check if user passes test (i.e. SESSION_PENDING_USER is set)
 | 
			
		||||
        user_test_result = self.get_test_func()()
 | 
			
		||||
        if not user_test_result:
 | 
			
		||||
            incorrect_domain_message = self._check_config_domain()
 | 
			
		||||
            if incorrect_domain_message:
 | 
			
		||||
                return incorrect_domain_message
 | 
			
		||||
            return self.handle_no_permission()
 | 
			
		||||
        # Extract pending user from session (only remember uid)
 | 
			
		||||
        self.pending_user = get_object_or_404(
 | 
			
		||||
@ -117,7 +139,7 @@ class AuthenticationView(UserPassesTestMixin, View):
 | 
			
		||||
        self._current_factor_class.request = request
 | 
			
		||||
        return super().dispatch(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get(self, request, *args, **kwargs):
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """pass get request to current factor"""
 | 
			
		||||
        LOGGER.debug(
 | 
			
		||||
            "Passing GET",
 | 
			
		||||
@ -125,7 +147,7 @@ class AuthenticationView(UserPassesTestMixin, View):
 | 
			
		||||
        )
 | 
			
		||||
        return self._current_factor_class.get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def post(self, request, *args, **kwargs):
 | 
			
		||||
    def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """pass post request to current factor"""
 | 
			
		||||
        LOGGER.debug(
 | 
			
		||||
            "Passing POST",
 | 
			
		||||
@ -133,7 +155,7 @@ class AuthenticationView(UserPassesTestMixin, View):
 | 
			
		||||
        )
 | 
			
		||||
        return self._current_factor_class.post(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def user_ok(self):
 | 
			
		||||
    def user_ok(self) -> HttpResponse:
 | 
			
		||||
        """Redirect to next Factor"""
 | 
			
		||||
        LOGGER.debug(
 | 
			
		||||
            "Factor passed",
 | 
			
		||||
@ -160,14 +182,14 @@ class AuthenticationView(UserPassesTestMixin, View):
 | 
			
		||||
        LOGGER.debug("User passed all factors, logging in", user=self.pending_user)
 | 
			
		||||
        return self._user_passed()
 | 
			
		||||
 | 
			
		||||
    def user_invalid(self):
 | 
			
		||||
    def user_invalid(self) -> HttpResponse:
 | 
			
		||||
        """Show error message, user cannot login.
 | 
			
		||||
        This should only be shown if user authenticated successfully, but is disabled/locked/etc"""
 | 
			
		||||
        LOGGER.debug("User invalid")
 | 
			
		||||
        self.cleanup()
 | 
			
		||||
        return _redirect_with_qs("passbook_core:auth-denied", self.request.GET)
 | 
			
		||||
 | 
			
		||||
    def _user_passed(self):
 | 
			
		||||
    def _user_passed(self) -> HttpResponse:
 | 
			
		||||
        """User Successfully passed all factors"""
 | 
			
		||||
        backend = self.request.session[AuthenticationView.SESSION_USER_BACKEND]
 | 
			
		||||
        login(self.request, self.pending_user, backend=backend)
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,6 @@ from urllib.parse import urlparse
 | 
			
		||||
 | 
			
		||||
import yaml
 | 
			
		||||
from django.conf import ImproperlyConfigured
 | 
			
		||||
from django.utils.autoreload import autoreload_started
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
SEARCH_PATHS = ["passbook/lib/default.yml", "/etc/passbook/config.yml", "",] + glob(
 | 
			
		||||
@ -142,12 +141,3 @@ class ConfigLoader:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
CONFIG = ConfigLoader()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def signal_handler(sender, **_):
 | 
			
		||||
    """Add all loaded config files to autoreload watcher"""
 | 
			
		||||
    for path in CONFIG.loaded_file:
 | 
			
		||||
        sender.watch_file(path)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
autoreload_started.connect(signal_handler)
 | 
			
		||||
 | 
			
		||||
@ -3,9 +3,9 @@ from hashlib import md5
 | 
			
		||||
from urllib.parse import urlencode
 | 
			
		||||
 | 
			
		||||
from django import template
 | 
			
		||||
from django.template import Context
 | 
			
		||||
from django.apps import apps
 | 
			
		||||
from django.db.models import Model
 | 
			
		||||
from django.template import Context
 | 
			
		||||
from django.utils.html import escape
 | 
			
		||||
from django.utils.translation import ugettext as _
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
"""passbook helper views"""
 | 
			
		||||
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.shortcuts import render
 | 
			
		||||
from django.views.generic import CreateView
 | 
			
		||||
from guardian.shortcuts import assign_perm
 | 
			
		||||
 | 
			
		||||
@ -20,6 +21,10 @@ class CreateAssignPermView(CreateView):
 | 
			
		||||
                self.object._meta.app_label,
 | 
			
		||||
                self.object._meta.model_name,
 | 
			
		||||
            )
 | 
			
		||||
            print(full_permission)
 | 
			
		||||
            assign_perm(full_permission, self.request.user, self.object)
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def bad_request_message(request: HttpRequest, message: str) -> HttpResponse:
 | 
			
		||||
    """Return generic error page with message, with status code set to 400"""
 | 
			
		||||
    return render(request, "error/400.html", {"message": message}, status=400)
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
"""passbook policy engine"""
 | 
			
		||||
from multiprocessing import Pipe
 | 
			
		||||
from multiprocessing import Pipe, set_start_method
 | 
			
		||||
from multiprocessing.connection import Connection
 | 
			
		||||
from typing import List, Optional, Tuple
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,9 @@ from passbook.policies.process import PolicyProcess, cache_key
 | 
			
		||||
from passbook.policies.struct import PolicyRequest, PolicyResult
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
# This is only really needed for macOS, because Python 3.8 changed the default to spawn
 | 
			
		||||
# spawn causes issues with objects that aren't picklable, and also the django setup
 | 
			
		||||
set_start_method("fork")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PolicyProcessInfo:
 | 
			
		||||
@ -36,13 +39,15 @@ class PolicyEngine:
 | 
			
		||||
    policies: List[Policy] = []
 | 
			
		||||
    request: PolicyRequest
 | 
			
		||||
 | 
			
		||||
    __processes: List[PolicyProcessInfo] = []
 | 
			
		||||
    __cached_policies: List[PolicyResult]
 | 
			
		||||
    __processes: List[PolicyProcessInfo]
 | 
			
		||||
 | 
			
		||||
    def __init__(self, policies, user: User, request: HttpRequest = None):
 | 
			
		||||
        self.policies = policies
 | 
			
		||||
        self.request = PolicyRequest(user)
 | 
			
		||||
        if request:
 | 
			
		||||
            self.request.http_request = request
 | 
			
		||||
        self.__cached_policies = []
 | 
			
		||||
        self.__processes = []
 | 
			
		||||
 | 
			
		||||
    def _select_subclasses(self) -> List[Policy]:
 | 
			
		||||
@ -55,13 +60,12 @@ class PolicyEngine:
 | 
			
		||||
 | 
			
		||||
    def build(self) -> "PolicyEngine":
 | 
			
		||||
        """Build task group"""
 | 
			
		||||
        cached_policies = []
 | 
			
		||||
        for policy in self._select_subclasses():
 | 
			
		||||
            cached_policy = cache.get(cache_key(policy, self.request.user), None)
 | 
			
		||||
            if cached_policy and self.use_cache:
 | 
			
		||||
                LOGGER.debug("Taking result from cache", policy=policy)
 | 
			
		||||
                cached_policies.append(cached_policy)
 | 
			
		||||
            else:
 | 
			
		||||
                self.__cached_policies.append(cached_policy)
 | 
			
		||||
                continue
 | 
			
		||||
            LOGGER.debug("Evaluating policy", policy=policy)
 | 
			
		||||
            our_end, task_end = Pipe(False)
 | 
			
		||||
            task = PolicyProcess(policy, self.request, task_end)
 | 
			
		||||
@ -82,13 +86,14 @@ class PolicyEngine:
 | 
			
		||||
    def result(self) -> Tuple[bool, List[str]]:
 | 
			
		||||
        """Get policy-checking result"""
 | 
			
		||||
        messages: List[str] = []
 | 
			
		||||
        for proc_info in self.__processes:
 | 
			
		||||
            LOGGER.debug(
 | 
			
		||||
                "Result", policy=proc_info.policy, passing=proc_info.result.passing
 | 
			
		||||
            )
 | 
			
		||||
            if proc_info.result.messages:
 | 
			
		||||
                messages += proc_info.result.messages
 | 
			
		||||
            if not proc_info.result.passing:
 | 
			
		||||
        process_results: List[PolicyResult] = [
 | 
			
		||||
            x.result for x in self.__processes if x.result
 | 
			
		||||
        ]
 | 
			
		||||
        for result in process_results + self.__cached_policies:
 | 
			
		||||
            LOGGER.debug("result", passing=result.passing)
 | 
			
		||||
            if result.messages:
 | 
			
		||||
                messages += result.messages
 | 
			
		||||
            if not result.passing:
 | 
			
		||||
                return False, messages
 | 
			
		||||
        return True, messages
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								passbook/policies/expression/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								passbook/policies/expression/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										5
									
								
								passbook/policies/expression/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								passbook/policies/expression/admin.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
			
		||||
"""Passbook passbook expression policy Admin"""
 | 
			
		||||
 | 
			
		||||
from passbook.lib.admin import admin_autoregister
 | 
			
		||||
 | 
			
		||||
admin_autoregister("passbook_policies_expression")
 | 
			
		||||
							
								
								
									
										21
									
								
								passbook/policies/expression/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								passbook/policies/expression/api.py
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										11
									
								
								passbook/policies/expression/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								passbook/policies/expression/apps.py
									
									
									
									
									
										Normal 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"
 | 
			
		||||
							
								
								
									
										22
									
								
								passbook/policies/expression/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								passbook/policies/expression/forms.py
									
									
									
									
									
										Normal 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(),
 | 
			
		||||
        }
 | 
			
		||||
							
								
								
									
										38
									
								
								passbook/policies/expression/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								passbook/policies/expression/migrations/0001_initial.py
									
									
									
									
									
										Normal 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",),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										0
									
								
								passbook/policies/expression/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								passbook/policies/expression/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										49
									
								
								passbook/policies/expression/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								passbook/policies/expression/models.py
									
									
									
									
									
										Normal 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")
 | 
			
		||||
@ -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 %}
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
"""passbook Application Security Gateway Forms"""
 | 
			
		||||
from django import forms
 | 
			
		||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
 | 
			
		||||
from oidc_provider.models import Client
 | 
			
		||||
from oidc_provider.models import Client, ResponseType
 | 
			
		||||
 | 
			
		||||
from passbook.providers.app_gw.models import ApplicationGatewayProvider
 | 
			
		||||
 | 
			
		||||
@ -16,9 +16,14 @@ class ApplicationGatewayProviderForm(forms.ModelForm):
 | 
			
		||||
                client_id=generate_client_id(), client_secret=generate_client_secret()
 | 
			
		||||
            )
 | 
			
		||||
        self.instance.client.name = self.instance.name
 | 
			
		||||
        self.instance.client.response_types.set(
 | 
			
		||||
            [ResponseType.objects.get_by_natural_key("code")]
 | 
			
		||||
        )
 | 
			
		||||
        self.instance.client.redirect_uris = [
 | 
			
		||||
            f"http://{self.instance.host}/oauth2/callback",
 | 
			
		||||
            f"https://{self.instance.host}/oauth2/callback",
 | 
			
		||||
            f"http://{self.instance.external_host}/oauth2/callback",
 | 
			
		||||
            f"https://{self.instance.external_host}/oauth2/callback",
 | 
			
		||||
            f"http://{self.instance.internal_host}/oauth2/callback",
 | 
			
		||||
            f"https://{self.instance.internal_host}/oauth2/callback",
 | 
			
		||||
        ]
 | 
			
		||||
        self.instance.client.scope = ["openid", "email"]
 | 
			
		||||
        self.instance.client.save()
 | 
			
		||||
@ -27,8 +32,9 @@ class ApplicationGatewayProviderForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        model = ApplicationGatewayProvider
 | 
			
		||||
        fields = ["name", "host"]
 | 
			
		||||
        fields = ["name", "internal_host", "external_host"]
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "name": forms.TextInput(),
 | 
			
		||||
            "host": forms.TextInput(),
 | 
			
		||||
            "internal_host": forms.TextInput(),
 | 
			
		||||
            "external_host": forms.TextInput(),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,24 @@
 | 
			
		||||
# Generated by Django 2.2.9 on 2020-01-02 15:05
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_providers_app_gw", "0003_applicationgatewayprovider"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RenameField(
 | 
			
		||||
            model_name="applicationgatewayprovider",
 | 
			
		||||
            old_name="host",
 | 
			
		||||
            new_name="external_host",
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="applicationgatewayprovider",
 | 
			
		||||
            name="internal_host",
 | 
			
		||||
            field=models.TextField(default=""),
 | 
			
		||||
            preserve_default=False,
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -14,7 +14,8 @@ class ApplicationGatewayProvider(Provider):
 | 
			
		||||
    """This provider uses oauth2_proxy with the OIDC Provider."""
 | 
			
		||||
 | 
			
		||||
    name = models.TextField()
 | 
			
		||||
    host = models.TextField()
 | 
			
		||||
    internal_host = models.TextField()
 | 
			
		||||
    external_host = models.TextField()
 | 
			
		||||
 | 
			
		||||
    client = models.ForeignKey(Client, on_delete=models.CASCADE)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -40,10 +40,10 @@ services:
 | 
			
		||||
    environment:
 | 
			
		||||
      OAUTH2_PROXY_CLIENT_ID: {{ provider.client.client_id }}
 | 
			
		||||
      OAUTH2_PROXY_CLIENT_SECRET: {{ provider.client.client_secret }}
 | 
			
		||||
      OAUTH2_PROXY_REDIRECT_URL: https://{{ provider.host }}/oauth2/callback
 | 
			
		||||
      OAUTH2_PROXY_OIDC_ISSUER_URL: https://{{ request.META.host }}/application/oidc
 | 
			
		||||
      OAUTH2_PROXY_REDIRECT_URL: https://{{ provider.external_host }}/oauth2/callback
 | 
			
		||||
      OAUTH2_PROXY_OIDC_ISSUER_URL: https://{{ request.META.HTTP_HOST }}/application/oidc
 | 
			
		||||
      OAUTH2_PROXY_COOKIE_SECRET: {{ cookie_secret }}
 | 
			
		||||
      OAUTH2_PROXY_UPSTREAM: http://{{ provider.host }}</textarea>
 | 
			
		||||
      OAUTH2_PROXY_UPSTREAMS: http://{{ provider.internal_host }}</textarea>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-footer">
 | 
			
		||||
        <button type="button" class="btn btn-primary" data-dismiss="modal">{% trans 'Close' %}</button>
 | 
			
		||||
 | 
			
		||||
@ -1,21 +1,38 @@
 | 
			
		||||
"""OIDC Permission checking"""
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from django.contrib import messages
 | 
			
		||||
from django.db.models.deletion import Collector
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.shortcuts import redirect
 | 
			
		||||
from oidc_provider.models import Client
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from passbook.audit.models import Event, EventAction
 | 
			
		||||
from passbook.core.models import Application
 | 
			
		||||
from passbook.core.models import Application, Provider, User
 | 
			
		||||
from passbook.policies.engine import PolicyEngine
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_permissions(request, user, client):
 | 
			
		||||
def check_permissions(
 | 
			
		||||
    request: HttpRequest, user: User, client: Client
 | 
			
		||||
) -> Optional[HttpResponse]:
 | 
			
		||||
    """Check permissions, used for
 | 
			
		||||
    https://django-oidc-provider.readthedocs.io/en/latest/
 | 
			
		||||
    sections/settings.html#oidc-after-userlogin-hook"""
 | 
			
		||||
    try:
 | 
			
		||||
        application = client.openidprovider.application
 | 
			
		||||
        # because oidc_provider is also used by app_gw, we can't be
 | 
			
		||||
        # sure an OpenIDPRovider instance exists. hence we look through all related models
 | 
			
		||||
        # and choose the one that inherits from Provider, which is guaranteed to
 | 
			
		||||
        # have the application property
 | 
			
		||||
        collector = Collector(using="default")
 | 
			
		||||
        collector.collect([client])
 | 
			
		||||
        for _, related in collector.data.items():
 | 
			
		||||
            related_object = next(iter(related))
 | 
			
		||||
            if isinstance(related_object, Provider):
 | 
			
		||||
                application = related_object.application
 | 
			
		||||
                break
 | 
			
		||||
    except Application.DoesNotExist:
 | 
			
		||||
        return redirect("passbook_providers_oauth:oauth2-permission-denied")
 | 
			
		||||
    LOGGER.debug(
 | 
			
		||||
 | 
			
		||||
@ -14,12 +14,16 @@ class SAMLProviderSerializer(ModelSerializer):
 | 
			
		||||
        fields = [
 | 
			
		||||
            "pk",
 | 
			
		||||
            "name",
 | 
			
		||||
            "property_mappings",
 | 
			
		||||
            "processor_path",
 | 
			
		||||
            "acs_url",
 | 
			
		||||
            "audience",
 | 
			
		||||
            "processor_path",
 | 
			
		||||
            "issuer",
 | 
			
		||||
            "assertion_valid_for",
 | 
			
		||||
            "assertion_valid_not_before",
 | 
			
		||||
            "assertion_valid_not_on_or_after",
 | 
			
		||||
            "session_valid_not_on_or_after",
 | 
			
		||||
            "property_mappings",
 | 
			
		||||
            "digest_algorithm",
 | 
			
		||||
            "signature_algorithm",
 | 
			
		||||
            "signing",
 | 
			
		||||
            "signing_cert",
 | 
			
		||||
            "signing_key",
 | 
			
		||||
@ -39,7 +43,7 @@ class SAMLPropertyMappingSerializer(ModelSerializer):
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        model = SAMLPropertyMapping
 | 
			
		||||
        fields = ["pk", "name", "saml_name", "friendly_name", "values"]
 | 
			
		||||
        fields = ["pk", "name", "saml_name", "friendly_name", "expression"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SAMLPropertyMappingViewSet(ModelViewSet):
 | 
			
		||||
 | 
			
		||||
@ -1,336 +0,0 @@
 | 
			
		||||
"""Basic SAML Processor"""
 | 
			
		||||
 | 
			
		||||
import time
 | 
			
		||||
import uuid
 | 
			
		||||
 | 
			
		||||
from defusedxml import ElementTree
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from passbook.providers.saml import exceptions, utils, xml_render
 | 
			
		||||
 | 
			
		||||
MINUTES = 60
 | 
			
		||||
HOURS = 60 * MINUTES
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_random_id():
 | 
			
		||||
    """Random hex id"""
 | 
			
		||||
    # It is very important that these random IDs NOT start with a number.
 | 
			
		||||
    random_id = "_" + uuid.uuid4().hex
 | 
			
		||||
    return random_id
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_time_string(delta=0):
 | 
			
		||||
    """Get Data formatted in SAML format"""
 | 
			
		||||
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + delta))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Design note: I've tried to make this easy to sub-class and override
 | 
			
		||||
# just the bits you need to override. I've made use of object properties,
 | 
			
		||||
# so that your sub-classes have access to all information: use wisely.
 | 
			
		||||
# Formatting note: These methods are alphabetized.
 | 
			
		||||
# pylint: disable=too-many-instance-attributes
 | 
			
		||||
class Processor:
 | 
			
		||||
    """Base SAML 2.0 AuthnRequest to Response Processor.
 | 
			
		||||
    Sub-classes should provide Service Provider-specific functionality."""
 | 
			
		||||
 | 
			
		||||
    is_idp_initiated = False
 | 
			
		||||
 | 
			
		||||
    _audience = ""
 | 
			
		||||
    _assertion_params = None
 | 
			
		||||
    _assertion_xml = None
 | 
			
		||||
    _assertion_id = None
 | 
			
		||||
    _django_request = None
 | 
			
		||||
    _relay_state = None
 | 
			
		||||
    _request = None
 | 
			
		||||
    _request_id = None
 | 
			
		||||
    _request_xml = None
 | 
			
		||||
    _request_params = None
 | 
			
		||||
    _response_id = None
 | 
			
		||||
    _response_xml = None
 | 
			
		||||
    _response_params = None
 | 
			
		||||
    _saml_request = None
 | 
			
		||||
    _saml_response = None
 | 
			
		||||
    _session_index = None
 | 
			
		||||
    _subject = None
 | 
			
		||||
    _subject_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
 | 
			
		||||
    _system_params = {}
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def dotted_path(self):
 | 
			
		||||
        """Return a dotted path to this class"""
 | 
			
		||||
        return "{module}.{class_name}".format(
 | 
			
		||||
            module=self.__module__, class_name=self.__class__.__name__
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def __init__(self, remote):
 | 
			
		||||
        self.name = remote.name
 | 
			
		||||
        self._remote = remote
 | 
			
		||||
        self._logger = get_logger()
 | 
			
		||||
        self._system_params["ISSUER"] = self._remote.issuer
 | 
			
		||||
        self._logger.debug("processor configured")
 | 
			
		||||
 | 
			
		||||
    def _build_assertion(self):
 | 
			
		||||
        """Builds _assertion_params."""
 | 
			
		||||
        self._determine_assertion_id()
 | 
			
		||||
        self._determine_audience()
 | 
			
		||||
        self._determine_subject()
 | 
			
		||||
        self._determine_session_index()
 | 
			
		||||
 | 
			
		||||
        self._assertion_params = {
 | 
			
		||||
            "ASSERTION_ID": self._assertion_id,
 | 
			
		||||
            "ASSERTION_SIGNATURE": "",  # it's unsigned
 | 
			
		||||
            "AUDIENCE": self._audience,
 | 
			
		||||
            "AUTH_INSTANT": get_time_string(),
 | 
			
		||||
            "ISSUE_INSTANT": get_time_string(),
 | 
			
		||||
            "NOT_BEFORE": get_time_string(-1 * HOURS),  # TODO: Make these settings.
 | 
			
		||||
            "NOT_ON_OR_AFTER": get_time_string(86400 * MINUTES),
 | 
			
		||||
            "SESSION_INDEX": self._session_index,
 | 
			
		||||
            "SESSION_NOT_ON_OR_AFTER": get_time_string(8 * HOURS),
 | 
			
		||||
            "SP_NAME_QUALIFIER": self._audience,
 | 
			
		||||
            "SUBJECT": self._subject,
 | 
			
		||||
            "SUBJECT_FORMAT": self._subject_format,
 | 
			
		||||
        }
 | 
			
		||||
        self._assertion_params.update(self._system_params)
 | 
			
		||||
        self._assertion_params.update(self._request_params)
 | 
			
		||||
 | 
			
		||||
    def _build_response(self):
 | 
			
		||||
        """Builds _response_params."""
 | 
			
		||||
        self._determine_response_id()
 | 
			
		||||
        self._response_params = {
 | 
			
		||||
            "ASSERTION": self._assertion_xml,
 | 
			
		||||
            "ISSUE_INSTANT": get_time_string(),
 | 
			
		||||
            "RESPONSE_ID": self._response_id,
 | 
			
		||||
            "RESPONSE_SIGNATURE": "",  # initially unsigned
 | 
			
		||||
        }
 | 
			
		||||
        self._response_params.update(self._system_params)
 | 
			
		||||
        self._response_params.update(self._request_params)
 | 
			
		||||
 | 
			
		||||
    def _decode_request(self):
 | 
			
		||||
        """Decodes _request_xml from _saml_request."""
 | 
			
		||||
 | 
			
		||||
        self._request_xml = utils.decode_base64_and_inflate(self._saml_request).decode(
 | 
			
		||||
            "utf-8"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self._logger.debug("SAML request decoded")
 | 
			
		||||
 | 
			
		||||
    def _determine_assertion_id(self):
 | 
			
		||||
        """Determines the _assertion_id."""
 | 
			
		||||
        self._assertion_id = get_random_id()
 | 
			
		||||
 | 
			
		||||
    def _determine_audience(self):
 | 
			
		||||
        """Determines the _audience."""
 | 
			
		||||
        self._audience = self._remote.audience
 | 
			
		||||
        self._logger.info("determined audience")
 | 
			
		||||
 | 
			
		||||
    def _determine_response_id(self):
 | 
			
		||||
        """Determines _response_id."""
 | 
			
		||||
        self._response_id = get_random_id()
 | 
			
		||||
 | 
			
		||||
    def _determine_session_index(self):
 | 
			
		||||
        self._session_index = self._django_request.session.session_key
 | 
			
		||||
 | 
			
		||||
    def _determine_subject(self):
 | 
			
		||||
        """Determines _subject and _subject_type for Assertion Subject."""
 | 
			
		||||
        self._subject = self._django_request.user.email
 | 
			
		||||
 | 
			
		||||
    def _encode_response(self):
 | 
			
		||||
        """Encodes _response_xml to _encoded_xml."""
 | 
			
		||||
        self._saml_response = utils.nice64(str.encode(self._response_xml))
 | 
			
		||||
 | 
			
		||||
    def _extract_saml_request(self):
 | 
			
		||||
        """Retrieves the _saml_request AuthnRequest from the _django_request."""
 | 
			
		||||
        self._saml_request = self._django_request.session["SAMLRequest"]
 | 
			
		||||
        self._relay_state = self._django_request.session["RelayState"]
 | 
			
		||||
 | 
			
		||||
    def _format_assertion(self):
 | 
			
		||||
        """Formats _assertion_params as _assertion_xml."""
 | 
			
		||||
        # https://commons.lbl.gov/display/IDMgmt/Attribute+Definitions
 | 
			
		||||
        self._assertion_params["ATTRIBUTES"] = [
 | 
			
		||||
            {
 | 
			
		||||
                "FriendlyName": "eduPersonPrincipalName",
 | 
			
		||||
                "Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
 | 
			
		||||
                "Value": self._django_request.user.email,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "FriendlyName": "cn",
 | 
			
		||||
                "Name": "urn:oid:2.5.4.3",
 | 
			
		||||
                "Value": self._django_request.user.name,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "FriendlyName": "mail",
 | 
			
		||||
                "Name": "urn:oid:0.9.2342.19200300.100.1.3",
 | 
			
		||||
                "Value": self._django_request.user.email,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "FriendlyName": "displayName",
 | 
			
		||||
                "Name": "urn:oid:2.16.840.1.113730.3.1.241",
 | 
			
		||||
                "Value": self._django_request.user.username,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "FriendlyName": "uid",
 | 
			
		||||
                "Name": "urn:oid:0.9.2342.19200300.100.1.1",
 | 
			
		||||
                "Value": self._django_request.user.pk,
 | 
			
		||||
            },
 | 
			
		||||
        ]
 | 
			
		||||
        from passbook.providers.saml.models import SAMLPropertyMapping
 | 
			
		||||
 | 
			
		||||
        for mapping in self._remote.property_mappings.all().select_subclasses():
 | 
			
		||||
            if isinstance(mapping, SAMLPropertyMapping):
 | 
			
		||||
                mapping_payload = {
 | 
			
		||||
                    "Name": mapping.saml_name,
 | 
			
		||||
                    "ValueArray": [],
 | 
			
		||||
                    "FriendlyName": mapping.friendly_name,
 | 
			
		||||
                }
 | 
			
		||||
                for value in mapping.values:
 | 
			
		||||
                    mapping_payload["ValueArray"].append(
 | 
			
		||||
                        value.format(
 | 
			
		||||
                            user=self._django_request.user, request=self._django_request
 | 
			
		||||
                        )
 | 
			
		||||
                    )
 | 
			
		||||
                self._assertion_params["ATTRIBUTES"].append(mapping_payload)
 | 
			
		||||
        self._assertion_xml = xml_render.get_assertion_xml(
 | 
			
		||||
            "saml/xml/assertions/generic.xml", self._assertion_params, signed=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def _format_response(self):
 | 
			
		||||
        """Formats _response_params as _response_xml."""
 | 
			
		||||
        assertion_id = self._assertion_params["ASSERTION_ID"]
 | 
			
		||||
        self._response_xml = xml_render.get_response_xml(
 | 
			
		||||
            self._response_params, saml_provider=self._remote, assertion_id=assertion_id
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def _get_django_response_params(self):
 | 
			
		||||
        """Returns a dictionary of parameters for the response template."""
 | 
			
		||||
        return {
 | 
			
		||||
            "acs_url": self._request_params["ACS_URL"],
 | 
			
		||||
            "saml_response": self._saml_response,
 | 
			
		||||
            "relay_state": self._relay_state,
 | 
			
		||||
            "autosubmit": self._remote.application.skip_authorization,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def _parse_request(self):
 | 
			
		||||
        """Parses various parameters from _request_xml into _request_params."""
 | 
			
		||||
        # Minimal test to verify that it's not binarily encoded still:
 | 
			
		||||
        if not str(self._request_xml.strip()).startswith("<"):
 | 
			
		||||
            raise Exception(
 | 
			
		||||
                "RequestXML is not valid XML; "
 | 
			
		||||
                "it may need to be decoded or decompressed."
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        root = ElementTree.fromstring(self._request_xml)
 | 
			
		||||
        params = {}
 | 
			
		||||
        params["ACS_URL"] = root.attrib["AssertionConsumerServiceURL"]
 | 
			
		||||
        params["REQUEST_ID"] = root.attrib["ID"]
 | 
			
		||||
        params["DESTINATION"] = root.attrib.get("Destination", "")
 | 
			
		||||
        params["PROVIDER_NAME"] = root.attrib.get("ProviderName", "")
 | 
			
		||||
        self._request_params = params
 | 
			
		||||
 | 
			
		||||
    def _reset(self, django_request, sp_config=None):
 | 
			
		||||
        """Initialize (and reset) object properties, so we don't risk carrying
 | 
			
		||||
        over anything from the last authentication.
 | 
			
		||||
        If provided, use sp_config throughout; otherwise, it will be set in
 | 
			
		||||
        _validate_request(). """
 | 
			
		||||
        self._assertion_params = sp_config
 | 
			
		||||
        self._assertion_xml = sp_config
 | 
			
		||||
        self._assertion_id = sp_config
 | 
			
		||||
        self._django_request = django_request
 | 
			
		||||
        self._relay_state = sp_config
 | 
			
		||||
        self._request = sp_config
 | 
			
		||||
        self._request_id = sp_config
 | 
			
		||||
        self._request_xml = sp_config
 | 
			
		||||
        self._request_params = sp_config
 | 
			
		||||
        self._response_id = sp_config
 | 
			
		||||
        self._response_xml = sp_config
 | 
			
		||||
        self._response_params = sp_config
 | 
			
		||||
        self._saml_request = sp_config
 | 
			
		||||
        self._saml_response = sp_config
 | 
			
		||||
        self._session_index = sp_config
 | 
			
		||||
        self._subject = sp_config
 | 
			
		||||
        self._subject_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
 | 
			
		||||
        self._system_params = {"ISSUER": self._remote.issuer}
 | 
			
		||||
 | 
			
		||||
    def _validate_request(self):
 | 
			
		||||
        """
 | 
			
		||||
        Validates the SAML request against the SP configuration of this
 | 
			
		||||
        processor. Sub-classes should override this and raise a
 | 
			
		||||
        `CannotHandleAssertion` exception if the validation fails.
 | 
			
		||||
 | 
			
		||||
        Raises:
 | 
			
		||||
            CannotHandleAssertion: if the ACS URL specified in the SAML request
 | 
			
		||||
                doesn't match the one specified in the processor config.
 | 
			
		||||
        """
 | 
			
		||||
        request_acs_url = self._request_params["ACS_URL"]
 | 
			
		||||
 | 
			
		||||
        if self._remote.acs_url != request_acs_url:
 | 
			
		||||
            msg = "couldn't find ACS url '{}' in SAML2IDP_REMOTES " "setting.".format(
 | 
			
		||||
                request_acs_url
 | 
			
		||||
            )
 | 
			
		||||
            self._logger.info(msg)
 | 
			
		||||
            raise exceptions.CannotHandleAssertion(msg)
 | 
			
		||||
 | 
			
		||||
    def _validate_user(self):
 | 
			
		||||
        """Validates the User. Sub-classes should override this and
 | 
			
		||||
        throw an CannotHandleAssertion Exception if the validation does not succeed."""
 | 
			
		||||
 | 
			
		||||
    def can_handle(self, request):
 | 
			
		||||
        """Returns true if this processor can handle this request."""
 | 
			
		||||
        self._reset(request)
 | 
			
		||||
        # Read the request.
 | 
			
		||||
        try:
 | 
			
		||||
            self._extract_saml_request()
 | 
			
		||||
        except Exception as exc:
 | 
			
		||||
            msg = "can't find SAML request in user session: %s" % exc
 | 
			
		||||
            self._logger.info(msg)
 | 
			
		||||
            raise exceptions.CannotHandleAssertion(msg)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            self._decode_request()
 | 
			
		||||
        except Exception as exc:
 | 
			
		||||
            msg = "can't decode SAML request: %s" % exc
 | 
			
		||||
            self._logger.info(msg)
 | 
			
		||||
            raise exceptions.CannotHandleAssertion(msg)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            self._parse_request()
 | 
			
		||||
        except Exception as exc:
 | 
			
		||||
            msg = "can't parse SAML request: %s" % exc
 | 
			
		||||
            self._logger.info(msg)
 | 
			
		||||
            raise exceptions.CannotHandleAssertion(msg)
 | 
			
		||||
 | 
			
		||||
        self._validate_request()
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def generate_response(self):
 | 
			
		||||
        """Processes request and returns template variables suitable for a response."""
 | 
			
		||||
        # Build the assertion and response.
 | 
			
		||||
        # Only call can_handle if SP initiated Request, otherwise we have no Request
 | 
			
		||||
        if not self.is_idp_initiated:
 | 
			
		||||
            self.can_handle(self._django_request)
 | 
			
		||||
 | 
			
		||||
        self._validate_user()
 | 
			
		||||
        self._build_assertion()
 | 
			
		||||
        self._format_assertion()
 | 
			
		||||
        self._build_response()
 | 
			
		||||
        self._format_response()
 | 
			
		||||
        self._encode_response()
 | 
			
		||||
 | 
			
		||||
        # Return proper template params.
 | 
			
		||||
        return self._get_django_response_params()
 | 
			
		||||
 | 
			
		||||
    def init_deep_link(self, request, url):
 | 
			
		||||
        """Initialize this Processor to make an IdP-initiated call to the SP's
 | 
			
		||||
        deep-linked URL."""
 | 
			
		||||
        self._reset(request)
 | 
			
		||||
        acs_url = self._remote.acs_url
 | 
			
		||||
        # NOTE: The following request params are made up. Some are blank,
 | 
			
		||||
        # because they comes over in the AuthnRequest, but we don't have an
 | 
			
		||||
        # AuthnRequest in this case:
 | 
			
		||||
        # - Destination: Should be this IdP's SSO endpoint URL. Not used in the response?
 | 
			
		||||
        # - ProviderName: According to the spec, this is optional.
 | 
			
		||||
        self._request_params = {
 | 
			
		||||
            "ACS_URL": acs_url,
 | 
			
		||||
            "DESTINATION": "",
 | 
			
		||||
            "PROVIDER_NAME": "",
 | 
			
		||||
        }
 | 
			
		||||
        self._relay_state = url
 | 
			
		||||
@ -3,7 +3,3 @@
 | 
			
		||||
 | 
			
		||||
class CannotHandleAssertion(Exception):
 | 
			
		||||
    """This processor does not handle this assertion."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserNotAuthorized(Exception):
 | 
			
		||||
    """User not authorized for SAML 2.0 authentication."""
 | 
			
		||||
 | 
			
		||||
@ -4,13 +4,12 @@ from django import forms
 | 
			
		||||
from django.contrib.admin.widgets import FilteredSelectMultiple
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
 | 
			
		||||
from passbook.lib.fields import DynamicArrayField
 | 
			
		||||
from passbook.providers.saml.models import (
 | 
			
		||||
    SAMLPropertyMapping,
 | 
			
		||||
    SAMLProvider,
 | 
			
		||||
    get_provider_choices,
 | 
			
		||||
)
 | 
			
		||||
from passbook.providers.saml.utils import CertificateBuilder
 | 
			
		||||
from passbook.providers.saml.utils.cert import CertificateBuilder
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SAMLProviderForm(forms.ModelForm):
 | 
			
		||||
@ -32,24 +31,27 @@ class SAMLProviderForm(forms.ModelForm):
 | 
			
		||||
        model = SAMLProvider
 | 
			
		||||
        fields = [
 | 
			
		||||
            "name",
 | 
			
		||||
            "property_mappings",
 | 
			
		||||
            "processor_path",
 | 
			
		||||
            "acs_url",
 | 
			
		||||
            "audience",
 | 
			
		||||
            "processor_path",
 | 
			
		||||
            "issuer",
 | 
			
		||||
            "assertion_valid_for",
 | 
			
		||||
            "assertion_valid_not_before",
 | 
			
		||||
            "assertion_valid_not_on_or_after",
 | 
			
		||||
            "session_valid_not_on_or_after",
 | 
			
		||||
            "property_mappings",
 | 
			
		||||
            "digest_algorithm",
 | 
			
		||||
            "signature_algorithm",
 | 
			
		||||
            "signing",
 | 
			
		||||
            "signing_cert",
 | 
			
		||||
            "signing_key",
 | 
			
		||||
        ]
 | 
			
		||||
        labels = {
 | 
			
		||||
            "acs_url": "ACS URL",
 | 
			
		||||
            "signing_cert": "Singing Certificate",
 | 
			
		||||
        }
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "name": forms.TextInput(),
 | 
			
		||||
            "audience": forms.TextInput(),
 | 
			
		||||
            "issuer": forms.TextInput(),
 | 
			
		||||
            "assertion_valid_not_before": forms.TextInput(),
 | 
			
		||||
            "assertion_valid_not_on_or_after": forms.TextInput(),
 | 
			
		||||
            "session_valid_not_on_or_after": forms.TextInput(),
 | 
			
		||||
            "property_mappings": FilteredSelectMultiple(_("Property Mappings"), False),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -57,16 +59,14 @@ class SAMLProviderForm(forms.ModelForm):
 | 
			
		||||
class SAMLPropertyMappingForm(forms.ModelForm):
 | 
			
		||||
    """SAML Property Mapping form"""
 | 
			
		||||
 | 
			
		||||
    template_name = "saml/idp/property_mapping_form.html"
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        model = SAMLPropertyMapping
 | 
			
		||||
        fields = ["name", "saml_name", "friendly_name", "values"]
 | 
			
		||||
        fields = ["name", "saml_name", "friendly_name", "expression"]
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "name": forms.TextInput(),
 | 
			
		||||
            "saml_name": forms.TextInput(),
 | 
			
		||||
            "friendly_name": forms.TextInput(),
 | 
			
		||||
        }
 | 
			
		||||
        field_classes = {"values": DynamicArrayField}
 | 
			
		||||
        help_texts = {
 | 
			
		||||
            "values": 'String substitution uses a syntax like "{variable} test}".'
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,61 @@
 | 
			
		||||
# Generated by Django 2.2.9 on 2020-02-14 13:54
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
import passbook.providers.saml.utils.time
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def migrate_valid_for(apps, schema_editor):
 | 
			
		||||
    """Migrate from single number standing for minutes to 'minutes=3'"""
 | 
			
		||||
    SAMLProvider = apps.get_model("passbook_providers_saml", "SAMLProvider")
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    for provider in SAMLProvider.objects.using(db_alias).all():
 | 
			
		||||
        provider.assertion_valid_not_on_or_after = (
 | 
			
		||||
            f"minutes={provider.assertion_valid_for}"
 | 
			
		||||
        )
 | 
			
		||||
        provider.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_providers_saml", "0001_initial"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="samlprovider",
 | 
			
		||||
            name="assertion_valid_not_before",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                default="minutes=5",
 | 
			
		||||
                help_text="Assertion valid not before current time - this value (Format: hours=1;minutes=2;seconds=3).",
 | 
			
		||||
                validators=[
 | 
			
		||||
                    passbook.providers.saml.utils.time.timedelta_string_validator
 | 
			
		||||
                ],
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="samlprovider",
 | 
			
		||||
            name="assertion_valid_not_on_or_after",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                default="minutes=5",
 | 
			
		||||
                help_text="Assertion not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
 | 
			
		||||
                validators=[
 | 
			
		||||
                    passbook.providers.saml.utils.time.timedelta_string_validator
 | 
			
		||||
                ],
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(migrate_valid_for),
 | 
			
		||||
        migrations.RemoveField(model_name="samlprovider", name="assertion_valid_for",),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="samlprovider",
 | 
			
		||||
            name="session_valid_not_on_or_after",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                default="minutes=86400",
 | 
			
		||||
                help_text="Session not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
 | 
			
		||||
                validators=[
 | 
			
		||||
                    passbook.providers.saml.utils.time.timedelta_string_validator
 | 
			
		||||
                ],
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,38 @@
 | 
			
		||||
# Generated by Django 2.2.9 on 2020-02-16 11:09
 | 
			
		||||
 | 
			
		||||
import django.contrib.postgres.fields
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_providers_saml", "0002_auto_20200214_1354"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="samlpropertymapping",
 | 
			
		||||
            name="saml_name",
 | 
			
		||||
            field=models.TextField(verbose_name="SAML Name"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="samlpropertymapping",
 | 
			
		||||
            name="values",
 | 
			
		||||
            field=django.contrib.postgres.fields.ArrayField(
 | 
			
		||||
                base_field=models.TextField(),
 | 
			
		||||
                help_text="This string can contain string substitutions delimited by {}. The following Variables are available: user, request",
 | 
			
		||||
                size=None,
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="samlprovider",
 | 
			
		||||
            name="acs_url",
 | 
			
		||||
            field=models.URLField(verbose_name="ACS URL"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="samlprovider",
 | 
			
		||||
            name="signing_cert",
 | 
			
		||||
            field=models.TextField(verbose_name="Singing Certificate"),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,41 @@
 | 
			
		||||
# Generated by Django 3.0.3 on 2020-02-17 15:26
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_providers_saml", "0003_auto_20200216_1109"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="samlprovider",
 | 
			
		||||
            name="digest_algorithm",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                choices=[("sha1", "SHA1"), ("sha256", "SHA256")],
 | 
			
		||||
                default="sha256",
 | 
			
		||||
                max_length=50,
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="samlprovider",
 | 
			
		||||
            name="signature_algorithm",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                choices=[
 | 
			
		||||
                    ("rsa-sha1", "RSA-SHA1"),
 | 
			
		||||
                    ("rsa-sha256", "RSA-SHA256"),
 | 
			
		||||
                    ("ecdsa-sha256", "ECDSA-SHA256"),
 | 
			
		||||
                    ("dsa-sha1", "DSA-SHA1"),
 | 
			
		||||
                ],
 | 
			
		||||
                default="rsa-sha256",
 | 
			
		||||
                max_length=50,
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="samlprovider",
 | 
			
		||||
            name="processor_path",
 | 
			
		||||
            field=models.CharField(choices=[], max_length=255),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,76 @@
 | 
			
		||||
# Generated by Django 3.0.3 on 2020-02-17 16:15
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cleanup_old_autogenerated(apps, schema_editor):
 | 
			
		||||
    SAMLPropertyMapping = apps.get_model(
 | 
			
		||||
        "passbook_providers_saml", "SAMLPropertyMapping"
 | 
			
		||||
    )
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    SAMLPropertyMapping.objects.using(db_alias).filter(
 | 
			
		||||
        name__startswith="Autogenerated"
 | 
			
		||||
    ).delete()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_default_property_mappings(apps, schema_editor):
 | 
			
		||||
    """Create default SAML Property Mappings"""
 | 
			
		||||
    SAMLPropertyMapping = apps.get_model(
 | 
			
		||||
        "passbook_providers_saml", "SAMLPropertyMapping"
 | 
			
		||||
    )
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    defaults = [
 | 
			
		||||
        {
 | 
			
		||||
            "FriendlyName": "eduPersonPrincipalName",
 | 
			
		||||
            "Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
 | 
			
		||||
            "Expression": "{{ user.email }}",
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            "FriendlyName": "cn",
 | 
			
		||||
            "Name": "urn:oid:2.5.4.3",
 | 
			
		||||
            "Expression": "{{ user.name }}",
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            "FriendlyName": "mail",
 | 
			
		||||
            "Name": "urn:oid:0.9.2342.19200300.100.1.3",
 | 
			
		||||
            "Expression": "{{ user.email }}",
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            "FriendlyName": "displayName",
 | 
			
		||||
            "Name": "urn:oid:2.16.840.1.113730.3.1.241",
 | 
			
		||||
            "Expression": "{{ user.username }}",
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            "FriendlyName": "uid",
 | 
			
		||||
            "Name": "urn:oid:0.9.2342.19200300.100.1.1",
 | 
			
		||||
            "Expression": "{{ user.pk }}",
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
            "FriendlyName": "member-of",
 | 
			
		||||
            "Name": "member-of",
 | 
			
		||||
            "Expression": "[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]",
 | 
			
		||||
        },
 | 
			
		||||
    ]
 | 
			
		||||
    for default in defaults:
 | 
			
		||||
        SAMLPropertyMapping.objects.using(db_alias).get_or_create(
 | 
			
		||||
            saml_name=default["Name"],
 | 
			
		||||
            friendly_name=default["FriendlyName"],
 | 
			
		||||
            expression=default["Expression"],
 | 
			
		||||
            defaults={
 | 
			
		||||
                "name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}"
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_providers_saml", "0004_auto_20200217_1526"),
 | 
			
		||||
        ("passbook_core", "0007_auto_20200217_1934"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(cleanup_old_autogenerated),
 | 
			
		||||
        migrations.RemoveField(model_name="samlpropertymapping", name="values",),
 | 
			
		||||
        migrations.RunPython(create_default_property_mappings),
 | 
			
		||||
    ]
 | 
			
		||||
@ -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
 | 
			
		||||
                ],
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -1,13 +1,13 @@
 | 
			
		||||
"""passbook saml_idp Models"""
 | 
			
		||||
from django.contrib.postgres.fields import ArrayField
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.shortcuts import reverse
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.utils.translation import ugettext_lazy as _
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from passbook.core.models import PropertyMapping, Provider
 | 
			
		||||
from passbook.lib.utils.reflection import class_to_path, path_to_class
 | 
			
		||||
from passbook.providers.saml.base import Processor
 | 
			
		||||
from passbook.providers.saml.processors.base import Processor
 | 
			
		||||
from passbook.providers.saml.utils.time import timedelta_string_validator
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
@ -16,13 +16,62 @@ class SAMLProvider(Provider):
 | 
			
		||||
    """Model to save information about a Remote SAML Endpoint"""
 | 
			
		||||
 | 
			
		||||
    name = models.TextField()
 | 
			
		||||
    acs_url = models.URLField()
 | 
			
		||||
    audience = models.TextField(default="")
 | 
			
		||||
    processor_path = models.CharField(max_length=255, choices=[])
 | 
			
		||||
 | 
			
		||||
    acs_url = models.URLField(verbose_name=_("ACS URL"))
 | 
			
		||||
    audience = models.TextField(default="")
 | 
			
		||||
    issuer = models.TextField()
 | 
			
		||||
    assertion_valid_for = models.IntegerField(default=86400)
 | 
			
		||||
 | 
			
		||||
    assertion_valid_not_before = models.TextField(
 | 
			
		||||
        default="minutes=-5",
 | 
			
		||||
        validators=[timedelta_string_validator],
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            (
 | 
			
		||||
                "Assertion valid not before current time + this value "
 | 
			
		||||
                "(Format: hours=-1;minutes=-2;seconds=-3)."
 | 
			
		||||
            )
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    assertion_valid_not_on_or_after = models.TextField(
 | 
			
		||||
        default="minutes=5",
 | 
			
		||||
        validators=[timedelta_string_validator],
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            (
 | 
			
		||||
                "Assertion not valid on or after current time + this value "
 | 
			
		||||
                "(Format: hours=1;minutes=2;seconds=3)."
 | 
			
		||||
            )
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    session_valid_not_on_or_after = models.TextField(
 | 
			
		||||
        default="minutes=86400",
 | 
			
		||||
        validators=[timedelta_string_validator],
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            (
 | 
			
		||||
                "Session not valid on or after current time + this value "
 | 
			
		||||
                "(Format: hours=1;minutes=2;seconds=3)."
 | 
			
		||||
            )
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    digest_algorithm = models.CharField(
 | 
			
		||||
        max_length=50,
 | 
			
		||||
        choices=(("sha1", _("SHA1")), ("sha256", _("SHA256")),),
 | 
			
		||||
        default="sha256",
 | 
			
		||||
    )
 | 
			
		||||
    signature_algorithm = models.CharField(
 | 
			
		||||
        max_length=50,
 | 
			
		||||
        choices=(
 | 
			
		||||
            ("rsa-sha1", _("RSA-SHA1")),
 | 
			
		||||
            ("rsa-sha256", _("RSA-SHA256")),
 | 
			
		||||
            ("ecdsa-sha256", _("ECDSA-SHA256")),
 | 
			
		||||
            ("dsa-sha1", _("DSA-SHA1")),
 | 
			
		||||
        ),
 | 
			
		||||
        default="rsa-sha256",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    signing = models.BooleanField(default=True)
 | 
			
		||||
    signing_cert = models.TextField()
 | 
			
		||||
    signing_cert = models.TextField(verbose_name=_("Singing Certificate"))
 | 
			
		||||
    signing_key = models.TextField()
 | 
			
		||||
 | 
			
		||||
    form = "passbook.providers.saml.forms.SAMLProviderForm"
 | 
			
		||||
@ -33,7 +82,7 @@ class SAMLProvider(Provider):
 | 
			
		||||
        self._meta.get_field("processor_path").choices = get_provider_choices()
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def processor(self):
 | 
			
		||||
    def processor(self) -> Processor:
 | 
			
		||||
        """Return selected processor as instance"""
 | 
			
		||||
        if not self._processor:
 | 
			
		||||
            try:
 | 
			
		||||
@ -44,7 +93,7 @@ class SAMLProvider(Provider):
 | 
			
		||||
        return self._processor
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return "SAML Provider %s" % self.name
 | 
			
		||||
        return f"SAML Provider {self.name}"
 | 
			
		||||
 | 
			
		||||
    def link_download_metadata(self):
 | 
			
		||||
        """Get link to download XML metadata for admin interface"""
 | 
			
		||||
@ -57,6 +106,16 @@ class SAMLProvider(Provider):
 | 
			
		||||
        except Provider.application.RelatedObjectDoesNotExist:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def html_metadata_view(self, request):
 | 
			
		||||
        """return template and context modal with to view Metadata without downloading it"""
 | 
			
		||||
        from passbook.providers.saml.views import DescriptorDownloadView
 | 
			
		||||
 | 
			
		||||
        metadata = DescriptorDownloadView.get_metadata(request, self)
 | 
			
		||||
        return (
 | 
			
		||||
            "saml/idp/admin_metadata_modal.html",
 | 
			
		||||
            {"provider": self, "metadata": metadata,},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        verbose_name = _("SAML Provider")
 | 
			
		||||
@ -66,14 +125,13 @@ class SAMLProvider(Provider):
 | 
			
		||||
class SAMLPropertyMapping(PropertyMapping):
 | 
			
		||||
    """SAML Property mapping, allowing Name/FriendlyName mapping to a list of strings"""
 | 
			
		||||
 | 
			
		||||
    saml_name = models.TextField()
 | 
			
		||||
    saml_name = models.TextField(verbose_name="SAML Name")
 | 
			
		||||
    friendly_name = models.TextField(default=None, blank=True, null=True)
 | 
			
		||||
    values = ArrayField(models.TextField())
 | 
			
		||||
 | 
			
		||||
    form = "passbook.providers.saml.forms.SAMLPropertyMappingForm"
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return "SAML Property Mapping %s" % self.saml_name
 | 
			
		||||
        return f"SAML Property Mapping {self.saml_name}"
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										230
									
								
								passbook/providers/saml/processors/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										230
									
								
								passbook/providers/saml/processors/base.py
									
									
									
									
									
										Normal 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 = ""
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
"""Generic Processor"""
 | 
			
		||||
 | 
			
		||||
from passbook.providers.saml.base import Processor
 | 
			
		||||
from passbook.providers.saml.processors.base import Processor
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GenericProcessor(Processor):
 | 
			
		||||
    """Generic Response Handler Processor for testing against django-saml2-sp."""
 | 
			
		||||
    """Generic SAML2 Processor"""
 | 
			
		||||
 | 
			
		||||
@ -1,16 +1,14 @@
 | 
			
		||||
"""Salesforce Processor"""
 | 
			
		||||
 | 
			
		||||
from passbook.providers.saml.base import Processor
 | 
			
		||||
from passbook.providers.saml.xml_render import get_assertion_xml
 | 
			
		||||
from passbook.providers.saml.processors.generic import GenericProcessor
 | 
			
		||||
from passbook.providers.saml.utils.xml_render import get_assertion_xml
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SalesForceProcessor(Processor):
 | 
			
		||||
class SalesForceProcessor(GenericProcessor):
 | 
			
		||||
    """SalesForce.com-specific SAML 2.0 AuthnRequest to Response Handler Processor."""
 | 
			
		||||
 | 
			
		||||
    def _determine_audience(self):
 | 
			
		||||
        self._audience = "IAMShowcase"
 | 
			
		||||
 | 
			
		||||
    def _format_assertion(self):
 | 
			
		||||
        super()._format_assertion()
 | 
			
		||||
        self._assertion_xml = get_assertion_xml(
 | 
			
		||||
            "saml/xml/assertions/salesforce.xml", self._assertion_params, signed=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										11
									
								
								passbook/providers/saml/processors/types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								passbook/providers/saml/processors/types.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
			
		||||
"""passbook saml provider types"""
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class SAMLResponseParams:
 | 
			
		||||
    """Class to keep track of SAML Response Parameters"""
 | 
			
		||||
 | 
			
		||||
    acs_url: str
 | 
			
		||||
    saml_response: str
 | 
			
		||||
    relay_state: str
 | 
			
		||||
@ -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>
 | 
			
		||||
@ -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 %}
 | 
			
		||||
@ -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 %}
 | 
			
		||||
@ -1,5 +1,9 @@
 | 
			
		||||
{% extends "saml/idp/base.html" %}
 | 
			
		||||
{% extends "login/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
{% block card %}
 | 
			
		||||
<p>
 | 
			
		||||
    {% trans "You have successfully logged out of the Identity Provider." %}
 | 
			
		||||
</p>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
@ -11,15 +11,15 @@
 | 
			
		||||
<header class="login-pf-header">
 | 
			
		||||
    <h1>{% trans 'Authorize Application' %}</h1>
 | 
			
		||||
</header>
 | 
			
		||||
<form method="POST" action="{{ acs_url }}">
 | 
			
		||||
<form method="POST" action="{{ saml_params.acs_url }}">
 | 
			
		||||
    {% csrf_token %}
 | 
			
		||||
    <input type="hidden" name="ACSUrl" value="{{ acs_url }}">
 | 
			
		||||
    <input type="hidden" name="RelayState" value="{{ relay_state }}" />
 | 
			
		||||
    <input type="hidden" name="SAMLResponse" value="{{ saml_response }}" />
 | 
			
		||||
    <input type="hidden" name="ACSUrl" value="{{ saml_params.acs_url }}">
 | 
			
		||||
    <input type="hidden" name="RelayState" value="{{ saml_params.relay_state }}" />
 | 
			
		||||
    <input type="hidden" name="SAMLResponse" value="{{ saml_params.saml_response }}" />
 | 
			
		||||
    <div class="login-group">
 | 
			
		||||
        <h3>
 | 
			
		||||
            {% blocktrans with remote=remote.application.name %}
 | 
			
		||||
            You're about to sign into {{ remote }}
 | 
			
		||||
            {% blocktrans with provider=provider.application.name %}
 | 
			
		||||
            You're about to sign into {{ provider }}
 | 
			
		||||
            {% endblocktrans %}
 | 
			
		||||
        </h3>
 | 
			
		||||
        <p>
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
@ -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 %}
 | 
			
		||||
							
								
								
									
										0
									
								
								passbook/providers/saml/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								passbook/providers/saml/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										30
									
								
								passbook/providers/saml/tests/test_utils_time.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								passbook/providers/saml/tests/test_utils_time.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,30 @@
 | 
			
		||||
"""Test time utils"""
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
from passbook.providers.saml.utils.time import (
 | 
			
		||||
    timedelta_from_string,
 | 
			
		||||
    timedelta_string_validator,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestTimeUtils(TestCase):
 | 
			
		||||
    """Test time-utils"""
 | 
			
		||||
 | 
			
		||||
    def test_valid(self):
 | 
			
		||||
        """Test valid expression"""
 | 
			
		||||
        expr = "hours=3;minutes=1"
 | 
			
		||||
        expected = timedelta(hours=3, minutes=1)
 | 
			
		||||
        self.assertEqual(timedelta_from_string(expr), expected)
 | 
			
		||||
 | 
			
		||||
    def test_invalid(self):
 | 
			
		||||
        """Test invalid expression"""
 | 
			
		||||
        with self.assertRaises(ValueError):
 | 
			
		||||
            timedelta_from_string("foo")
 | 
			
		||||
 | 
			
		||||
    def test_validation(self):
 | 
			
		||||
        """Test Django model field validator"""
 | 
			
		||||
        with self.assertRaises(ValidationError):
 | 
			
		||||
            timedelta_string_validator("foo")
 | 
			
		||||
@ -4,14 +4,17 @@ from django.urls import path
 | 
			
		||||
from passbook.providers.saml import views
 | 
			
		||||
 | 
			
		||||
urlpatterns = [
 | 
			
		||||
    path(
 | 
			
		||||
        "<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login"
 | 
			
		||||
    ),
 | 
			
		||||
    # This view is used to initiate a Login-flow from the IDP
 | 
			
		||||
    path(
 | 
			
		||||
        "<slug:application>/login/initiate/",
 | 
			
		||||
        views.InitiateLoginView.as_view(),
 | 
			
		||||
        name="saml-login-initiate",
 | 
			
		||||
    ),
 | 
			
		||||
    # This view is the endpoint a SP would redirect to, and saves data into the session
 | 
			
		||||
    # this is required as the process view which it redirects to might have to login first.
 | 
			
		||||
    path(
 | 
			
		||||
        "<slug:application>/login/", views.LoginProcessView.as_view(), name="saml-login"
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "<slug:application>/login/process/",
 | 
			
		||||
        views.LoginProcessView.as_view(),
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										18
									
								
								passbook/providers/saml/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								passbook/providers/saml/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
"""Small helper functions"""
 | 
			
		||||
import uuid
 | 
			
		||||
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.shortcuts import render
 | 
			
		||||
from django.template.context import Context
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def render_xml(request: HttpRequest, template: str, ctx: Context) -> HttpResponse:
 | 
			
		||||
    """Render template with content_type application/xml"""
 | 
			
		||||
    return render(request, template, context=ctx, content_type="application/xml")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_random_id() -> str:
 | 
			
		||||
    """Random hex id"""
 | 
			
		||||
    # It is very important that these random IDs NOT start with a number.
 | 
			
		||||
    random_id = "_" + uuid.uuid4().hex
 | 
			
		||||
    return random_id
 | 
			
		||||
@ -1,8 +1,6 @@
 | 
			
		||||
"""Wrappers to de/encode and de/inflate strings"""
 | 
			
		||||
import base64
 | 
			
		||||
"""Create self-signed certificates"""
 | 
			
		||||
import datetime
 | 
			
		||||
import uuid
 | 
			
		||||
import zlib
 | 
			
		||||
 | 
			
		||||
from cryptography import x509
 | 
			
		||||
from cryptography.hazmat.backends import default_backend
 | 
			
		||||
@ -11,24 +9,6 @@ from cryptography.hazmat.primitives.asymmetric import rsa
 | 
			
		||||
from cryptography.x509.oid import NameOID
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def decode_base64_and_inflate(b64string):
 | 
			
		||||
    """Base64 decode and ZLib decompress b64string"""
 | 
			
		||||
    decoded_data = base64.b64decode(b64string)
 | 
			
		||||
    return zlib.decompress(decoded_data, -15)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def deflate_and_base64_encode(string_val):
 | 
			
		||||
    """Base64 and ZLib Compress b64string"""
 | 
			
		||||
    zlibbed_str = zlib.compress(string_val)
 | 
			
		||||
    compressed_string = zlibbed_str[2:-4]
 | 
			
		||||
    return base64.b64encode(compressed_string)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def nice64(src):
 | 
			
		||||
    """ Returns src base64-encoded and formatted nicely for our XML. """
 | 
			
		||||
    return base64.b64encode(src).decode("utf-8").replace("\n", "")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CertificateBuilder:
 | 
			
		||||
    """Build self-signed certificates"""
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										24
									
								
								passbook/providers/saml/utils/encoding.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								passbook/providers/saml/utils/encoding.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
"""Wrappers to de/encode and de/inflate strings"""
 | 
			
		||||
import base64
 | 
			
		||||
import zlib
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def decode_base64_and_inflate(b64string):
 | 
			
		||||
    """Base64 decode and ZLib decompress b64string"""
 | 
			
		||||
    decoded_data = base64.b64decode(b64string)
 | 
			
		||||
    try:
 | 
			
		||||
        return zlib.decompress(decoded_data, -15)
 | 
			
		||||
    except zlib.error:
 | 
			
		||||
        return decoded_data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def deflate_and_base64_encode(string_val):
 | 
			
		||||
    """Base64 and ZLib Compress b64string"""
 | 
			
		||||
    zlibbed_str = zlib.compress(string_val)
 | 
			
		||||
    compressed_string = zlibbed_str[2:-4]
 | 
			
		||||
    return base64.b64encode(compressed_string)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def nice64(src):
 | 
			
		||||
    """ Returns src base64-encoded and formatted nicely for our XML. """
 | 
			
		||||
    return base64.b64encode(src).decode("utf-8").replace("\n", "")
 | 
			
		||||
							
								
								
									
										47
									
								
								passbook/providers/saml/utils/time.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								passbook/providers/saml/utils/time.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,47 @@
 | 
			
		||||
"""Time utilities"""
 | 
			
		||||
import datetime
 | 
			
		||||
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
ALLOWED_KEYS = (
 | 
			
		||||
    "days",
 | 
			
		||||
    "seconds",
 | 
			
		||||
    "microseconds",
 | 
			
		||||
    "milliseconds",
 | 
			
		||||
    "minutes",
 | 
			
		||||
    "hours",
 | 
			
		||||
    "weeks",
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def timedelta_string_validator(value: str):
 | 
			
		||||
    """Validator for Django that checks if value can be parsed with `timedelta_from_string`"""
 | 
			
		||||
    try:
 | 
			
		||||
        timedelta_from_string(value)
 | 
			
		||||
    except ValueError as exc:
 | 
			
		||||
        raise ValidationError(
 | 
			
		||||
            _("%(value)s is not in the correct format of 'hours=3;minutes=1'."),
 | 
			
		||||
            params={"value": value},
 | 
			
		||||
        ) from exc
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def timedelta_from_string(expr: str) -> datetime.timedelta:
 | 
			
		||||
    """Convert a string with the format of 'hours=1;minute=3;seconds=5' to a
 | 
			
		||||
    `datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5"""
 | 
			
		||||
    kwargs = {}
 | 
			
		||||
    for duration_pair in expr.split(";"):
 | 
			
		||||
        key, value = duration_pair.split("=")
 | 
			
		||||
        if key.lower() not in ALLOWED_KEYS:
 | 
			
		||||
            continue
 | 
			
		||||
        kwargs[key.lower()] = float(value)
 | 
			
		||||
    return datetime.timedelta(**kwargs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_time_string(delta: datetime.timedelta = None) -> str:
 | 
			
		||||
    """Get Data formatted in SAML format"""
 | 
			
		||||
    if delta is None:
 | 
			
		||||
        delta = datetime.timedelta()
 | 
			
		||||
    now = datetime.datetime.now()
 | 
			
		||||
    final = now + delta
 | 
			
		||||
    return final.strftime("%Y-%m-%dT%H:%M:%SZ")
 | 
			
		||||
@ -6,7 +6,10 @@ from typing import TYPE_CHECKING
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from passbook.lib.utils.template import render_to_string
 | 
			
		||||
from passbook.providers.saml.xml_signing import get_signature_xml, sign_with_signxml
 | 
			
		||||
from passbook.providers.saml.utils.xml_signing import (
 | 
			
		||||
    get_signature_xml,
 | 
			
		||||
    sign_with_signxml,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from passbook.providers.saml.models import SAMLProvider
 | 
			
		||||
@ -60,7 +63,6 @@ def get_assertion_xml(template, parameters, signed=False):
 | 
			
		||||
    _get_attribute_statement(params)
 | 
			
		||||
 | 
			
		||||
    unsigned = render_to_string(template, params)
 | 
			
		||||
    # LOGGER.debug('Unsigned: %s', unsigned)
 | 
			
		||||
    if not signed:
 | 
			
		||||
        return unsigned
 | 
			
		||||
 | 
			
		||||
@ -80,18 +82,11 @@ def get_response_xml(parameters, saml_provider: SAMLProvider, assertion_id=""):
 | 
			
		||||
 | 
			
		||||
    raw_response = render_to_string("saml/xml/response.xml", params)
 | 
			
		||||
 | 
			
		||||
    # LOGGER.debug('Unsigned: %s', unsigned)
 | 
			
		||||
    if not saml_provider.signing:
 | 
			
		||||
        return raw_response
 | 
			
		||||
 | 
			
		||||
    signature_xml = get_signature_xml()
 | 
			
		||||
    params["RESPONSE_SIGNATURE"] = signature_xml
 | 
			
		||||
    # LOGGER.debug("Raw response: %s", raw_response)
 | 
			
		||||
 | 
			
		||||
    signed = sign_with_signxml(
 | 
			
		||||
        saml_provider.signing_key,
 | 
			
		||||
        raw_response,
 | 
			
		||||
        saml_provider.signing_cert,
 | 
			
		||||
        reference_uri=assertion_id,
 | 
			
		||||
    )
 | 
			
		||||
    signed = sign_with_signxml(raw_response, saml_provider, reference_uri=assertion_id,)
 | 
			
		||||
    return signed
 | 
			
		||||
@ -1,4 +1,6 @@
 | 
			
		||||
"""Signing code goes here."""
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
from cryptography.hazmat.backends import default_backend
 | 
			
		||||
from cryptography.hazmat.primitives import serialization
 | 
			
		||||
from lxml import etree  # nosec
 | 
			
		||||
@ -7,25 +9,34 @@ from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from passbook.lib.utils.template import render_to_string
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from passbook.providers.saml.models import SAMLProvider
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def sign_with_signxml(private_key, data, cert, reference_uri=None):
 | 
			
		||||
def sign_with_signxml(data: str, provider: "SAMLProvider", reference_uri=None) -> str:
 | 
			
		||||
    """Sign Data with signxml"""
 | 
			
		||||
    key = serialization.load_pem_private_key(
 | 
			
		||||
        str.encode("\n".join([x.strip() for x in private_key.split("\n")])),
 | 
			
		||||
        str.encode("\n".join([x.strip() for x in provider.signing_key.split("\n")])),
 | 
			
		||||
        password=None,
 | 
			
		||||
        backend=default_backend(),
 | 
			
		||||
    )
 | 
			
		||||
    # defused XML is not used here because it messes up XML namespaces
 | 
			
		||||
    # Data is trusted, so lxml is ok
 | 
			
		||||
    root = etree.fromstring(data)  # nosec
 | 
			
		||||
    signer = XMLSigner(c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#")
 | 
			
		||||
    signed = signer.sign(root, key=key, cert=[cert], reference_uri=reference_uri)
 | 
			
		||||
    XMLVerifier().verify(signed, x509_cert=cert)
 | 
			
		||||
    signer = XMLSigner(
 | 
			
		||||
        c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#",
 | 
			
		||||
        signature_algorithm=provider.signature_algorithm,
 | 
			
		||||
        digest_algorithm=provider.digest_algorithm,
 | 
			
		||||
    )
 | 
			
		||||
    signed = signer.sign(
 | 
			
		||||
        root, key=key, cert=[provider.signing_cert], reference_uri=reference_uri
 | 
			
		||||
    )
 | 
			
		||||
    XMLVerifier().verify(signed, x509_cert=provider.signing_cert)
 | 
			
		||||
    return etree.tostring(signed).decode("utf-8")  # nosec
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_signature_xml():
 | 
			
		||||
def get_signature_xml() -> str:
 | 
			
		||||
    """Returns XML Signature for subject."""
 | 
			
		||||
    return render_to_string("saml/xml/signature.xml", {})
 | 
			
		||||
@ -1,12 +1,15 @@
 | 
			
		||||
"""passbook SAML IDP Views"""
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth import logout
 | 
			
		||||
from django.contrib.auth.mixins import AccessMixin
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.core.validators import URLValidator
 | 
			
		||||
from django.http import HttpResponse, HttpResponseBadRequest
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.shortcuts import get_object_or_404, redirect, render, reverse
 | 
			
		||||
from django.utils.datastructures import MultiValueDictKeyError
 | 
			
		||||
from django.utils.decorators import method_decorator
 | 
			
		||||
from django.utils.html import mark_safe
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.views import View
 | 
			
		||||
from django.views.decorators.csrf import csrf_exempt
 | 
			
		||||
@ -17,40 +20,23 @@ from passbook.audit.models import Event, EventAction
 | 
			
		||||
from passbook.core.models import Application
 | 
			
		||||
from passbook.lib.mixins import CSRFExemptMixin
 | 
			
		||||
from passbook.lib.utils.template import render_to_string
 | 
			
		||||
from passbook.lib.views import bad_request_message
 | 
			
		||||
from passbook.policies.engine import PolicyEngine
 | 
			
		||||
from passbook.providers.saml import exceptions
 | 
			
		||||
from passbook.providers.saml.models import SAMLProvider
 | 
			
		||||
from passbook.providers.saml.processors.types import SAMLResponseParams
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
URL_VALIDATOR = URLValidator(schemes=("http", "https"))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _generate_response(request, provider: SAMLProvider):
 | 
			
		||||
    """Generate a SAML response using processor_instance and return it in the proper Django
 | 
			
		||||
    response."""
 | 
			
		||||
    try:
 | 
			
		||||
        provider.processor.init_deep_link(request, "")
 | 
			
		||||
        ctx = provider.processor.generate_response()
 | 
			
		||||
        ctx["remote"] = provider
 | 
			
		||||
        ctx["is_login"] = True
 | 
			
		||||
    except exceptions.UserNotAuthorized:
 | 
			
		||||
        return render(request, "saml/idp/invalid_user.html")
 | 
			
		||||
 | 
			
		||||
    return render(request, "saml/idp/login.html", ctx)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def render_xml(request, template, ctx):
 | 
			
		||||
    """Render template with content_type application/xml"""
 | 
			
		||||
    return render(request, template, context=ctx, content_type="application/xml")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccessRequiredView(AccessMixin, View):
 | 
			
		||||
    """Mixin class for Views using a provider instance"""
 | 
			
		||||
 | 
			
		||||
    _provider = None
 | 
			
		||||
    _provider: Optional[SAMLProvider] = None
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def provider(self):
 | 
			
		||||
    def provider(self) -> SAMLProvider:
 | 
			
		||||
        """Get provider instance"""
 | 
			
		||||
        if not self._provider:
 | 
			
		||||
            application = get_object_or_404(
 | 
			
		||||
@ -59,15 +45,18 @@ class AccessRequiredView(AccessMixin, View):
 | 
			
		||||
            self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id)
 | 
			
		||||
        return self._provider
 | 
			
		||||
 | 
			
		||||
    def _has_access(self):
 | 
			
		||||
    def _has_access(self) -> bool:
 | 
			
		||||
        """Check if user has access to application"""
 | 
			
		||||
        LOGGER.debug(
 | 
			
		||||
            "_has_access", user=self.request.user, app=self.provider.application
 | 
			
		||||
        )
 | 
			
		||||
        policy_engine = PolicyEngine(
 | 
			
		||||
            self.provider.application.policies.all(), self.request.user, self.request
 | 
			
		||||
        )
 | 
			
		||||
        policy_engine.build()
 | 
			
		||||
        return policy_engine.passing
 | 
			
		||||
 | 
			
		||||
    def dispatch(self, request, *args, **kwargs):
 | 
			
		||||
    def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        if not request.user.is_authenticated:
 | 
			
		||||
            return self.handle_no_permission()
 | 
			
		||||
        if not self._has_access():
 | 
			
		||||
@ -87,17 +76,17 @@ class LoginBeginView(AccessRequiredView):
 | 
			
		||||
    stores it in the session prior to enforcing login."""
 | 
			
		||||
 | 
			
		||||
    @method_decorator(csrf_exempt)
 | 
			
		||||
    def dispatch(self, request, application):
 | 
			
		||||
    def dispatch(self, request: HttpRequest, application: str) -> HttpResponse:
 | 
			
		||||
        if request.method == "POST":
 | 
			
		||||
            source = request.POST
 | 
			
		||||
        else:
 | 
			
		||||
            source = request.GET
 | 
			
		||||
        # Store these values now, because Django's login cycle won't preserve them.
 | 
			
		||||
 | 
			
		||||
        # Store these values now, because Django's login cycle won't preserve them.
 | 
			
		||||
        try:
 | 
			
		||||
            request.session["SAMLRequest"] = source["SAMLRequest"]
 | 
			
		||||
        except (KeyError, MultiValueDictKeyError):
 | 
			
		||||
            return HttpResponseBadRequest("the SAML request payload is missing")
 | 
			
		||||
            return bad_request_message(request, "The SAML request payload is missing.")
 | 
			
		||||
 | 
			
		||||
        request.session["RelayState"] = source.get("RelayState", "")
 | 
			
		||||
        return redirect(
 | 
			
		||||
@ -108,73 +97,83 @@ class LoginBeginView(AccessRequiredView):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RedirectToSPView(AccessRequiredView):
 | 
			
		||||
    """Return autosubmit form"""
 | 
			
		||||
 | 
			
		||||
    def get(self, request, acs_url, saml_response, relay_state):
 | 
			
		||||
        """Return autosubmit form"""
 | 
			
		||||
        return render(
 | 
			
		||||
            request,
 | 
			
		||||
            "core/autosubmit_form.html",
 | 
			
		||||
            {
 | 
			
		||||
                "url": acs_url,
 | 
			
		||||
                "attrs": {"SAMLResponse": saml_response, "RelayState": relay_state},
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LoginProcessView(AccessRequiredView):
 | 
			
		||||
    """Processor-based login continuation.
 | 
			
		||||
    Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request, application):
 | 
			
		||||
        """Handle get request, i.e. render form"""
 | 
			
		||||
        LOGGER.debug("SAMLLoginProcessView", request=request, method="get")
 | 
			
		||||
        # Check if user has access
 | 
			
		||||
        if self.provider.application.skip_authorization:
 | 
			
		||||
            ctx = self.provider.processor.generate_response()
 | 
			
		||||
    def handle_redirect(
 | 
			
		||||
        self, params: SAMLResponseParams, skipped_authorization: bool
 | 
			
		||||
    ) -> HttpResponse:
 | 
			
		||||
        """Handle direct redirect to SP"""
 | 
			
		||||
        # Log Application Authorization
 | 
			
		||||
        Event.new(
 | 
			
		||||
            EventAction.AUTHORIZE_APPLICATION,
 | 
			
		||||
            authorized_application=self.provider.application,
 | 
			
		||||
                skipped_authorization=True,
 | 
			
		||||
            ).from_http(request)
 | 
			
		||||
            return RedirectToSPView.as_view()(
 | 
			
		||||
                request=request,
 | 
			
		||||
                acs_url=ctx["acs_url"],
 | 
			
		||||
                saml_response=ctx["saml_response"],
 | 
			
		||||
                relay_state=ctx["relay_state"],
 | 
			
		||||
            skipped_authorization=skipped_authorization,
 | 
			
		||||
        ).from_http(self.request)
 | 
			
		||||
        return render(
 | 
			
		||||
            self.request,
 | 
			
		||||
            "saml/idp/autosubmit_form.html",
 | 
			
		||||
            {
 | 
			
		||||
                "url": params.acs_url,
 | 
			
		||||
                "attrs": {
 | 
			
		||||
                    "SAMLResponse": params.saml_response,
 | 
			
		||||
                    "RelayState": params.relay_state,
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get(self, request: HttpRequest, application: str) -> HttpResponse:
 | 
			
		||||
        """Handle get request, i.e. render form"""
 | 
			
		||||
        # User access gets checked in dispatch
 | 
			
		||||
 | 
			
		||||
        # Otherwise we generate the IdP initiated session
 | 
			
		||||
        try:
 | 
			
		||||
            full_res = _generate_response(request, self.provider)
 | 
			
		||||
            return full_res
 | 
			
		||||
            # application.skip_authorization is set so we directly redirect the user
 | 
			
		||||
            if self.provider.application.skip_authorization:
 | 
			
		||||
                self.provider.processor.can_handle(request)
 | 
			
		||||
                saml_params = self.provider.processor.generate_response()
 | 
			
		||||
                return self.handle_redirect(saml_params, True)
 | 
			
		||||
 | 
			
		||||
            self.provider.processor.init_deep_link(request)
 | 
			
		||||
            params = self.provider.processor.generate_response()
 | 
			
		||||
 | 
			
		||||
            return render(
 | 
			
		||||
                request,
 | 
			
		||||
                "saml/idp/login.html",
 | 
			
		||||
                {
 | 
			
		||||
                    "saml_params": params,
 | 
			
		||||
                    "provider": self.provider,
 | 
			
		||||
                    # This is only needed to for the template to render correctly
 | 
			
		||||
                    "is_login": True,
 | 
			
		||||
                },
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        except exceptions.CannotHandleAssertion as exc:
 | 
			
		||||
            LOGGER.debug(exc)
 | 
			
		||||
            LOGGER.error(exc)
 | 
			
		||||
            did_you_mean_link = request.build_absolute_uri(
 | 
			
		||||
                reverse(
 | 
			
		||||
                    "passbook_providers_saml:saml-login-initiate",
 | 
			
		||||
                    kwargs={"application": application},
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
            did_you_mean_message = (
 | 
			
		||||
                f" Did you mean to go <a href='{did_you_mean_link}'>here</a>?"
 | 
			
		||||
            )
 | 
			
		||||
            return bad_request_message(
 | 
			
		||||
                request, mark_safe(str(exc) + did_you_mean_message)
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def post(self, request, application):
 | 
			
		||||
    def post(self, request: HttpRequest, application: str) -> HttpResponse:
 | 
			
		||||
        """Handle post request, return back to ACS"""
 | 
			
		||||
        LOGGER.debug("SAMLLoginProcessView", request=request, method="post")
 | 
			
		||||
        # Check if user has access
 | 
			
		||||
        if request.POST.get("ACSUrl", None):
 | 
			
		||||
            # User accepted request
 | 
			
		||||
            Event.new(
 | 
			
		||||
                EventAction.AUTHORIZE_APPLICATION,
 | 
			
		||||
                authorized_application=self.provider.application,
 | 
			
		||||
                skipped_authorization=False,
 | 
			
		||||
            ).from_http(request)
 | 
			
		||||
            return RedirectToSPView.as_view()(
 | 
			
		||||
                request=request,
 | 
			
		||||
                acs_url=request.POST.get("ACSUrl"),
 | 
			
		||||
                saml_response=request.POST.get("SAMLResponse"),
 | 
			
		||||
                relay_state=request.POST.get("RelayState"),
 | 
			
		||||
            )
 | 
			
		||||
        try:
 | 
			
		||||
            full_res = _generate_response(request, self.provider)
 | 
			
		||||
            return full_res
 | 
			
		||||
        except exceptions.CannotHandleAssertion as exc:
 | 
			
		||||
            LOGGER.debug(exc)
 | 
			
		||||
        # User access gets checked in dispatch
 | 
			
		||||
 | 
			
		||||
        # we get here when skip_authorization is False, and after the user accepted
 | 
			
		||||
        # the authorization form
 | 
			
		||||
        self.provider.processor.can_handle(request)
 | 
			
		||||
        saml_params = self.provider.processor.generate_response()
 | 
			
		||||
        return self.handle_redirect(saml_params, True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LogoutView(CSRFExemptMixin, AccessRequiredView):
 | 
			
		||||
@ -183,7 +182,7 @@ class LogoutView(CSRFExemptMixin, AccessRequiredView):
 | 
			
		||||
    though it's technically not SAML 2.0)."""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request, application):
 | 
			
		||||
    def get(self, request: HttpRequest, application: str) -> HttpResponse:
 | 
			
		||||
        """Perform logout"""
 | 
			
		||||
        logout(request)
 | 
			
		||||
 | 
			
		||||
@ -204,7 +203,7 @@ class SLOLogout(CSRFExemptMixin, AccessRequiredView):
 | 
			
		||||
    logs out the user and returns a standard logged-out page."""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def post(self, request, application):
 | 
			
		||||
    def post(self, request: HttpRequest, application: str) -> HttpResponse:
 | 
			
		||||
        """Perform logout"""
 | 
			
		||||
        request.session["SAMLRequest"] = request.POST["SAMLRequest"]
 | 
			
		||||
        # TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
 | 
			
		||||
@ -219,22 +218,23 @@ class SLOLogout(CSRFExemptMixin, AccessRequiredView):
 | 
			
		||||
class DescriptorDownloadView(AccessRequiredView):
 | 
			
		||||
    """Replies with the XML Metadata IDSSODescriptor."""
 | 
			
		||||
 | 
			
		||||
    def get(self, request, application):
 | 
			
		||||
        """Replies with the XML Metadata IDSSODescriptor."""
 | 
			
		||||
        entity_id = self.provider.issuer
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
 | 
			
		||||
        """Return rendered XML Metadata"""
 | 
			
		||||
        entity_id = provider.issuer
 | 
			
		||||
        slo_url = request.build_absolute_uri(
 | 
			
		||||
            reverse(
 | 
			
		||||
                "passbook_providers_saml:saml-logout",
 | 
			
		||||
                kwargs={"application": application},
 | 
			
		||||
                kwargs={"application": provider.application},
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        sso_url = request.build_absolute_uri(
 | 
			
		||||
            reverse(
 | 
			
		||||
                "passbook_providers_saml:saml-login",
 | 
			
		||||
                kwargs={"application": application},
 | 
			
		||||
                kwargs={"application": provider.application},
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        pubkey = strip_pem_header(self.provider.signing_cert.replace("\r", "")).replace(
 | 
			
		||||
        pubkey = strip_pem_header(provider.signing_cert.replace("\r", "")).replace(
 | 
			
		||||
            "\n", ""
 | 
			
		||||
        )
 | 
			
		||||
        ctx = {
 | 
			
		||||
@ -243,7 +243,12 @@ class DescriptorDownloadView(AccessRequiredView):
 | 
			
		||||
            "slo_url": slo_url,
 | 
			
		||||
            "sso_url": sso_url,
 | 
			
		||||
        }
 | 
			
		||||
        metadata = render_to_string("saml/xml/metadata.xml", ctx)
 | 
			
		||||
        return render_to_string("saml/xml/metadata.xml", ctx)
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request: HttpRequest, application: str) -> HttpResponse:
 | 
			
		||||
        """Replies with the XML Metadata IDSSODescriptor."""
 | 
			
		||||
        metadata = DescriptorDownloadView.get_metadata(request, self.provider)
 | 
			
		||||
        response = HttpResponse(metadata, content_type="application/xml")
 | 
			
		||||
        response["Content-Disposition"] = (
 | 
			
		||||
            'attachment; filename="' '%s_passbook_meta.xml"' % self.provider.name
 | 
			
		||||
@ -254,9 +259,46 @@ class DescriptorDownloadView(AccessRequiredView):
 | 
			
		||||
class InitiateLoginView(AccessRequiredView):
 | 
			
		||||
    """IdP-initiated Login"""
 | 
			
		||||
 | 
			
		||||
    def handle_redirect(
 | 
			
		||||
        self, params: SAMLResponseParams, skipped_authorization: bool
 | 
			
		||||
    ) -> HttpResponse:
 | 
			
		||||
        """Handle direct redirect to SP"""
 | 
			
		||||
        # Log Application Authorization
 | 
			
		||||
        Event.new(
 | 
			
		||||
            EventAction.AUTHORIZE_APPLICATION,
 | 
			
		||||
            authorized_application=self.provider.application,
 | 
			
		||||
            skipped_authorization=skipped_authorization,
 | 
			
		||||
        ).from_http(self.request)
 | 
			
		||||
        return render(
 | 
			
		||||
            self.request,
 | 
			
		||||
            "saml/idp/autosubmit_form.html",
 | 
			
		||||
            {
 | 
			
		||||
                "url": params.acs_url,
 | 
			
		||||
                "attrs": {
 | 
			
		||||
                    "SAMLResponse": params.saml_response,
 | 
			
		||||
                    "RelayState": params.relay_state,
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request, application):
 | 
			
		||||
    def get(self, request: HttpRequest, application: str) -> HttpResponse:
 | 
			
		||||
        """Initiates an IdP-initiated link to a simple SP resource/target URL."""
 | 
			
		||||
        self.provider.processor.init_deep_link(request, "")
 | 
			
		||||
        self.provider.processor.is_idp_initiated = True
 | 
			
		||||
        return _generate_response(request, self.provider)
 | 
			
		||||
        self.provider.processor.init_deep_link(request)
 | 
			
		||||
        params = self.provider.processor.generate_response()
 | 
			
		||||
 | 
			
		||||
        # IdP-initiated Login Flow
 | 
			
		||||
        if self.provider.application.skip_authorization:
 | 
			
		||||
            return self.handle_redirect(params, True)
 | 
			
		||||
 | 
			
		||||
        return render(
 | 
			
		||||
            request,
 | 
			
		||||
            "saml/idp/login.html",
 | 
			
		||||
            {
 | 
			
		||||
                "saml_params": params,
 | 
			
		||||
                "provider": self.provider,
 | 
			
		||||
                # This is only needed to for the template to render correctly
 | 
			
		||||
                "is_login": True,
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
from base64 import b64encode
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.http import Http404, HttpRequest, HttpResponse
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.views import View
 | 
			
		||||
from django_prometheus.exports import ExportToDjangoView
 | 
			
		||||
 | 
			
		||||
@ -13,11 +13,13 @@ class MetricsView(View):
 | 
			
		||||
    def get(self, request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        """Check for HTTP-Basic auth"""
 | 
			
		||||
        auth_header = request.META.get("HTTP_AUTHORIZATION", "")
 | 
			
		||||
        auth_type, _, credentials = auth_header.partition(" ")
 | 
			
		||||
        auth_type, _, given_credentials = auth_header.partition(" ")
 | 
			
		||||
        credentials = f"monitor:{settings.SECRET_KEY}"
 | 
			
		||||
        expected = b64encode(str.encode(credentials)).decode()
 | 
			
		||||
 | 
			
		||||
        if auth_type != "Basic" or credentials != expected:
 | 
			
		||||
            raise Http404
 | 
			
		||||
        if auth_type != "Basic" or given_credentials != expected:
 | 
			
		||||
            response = HttpResponse(status=401)
 | 
			
		||||
            response["WWW-Authenticate"] = 'Basic realm="passbook-monitoring"'
 | 
			
		||||
            return response
 | 
			
		||||
 | 
			
		||||
        return ExportToDjangoView(request)
 | 
			
		||||
 | 
			
		||||
@ -98,6 +98,7 @@ INSTALLED_APPS = [
 | 
			
		||||
    "passbook.policies.password.apps.PassbookPoliciesPasswordConfig",
 | 
			
		||||
    "passbook.policies.sso.apps.PassbookPoliciesSSOConfig",
 | 
			
		||||
    "passbook.policies.webhook.apps.PassbookPoliciesWebhookConfig",
 | 
			
		||||
    "passbook.policies.expression.apps.PassbookPolicyExpressionConfig",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
GUARDIAN_MONKEY_PATCH = False
 | 
			
		||||
@ -276,7 +277,7 @@ structlog.configure_once(
 | 
			
		||||
        structlog.stdlib.PositionalArgumentsFormatter(),
 | 
			
		||||
        structlog.processors.TimeStamper(),
 | 
			
		||||
        structlog.processors.StackInfoRenderer(),
 | 
			
		||||
        # structlog.processors.format_exc_info,
 | 
			
		||||
        structlog.processors.format_exc_info,
 | 
			
		||||
        structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
 | 
			
		||||
    ],
 | 
			
		||||
    context_class=structlog.threadlocal.wrap_dict(dict),
 | 
			
		||||
 | 
			
		||||
@ -35,7 +35,7 @@ for _passbook_app in get_apps():
 | 
			
		||||
urlpatterns += [
 | 
			
		||||
    # Administration
 | 
			
		||||
    path("administration/django/", admin.site.urls),
 | 
			
		||||
    path("metrics", MetricsView.as_view(), name="metrics"),
 | 
			
		||||
    path("metrics/", MetricsView.as_view(), name="metrics"),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
if settings.DEBUG:
 | 
			
		||||
 | 
			
		||||
@ -62,7 +62,8 @@ class WSGILogger:
 | 
			
		||||
        if environ.get("QUERY_STRING") != "":
 | 
			
		||||
            query_string = f"?{environ.get('QUERY_STRING')}"
 | 
			
		||||
        self.logger.info(
 | 
			
		||||
            f"{environ.get('PATH_INFO', '')}{query_string}",
 | 
			
		||||
            "request",
 | 
			
		||||
            path=f"{environ.get('PATH_INFO', '')}{query_string}",
 | 
			
		||||
            host=host,
 | 
			
		||||
            method=environ.get("REQUEST_METHOD", ""),
 | 
			
		||||
            protocol=environ.get("SERVER_PROTOCOL", ""),
 | 
			
		||||
 | 
			
		||||
@ -35,7 +35,7 @@ class LDAPPropertyMappingSerializer(ModelSerializer):
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = LDAPPropertyMapping
 | 
			
		||||
        fields = ["pk", "name", "ldap_property", "object_field"]
 | 
			
		||||
        fields = ["pk", "name", "expression", "object_field"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LDAPSourceViewSet(ModelViewSet):
 | 
			
		||||
 | 
			
		||||
@ -5,8 +5,9 @@ import ldap3
 | 
			
		||||
import ldap3.core.exceptions
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from passbook.core.exceptions import PropertyMappingExpressionException
 | 
			
		||||
from passbook.core.models import Group, User
 | 
			
		||||
from passbook.sources.ldap.models import LDAPSource
 | 
			
		||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
@ -154,7 +155,14 @@ class Connector:
 | 
			
		||||
    ) -> Dict[str, Dict[Any, Any]]:
 | 
			
		||||
        properties = {"attributes": {}}
 | 
			
		||||
        for mapping in self._source.property_mappings.all().select_subclasses():
 | 
			
		||||
            properties[mapping.object_field] = attributes.get(mapping.ldap_property, "")
 | 
			
		||||
            mapping: LDAPPropertyMapping
 | 
			
		||||
            try:
 | 
			
		||||
                properties[mapping.object_field] = mapping.evaluate(
 | 
			
		||||
                    user=None, request=None, ldap=attributes
 | 
			
		||||
                )
 | 
			
		||||
            except PropertyMappingExpressionException as exc:
 | 
			
		||||
                LOGGER.warning(exc)
 | 
			
		||||
                continue
 | 
			
		||||
        if self._source.object_uniqueness_field in attributes:
 | 
			
		||||
            properties["attributes"]["ldap_uniq"] = attributes.get(
 | 
			
		||||
                self._source.object_uniqueness_field
 | 
			
		||||
 | 
			
		||||
@ -45,23 +45,17 @@ class LDAPSourceForm(forms.ModelForm):
 | 
			
		||||
            "policies": FilteredSelectMultiple(_("policies"), False),
 | 
			
		||||
            "property_mappings": FilteredSelectMultiple(_("Property Mappings"), False),
 | 
			
		||||
        }
 | 
			
		||||
        labels = {
 | 
			
		||||
            "server_uri": _("Server URI"),
 | 
			
		||||
            "bind_cn": _("Bind CN"),
 | 
			
		||||
            "start_tls": _("Enable Start TLS"),
 | 
			
		||||
            "base_dn": _("Base DN"),
 | 
			
		||||
            "additional_user_dn": _("Addition User DN"),
 | 
			
		||||
            "additional_group_dn": _("Addition Group DN"),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LDAPPropertyMappingForm(forms.ModelForm):
 | 
			
		||||
    """LDAP Property Mapping form"""
 | 
			
		||||
 | 
			
		||||
    template_name = "ldap/property_mapping_form.html"
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        model = LDAPPropertyMapping
 | 
			
		||||
        fields = ["name", "ldap_property", "object_field"]
 | 
			
		||||
        fields = ["name", "object_field", "expression"]
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "name": forms.TextInput(),
 | 
			
		||||
            "ldap_property": forms.TextInput(),
 | 
			
		||||
 | 
			
		||||
@ -13,8 +13,9 @@ def create_default_ad_property_mappings(apps: Apps, schema_editor):
 | 
			
		||||
        "sAMAccountName": "username",
 | 
			
		||||
        "mail": "email",
 | 
			
		||||
    }
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    for ldap_property, object_field in mapping.items():
 | 
			
		||||
        LDAPPropertyMapping.objects.get_or_create(
 | 
			
		||||
        LDAPPropertyMapping.objects.using(db_alias).get_or_create(
 | 
			
		||||
            ldap_property=ldap_property,
 | 
			
		||||
            object_field=object_field,
 | 
			
		||||
            defaults={
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										60
									
								
								passbook/sources/ldap/migrations/0006_auto_20200216_1116.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								passbook/sources/ldap/migrations/0006_auto_20200216_1116.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,60 @@
 | 
			
		||||
# Generated by Django 2.2.9 on 2020-02-16 11:16
 | 
			
		||||
 | 
			
		||||
import django.core.validators
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_sources_ldap", "0005_auto_20191011_1059"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="ldappropertymapping",
 | 
			
		||||
            name="ldap_property",
 | 
			
		||||
            field=models.TextField(verbose_name="LDAP Property"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="ldapsource",
 | 
			
		||||
            name="additional_group_dn",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                help_text="Prepended to Base DN for Group-queries.",
 | 
			
		||||
                verbose_name="Addition Group DN",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="ldapsource",
 | 
			
		||||
            name="additional_user_dn",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                help_text="Prepended to Base DN for User-queries.",
 | 
			
		||||
                verbose_name="Addition User DN",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="ldapsource",
 | 
			
		||||
            name="base_dn",
 | 
			
		||||
            field=models.TextField(verbose_name="Base DN"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="ldapsource",
 | 
			
		||||
            name="bind_cn",
 | 
			
		||||
            field=models.TextField(verbose_name="Bind CN"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="ldapsource",
 | 
			
		||||
            name="server_uri",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                validators=[
 | 
			
		||||
                    django.core.validators.URLValidator(schemes=["ldap", "ldaps"])
 | 
			
		||||
                ],
 | 
			
		||||
                verbose_name="Server URI",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="ldapsource",
 | 
			
		||||
            name="start_tls",
 | 
			
		||||
            field=models.BooleanField(default=False, verbose_name="Enable Start TLS"),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,46 @@
 | 
			
		||||
# Generated by Django 3.0.3 on 2020-02-17 16:19
 | 
			
		||||
 | 
			
		||||
from django.apps.registry import Apps
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cleanup_old_autogenerated(apps, schema_editor):
 | 
			
		||||
    LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping")
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    LDAPPropertyMapping.objects.using(db_alias).filter(
 | 
			
		||||
        name__startswith="Autogenerated"
 | 
			
		||||
    ).delete()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_default_ad_property_mappings(apps: Apps, schema_editor):
 | 
			
		||||
    LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping")
 | 
			
		||||
    mapping = {
 | 
			
		||||
        "name": "{{ ldap.name }}",
 | 
			
		||||
        "first_name": "{{ ldap.givenName }}",
 | 
			
		||||
        "last_name": "{{ ldap.sn }}",
 | 
			
		||||
        "username": "{{ ldap.sAMAccountName }}",
 | 
			
		||||
        "email": "{{ ldap.mail }}",
 | 
			
		||||
    }
 | 
			
		||||
    db_alias = schema_editor.connection.alias
 | 
			
		||||
    for object_field, expression in mapping.items():
 | 
			
		||||
        LDAPPropertyMapping.objects.using(db_alias).get_or_create(
 | 
			
		||||
            expression=expression,
 | 
			
		||||
            object_field=object_field,
 | 
			
		||||
            defaults={
 | 
			
		||||
                "name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}"
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_sources_ldap", "0006_auto_20200216_1116"),
 | 
			
		||||
        ("passbook_core", "0007_auto_20200217_1934"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.RunPython(cleanup_old_autogenerated),
 | 
			
		||||
        migrations.RemoveField(model_name="ldappropertymapping", name="ldap_property",),
 | 
			
		||||
        migrations.RunPython(create_default_ad_property_mappings),
 | 
			
		||||
    ]
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
from django.core.validators import URLValidator
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from passbook.core.models import Group, PropertyMapping, Source
 | 
			
		||||
 | 
			
		||||
@ -10,17 +10,22 @@ from passbook.core.models import Group, PropertyMapping, Source
 | 
			
		||||
class LDAPSource(Source):
 | 
			
		||||
    """LDAP Authentication source"""
 | 
			
		||||
 | 
			
		||||
    server_uri = models.TextField(validators=[URLValidator(schemes=["ldap", "ldaps"])])
 | 
			
		||||
    bind_cn = models.TextField()
 | 
			
		||||
    server_uri = models.TextField(
 | 
			
		||||
        validators=[URLValidator(schemes=["ldap", "ldaps"])],
 | 
			
		||||
        verbose_name=_("Server URI"),
 | 
			
		||||
    )
 | 
			
		||||
    bind_cn = models.TextField(verbose_name=_("Bind CN"))
 | 
			
		||||
    bind_password = models.TextField()
 | 
			
		||||
    start_tls = models.BooleanField(default=False)
 | 
			
		||||
    start_tls = models.BooleanField(default=False, verbose_name=_("Enable Start TLS"))
 | 
			
		||||
 | 
			
		||||
    base_dn = models.TextField()
 | 
			
		||||
    base_dn = models.TextField(verbose_name=_("Base DN"))
 | 
			
		||||
    additional_user_dn = models.TextField(
 | 
			
		||||
        help_text=_("Prepended to Base DN for User-queries.")
 | 
			
		||||
        help_text=_("Prepended to Base DN for User-queries."),
 | 
			
		||||
        verbose_name=_("Addition User DN"),
 | 
			
		||||
    )
 | 
			
		||||
    additional_group_dn = models.TextField(
 | 
			
		||||
        help_text=_("Prepended to Base DN for Group-queries.")
 | 
			
		||||
        help_text=_("Prepended to Base DN for Group-queries."),
 | 
			
		||||
        verbose_name=_("Addition Group DN"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    user_object_filter = models.TextField(
 | 
			
		||||
@ -54,13 +59,12 @@ class LDAPSource(Source):
 | 
			
		||||
class LDAPPropertyMapping(PropertyMapping):
 | 
			
		||||
    """Map LDAP Property to User or Group object"""
 | 
			
		||||
 | 
			
		||||
    ldap_property = models.TextField()
 | 
			
		||||
    object_field = models.TextField()
 | 
			
		||||
 | 
			
		||||
    form = "passbook.sources.ldap.forms.LDAPPropertyMappingForm"
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"LDAP Property Mapping {self.ldap_property} -> {self.object_field}"
 | 
			
		||||
        return f"LDAP Property Mapping {self.expression} -> {self.object_field}"
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,18 @@
 | 
			
		||||
{% extends "generic/form.html" %}
 | 
			
		||||
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
{% block beneath_form %}
 | 
			
		||||
<div class="form-group ">
 | 
			
		||||
    <label class="col-sm-2 control-label" for="friendly_name-2">
 | 
			
		||||
    </label>
 | 
			
		||||
    <div class="col-sm-10">
 | 
			
		||||
        <p>
 | 
			
		||||
            Expression using <a href="https://jinja.palletsprojects.com/en/2.11.x/templates/">Jinja</a>. Following variables are available:
 | 
			
		||||
            <ul>
 | 
			
		||||
                <li><code>ldap</code>: A Dictionary of all values retrieved from LDAP.</li>
 | 
			
		||||
            </ul>
 | 
			
		||||
        </p>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
@ -38,12 +38,6 @@ class OAuthSourceForm(forms.ModelForm):
 | 
			
		||||
            "provider_type": forms.Select(choices=MANAGER.get_name_tuple()),
 | 
			
		||||
            "policies": FilteredSelectMultiple(_("policies"), False),
 | 
			
		||||
        }
 | 
			
		||||
        labels = {
 | 
			
		||||
            "request_token_url": _("Request Token URL"),
 | 
			
		||||
            "authorization_url": _("Authorization URL"),
 | 
			
		||||
            "access_token_url": _("Access Token URL"),
 | 
			
		||||
            "profile_url": _("Profile URL"),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GitHubOAuthSourceForm(OAuthSourceForm):
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										35
									
								
								passbook/sources/oauth/migrations/0002_auto_20200217_1526.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								passbook/sources/oauth/migrations/0002_auto_20200217_1526.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
			
		||||
# Generated by Django 3.0.3 on 2020-02-17 15:26
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_sources_oauth", "0001_initial"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="oauthsource",
 | 
			
		||||
            name="access_token_url",
 | 
			
		||||
            field=models.CharField(max_length=255, verbose_name="Access Token URL"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="oauthsource",
 | 
			
		||||
            name="authorization_url",
 | 
			
		||||
            field=models.CharField(max_length=255, verbose_name="Authorization URL"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="oauthsource",
 | 
			
		||||
            name="profile_url",
 | 
			
		||||
            field=models.CharField(max_length=255, verbose_name="Profile URL"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="oauthsource",
 | 
			
		||||
            name="request_token_url",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                blank=True, max_length=255, verbose_name="Request Token URL"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.urls import reverse, reverse_lazy
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from passbook.core.models import Source, UserSettings, UserSourceConnection
 | 
			
		||||
from passbook.sources.oauth.clients import get_client
 | 
			
		||||
@ -12,10 +12,16 @@ class OAuthSource(Source):
 | 
			
		||||
    """Configuration for OAuth provider."""
 | 
			
		||||
 | 
			
		||||
    provider_type = models.CharField(max_length=255)
 | 
			
		||||
    request_token_url = models.CharField(blank=True, max_length=255)
 | 
			
		||||
    authorization_url = models.CharField(max_length=255)
 | 
			
		||||
    access_token_url = models.CharField(max_length=255)
 | 
			
		||||
    profile_url = models.CharField(max_length=255)
 | 
			
		||||
    request_token_url = models.CharField(
 | 
			
		||||
        blank=True, max_length=255, verbose_name=_("Request Token URL")
 | 
			
		||||
    )
 | 
			
		||||
    authorization_url = models.CharField(
 | 
			
		||||
        max_length=255, verbose_name=_("Authorization URL")
 | 
			
		||||
    )
 | 
			
		||||
    access_token_url = models.CharField(
 | 
			
		||||
        max_length=255, verbose_name=_("Access Token URL")
 | 
			
		||||
    )
 | 
			
		||||
    profile_url = models.CharField(max_length=255, verbose_name=_("Profile URL"))
 | 
			
		||||
    consumer_key = models.TextField()
 | 
			
		||||
    consumer_secret = models.TextField()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
 | 
			
		||||
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
 | 
			
		||||
from passbook.providers.saml.utils import CertificateBuilder
 | 
			
		||||
from passbook.providers.saml.utils.cert import CertificateBuilder
 | 
			
		||||
from passbook.sources.saml.models import SAMLSource
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -28,11 +28,6 @@ class SAMLSourceForm(forms.ModelForm):
 | 
			
		||||
            "auto_logout",
 | 
			
		||||
            "signing_cert",
 | 
			
		||||
        ]
 | 
			
		||||
        labels = {
 | 
			
		||||
            "entity_id": "Entity ID",
 | 
			
		||||
            "idp_url": "IDP URL",
 | 
			
		||||
            "idp_logout_url": "IDP Logout URL",
 | 
			
		||||
        }
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "name": forms.TextInput(),
 | 
			
		||||
            "policies": FilteredSelectMultiple(_("policies"), False),
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										30
									
								
								passbook/sources/saml/migrations/0004_auto_20200217_1526.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								passbook/sources/saml/migrations/0004_auto_20200217_1526.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,30 @@
 | 
			
		||||
# Generated by Django 3.0.3 on 2020-02-17 15:26
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("passbook_sources_saml", "0003_auto_20191107_1550"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="samlsource",
 | 
			
		||||
            name="entity_id",
 | 
			
		||||
            field=models.TextField(blank=True, default=None, verbose_name="Entity ID"),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="samlsource",
 | 
			
		||||
            name="idp_logout_url",
 | 
			
		||||
            field=models.URLField(
 | 
			
		||||
                blank=True, default=None, null=True, verbose_name="IDP Logout URL"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="samlsource",
 | 
			
		||||
            name="idp_url",
 | 
			
		||||
            field=models.URLField(verbose_name="IDP URL"),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
"""saml sp models"""
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from passbook.core.models import Source
 | 
			
		||||
 | 
			
		||||
@ -9,9 +9,11 @@ from passbook.core.models import Source
 | 
			
		||||
class SAMLSource(Source):
 | 
			
		||||
    """SAML2 Source"""
 | 
			
		||||
 | 
			
		||||
    entity_id = models.TextField(blank=True, default=None)
 | 
			
		||||
    idp_url = models.URLField()
 | 
			
		||||
    idp_logout_url = models.URLField(default=None, blank=True, null=True)
 | 
			
		||||
    entity_id = models.TextField(blank=True, default=None, verbose_name=_("Entity ID"))
 | 
			
		||||
    idp_url = models.URLField(verbose_name=_("IDP URL"))
 | 
			
		||||
    idp_logout_url = models.URLField(
 | 
			
		||||
        default=None, blank=True, null=True, verbose_name=_("IDP Logout URL")
 | 
			
		||||
    )
 | 
			
		||||
    auto_logout = models.BooleanField(default=False)
 | 
			
		||||
    signing_cert = models.TextField()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -9,9 +9,9 @@ from django.utils.decorators import method_decorator
 | 
			
		||||
from django.views import View
 | 
			
		||||
from django.views.decorators.csrf import csrf_exempt
 | 
			
		||||
 | 
			
		||||
from passbook.providers.saml.base import get_random_id, get_time_string
 | 
			
		||||
from passbook.providers.saml.utils import nice64
 | 
			
		||||
from passbook.providers.saml.views import render_xml
 | 
			
		||||
from passbook.providers.saml.utils import get_random_id, render_xml
 | 
			
		||||
from passbook.providers.saml.utils.encoding import nice64
 | 
			
		||||
from passbook.providers.saml.utils.time import get_time_string
 | 
			
		||||
from passbook.sources.saml.models import SAMLSource
 | 
			
		||||
from passbook.sources.saml.utils import (
 | 
			
		||||
    _get_user_from_response,
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from passbook.lib.utils.template import render_to_string
 | 
			
		||||
from passbook.providers.saml.xml_signing import get_signature_xml
 | 
			
		||||
from passbook.providers.saml.utils.xml_signing import get_signature_xml
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user