Compare commits
89 Commits
version/0.
...
version/0.
| Author | SHA1 | Date | |
|---|---|---|---|
| e4886f0c6f | |||
| 8b2ce5476a | |||
| 1b82283a20 | |||
| 7f3d0113c2 | |||
| 0f6dd33a6b | |||
| 5b79b3fd22 | |||
| d68c72f1fa | |||
| 9267d0c1dd | |||
| 865abc005a | |||
| a2725d5b82 | |||
| 4a05bc6e02 | |||
| 4e8238603a | |||
| ff25c1c057 | |||
| 78cddca0d7 | |||
| 4742ee1d93 | |||
| 0c2dc309e7 | |||
| 144935d10f | |||
| 74ad1b6759 | |||
| 591d2f89a1 | |||
| 7c353f9297 | |||
| cd1af15c56 | |||
| 878169ea2e | |||
| 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 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.7.9-beta
|
||||
current_version = 0.8.0-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.9-beta
|
||||
-t beryju/passbook:0.8.0-beta
|
||||
-t beryju/passbook:latest
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook:0.7.9-beta
|
||||
run: docker push beryju/passbook:0.8.0-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.8.0-beta \
|
||||
-t beryju/passbook-gatekeeper:latest \
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-gatekeeper:0.8.0-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.9-beta
|
||||
-t beryju/passbook-static:0.8.0-beta
|
||||
-t beryju/passbook-static:latest
|
||||
-f static.Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-static:0.7.9-beta
|
||||
run: docker push beryju/passbook-static:0.8.0-beta
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-static:latest
|
||||
test-release:
|
||||
|
||||
11
.github/workflows/tag.yml
vendored
11
.github/workflows/tag.yml
vendored
@ -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
|
||||
|
||||
```
|
||||
|
||||
@ -7,4 +7,4 @@ threads = 2
|
||||
enable-threads = true
|
||||
uid = passbook
|
||||
gid = passbook
|
||||
disable-logging=True
|
||||
disable-logging = True
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM python:3.7-slim-buster as builder
|
||||
FROM python:3.8-slim-buster as builder
|
||||
|
||||
WORKDIR /mkdocs
|
||||
|
||||
|
||||
32
docs/integrations/services/aws/index.md
Normal file
32
docs/integrations/services/aws/index.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Amazon Web Services Integration
|
||||
|
||||
## What is AWS
|
||||
|
||||
!!! note ""
|
||||
Amazon Web Services (AWS) is the world’s most comprehensive and broadly adopted cloud platform, offering over 175 fully featured services from data centers globally. Millions of customers—including the fastest-growing startups, largest enterprises, and leading government agencies—are using AWS to lower costs, become more agile, and innovate faster.
|
||||
|
||||
## Preparation
|
||||
|
||||
The following placeholders will be used:
|
||||
|
||||
- `passbook.company` is the FQDN of the passbook Install
|
||||
|
||||
Create an application in passbook and note the slug, as this will be used later. Create a SAML Provider with the following Parameters:
|
||||
|
||||
- ACS URL: `https://signin.aws.amazon.com/saml`
|
||||
- Audience: `urn:amazon:webservices`
|
||||
- Issuer: `passbook`
|
||||
|
||||
You can of course use a custom Signing Certificate, and adjust durations.
|
||||
|
||||
## AWS
|
||||
|
||||
Create a Role with the Permissions you desire, and note the ARN.
|
||||
|
||||
AWS requires two custom PropertyMappings; `Role` and `RoleSessionName`. Create them as following:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Afterwards export the Metadata from passbook, and create an Identity Provider [here](https://console.aws.amazon.com/iam/home#/providers).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/integrations/services/aws/property-mapping-role.png
Normal file
BIN
docs/integrations/services/aws/property-mapping-role.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@ -4,9 +4,8 @@
|
||||
|
||||
From https://about.gitlab.com/what-is-gitlab/
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
!!! 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
|
||||
|
||||
@ -21,7 +20,7 @@ Create an application in passbook and note the slug, as this will be used later.
|
||||
- Audience: `https://gitlab.company`
|
||||
- Issuer: `https://gitlab.company`
|
||||
|
||||
You can of course use a custom Signing Certificate, and adjust the Assertion Length. To get the value for `idp_cert_fingerprint`, you can use a tool like [this](https://www.samltool.com/fingerprint.php).
|
||||
You can of course use a custom Signing Certificate, and adjust durations. To get the value for `idp_cert_fingerprint`, you can use a tool like [this](https://www.samltool.com/fingerprint.php).
|
||||
|
||||
## GitLab Configuration
|
||||
|
||||
|
||||
@ -4,9 +4,8 @@
|
||||
|
||||
From https://goharbor.io
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
!!! 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
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
!!! 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
|
||||
|
||||
@ -22,7 +21,7 @@ Create an application in passbook and note the slug, as this will be used later.
|
||||
- Audience: `https://rancher.company/v1-saml/adfs/saml/metadata`
|
||||
- Issuer: `passbook`
|
||||
|
||||
You can of course use a custom Signing Certificate, and adjust the Assertion Length.
|
||||
You can of course use a custom Signing Certificate, and adjust durations.
|
||||
|
||||
## Rancher
|
||||
|
||||
|
||||
@ -4,13 +4,12 @@
|
||||
|
||||
From https://sentry.io
|
||||
|
||||
```
|
||||
Sentry provides self-hosted and cloud-based error monitoring that helps all software
|
||||
teams discover, triage, and prioritize errors in real-time.
|
||||
!!! 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?
|
||||
```
|
||||
One million developers at over fifty thousand companies already ship
|
||||
better software faster with Sentry. Won’t you join them?
|
||||
|
||||
## Preparation
|
||||
|
||||
|
||||
74
docs/integrations/services/tower-awx/index.md
Normal file
74
docs/integrations/services/tower-awx/index.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Ansible Tower / AWX Integration
|
||||
|
||||
## What is Tower
|
||||
|
||||
From https://docs.ansible.com/ansible/2.5/reference_appendices/tower.html
|
||||
|
||||
!!! note ""
|
||||
Ansible Tower (formerly ‘AWX’) is a web-based solution that makes Ansible even more easy to use for IT teams of all kinds. It’s designed to be the hub for all of your automation tasks.
|
||||
|
||||
Tower allows you to control access to who can access what, even allowing sharing of SSH credentials without someone being able to transfer those credentials. Inventory can be graphically managed or synced with a wide variety of cloud sources. It logs all of your jobs, integrates well with LDAP, and has an amazing browsable REST API. Command line tools are available for easy integration with Jenkins as well. Provisioning callbacks provide great support for autoscaling topologies.
|
||||
|
||||
!!! note
|
||||
AWX is the Open-Source version of Tower, and AWX will be used interchangeably throughout this document.
|
||||
|
||||
## Preparation
|
||||
|
||||
The following placeholders will be used:
|
||||
|
||||
- `awx.company` is the FQDN of the AWX/Tower Install
|
||||
- `passbook.company` is the FQDN of the passbook Install
|
||||
|
||||
Create an application in passbook and note the slug, as this will be used later. Create a SAML Provider with the following Parameters:
|
||||
|
||||
- ACS URL: `https://awx.company/sso/complete/saml/`
|
||||
- Audience: `awx`
|
||||
- Issuer: `https://awx.company/sso/metadata/saml/`
|
||||
|
||||
You can of course use a custom Signing Certificate, and adjust durations.
|
||||
|
||||
## AWX Configuration
|
||||
|
||||
Navigate to `https://awx.company/#/settings/auth` to configure SAML. Set the Field `SAML SERVICE PROVIDER ENTITY ID` to `awx`.
|
||||
|
||||
For the fields `SAML SERVICE PROVIDER PUBLIC CERTIFICATE` and `SAML SERVICE PROVIDER PRIVATE KEY`, you can either use custom Certificates, or use the self-signed Pair generated by Passbook.
|
||||
|
||||
Provide Metadata in the `SAML Service Provider Organization Info` Field:
|
||||
|
||||
```json
|
||||
{
|
||||
"en-US": {
|
||||
"name": "passbook",
|
||||
"url": "https://passbook.company",
|
||||
"displayname": "passbook"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Provide Metadata in the `SAML Service Provider Technical Contact` and `SAML Service Provider Technical Contact` Fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"givenName": "Admin Name",
|
||||
"emailAddress": "admin@company"
|
||||
}
|
||||
```
|
||||
|
||||
In the `SAML Enabled Identity Providers` paste the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"passbook": {
|
||||
"attr_username": "urn:oid:2.16.840.1.113730.3.1.241",
|
||||
"attr_user_permanent_id": "urn:oid:0.9.2342.19200300.100.1.1",
|
||||
"x509cert": "MIIDEjCCAfqgAwIBAgIRAJZ9pOZ1g0xjiHtQAAejsMEwDQYJKoZIhvcNAQELBQAwMDEuMCwGA1UEAwwlcGFzc2Jvb2sgU2VsZi1zaWduZWQgU0FNTCBDZXJ0aWZpY2F0ZTAeFw0xOTEyMjYyMDEwNDFaFw0yMDEyMjYyMDEwNDFaMFkxLjAsBgNVBAMMJXBhc3Nib29rIFNlbGYtc2lnbmVkIFNBTUwgQ2VydGlmaWNhdGUxETAPBgNVBAoMCHBhc3Nib29rMRQwEgYDVQQLDAtTZWxmLXNpZ25lZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO/ktBYZkY9xAijF4acvzX6Q1K8KoIZeyde8fVgcWBz4L5FgDQ4/dni4k2YAcPdwteGL4nKVzetUzjbRCBUNuO6lqU4J4WNNX4Xg4Ir7XLRoAQeo+omTPBdpJ1p02HjtN5jT01umN3bK2yto1e37CJhK6WJiaXqRewPxh4lI4aqdj3BhFkJ3I3r2qxaWOAXQ6X7fg3w/ny7QP53//ouZo7hSLY3GIcRKgvdjjVM3OW5C3WLpOq5Dez5GWVJ17aeFCfGQ8bwFKde6qfYqyGcU9xHB36TtVHB9hSFP/tUFhkiSOxtsrYwCgCyXm4UTSpP+wiNyjKfFw7qGLBvA2hGTNw8CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAh9PeAqPRQk1/SSygIFADZBi08O/DPCshFwEHvJATIcTzcDD8UGAjXh+H5OlkDyX7KyrcaNvYaafCUo63A+WprdtdY5Ty6SBEwTYyiQyQfwM9BfK+imCoif1Ai7xAelD7p9lNazWq7JU+H/Ep7U7Q7LvpxAbK0JArt+IWTb2NcMb3OWE1r0gFbs44O1l6W9UbJTbyLMzbGbe5i+NHlgnwPwuhtRMh0NUYabGHKcHbhwyFhfGAQv2dAp5KF1E5gu6ZzCiFePzc0FrqXQyb2zpFYcJHXquiqaOeG7cZxRHYcjrl10Vxzki64XVA9BpdELgKSnupDGUEJsRUt3WVOmvZuA==",
|
||||
"url": "https://passbook.company/application/saml/awx/login/",
|
||||
"attr_last_name": "User.LastName",
|
||||
"entity_id": "https://awx.company/sso/metadata/saml/",
|
||||
"attr_email": "urn:oid:0.9.2342.19200300.100.1.3",
|
||||
"attr_first_name": "urn:oid:2.5.4.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`x509cert` is the Certificate configured in passbook. Remove the --BEGIN CERTIFICATE-- and --END CERTIFICATE-- headers, then enter the cert as one non-breaking string.
|
||||
19
docs/policies/expression/index.md
Normal file
19
docs/policies/expression/index.md
Normal file
@ -0,0 +1,19 @@
|
||||
# Expression Policy
|
||||
|
||||
Expression Policies allows you to write custom Policy Logic using Jinja2 Templating language.
|
||||
|
||||
For a language reference, see [here](https://jinja.palletsprojects.com/en/2.11.x/templates/).
|
||||
|
||||
The following objects are passed into the variable:
|
||||
|
||||
- `request`: A PolicyRequest object, which has the following properties:
|
||||
- `request.user`: The current User, which the Policy is applied against. ([ref](../../property-mappings/reference/user-object.md))
|
||||
- `request.http_request`: The Django HTTP Request, as documented [here](https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects).
|
||||
- `request.obj`: A Django Model instance. This is only set if the Policy is ran against an object.
|
||||
- `pb_is_sso_flow`: Boolean which is true if request was initiated by authenticating through an external Provider.
|
||||
- `pb_is_group_member(user, group_name)`: Function which checks if `user` is member of a Group with Name `gorup_name`.
|
||||
|
||||
There are also the following custom filters available:
|
||||
|
||||
- `regex_match(regex)`: Return True if value matches `regex`
|
||||
- `regex_replace(regex, repl)`: Replace string matched by `regex` with `repl`
|
||||
@ -18,27 +18,9 @@ passbook keeps track of failed login attempts by Source IP and Attempted Usernam
|
||||
|
||||
This policy can be used to for example prompt Clients with a low score to pass a Captcha before they can continue.
|
||||
|
||||
### Field matcher Policy
|
||||
## Expression Policy
|
||||
|
||||
This policy allows you to evaluate arbitrary comparisons against the User instance. Currently supported fields are:
|
||||
|
||||
- Username
|
||||
- E-Mail
|
||||
- Name
|
||||
- Is_active
|
||||
- Date joined
|
||||
|
||||
Any of the following operations are supported:
|
||||
|
||||
- Starts with
|
||||
- Ends with
|
||||
- Contains
|
||||
- Regexp (standard Python engine)
|
||||
- Exact
|
||||
|
||||
### SSO Policy
|
||||
|
||||
This policy evaluates to True if the current Authentication Flow has been initiated through an external Source, like OAuth and SAML.
|
||||
See [Expression Policy](expression/index.md).
|
||||
|
||||
### Webhook Policy
|
||||
|
||||
20
docs/property-mappings/reference/user-object.md
Normal file
20
docs/property-mappings/reference/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 %}]
|
||||
```
|
||||
@ -13,11 +13,5 @@ The API exposes Username, E-Mail, Name and Groups in a GitHub-compatible format.
|
||||
|
||||
## SAML Provider
|
||||
|
||||
This provider allows you to integrate Enterprise Software using the SAML2 Protocol. It supports signed Requests. This Provider also has [Property Mappings](property-mappings.md#saml-property-mapping), which allows you to expose Vendor-specific Fields.
|
||||
Default fields are:
|
||||
|
||||
- `eduPersonPrincipalName`: User's E-Mail
|
||||
- `cn`: User's Full Name
|
||||
- `mail`: User's E-Mail
|
||||
- `displayName`: User's Username
|
||||
- `uid`: User Unique Identifier
|
||||
This provider allows you to integrate Enterprise Software using the SAML2 Protocol. It supports signed Requests. This Provider uses [Property Mappings](property-mappings/index.md#saml-property-mapping) to determine which fields are exposed and what values they return. This makes it possible to expose Vendor-specific Fields.
|
||||
Default fields are exposed through Auto-generated Property Mappings, which are prefixed with "Autogenerated..."
|
||||
|
||||
@ -36,4 +36,4 @@ This source allows you to import Users and Groups from an LDAP Server
|
||||
- Object uniqueness field: Field which contains a unique Identifier.
|
||||
- Sync groups: Enable/disable Group synchronization. Groups are synced in the background every 5 minutes.
|
||||
- Sync parent group: Optionally set this Group as parent Group for all synced Groups (allows you to, for example, import AD Groups under a root `imported-from-ad` group.)
|
||||
- Property mappings: Define which LDAP Properties map to which passbook Properties. The default set of Property Mappings is generated for Active Directory. See also [LDAP Property Mappings](property-mappings.md#ldap-property-mapping)
|
||||
- Property mappings: Define which LDAP Properties map to which passbook Properties. The default set of Property Mappings is generated for Active Directory. See also [LDAP Property Mappings](property-mappings/index.md#ldap-property-mapping)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
apiVersion: v1
|
||||
appVersion: "0.7.9-beta"
|
||||
appVersion: "0.8.0-beta"
|
||||
description: A Helm chart for passbook.
|
||||
name: passbook
|
||||
version: "0.7.9-beta"
|
||||
version: "0.8.0-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.9-beta
|
||||
tag: 0.8.0-beta
|
||||
|
||||
nameOverride: ""
|
||||
|
||||
|
||||
14
mkdocs.yml
14
mkdocs.yml
@ -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:
|
||||
@ -10,15 +10,22 @@ nav:
|
||||
- Kubernetes: installation/kubernetes.md
|
||||
- Sources: sources.md
|
||||
- Providers: providers.md
|
||||
- Property Mappings: property-mappings.md
|
||||
- Property Mappings:
|
||||
- Overview: property-mappings/index.md
|
||||
- Reference:
|
||||
- User Object: property-mappings/reference/user-object.md
|
||||
- Factors: factors.md
|
||||
- Policies: policies.md
|
||||
- Policies:
|
||||
- Overview: policies/index.md
|
||||
- Expression: policies/expression/index.md
|
||||
- Integrations:
|
||||
- as Provider:
|
||||
- Amazon Web Services: integrations/services/aws/index.md
|
||||
- GitLab: integrations/services/gitlab/index.md
|
||||
- Rancher: integrations/services/rancher/index.md
|
||||
- Harbor: integrations/services/harbor/index.md
|
||||
- Sentry: integrations/services/sentry/index.md
|
||||
- Ansible Tower/AWX: integrations/services/tower-awx/index.md
|
||||
|
||||
repo_name: "BeryJu.org/passbook"
|
||||
repo_url: https://github.com/BeryJu/passbook
|
||||
@ -29,3 +36,4 @@ theme:
|
||||
markdown_extensions:
|
||||
- toc:
|
||||
permalink: "¶"
|
||||
- admonition
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
"""passbook"""
|
||||
__version__ = "0.7.9-beta"
|
||||
__version__ = "0.8.0-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 %}
|
||||
|
||||
@ -18,7 +18,7 @@ def get_links(model_instance):
|
||||
links = {}
|
||||
|
||||
if not isinstance(model_instance, Model):
|
||||
LOGGER.warning("Model %s is not instance of Model", model_instance)
|
||||
LOGGER.warning("Model is not instance of Model", model_instance=model_instance)
|
||||
return links
|
||||
|
||||
try:
|
||||
@ -43,7 +43,7 @@ def get_htmls(context, model_instance):
|
||||
htmls = []
|
||||
|
||||
if not isinstance(model_instance, Model):
|
||||
LOGGER.warning("Model %s is not instance of Model", model_instance)
|
||||
LOGGER.warning("Model is not instance of Model", model_instance=model_instance)
|
||||
return htmls
|
||||
|
||||
try:
|
||||
|
||||
@ -52,6 +52,13 @@ class PolicyCreateView(
|
||||
success_url = reverse_lazy("passbook_admin:policies")
|
||||
success_message = _("Successfully created Policy")
|
||||
|
||||
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):
|
||||
policy_type = self.request.GET.get("type")
|
||||
model = next(x for x in Policy.__subclasses__() if x.__name__ == policy_type)
|
||||
@ -72,6 +79,13 @@ class PolicyUpdateView(
|
||||
success_url = reverse_lazy("passbook_admin:policies")
|
||||
success_message = _("Successfully updated Policy")
|
||||
|
||||
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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -67,6 +67,8 @@ class UserDeleteView(
|
||||
model = User
|
||||
permission_required = "passbook_core.delete_user"
|
||||
|
||||
# By default the object's name is user which is used by other checks
|
||||
context_object_name = "object"
|
||||
template_name = "generic/delete.html"
|
||||
success_url = reverse_lazy("passbook_admin:users")
|
||||
success_message = _("Successfully deleted User")
|
||||
|
||||
@ -24,12 +24,10 @@ from passbook.factors.otp.api import OTPFactorViewSet
|
||||
from passbook.factors.password.api import PasswordFactorViewSet
|
||||
from passbook.lib.utils.reflection import get_apps
|
||||
from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet
|
||||
from passbook.policies.group.api import GroupMembershipPolicyViewSet
|
||||
from passbook.policies.expression.api import ExpressionPolicyViewSet
|
||||
from passbook.policies.hibp.api import HaveIBeenPwendPolicyViewSet
|
||||
from passbook.policies.matcher.api import FieldMatcherPolicyViewSet
|
||||
from passbook.policies.password.api import PasswordPolicyViewSet
|
||||
from passbook.policies.reputation.api import ReputationPolicyViewSet
|
||||
from passbook.policies.sso.api import SSOLoginPolicyViewSet
|
||||
from passbook.policies.webhook.api import WebhookPolicyViewSet
|
||||
from passbook.providers.app_gw.api import ApplicationGatewayProviderViewSet
|
||||
from passbook.providers.oauth.api import OAuth2ProviderViewSet
|
||||
@ -57,13 +55,11 @@ router.register("sources/ldap", LDAPSourceViewSet)
|
||||
router.register("sources/oauth", OAuthSourceViewSet)
|
||||
router.register("policies/all", PolicyViewSet)
|
||||
router.register("policies/passwordexpiry", PasswordExpiryPolicyViewSet)
|
||||
router.register("policies/groupmembership", GroupMembershipPolicyViewSet)
|
||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
|
||||
router.register("policies/fieldmatcher", FieldMatcherPolicyViewSet)
|
||||
router.register("policies/password", PasswordPolicyViewSet)
|
||||
router.register("policies/reputation", ReputationPolicyViewSet)
|
||||
router.register("policies/ssologin", SSOLoginPolicyViewSet)
|
||||
router.register("policies/webhook", WebhookPolicyViewSet)
|
||||
router.register("policies/expression", ExpressionPolicyViewSet)
|
||||
router.register("providers/all", ProviderViewSet)
|
||||
router.register("providers/applicationgateway", ApplicationGatewayProviderViewSet)
|
||||
router.register("providers/oauth", OAuth2ProviderViewSet)
|
||||
|
||||
@ -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 _
|
||||
@ -100,7 +100,6 @@ class Event(UUIDModel):
|
||||
app = getmodule(stack()[_inspect_offset][0]).__name__
|
||||
cleaned_kwargs = sanitize_dict(kwargs)
|
||||
event = Event(action=action.value, app=app, context=cleaned_kwargs)
|
||||
LOGGER.debug("Created Audit event", action=action, context=cleaned_kwargs)
|
||||
return event
|
||||
|
||||
def from_http(
|
||||
@ -129,6 +128,12 @@ class Event(UUIDModel):
|
||||
raise ValidationError(
|
||||
"you may not edit an existing %s" % self._meta.model_name
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Created Audit event",
|
||||
action=self.action,
|
||||
context=self.context,
|
||||
client_ip=self.client_ip,
|
||||
)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
|
||||
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."""
|
||||
@ -70,7 +70,7 @@ class SignUpForm(forms.Form):
|
||||
"""Check if username is used already"""
|
||||
username = self.cleaned_data.get("username")
|
||||
if User.objects.filter(username=username).exists():
|
||||
LOGGER.warning("Username %s already exists", username)
|
||||
LOGGER.warning("username already exists", username=username)
|
||||
raise ValidationError(_("Username already exists"))
|
||||
return username
|
||||
|
||||
@ -79,7 +79,7 @@ class SignUpForm(forms.Form):
|
||||
email = self.cleaned_data.get("email")
|
||||
# Check if user exists already, error early
|
||||
if User.objects.filter(email=email).exists():
|
||||
LOGGER.debug("email %s exists in django", email)
|
||||
LOGGER.debug("email already exists", email=email)
|
||||
raise ValidationError(_("Email already exists"))
|
||||
return email
|
||||
|
||||
|
||||
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,31 @@ 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: Optional[User], request: Optional[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 %}
|
||||
|
||||
@ -51,7 +51,10 @@
|
||||
{% for url, icon, name in sources %}
|
||||
<li class="login-pf-social-link">
|
||||
<a href="{{ url }}">
|
||||
<img src="{% static 'img/logos/' %}{{ icon }}.svg" alt="{{ name }}"> {{ name }}
|
||||
{% if icon %}
|
||||
<img src="{% static 'img/logos/' %}{{ icon }}.svg" alt="{{ name }}">
|
||||
{% endif %}
|
||||
{{ name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@ -156,26 +156,9 @@ class SignUpView(UserPassesTestMixin, FormView):
|
||||
for error in exc.messages:
|
||||
errors.append(error)
|
||||
return self.form_invalid(form)
|
||||
# needs_confirmation = True
|
||||
# if self._invitation and not self._invitation.needs_confirmation:
|
||||
# needs_confirmation = False
|
||||
# if needs_confirmation:
|
||||
# nonce = Nonce.objects.create(user=self._user)
|
||||
# LOGGER.debug(str(nonce.uuid))
|
||||
# # Send email to user
|
||||
# send_email.delay(self._user.email, _('Confirm your account.'),
|
||||
# 'email/account_confirm.html', {
|
||||
# 'url': self.request.build_absolute_uri(
|
||||
# reverse('passbook_core:auth-sign-up-confirm', kwargs={
|
||||
# 'nonce': nonce.uuid
|
||||
# })
|
||||
# )
|
||||
# })
|
||||
# self._user.is_active = False
|
||||
# self._user.save()
|
||||
self.consume_invitation()
|
||||
messages.success(self.request, _("Successfully signed up!"))
|
||||
LOGGER.debug("Successfully signed up %s", form.cleaned_data.get("email"))
|
||||
LOGGER.debug("Successfully signed up", email=form.cleaned_data.get("email"))
|
||||
return redirect(reverse("passbook_core:auth-login"))
|
||||
|
||||
def consume_invitation(self):
|
||||
|
||||
@ -15,7 +15,7 @@ class OverviewView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["applications"] = []
|
||||
for application in Application.objects.all():
|
||||
for application in Application.objects.all().order_by("name"):
|
||||
engine = PolicyEngine(
|
||||
application.policies.all(), self.request.user, self.request
|
||||
)
|
||||
|
||||
@ -7,8 +7,10 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views import View
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.generic import FormView, TemplateView
|
||||
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
@ -19,7 +21,6 @@ from structlog import get_logger
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.factors.otp.forms import OTPSetupForm
|
||||
from passbook.factors.otp.utils import otpauth_url
|
||||
from passbook.lib.boilerplate import NeverCacheMixin
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
OTP_SESSION_KEY = "passbook_factors_otp_key"
|
||||
@ -146,7 +147,8 @@ class EnableView(LoginRequiredMixin, FormView):
|
||||
return redirect("passbook_factors_otp:otp-user-settings")
|
||||
|
||||
|
||||
class QRView(NeverCacheMixin, View):
|
||||
@method_decorator(never_cache, name="dispatch")
|
||||
class QRView(View):
|
||||
"""View returns an SVG image with the OTP token information"""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
|
||||
@ -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,28 @@ 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()
|
||||
if ":" in current_domain:
|
||||
current_domain, _ = current_domain.split(":")
|
||||
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 +75,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 +87,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 +102,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 +141,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 +149,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 +157,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 +184,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)
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
"""passbook django boilerplate code"""
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import never_cache
|
||||
|
||||
|
||||
class NeverCacheMixin:
|
||||
"""Use never_cache as mixin for CBV"""
|
||||
|
||||
@method_decorator(never_cache)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
"""Use never_cache as mixin for CBV"""
|
||||
return super().dispatch(*args, **kwargs)
|
||||
@ -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)
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
"""passbook util mixins"""
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
|
||||
class CSRFExemptMixin:
|
||||
"""wrapper to apply @csrf_exempt to CBV"""
|
||||
|
||||
@method_decorator(csrf_exempt)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
"""wrapper to apply @csrf_exempt to CBV"""
|
||||
return super().dispatch(*args, **kwargs)
|
||||
@ -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,21 +60,20 @@ class PolicyEngine:
|
||||
|
||||
def build(self) -> "PolicyEngine":
|
||||
"""Build task group"""
|
||||
cached_policies = []
|
||||
for policy in self._select_subclasses():
|
||||
cached_policy = cache.get(cache_key(policy, self.request.user), None)
|
||||
if cached_policy and self.use_cache:
|
||||
LOGGER.debug("Taking result from cache", policy=policy)
|
||||
cached_policies.append(cached_policy)
|
||||
else:
|
||||
LOGGER.debug("Evaluating policy", policy=policy)
|
||||
our_end, task_end = Pipe(False)
|
||||
task = PolicyProcess(policy, self.request, task_end)
|
||||
LOGGER.debug("Starting Process", policy=policy)
|
||||
task.start()
|
||||
self.__processes.append(
|
||||
PolicyProcessInfo(process=task, connection=our_end, policy=policy)
|
||||
)
|
||||
self.__cached_policies.append(cached_policy)
|
||||
continue
|
||||
LOGGER.debug("Evaluating policy", policy=policy)
|
||||
our_end, task_end = Pipe(False)
|
||||
task = PolicyProcess(policy, self.request, task_end)
|
||||
LOGGER.debug("Starting Process", policy=policy)
|
||||
task.start()
|
||||
self.__processes.append(
|
||||
PolicyProcessInfo(process=task, connection=our_end, policy=policy)
|
||||
)
|
||||
# If all policies are cached, we have an empty list here.
|
||||
for proc_info in self.__processes:
|
||||
proc_info.process.join(proc_info.policy.timeout)
|
||||
@ -82,13 +86,14 @@ class PolicyEngine:
|
||||
def result(self) -> Tuple[bool, List[str]]:
|
||||
"""Get policy-checking result"""
|
||||
messages: List[str] = []
|
||||
for proc_info in self.__processes:
|
||||
LOGGER.debug(
|
||||
"Result", policy=proc_info.policy, passing=proc_info.result.passing
|
||||
)
|
||||
if proc_info.result.messages:
|
||||
messages += proc_info.result.messages
|
||||
if not proc_info.result.passing:
|
||||
process_results: List[PolicyResult] = [
|
||||
x.result for x in self.__processes if x.result
|
||||
]
|
||||
for result in process_results + self.__cached_policies:
|
||||
LOGGER.debug("result", passing=result.passing)
|
||||
if result.messages:
|
||||
messages += result.messages
|
||||
if not result.passing:
|
||||
return False, messages
|
||||
return True, messages
|
||||
|
||||
|
||||
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"
|
||||
84
passbook/policies/expression/evaluator.py
Normal file
84
passbook/policies/expression/evaluator.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""passbook expression policy evaluator"""
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Dict
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
|
||||
from jinja2.nativetypes import NativeEnvironment
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.factors.view import AuthenticationView
|
||||
from passbook.policies.struct import PolicyRequest, PolicyResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from passbook.core.models import User
|
||||
|
||||
|
||||
class Evaluator:
|
||||
"""Validate and evaulate jinja2-based expressions"""
|
||||
|
||||
_env: NativeEnvironment
|
||||
|
||||
def __init__(self):
|
||||
self._env = NativeEnvironment()
|
||||
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||
# update docs/policies/expression/index.md
|
||||
self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match
|
||||
self._env.filters["regex_replace"] = Evaluator.jinja2_filter_regex_replace
|
||||
|
||||
@staticmethod
|
||||
def jinja2_filter_regex_match(value: Any, regex: str) -> bool:
|
||||
"""Jinja2 Filter to run re.search"""
|
||||
return re.search(regex, value) is None
|
||||
|
||||
@staticmethod
|
||||
def jinja2_filter_regex_replace(value: Any, regex: str, repl: str) -> str:
|
||||
"""Jinja2 Filter to run re.sub"""
|
||||
return re.sub(regex, repl, value)
|
||||
|
||||
@staticmethod
|
||||
def jinja2_func_is_group_member(user: "User", group_name: str) -> bool:
|
||||
"""Check if `user` is member of group with name `group_name`"""
|
||||
return user.groups.filter(name=group_name).exists()
|
||||
|
||||
def _get_expression_context(
|
||||
self, request: PolicyRequest, **kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""Return dictionary with additional global variables passed to expression"""
|
||||
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||
# update docs/policies/expression/index.md
|
||||
kwargs["pb_is_sso_flow"] = request.user.session.get(
|
||||
AuthenticationView.SESSION_IS_SSO_LOGIN, False
|
||||
)
|
||||
kwargs["pb_is_group_member"] = Evaluator.jinja2_func_is_group_member
|
||||
kwargs["pb_logger"] = get_logger()
|
||||
return kwargs
|
||||
|
||||
def evaluate(self, expression_source: str, request: PolicyRequest) -> PolicyResult:
|
||||
"""Parse and evaluate expression.
|
||||
If the Expression evaluates to a list with 2 items, the first is used as passing bool and
|
||||
the second as messages.
|
||||
If the Expression evaluates to a truthy-object, it is used as passing bool."""
|
||||
try:
|
||||
expression = self._env.from_string(expression_source)
|
||||
except TemplateSyntaxError as exc:
|
||||
return PolicyResult(False, str(exc))
|
||||
try:
|
||||
result = expression.render(
|
||||
request=request, **self._get_expression_context(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 validate(self, expression: str):
|
||||
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
|
||||
try:
|
||||
self._env.from_string(expression)
|
||||
return True
|
||||
except TemplateSyntaxError as exc:
|
||||
raise ValidationError("Expression Syntax Error") from exc
|
||||
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(),
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 2.2.6 on 2019-10-07 14:07
|
||||
# Generated by Django 3.0.3 on 2020-02-18 14:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
@ -9,12 +9,12 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0001_initial"),
|
||||
("passbook_core", "0007_auto_20200217_1934"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SSOLoginPolicy",
|
||||
name="ExpressionPolicy",
|
||||
fields=[
|
||||
(
|
||||
"policy_ptr",
|
||||
@ -27,10 +27,11 @@ class Migration(migrations.Migration):
|
||||
to="passbook_core.Policy",
|
||||
),
|
||||
),
|
||||
("expression", models.TextField()),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SSO Login Policy",
|
||||
"verbose_name_plural": "SSO Login Policies",
|
||||
"verbose_name": "Expression Policy",
|
||||
"verbose_name_plural": "Expression Policies",
|
||||
},
|
||||
bases=("passbook_core.policy",),
|
||||
),
|
||||
28
passbook/policies/expression/models.py
Normal file
28
passbook/policies/expression/models.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""passbook expression Policy Models"""
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.core.models import Policy
|
||||
from passbook.policies.expression.evaluator import Evaluator
|
||||
from passbook.policies.struct import PolicyRequest, PolicyResult
|
||||
|
||||
|
||||
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."""
|
||||
return Evaluator().evaluate(self.expression, request)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
Evaluator().validate(self.expression)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Expression Policy")
|
||||
verbose_name_plural = _("Expression Policies")
|
||||
@ -0,0 +1,27 @@
|
||||
{% 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:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>request.user</code>: Passbook User Object (<a href="https://beryju.github.io/passbook/property-mappings/reference/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>
|
||||
<li><code>pb_is_sso_flow</code>: Boolean which is true if request was initiated by authenticating through an external Provider.</li>
|
||||
<li><code>pb_is_group_member(user, group_name)</code>: Function which checks if <code>user</code> is member of a Group with Name <code>group_name</code>.</li>
|
||||
</ul>
|
||||
<p>Custom Filters:</p>
|
||||
<ul>
|
||||
<li><code>regex_match(regex)</code>: Checks if value matches <code>regex</code></li>
|
||||
<li><code>regex_replace(regex, repl)</code>: Replace string matched by <code>regex</code> with <code>repl</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,4 +0,0 @@
|
||||
"""autodiscover admin"""
|
||||
from passbook.lib.admin import admin_autoregister
|
||||
|
||||
admin_autoregister("passbook_policies_group")
|
||||
@ -1,21 +0,0 @@
|
||||
"""Source API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS
|
||||
from passbook.policies.group.models import GroupMembershipPolicy
|
||||
|
||||
|
||||
class GroupMembershipPolicySerializer(ModelSerializer):
|
||||
"""Group Membership Policy Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = GroupMembershipPolicy
|
||||
fields = GENERAL_SERIALIZER_FIELDS + ["group"]
|
||||
|
||||
|
||||
class GroupMembershipPolicyViewSet(ModelViewSet):
|
||||
"""Source Viewset"""
|
||||
|
||||
queryset = GroupMembershipPolicy.objects.all()
|
||||
serializer_class = GroupMembershipPolicySerializer
|
||||
@ -1,11 +0,0 @@
|
||||
"""passbook Group policy app config"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookPoliciesGroupConfig(AppConfig):
|
||||
"""passbook Group policy app config"""
|
||||
|
||||
name = "passbook.policies.group"
|
||||
label = "passbook_policies_group"
|
||||
verbose_name = "passbook Policies.Group"
|
||||
@ -1,21 +0,0 @@
|
||||
"""passbook Policy forms"""
|
||||
|
||||
from django import forms
|
||||
|
||||
from passbook.policies.forms import GENERAL_FIELDS
|
||||
from passbook.policies.group.models import GroupMembershipPolicy
|
||||
|
||||
|
||||
class GroupMembershipPolicyForm(forms.ModelForm):
|
||||
"""GroupMembershipPolicy Form"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = GroupMembershipPolicy
|
||||
fields = GENERAL_FIELDS + [
|
||||
"group",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"order": forms.NumberInput(),
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
# Generated by Django 2.2.6 on 2019-10-07 14:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="GroupMembershipPolicy",
|
||||
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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"group",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="passbook_core.Group",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Group Membership Policy",
|
||||
"verbose_name_plural": "Group Membership Policies",
|
||||
},
|
||||
bases=("passbook_core.policy",),
|
||||
),
|
||||
]
|
||||
@ -1,22 +0,0 @@
|
||||
"""passbook group models models"""
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.core.models import Group, Policy
|
||||
from passbook.policies.struct import PolicyRequest, PolicyResult
|
||||
|
||||
|
||||
class GroupMembershipPolicy(Policy):
|
||||
"""Policy to check if the user is member in a certain group"""
|
||||
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
|
||||
form = "passbook.policies.group.forms.GroupMembershipPolicyForm"
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
return PolicyResult(self.group.user_set.filter(pk=request.user.pk).exists())
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Group Membership Policy")
|
||||
verbose_name_plural = _("Group Membership Policies")
|
||||
@ -35,7 +35,7 @@ class HaveIBeenPwendPolicy(Policy):
|
||||
full_hash, count = line.split(":")
|
||||
if pw_hash[5:] == full_hash.lower():
|
||||
final_count = int(count)
|
||||
LOGGER.debug("Got count %d for hash %s", final_count, pw_hash[:5])
|
||||
LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5])
|
||||
if final_count > self.allowed_count:
|
||||
message = _(
|
||||
"Password exists on %(count)d online lists." % {"count": final_count}
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
"""autodiscover admin"""
|
||||
from passbook.lib.admin import admin_autoregister
|
||||
|
||||
admin_autoregister("passbook_policies_matcher")
|
||||
@ -1,25 +0,0 @@
|
||||
"""Source API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS
|
||||
from passbook.policies.matcher.models import FieldMatcherPolicy
|
||||
|
||||
|
||||
class FieldMatcherPolicySerializer(ModelSerializer):
|
||||
"""Field Matcher Policy Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = FieldMatcherPolicy
|
||||
fields = GENERAL_SERIALIZER_FIELDS + [
|
||||
"user_field",
|
||||
"match_action",
|
||||
"value",
|
||||
]
|
||||
|
||||
|
||||
class FieldMatcherPolicyViewSet(ModelViewSet):
|
||||
"""Source Viewset"""
|
||||
|
||||
queryset = FieldMatcherPolicy.objects.all()
|
||||
serializer_class = FieldMatcherPolicySerializer
|
||||
@ -1,11 +0,0 @@
|
||||
"""passbook Matcher policy app config"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookPoliciesMatcherConfig(AppConfig):
|
||||
"""passbook Matcher policy app config"""
|
||||
|
||||
name = "passbook.policies.matcher"
|
||||
label = "passbook_policies_matcher"
|
||||
verbose_name = "passbook Policies.Matcher"
|
||||
@ -1,23 +0,0 @@
|
||||
"""passbook Policy forms"""
|
||||
|
||||
from django import forms
|
||||
|
||||
from passbook.policies.forms import GENERAL_FIELDS
|
||||
from passbook.policies.matcher.models import FieldMatcherPolicy
|
||||
|
||||
|
||||
class FieldMatcherPolicyForm(forms.ModelForm):
|
||||
"""FieldMatcherPolicy Form"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = FieldMatcherPolicy
|
||||
fields = GENERAL_FIELDS + [
|
||||
"user_field",
|
||||
"match_action",
|
||||
"value",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"value": forms.TextInput(),
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
# Generated by Django 2.2.6 on 2019-10-07 14:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FieldMatcherPolicy",
|
||||
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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_field",
|
||||
models.TextField(
|
||||
choices=[
|
||||
("username", "Username"),
|
||||
("name", "Name"),
|
||||
("email", "E-Mail"),
|
||||
("is_staff", "Is staff"),
|
||||
("is_active", "Is active"),
|
||||
("data_joined", "Date joined"),
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"match_action",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("startswith", "Starts with"),
|
||||
("endswith", "Ends with"),
|
||||
("contains", "Contains"),
|
||||
("regexp", "Regexp"),
|
||||
("exact", "Exact"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
("value", models.TextField()),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Field matcher Policy",
|
||||
"verbose_name_plural": "Field matcher Policies",
|
||||
},
|
||||
bases=("passbook_core.policy",),
|
||||
),
|
||||
]
|
||||
@ -1,83 +0,0 @@
|
||||
"""user field matcher models"""
|
||||
import re
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Policy
|
||||
from passbook.policies.struct import PolicyRequest, PolicyResult
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class FieldMatcherPolicy(Policy):
|
||||
"""Policy which checks if a field of the User model matches/doesn't match a
|
||||
certain pattern"""
|
||||
|
||||
MATCH_STARTSWITH = "startswith"
|
||||
MATCH_ENDSWITH = "endswith"
|
||||
MATCH_CONTAINS = "contains"
|
||||
MATCH_REGEXP = "regexp"
|
||||
MATCH_EXACT = "exact"
|
||||
|
||||
MATCHES = (
|
||||
(MATCH_STARTSWITH, _("Starts with")),
|
||||
(MATCH_ENDSWITH, _("Ends with")),
|
||||
(MATCH_CONTAINS, _("Contains")),
|
||||
(MATCH_REGEXP, _("Regexp")),
|
||||
(MATCH_EXACT, _("Exact")),
|
||||
)
|
||||
|
||||
USER_FIELDS = (
|
||||
("username", _("Username"),),
|
||||
("name", _("Name"),),
|
||||
("email", _("E-Mail"),),
|
||||
("is_staff", _("Is staff"),),
|
||||
("is_active", _("Is active"),),
|
||||
("data_joined", _("Date joined"),),
|
||||
)
|
||||
|
||||
user_field = models.TextField(choices=USER_FIELDS)
|
||||
match_action = models.CharField(max_length=50, choices=MATCHES)
|
||||
value = models.TextField()
|
||||
|
||||
form = "passbook.policies.matcher.forms.FieldMatcherPolicyForm"
|
||||
|
||||
def __str__(self):
|
||||
description = (
|
||||
f"{self.name}, user.{self.user_field} {self.match_action} '{self.value}'"
|
||||
)
|
||||
if self.name:
|
||||
description = f"{self.name}: {description}"
|
||||
return description
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
"""Check if user instance passes this role"""
|
||||
if not hasattr(request.user, self.user_field):
|
||||
raise ValueError("Field does not exist")
|
||||
user_field_value = getattr(request.user, self.user_field, None)
|
||||
LOGGER.debug(
|
||||
"Checking field",
|
||||
value=user_field_value,
|
||||
action=self.match_action,
|
||||
should_be=self.value,
|
||||
)
|
||||
passes = False
|
||||
if self.match_action == FieldMatcherPolicy.MATCH_STARTSWITH:
|
||||
passes = user_field_value.startswith(self.value)
|
||||
if self.match_action == FieldMatcherPolicy.MATCH_ENDSWITH:
|
||||
passes = user_field_value.endswith(self.value)
|
||||
if self.match_action == FieldMatcherPolicy.MATCH_CONTAINS:
|
||||
passes = self.value in user_field_value
|
||||
if self.match_action == FieldMatcherPolicy.MATCH_REGEXP:
|
||||
pattern = re.compile(self.value)
|
||||
passes = bool(pattern.match(user_field_value))
|
||||
if self.match_action == FieldMatcherPolicy.MATCH_EXACT:
|
||||
passes = user_field_value == self.value
|
||||
return PolicyResult(passes)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Field matcher Policy")
|
||||
verbose_name_plural = _("Field matcher Policies")
|
||||
@ -1,4 +0,0 @@
|
||||
"""autodiscover admin"""
|
||||
from passbook.lib.admin import admin_autoregister
|
||||
|
||||
admin_autoregister("passbook_policies_sso")
|
||||
@ -1,21 +0,0 @@
|
||||
"""Source API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.policies.forms import GENERAL_SERIALIZER_FIELDS
|
||||
from passbook.policies.sso.models import SSOLoginPolicy
|
||||
|
||||
|
||||
class SSOLoginPolicySerializer(ModelSerializer):
|
||||
"""SSO Login Policy Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = SSOLoginPolicy
|
||||
fields = GENERAL_SERIALIZER_FIELDS
|
||||
|
||||
|
||||
class SSOLoginPolicyViewSet(ModelViewSet):
|
||||
"""Source Viewset"""
|
||||
|
||||
queryset = SSOLoginPolicy.objects.all()
|
||||
serializer_class = SSOLoginPolicySerializer
|
||||
@ -1,11 +0,0 @@
|
||||
"""passbook sso policy app config"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookPoliciesSSOConfig(AppConfig):
|
||||
"""passbook sso policy app config"""
|
||||
|
||||
name = "passbook.policies.sso"
|
||||
label = "passbook_policies_sso"
|
||||
verbose_name = "passbook Policies.SSO"
|
||||
@ -1,19 +0,0 @@
|
||||
"""passbook Policy forms"""
|
||||
|
||||
from django import forms
|
||||
|
||||
from passbook.policies.forms import GENERAL_FIELDS
|
||||
from passbook.policies.sso.models import SSOLoginPolicy
|
||||
|
||||
|
||||
class SSOLoginPolicyForm(forms.ModelForm):
|
||||
"""Edit SSOLoginPolicy instances"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = SSOLoginPolicy
|
||||
fields = GENERAL_FIELDS
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"order": forms.NumberInput(),
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
"""sso models"""
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.core.models import Policy
|
||||
from passbook.policies.struct import PolicyRequest, PolicyResult
|
||||
|
||||
|
||||
class SSOLoginPolicy(Policy):
|
||||
"""Policy that applies to users that have authenticated themselves through SSO"""
|
||||
|
||||
form = "passbook.policies.sso.forms.SSOLoginPolicyForm"
|
||||
|
||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||
"""Check if user instance passes this policy"""
|
||||
from passbook.factors.view import AuthenticationView
|
||||
|
||||
is_sso_login = request.user.session.get(
|
||||
AuthenticationView.SESSION_IS_SSO_LOGIN, False
|
||||
)
|
||||
return PolicyResult(is_sso_login)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("SSO Login Policy")
|
||||
verbose_name_plural = _("SSO Login Policies")
|
||||
@ -1,7 +1,7 @@
|
||||
"""policy structures"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
from typing import TYPE_CHECKING, Tuple
|
||||
|
||||
from django.db.models import Model
|
||||
from django.http import HttpRequest
|
||||
@ -27,8 +27,8 @@ class PolicyRequest:
|
||||
class PolicyResult:
|
||||
"""Small data-class to hold policy results"""
|
||||
|
||||
passing: bool = False
|
||||
messages: List[str] = []
|
||||
passing: bool
|
||||
messages: Tuple[str]
|
||||
|
||||
def __init__(self, passing: bool, *messages: str):
|
||||
self.passing = passing
|
||||
|
||||
@ -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,46 @@
|
||||
"""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 client_related_provider(client: Client) -> Optional[Provider]:
|
||||
"""Lookup related Application from Client"""
|
||||
# because oidc_provider is also used by app_gw, we can't be
|
||||
# sure an OpenIDPRovider instance exists. hence we look through all related models
|
||||
# and choose the one that inherits from Provider, which is guaranteed to
|
||||
# have the application property
|
||||
collector = Collector(using="default")
|
||||
collector.collect([client])
|
||||
for _, related in collector.data.items():
|
||||
related_object = next(iter(related))
|
||||
if isinstance(related_object, Provider):
|
||||
return related_object
|
||||
return None
|
||||
|
||||
|
||||
def check_permissions(
|
||||
request: HttpRequest, user: User, client: Client
|
||||
) -> Optional[HttpResponse]:
|
||||
"""Check permissions, used for
|
||||
https://django-oidc-provider.readthedocs.io/en/latest/
|
||||
sections/settings.html#oidc-after-userlogin-hook"""
|
||||
provider = client_related_provider(client)
|
||||
if not provider:
|
||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||
try:
|
||||
application = client.openidprovider.application
|
||||
application = provider.application
|
||||
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),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user