Compare commits
38 Commits
version/0.
...
version/0.
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e2375f970 | |||
| 38ad8e5fd3 | |||
| c481558a46 | |||
| e27a05a7fc | |||
| 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 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.7.16-beta
|
||||
current_version = 0.8.1-beta
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@ -16,11 +16,11 @@ jobs:
|
||||
- name: Building Docker Image
|
||||
run: docker build
|
||||
--no-cache
|
||||
-t beryju/passbook:0.7.16-beta
|
||||
-t beryju/passbook:0.8.1-beta
|
||||
-t beryju/passbook:latest
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook:0.7.16-beta
|
||||
run: docker push beryju/passbook:0.8.1-beta
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook:latest
|
||||
build-gatekeeper:
|
||||
@ -37,11 +37,11 @@ jobs:
|
||||
cd gatekeeper
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/passbook-gatekeeper:0.7.16-beta \
|
||||
-t beryju/passbook-gatekeeper:0.8.1-beta \
|
||||
-t beryju/passbook-gatekeeper:latest \
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-gatekeeper:0.7.16-beta
|
||||
run: docker push beryju/passbook-gatekeeper:0.8.1-beta
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-gatekeeper:latest
|
||||
build-static:
|
||||
@ -66,11 +66,11 @@ jobs:
|
||||
run: docker build
|
||||
--no-cache
|
||||
--network=$(docker network ls | grep github | awk '{print $1}')
|
||||
-t beryju/passbook-static:0.7.16-beta
|
||||
-t beryju/passbook-static:0.8.1-beta
|
||||
-t beryju/passbook-static:latest
|
||||
-f static.Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-static:0.7.16-beta
|
||||
run: docker push beryju/passbook-static:0.8.1-beta
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-static:latest
|
||||
test-release:
|
||||
|
||||
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
|
||||
|
||||
@ -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.16-beta"
|
||||
appVersion: "0.8.1-beta"
|
||||
description: A Helm chart for passbook.
|
||||
name: passbook
|
||||
version: "0.7.16-beta"
|
||||
version: "0.8.1-beta"
|
||||
icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
# This is a YAML-formatted file.
|
||||
# Declare variables to be passed into your templates.
|
||||
image:
|
||||
tag: 0.7.16-beta
|
||||
tag: 0.8.1-beta
|
||||
|
||||
nameOverride: ""
|
||||
|
||||
@ -42,3 +42,5 @@ redis:
|
||||
master:
|
||||
persistence:
|
||||
enabled: false
|
||||
# https://stackoverflow.com/a/59189742
|
||||
disableCommands: []
|
||||
|
||||
15
mkdocs.yml
15
mkdocs.yml
@ -10,18 +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
|
||||
- Reference:
|
||||
- Property Mappings:
|
||||
- User Object: reference/property-mappings/user-object.md
|
||||
- Ansible Tower/AWX: integrations/services/tower-awx/index.md
|
||||
|
||||
repo_name: "BeryJu.org/passbook"
|
||||
repo_url: https://github.com/BeryJu/passbook
|
||||
@ -32,3 +36,4 @@ theme:
|
||||
markdown_extensions:
|
||||
- toc:
|
||||
permalink: "¶"
|
||||
- admonition
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
"""passbook"""
|
||||
__version__ = "0.7.16-beta"
|
||||
__version__ = "0.8.1-beta"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -2,22 +2,25 @@
|
||||
from datetime import timedelta
|
||||
from random import SystemRandom
|
||||
from time import sleep
|
||||
from typing import Optional, Any
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from jinja2.nativetypes import NativeEnvironment
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse_lazy
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.timezone import now
|
||||
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
|
||||
@ -301,10 +304,25 @@ class PropertyMapping(UUIDModel):
|
||||
form = ""
|
||||
objects = InheritanceManager()
|
||||
|
||||
def evaluate(self, user: User, request: HttpRequest, **kwargs) -> Any:
|
||||
def evaluate(
|
||||
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
|
||||
) -> Any:
|
||||
"""Evaluate `self.expression` using `**kwargs` as Context."""
|
||||
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
|
||||
return expression.render(user=user, request=request, **kwargs)
|
||||
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 %}
|
||||
|
||||
@ -72,7 +72,6 @@ class LoginView(UserPassesTestMixin, FormView):
|
||||
if not pre_user:
|
||||
# No user found
|
||||
return self.invalid_login(self.request)
|
||||
# self.request.session.flush()
|
||||
self.request.session[AuthenticationView.SESSION_PENDING_USER] = pre_user.pk
|
||||
return _redirect_with_qs("passbook_core:auth-process", self.request.GET)
|
||||
|
||||
@ -156,26 +155,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
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -8,6 +8,7 @@ def before_send(event, hint):
|
||||
"""Check if error is database error, and ignore if so"""
|
||||
from django_redis.exceptions import ConnectionInterrupted
|
||||
from django.db import OperationalError, InternalError
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.exceptions import APIException
|
||||
from billiard.exceptions import WorkerLostError
|
||||
from django.core.exceptions import DisallowedHost
|
||||
@ -24,6 +25,7 @@ def before_send(event, hint):
|
||||
ConnectionResetError,
|
||||
KeyboardInterrupt,
|
||||
ClientError,
|
||||
ValidationError,
|
||||
)
|
||||
if "exc_info" in hint:
|
||||
_exc_type, exc_value, _ = hint["exc_info"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -22,3 +23,8 @@ class CreateAssignPermView(CreateView):
|
||||
)
|
||||
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)
|
||||
|
||||
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.http_request.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
|
||||
|
||||
@ -15,24 +15,32 @@ from passbook.policies.engine import PolicyEngine
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
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:
|
||||
# because oidc_provider is also used by app_gw, we can't be
|
||||
# sure an OpenIDPRovider instance exists. hence we look through all related models
|
||||
# and choose the one that inherits from Provider, which is guaranteed to
|
||||
# have the application property
|
||||
collector = Collector(using="default")
|
||||
collector.collect([client])
|
||||
for _, related in collector.data.items():
|
||||
related_object = next(iter(related))
|
||||
if isinstance(related_object, Provider):
|
||||
application = related_object.application
|
||||
break
|
||||
application = provider.application
|
||||
except Application.DoesNotExist:
|
||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||
LOGGER.debug(
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-17 20:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import passbook.providers.saml.utils.time
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_saml", "0005_remove_samlpropertymapping_values"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="samlprovider",
|
||||
name="assertion_valid_not_before",
|
||||
field=models.TextField(
|
||||
default="minutes=-5",
|
||||
help_text="Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).",
|
||||
validators=[
|
||||
passbook.providers.saml.utils.time.timedelta_string_validator
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -23,12 +23,12 @@ class SAMLProvider(Provider):
|
||||
issuer = models.TextField()
|
||||
|
||||
assertion_valid_not_before = models.TextField(
|
||||
default="minutes=5",
|
||||
default="minutes=-5",
|
||||
validators=[timedelta_string_validator],
|
||||
help_text=_(
|
||||
(
|
||||
"Assertion valid not before current time - this value "
|
||||
"(Format: hours=1;minutes=2;seconds=3)."
|
||||
"Assertion valid not before current time + this value "
|
||||
"(Format: hours=-1;minutes=-2;seconds=-3)."
|
||||
)
|
||||
),
|
||||
)
|
||||
@ -82,7 +82,7 @@ class SAMLProvider(Provider):
|
||||
self._meta.get_field("processor_path").choices = get_provider_choices()
|
||||
|
||||
@property
|
||||
def processor(self):
|
||||
def processor(self) -> Processor:
|
||||
"""Return selected processor as instance"""
|
||||
if not self._processor:
|
||||
try:
|
||||
@ -106,6 +106,16 @@ class SAMLProvider(Provider):
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
def html_metadata_view(self, request):
|
||||
"""return template and context modal with to view Metadata without downloading it"""
|
||||
from passbook.providers.saml.views import DescriptorDownloadView
|
||||
|
||||
metadata = DescriptorDownloadView.get_metadata(request, self)
|
||||
return (
|
||||
"saml/idp/admin_metadata_modal.html",
|
||||
{"provider": self, "metadata": metadata,},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("SAML Provider")
|
||||
|
||||
@ -5,7 +5,9 @@ from defusedxml import ElementTree
|
||||
from django.http import HttpRequest
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.exceptions import PropertyMappingExpressionException
|
||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||
from passbook.providers.saml.processors.types import SAMLResponseParams
|
||||
from passbook.providers.saml.utils import get_random_id
|
||||
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate, nice64
|
||||
from passbook.providers.saml.utils.time import get_time_string, timedelta_from_string
|
||||
@ -97,7 +99,10 @@ class Processor:
|
||||
from passbook.providers.saml.models import SAMLPropertyMapping
|
||||
|
||||
for mapping in self._remote.property_mappings.all().select_subclasses():
|
||||
if isinstance(mapping, SAMLPropertyMapping):
|
||||
if not isinstance(mapping, SAMLPropertyMapping):
|
||||
continue
|
||||
try:
|
||||
mapping: SAMLPropertyMapping
|
||||
value = mapping.evaluate(
|
||||
user=self._http_request.user,
|
||||
request=self._http_request,
|
||||
@ -107,11 +112,16 @@ class Processor:
|
||||
"Name": mapping.saml_name,
|
||||
"FriendlyName": mapping.friendly_name,
|
||||
}
|
||||
# Normal values and arrays need different dict keys as they are handeled
|
||||
# differently in the template
|
||||
if isinstance(value, list):
|
||||
mapping_payload["ValueArray"] = value
|
||||
else:
|
||||
mapping_payload["Value"] = value
|
||||
attributes.append(mapping_payload)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
self._logger.warning(exc)
|
||||
continue
|
||||
self._assertion_params["ATTRIBUTES"] = attributes
|
||||
self._assertion_xml = get_assertion_xml(
|
||||
"saml/xml/assertions/generic.xml", self._assertion_params, signed=True
|
||||
@ -124,14 +134,13 @@ class Processor:
|
||||
self._response_params, saml_provider=self._remote, assertion_id=assertion_id
|
||||
)
|
||||
|
||||
def _get_django_response_params(self) -> Dict[str, str]:
|
||||
def _get_saml_response_params(self) -> SAMLResponseParams:
|
||||
"""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,
|
||||
}
|
||||
return SAMLResponseParams(
|
||||
acs_url=self._request_params["ACS_URL"],
|
||||
saml_response=self._saml_response,
|
||||
relay_state=self._relay_state,
|
||||
)
|
||||
|
||||
def _decode_and_parse_request(self):
|
||||
"""Parses various parameters from _request_xml into _request_params."""
|
||||
@ -174,7 +183,7 @@ class Processor:
|
||||
# Read the request.
|
||||
try:
|
||||
self._extract_saml_request()
|
||||
except Exception as exc:
|
||||
except KeyError as exc:
|
||||
raise CannotHandleAssertion(
|
||||
f"can't find SAML request in user session: {exc}"
|
||||
) from exc
|
||||
@ -187,7 +196,7 @@ class Processor:
|
||||
self._validate_request()
|
||||
return True
|
||||
|
||||
def generate_response(self) -> Dict[str, str]:
|
||||
def generate_response(self) -> SAMLResponseParams:
|
||||
"""Processes request and returns template variables suitable for a response."""
|
||||
# Build the assertion and response.
|
||||
# Only call can_handle if SP initiated Request, otherwise we have no Request
|
||||
@ -201,9 +210,9 @@ class Processor:
|
||||
self._encode_response()
|
||||
|
||||
# Return proper template params.
|
||||
return self._get_django_response_params()
|
||||
return self._get_saml_response_params()
|
||||
|
||||
def init_deep_link(self, request: HttpRequest, url: str):
|
||||
def init_deep_link(self, request: HttpRequest):
|
||||
"""Initialize this Processor to make an IdP-initiated call to the SP's
|
||||
deep-linked URL."""
|
||||
self._http_request = request
|
||||
@ -218,4 +227,4 @@ class Processor:
|
||||
"DESTINATION": "",
|
||||
"PROVIDER_NAME": "",
|
||||
}
|
||||
self._relay_state = url
|
||||
self._relay_state = ""
|
||||
|
||||
11
passbook/providers/saml/processors/types.py
Normal file
11
passbook/providers/saml/processors/types.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""passbook saml provider types"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SAMLResponseParams:
|
||||
"""Class to keep track of SAML Response Parameters"""
|
||||
|
||||
acs_url: str
|
||||
saml_response: str
|
||||
relay_state: str
|
||||
@ -0,0 +1,41 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
<script src="{% static 'codemirror/lib/codemirror.js' %}"></script>
|
||||
<script src="{% static 'codemirror/addon/display/autorefresh.js' %}"></script>
|
||||
<link rel="stylesheet" href="{% static 'codemirror/lib/codemirror.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'codemirror/theme/monokai.css' %}">
|
||||
<script src="{% static 'codemirror/mode/xml/xml.js' %}"></script>
|
||||
|
||||
<button class="btn btn-default btn-sm" data-toggle="modal" data-target="#{{ provider.pk }}">{% trans 'View Metadata' %}</button>
|
||||
<div class="modal fade" id="{{ provider.pk }}" tabindex="-1" role="dialog" aria-labelledby="{{ provider.pk }}Label" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true" aria-label="Close">
|
||||
<span class="pficon pficon-close"></span>
|
||||
</button>
|
||||
<h4 class="modal-title" id="{{ provider.pk }}Label">{% trans 'Metadata' %}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-horizontal">
|
||||
<textarea class="codemirror" id="{{ provider.pk }}-textarea">
|
||||
{{ metadata }}
|
||||
</textarea>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-dismiss="modal">{% trans 'Close' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
CodeMirror.fromTextArea(document.getElementById("{{ provider.pk }}-textarea"), {
|
||||
mode: 'xml',
|
||||
theme: 'monokai',
|
||||
lineNumbers: false,
|
||||
readOnly: true,
|
||||
autoRefresh: true,
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,39 @@
|
||||
{% extends "login/base.html" %}
|
||||
|
||||
{% load utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
{% title 'Redirecting...' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<header class="login-pf-header">
|
||||
<h1>{% trans 'Redirecting...' %}</h1>
|
||||
</header>
|
||||
<form method="POST" action="{{ url }}">
|
||||
{% csrf_token %}
|
||||
{% for key, value in attrs.items %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
{% endfor %}
|
||||
<div class="login-group">
|
||||
<h3>
|
||||
{% trans "Redirecting..." %}
|
||||
</h3>
|
||||
<p>
|
||||
{% blocktrans with user=user %}
|
||||
You are logged in as {{ user }}.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Not you?' %}</a>
|
||||
</p>
|
||||
<input class="btn btn-primary btn-block btn-lg" type="submit" value="{% trans 'Continue' %}" />
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
$('form').submit();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,5 +0,0 @@
|
||||
{% extends "saml/idp/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
{% trans "You have logged in, but your user account is not enabled for SAML 2.0." %}
|
||||
{% endblock %}
|
||||
@ -1,5 +1,9 @@
|
||||
{% extends "saml/idp/base.html" %}
|
||||
{% extends "login/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
{% trans "You have successfully logged out of the Identity Provider." %}
|
||||
|
||||
{% block card %}
|
||||
<p>
|
||||
{% trans "You have successfully logged out of the Identity Provider." %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@ -11,15 +11,15 @@
|
||||
<header class="login-pf-header">
|
||||
<h1>{% trans 'Authorize Application' %}</h1>
|
||||
</header>
|
||||
<form method="POST" action="{{ acs_url }}">
|
||||
<form method="POST" action="{{ saml_params.acs_url }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="ACSUrl" value="{{ acs_url }}">
|
||||
<input type="hidden" name="RelayState" value="{{ relay_state }}" />
|
||||
<input type="hidden" name="SAMLResponse" value="{{ saml_response }}" />
|
||||
<input type="hidden" name="ACSUrl" value="{{ saml_params.acs_url }}">
|
||||
<input type="hidden" name="RelayState" value="{{ saml_params.relay_state }}" />
|
||||
<input type="hidden" name="SAMLResponse" value="{{ saml_params.saml_response }}" />
|
||||
<div class="login-group">
|
||||
<h3>
|
||||
{% blocktrans with remote=remote.application.name %}
|
||||
You're about to sign into {{ remote }}
|
||||
{% blocktrans with provider=provider.application.name %}
|
||||
You're about to sign into {{ provider }}
|
||||
{% endblocktrans %}
|
||||
</h3>
|
||||
<p>
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
{% extends "_admin/module_default.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load utils %}
|
||||
|
||||
{% block title %}
|
||||
{% title "Overview" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block module_content %}
|
||||
<h2><clr-icon shape="application" size="32"></clr-icon>{% trans 'SAML2 IDP' %}</h2>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2><clr-icon shape="settings" size="32"></clr-icon>{% trans 'Settings' %}</h2>
|
||||
</div>
|
||||
<form role="form" method="POST">
|
||||
<div class="card-block">
|
||||
{% include 'partials/form.html' with form=form %}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="submit" class="btn btn-primary">{% trans 'Update' %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2><clr-icon shape="bank" size="32"></clr-icon>{% trans 'Metadata' %}</h2>
|
||||
</div>
|
||||
<div class="card-block">
|
||||
<p>{% trans 'Cert Fingerprint (SHA1):' %} <pre>{{ fingerprint }}</pre></p>
|
||||
<section class="form-block">
|
||||
<pre lang="xml" >{{ metadata }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a href="{% url 'passbook_providers_saml:saml-metadata' %}" class="btn btn-primary"><clr-icon shape="download"></clr-icon>{% trans 'Download Metadata' %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -4,14 +4,17 @@ from django.urls import path
|
||||
from passbook.providers.saml import views
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"<slug:application>/login/", views.LoginBeginView.as_view(), name="saml-login"
|
||||
),
|
||||
# This view is used to initiate a Login-flow from the IDP
|
||||
path(
|
||||
"<slug:application>/login/initiate/",
|
||||
views.InitiateLoginView.as_view(),
|
||||
name="saml-login-initiate",
|
||||
),
|
||||
# This view is the endpoint a SP would redirect to, and saves data into the session
|
||||
# this is required as the process view which it redirects to might have to login first.
|
||||
path(
|
||||
"<slug:application>/login/", views.LoginProcessView.as_view(), name="saml-login"
|
||||
),
|
||||
path(
|
||||
"<slug:application>/login/process/",
|
||||
views.LoginProcessView.as_view(),
|
||||
|
||||
@ -5,10 +5,11 @@ from django.contrib.auth import logout
|
||||
from django.contrib.auth.mixins import AccessMixin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render, reverse
|
||||
from django.utils.datastructures import MultiValueDictKeyError
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.html import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@ -17,30 +18,17 @@ from structlog import get_logger
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.core.models import Application
|
||||
from passbook.lib.mixins import CSRFExemptMixin
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.lib.views import bad_request_message
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
from passbook.providers.saml import exceptions
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
from passbook.providers.saml.processors.types import SAMLResponseParams
|
||||
|
||||
LOGGER = get_logger()
|
||||
URL_VALIDATOR = URLValidator(schemes=("http", "https"))
|
||||
|
||||
|
||||
def _generate_response(request: HttpRequest, provider: SAMLProvider) -> HttpResponse:
|
||||
"""Generate a SAML response using processor_instance and return it in the proper Django
|
||||
response."""
|
||||
try:
|
||||
provider.processor.init_deep_link(request, "")
|
||||
ctx = provider.processor.generate_response()
|
||||
ctx["remote"] = provider
|
||||
ctx["is_login"] = True
|
||||
except exceptions.UserNotAuthorized:
|
||||
return render(request, "saml/idp/invalid_user.html")
|
||||
|
||||
return render(request, "saml/idp/login.html", ctx)
|
||||
|
||||
|
||||
class AccessRequiredView(AccessMixin, View):
|
||||
"""Mixin class for Views using a provider instance"""
|
||||
|
||||
@ -53,7 +41,11 @@ class AccessRequiredView(AccessMixin, View):
|
||||
application = get_object_or_404(
|
||||
Application, slug=self.kwargs["application"]
|
||||
)
|
||||
self._provider = get_object_or_404(SAMLProvider, pk=application.provider_id)
|
||||
provider: SAMLProvider = get_object_or_404(
|
||||
SAMLProvider, pk=application.provider_id
|
||||
)
|
||||
self._provider = provider
|
||||
return self._provider
|
||||
return self._provider
|
||||
|
||||
def _has_access(self) -> bool:
|
||||
@ -97,7 +89,7 @@ class LoginBeginView(AccessRequiredView):
|
||||
try:
|
||||
request.session["SAMLRequest"] = source["SAMLRequest"]
|
||||
except (KeyError, MultiValueDictKeyError):
|
||||
return HttpResponseBadRequest("the SAML request payload is missing")
|
||||
return bad_request_message(request, "The SAML request payload is missing.")
|
||||
|
||||
request.session["RelayState"] = source.get("RelayState", "")
|
||||
return redirect(
|
||||
@ -108,76 +100,87 @@ class LoginBeginView(AccessRequiredView):
|
||||
)
|
||||
|
||||
|
||||
class RedirectToSPView(AccessRequiredView):
|
||||
"""Return autosubmit form"""
|
||||
|
||||
def get(
|
||||
self, request: HttpRequest, acs_url: str, saml_response: str, relay_state: str
|
||||
) -> HttpResponse:
|
||||
"""Return autosubmit form"""
|
||||
return render(
|
||||
request,
|
||||
"core/autosubmit_form.html",
|
||||
{
|
||||
"url": acs_url,
|
||||
"attrs": {"SAMLResponse": saml_response, "RelayState": relay_state},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class LoginProcessView(AccessRequiredView):
|
||||
"""Processor-based login continuation.
|
||||
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def handle_redirect(
|
||||
self, params: SAMLResponseParams, skipped_authorization: bool
|
||||
) -> HttpResponse:
|
||||
"""Handle direct redirect to SP"""
|
||||
# Log Application Authorization
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=self.provider.application,
|
||||
skipped_authorization=skipped_authorization,
|
||||
).from_http(self.request)
|
||||
return render(
|
||||
self.request,
|
||||
"saml/idp/autosubmit_form.html",
|
||||
{
|
||||
"url": params.acs_url,
|
||||
"attrs": {
|
||||
"SAMLResponse": params.saml_response,
|
||||
"RelayState": params.relay_state,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Handle get request, i.e. render form"""
|
||||
# User access gets checked in dispatch
|
||||
if self.provider.application.skip_authorization:
|
||||
ctx = self.provider.processor.generate_response()
|
||||
# Log Application Authorization
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=self.provider.application,
|
||||
skipped_authorization=True,
|
||||
).from_http(request)
|
||||
return RedirectToSPView.as_view()(
|
||||
request=request,
|
||||
acs_url=ctx["acs_url"],
|
||||
saml_response=ctx["saml_response"],
|
||||
relay_state=ctx["relay_state"],
|
||||
)
|
||||
|
||||
# Otherwise we generate the IdP initiated session
|
||||
try:
|
||||
return _generate_response(request, self.provider)
|
||||
# application.skip_authorization is set so we directly redirect the user
|
||||
if self.provider.application.skip_authorization:
|
||||
self.provider.processor.can_handle(request)
|
||||
saml_params = self.provider.processor.generate_response()
|
||||
return self.handle_redirect(saml_params, True)
|
||||
|
||||
self.provider.processor.init_deep_link(request)
|
||||
params = self.provider.processor.generate_response()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"saml/idp/login.html",
|
||||
{
|
||||
"saml_params": params,
|
||||
"provider": self.provider,
|
||||
# This is only needed to for the template to render correctly
|
||||
"is_login": True,
|
||||
},
|
||||
)
|
||||
|
||||
except exceptions.CannotHandleAssertion as exc:
|
||||
LOGGER.debug(exc)
|
||||
return HttpResponseBadRequest()
|
||||
LOGGER.error(exc)
|
||||
did_you_mean_link = request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:saml-login-initiate",
|
||||
kwargs={"application": application},
|
||||
)
|
||||
)
|
||||
did_you_mean_message = (
|
||||
f" Did you mean to go <a href='{did_you_mean_link}'>here</a>?"
|
||||
)
|
||||
return bad_request_message(
|
||||
request, mark_safe(str(exc) + did_you_mean_message)
|
||||
)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Handle post request, return back to ACS"""
|
||||
# User access gets checked in dispatch
|
||||
if request.POST.get("ACSUrl", None):
|
||||
# User accepted request
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=self.provider.application,
|
||||
skipped_authorization=False,
|
||||
).from_http(request)
|
||||
return RedirectToSPView.as_view()(
|
||||
request=request,
|
||||
acs_url=request.POST.get("ACSUrl"),
|
||||
saml_response=request.POST.get("SAMLResponse"),
|
||||
relay_state=request.POST.get("RelayState"),
|
||||
)
|
||||
try:
|
||||
return _generate_response(request, self.provider)
|
||||
except exceptions.CannotHandleAssertion as exc:
|
||||
LOGGER.debug(exc)
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# we get here when skip_authorization is False, and after the user accepted
|
||||
# the authorization form
|
||||
self.provider.processor.can_handle(request)
|
||||
saml_params = self.provider.processor.generate_response()
|
||||
return self.handle_redirect(saml_params, True)
|
||||
|
||||
|
||||
class LogoutView(CSRFExemptMixin, AccessRequiredView):
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class LogoutView(AccessRequiredView):
|
||||
"""Allows a non-SAML 2.0 URL to log out the user and
|
||||
returns a standard logged-out page. (SalesForce and others use this method,
|
||||
though it's technically not SAML 2.0)."""
|
||||
@ -199,7 +202,8 @@ class LogoutView(CSRFExemptMixin, AccessRequiredView):
|
||||
return render(request, "saml/idp/logged_out.html")
|
||||
|
||||
|
||||
class SLOLogout(CSRFExemptMixin, AccessRequiredView):
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class SLOLogout(AccessRequiredView):
|
||||
"""Receives a SAML 2.0 LogoutRequest from a Service Provider,
|
||||
logs out the user and returns a standard logged-out page."""
|
||||
|
||||
@ -219,22 +223,23 @@ class SLOLogout(CSRFExemptMixin, AccessRequiredView):
|
||||
class DescriptorDownloadView(AccessRequiredView):
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
|
||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
entity_id = self.provider.issuer
|
||||
@staticmethod
|
||||
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
|
||||
"""Return rendered XML Metadata"""
|
||||
entity_id = provider.issuer
|
||||
slo_url = request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:saml-logout",
|
||||
kwargs={"application": application},
|
||||
kwargs={"application": provider.application.slug},
|
||||
)
|
||||
)
|
||||
sso_url = request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_saml:saml-login",
|
||||
kwargs={"application": application},
|
||||
kwargs={"application": provider.application.slug},
|
||||
)
|
||||
)
|
||||
pubkey = strip_pem_header(self.provider.signing_cert.replace("\r", "")).replace(
|
||||
pubkey = strip_pem_header(provider.signing_cert.replace("\r", "")).replace(
|
||||
"\n", ""
|
||||
)
|
||||
ctx = {
|
||||
@ -243,7 +248,12 @@ class DescriptorDownloadView(AccessRequiredView):
|
||||
"slo_url": slo_url,
|
||||
"sso_url": sso_url,
|
||||
}
|
||||
metadata = render_to_string("saml/xml/metadata.xml", ctx)
|
||||
return render_to_string("saml/xml/metadata.xml", ctx)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||
metadata = DescriptorDownloadView.get_metadata(request, self.provider)
|
||||
response = HttpResponse(metadata, content_type="application/xml")
|
||||
response["Content-Disposition"] = (
|
||||
'attachment; filename="' '%s_passbook_meta.xml"' % self.provider.name
|
||||
@ -254,9 +264,46 @@ class DescriptorDownloadView(AccessRequiredView):
|
||||
class InitiateLoginView(AccessRequiredView):
|
||||
"""IdP-initiated Login"""
|
||||
|
||||
def handle_redirect(
|
||||
self, params: SAMLResponseParams, skipped_authorization: bool
|
||||
) -> HttpResponse:
|
||||
"""Handle direct redirect to SP"""
|
||||
# Log Application Authorization
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=self.provider.application,
|
||||
skipped_authorization=skipped_authorization,
|
||||
).from_http(self.request)
|
||||
return render(
|
||||
self.request,
|
||||
"saml/idp/autosubmit_form.html",
|
||||
{
|
||||
"url": params.acs_url,
|
||||
"attrs": {
|
||||
"SAMLResponse": params.saml_response,
|
||||
"RelayState": params.relay_state,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
||||
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
|
||||
self.provider.processor.init_deep_link(request, "")
|
||||
self.provider.processor.is_idp_initiated = True
|
||||
return _generate_response(request, self.provider)
|
||||
self.provider.processor.init_deep_link(request)
|
||||
params = self.provider.processor.generate_response()
|
||||
|
||||
# IdP-initiated Login Flow
|
||||
if self.provider.application.skip_authorization:
|
||||
return self.handle_redirect(params, True)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"saml/idp/login.html",
|
||||
{
|
||||
"saml_params": params,
|
||||
"provider": self.provider,
|
||||
# This is only needed to for the template to render correctly
|
||||
"is_login": True,
|
||||
},
|
||||
)
|
||||
|
||||
@ -93,11 +93,9 @@ INSTALLED_APPS = [
|
||||
"passbook.policies.expiry.apps.PassbookPolicyExpiryConfig",
|
||||
"passbook.policies.reputation.apps.PassbookPolicyReputationConfig",
|
||||
"passbook.policies.hibp.apps.PassbookPolicyHIBPConfig",
|
||||
"passbook.policies.group.apps.PassbookPoliciesGroupConfig",
|
||||
"passbook.policies.matcher.apps.PassbookPoliciesMatcherConfig",
|
||||
"passbook.policies.password.apps.PassbookPoliciesPasswordConfig",
|
||||
"passbook.policies.sso.apps.PassbookPoliciesSSOConfig",
|
||||
"passbook.policies.webhook.apps.PassbookPoliciesWebhookConfig",
|
||||
"passbook.policies.expression.apps.PassbookPolicyExpressionConfig",
|
||||
]
|
||||
|
||||
GUARDIAN_MONKEY_PATCH = False
|
||||
@ -276,7 +274,7 @@ structlog.configure_once(
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
# structlog.processors.format_exc_info,
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
context_class=structlog.threadlocal.wrap_dict(dict),
|
||||
|
||||
@ -5,8 +5,9 @@ import ldap3
|
||||
import ldap3.core.exceptions
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.exceptions import PropertyMappingExpressionException
|
||||
from passbook.core.models import Group, User
|
||||
from passbook.sources.ldap.models import LDAPSource, LDAPPropertyMapping
|
||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -155,9 +156,13 @@ class Connector:
|
||||
properties = {"attributes": {}}
|
||||
for mapping in self._source.property_mappings.all().select_subclasses():
|
||||
mapping: LDAPPropertyMapping
|
||||
properties[mapping.object_field] = mapping.evaluate(
|
||||
user=None, request=None, ldap=attributes
|
||||
)
|
||||
try:
|
||||
properties[mapping.object_field] = mapping.evaluate(
|
||||
user=None, request=None, ldap=attributes
|
||||
)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
LOGGER.warning(exc)
|
||||
continue
|
||||
if self._source.object_uniqueness_field in attributes:
|
||||
properties["attributes"]["ldap_uniq"] = attributes.get(
|
||||
self._source.object_uniqueness_field
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""OAuth Clients"""
|
||||
|
||||
import json
|
||||
from typing import Dict
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from django.utils.crypto import constant_time_compare, get_random_string
|
||||
@ -18,13 +18,13 @@ LOGGER = get_logger()
|
||||
class BaseOAuthClient:
|
||||
"""Base OAuth Client"""
|
||||
|
||||
_session = None
|
||||
_session: Session = None
|
||||
|
||||
def __init__(self, source, token=""): # nosec
|
||||
self.source = source
|
||||
self.token = token
|
||||
self._session = Session()
|
||||
self._session.headers.update({"User-Agent": "web:passbook:%s" % __version__})
|
||||
self._session.headers.update({"User-Agent": "passbook %s" % __version__})
|
||||
|
||||
def get_access_token(self, request, callback=None):
|
||||
"Fetch access token from callback request."
|
||||
@ -33,15 +33,24 @@ class BaseOAuthClient:
|
||||
def get_profile_info(self, raw_token):
|
||||
"Fetch user profile information."
|
||||
try:
|
||||
response = self.request("get", self.source.profile_url, token=raw_token)
|
||||
token = json.loads(raw_token)
|
||||
headers = {
|
||||
"Authorization": f"{token['token_type']} {token['access_token']}"
|
||||
}
|
||||
response = self.request(
|
||||
"get",
|
||||
self.source.profile_url,
|
||||
token=token["access_token"],
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch user profile: %s", exc)
|
||||
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
||||
return None
|
||||
else:
|
||||
return response.json() or response.text
|
||||
|
||||
def get_redirect_args(self, request, callback):
|
||||
def get_redirect_args(self, request, callback) -> Dict[str, str]:
|
||||
"Get request parameters for redirect url."
|
||||
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
||||
|
||||
@ -51,7 +60,7 @@ class BaseOAuthClient:
|
||||
additional = parameters or {}
|
||||
args.update(additional)
|
||||
params = urlencode(args)
|
||||
LOGGER.info("Redirect args: %s", args)
|
||||
LOGGER.info("redirect args", **args)
|
||||
return "{0}?{1}".format(self.source.authorization_url, params)
|
||||
|
||||
def parse_raw_token(self, raw_token):
|
||||
@ -64,9 +73,7 @@ class BaseOAuthClient:
|
||||
|
||||
@property
|
||||
def session_key(self):
|
||||
"""
|
||||
Return Session Key
|
||||
"""
|
||||
"""Return Session Key"""
|
||||
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
||||
|
||||
|
||||
@ -91,7 +98,7 @@ class OAuthClient(BaseOAuthClient):
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch access token: %s", exc)
|
||||
LOGGER.warning("Unable to fetch access token", exc=exc)
|
||||
return None
|
||||
else:
|
||||
return response.text
|
||||
@ -106,7 +113,7 @@ class OAuthClient(BaseOAuthClient):
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch request token: %s", exc)
|
||||
LOGGER.warning("Unable to fetch request token", exc=exc)
|
||||
return None
|
||||
else:
|
||||
return response.text
|
||||
@ -195,7 +202,7 @@ class OAuth2Client(BaseOAuthClient):
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch access token: %s", exc)
|
||||
LOGGER.warning("Unable to fetch access token", exc=exc)
|
||||
return None
|
||||
else:
|
||||
return response.text
|
||||
|
||||
@ -50,7 +50,7 @@ class GitHubOAuthSourceForm(OAuthSourceForm):
|
||||
"request_token_url": "",
|
||||
"authorization_url": "https://github.com/login/oauth/authorize",
|
||||
"access_token_url": "https://github.com/login/oauth/access_token",
|
||||
"profile_url": " https://api.github.com/user",
|
||||
"profile_url": "https://api.github.com/user",
|
||||
}
|
||||
|
||||
|
||||
@ -64,7 +64,10 @@ class TwitterOAuthSourceForm(OAuthSourceForm):
|
||||
"request_token_url": "https://api.twitter.com/oauth/request_token",
|
||||
"authorization_url": "https://api.twitter.com/oauth/authenticate",
|
||||
"access_token_url": "https://api.twitter.com/oauth/access_token",
|
||||
"profile_url": " https://api.twitter.com/1.1/account/verify_credentials.json",
|
||||
"profile_url": (
|
||||
"https://api.twitter.com/1.1/account/"
|
||||
"verify_credentials.json?include_email=true"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@ -78,7 +81,7 @@ class FacebookOAuthSourceForm(OAuthSourceForm):
|
||||
"request_token_url": "",
|
||||
"authorization_url": "https://www.facebook.com/v2.8/dialog/oauth",
|
||||
"access_token_url": "https://graph.facebook.com/v2.8/oauth/access_token",
|
||||
"profile_url": " https://graph.facebook.com/v2.8/me?fields=name,email,short_name",
|
||||
"profile_url": "https://graph.facebook.com/v2.8/me?fields=name,email,short_name",
|
||||
}
|
||||
|
||||
|
||||
@ -92,7 +95,7 @@ class DiscordOAuthSourceForm(OAuthSourceForm):
|
||||
"request_token_url": "",
|
||||
"authorization_url": "https://discordapp.com/api/oauth2/authorize",
|
||||
"access_token_url": "https://discordapp.com/api/oauth2/token",
|
||||
"profile_url": " https://discordapp.com/api/users/@me",
|
||||
"profile_url": "https://discordapp.com/api/users/@me",
|
||||
}
|
||||
|
||||
|
||||
@ -106,7 +109,7 @@ class GoogleOAuthSourceForm(OAuthSourceForm):
|
||||
"request_token_url": "",
|
||||
"authorization_url": "https://accounts.google.com/o/oauth2/auth",
|
||||
"access_token_url": "https://accounts.google.com/o/oauth2/token",
|
||||
"profile_url": " https://www.googleapis.com/oauth2/v1/userinfo",
|
||||
"profile_url": "https://www.googleapis.com/oauth2/v1/userinfo",
|
||||
}
|
||||
|
||||
|
||||
@ -120,5 +123,5 @@ class AzureADOAuthSourceForm(OAuthSourceForm):
|
||||
"request_token_url": "",
|
||||
"authorization_url": "https://login.microsoftonline.com/common/oauth2/authorize",
|
||||
"access_token_url": "https://login.microsoftonline.com/common/oauth2/token",
|
||||
"profile_url": " https://graph.windows.net/myorganization/me?api-version=1.6",
|
||||
"profile_url": "https://graph.windows.net/myorganization/me?api-version=1.6",
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ PASSBOOK_SOURCES_OAUTH_TYPES = [
|
||||
"passbook.sources.oauth.types.github",
|
||||
"passbook.sources.oauth.types.google",
|
||||
"passbook.sources.oauth.types.reddit",
|
||||
"passbook.sources.oauth.types.supervisr",
|
||||
"passbook.sources.oauth.types.twitter",
|
||||
"passbook.sources.oauth.types.azure_ad",
|
||||
]
|
||||
|
||||
@ -1,41 +1,15 @@
|
||||
"""AzureAD OAuth2 Views"""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from requests.exceptions import RequestException
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.sources.oauth.clients import OAuth2Client
|
||||
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
||||
from passbook.sources.oauth.utils import user_get_or_create
|
||||
from passbook.sources.oauth.views.core import OAuthCallback
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class AzureADOAuth2Client(OAuth2Client):
|
||||
"""AzureAD OAuth2 Client"""
|
||||
|
||||
def get_profile_info(self, raw_token):
|
||||
"Fetch user profile information."
|
||||
try:
|
||||
token = json.loads(raw_token)["access_token"]
|
||||
headers = {"Authorization": "Bearer %s" % token}
|
||||
response = self.request("get", self.source.profile_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch user profile: %s", exc)
|
||||
return None
|
||||
else:
|
||||
return response.json() or response.text
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.callback, name="Azure AD")
|
||||
class AzureADOAuthCallback(OAuthCallback):
|
||||
"""AzureAD OAuth2 Callback"""
|
||||
|
||||
client_class = AzureADOAuth2Client
|
||||
|
||||
def get_user_id(self, source, info):
|
||||
return uuid.UUID(info.get("objectId")).int
|
||||
|
||||
|
||||
@ -1,16 +1,8 @@
|
||||
"""Discord OAuth Views"""
|
||||
import json
|
||||
|
||||
from requests.exceptions import RequestException
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.sources.oauth.clients import OAuth2Client
|
||||
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
||||
from passbook.sources.oauth.utils import user_get_or_create
|
||||
from passbook.sources.oauth.views.core import OAuthCallback, OAuthRedirect
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.redirect, name="Discord")
|
||||
class DiscordOAuthRedirect(OAuthRedirect):
|
||||
@ -22,36 +14,10 @@ class DiscordOAuthRedirect(OAuthRedirect):
|
||||
}
|
||||
|
||||
|
||||
class DiscordOAuth2Client(OAuth2Client):
|
||||
"""Discord OAuth2 Client"""
|
||||
|
||||
def get_profile_info(self, raw_token):
|
||||
"Fetch user profile information."
|
||||
try:
|
||||
token = json.loads(raw_token)
|
||||
headers = {
|
||||
"Authorization": "%s %s" % (token["token_type"], token["access_token"])
|
||||
}
|
||||
response = self.request(
|
||||
"get",
|
||||
self.source.profile_url,
|
||||
token=token["access_token"],
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch user profile: %s", exc)
|
||||
return None
|
||||
else:
|
||||
return response.json() or response.text
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.callback, name="Discord")
|
||||
class DiscordOAuth2Callback(OAuthCallback):
|
||||
"""Discord OAuth2 Callback"""
|
||||
|
||||
client_class = DiscordOAuth2Client
|
||||
|
||||
def get_or_create_user(self, source, access, info):
|
||||
user_data = {
|
||||
"username": info.get("username"),
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Facebook OAuth Views"""
|
||||
|
||||
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
||||
from passbook.sources.oauth.utils import user_get_or_create
|
||||
from passbook.sources.oauth.views.core import OAuthCallback, OAuthRedirect
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""GitHub OAuth Views"""
|
||||
|
||||
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
||||
from passbook.sources.oauth.utils import user_get_or_create
|
||||
from passbook.sources.oauth.views.core import OAuthCallback
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
"""Reddit OAuth Views"""
|
||||
import json
|
||||
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from requests.exceptions import RequestException
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.sources.oauth.clients import OAuth2Client
|
||||
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
||||
from passbook.sources.oauth.utils import user_get_or_create
|
||||
from passbook.sources.oauth.views.core import OAuthCallback, OAuthRedirect
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.redirect, name="reddit")
|
||||
class RedditOAuthRedirect(OAuthRedirect):
|
||||
@ -34,26 +28,6 @@ class RedditOAuth2Client(OAuth2Client):
|
||||
request, callback, auth=auth
|
||||
)
|
||||
|
||||
def get_profile_info(self, raw_token):
|
||||
"Fetch user profile information."
|
||||
try:
|
||||
token = json.loads(raw_token)
|
||||
headers = {
|
||||
"Authorization": "%s %s" % (token["token_type"], token["access_token"])
|
||||
}
|
||||
response = self.request(
|
||||
"get",
|
||||
self.source.profile_url,
|
||||
token=token["access_token"],
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch user profile: %s", exc)
|
||||
return None
|
||||
else:
|
||||
return response.json() or response.text
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.callback, name="reddit")
|
||||
class RedditOAuth2Callback(OAuthCallback):
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
"""Supervisr OAuth2 Views"""
|
||||
|
||||
import json
|
||||
|
||||
from requests.exceptions import RequestException
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.sources.oauth.clients import OAuth2Client
|
||||
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
||||
from passbook.sources.oauth.utils import user_get_or_create
|
||||
from passbook.sources.oauth.views.core import OAuthCallback
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class SupervisrOAuth2Client(OAuth2Client):
|
||||
"""Supervisr OAuth2 Client"""
|
||||
|
||||
def get_profile_info(self, raw_token):
|
||||
"Fetch user profile information."
|
||||
try:
|
||||
token = json.loads(raw_token)["access_token"]
|
||||
headers = {"Authorization": "Bearer:%s" % token}
|
||||
response = self.request(
|
||||
"get", self.source.profile_url, token=raw_token, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch user profile: %s", exc)
|
||||
return None
|
||||
else:
|
||||
return response.json() or response.text
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.callback, name="supervisr")
|
||||
class SupervisrOAuthCallback(OAuthCallback):
|
||||
"""Supervisr OAuth2 Callback"""
|
||||
|
||||
client_class = SupervisrOAuth2Client
|
||||
|
||||
def get_user_id(self, source, info):
|
||||
return info["pk"]
|
||||
|
||||
def get_or_create_user(self, source, access, info):
|
||||
user_data = {
|
||||
"username": info.get("username"),
|
||||
"email": info.get("email", ""),
|
||||
"name": info.get("first_name"),
|
||||
"password": None,
|
||||
}
|
||||
sv_user = user_get_or_create(**user_data)
|
||||
return sv_user
|
||||
@ -1,39 +1,13 @@
|
||||
"""Twitter OAuth Views"""
|
||||
|
||||
from requests.exceptions import RequestException
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.sources.oauth.clients import OAuthClient
|
||||
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
||||
from passbook.sources.oauth.utils import user_get_or_create
|
||||
from passbook.sources.oauth.views.core import OAuthCallback
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class TwitterOAuthClient(OAuthClient):
|
||||
"""Twitter OAuth2 Client"""
|
||||
|
||||
def get_profile_info(self, raw_token):
|
||||
"Fetch user profile information."
|
||||
try:
|
||||
response = self.request(
|
||||
"get", self.source.profile_url + "?include_email=true", token=raw_token
|
||||
)
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
LOGGER.warning("Unable to fetch user profile: %s", exc)
|
||||
return None
|
||||
else:
|
||||
return response.json() or response.text
|
||||
|
||||
|
||||
@MANAGER.source(kind=RequestKind.callback, name="Twitter")
|
||||
class TwitterOAuthCallback(OAuthCallback):
|
||||
"""Twitter OAuth2 Callback"""
|
||||
|
||||
client_class = TwitterOAuthClient
|
||||
|
||||
def get_or_create_user(self, source, access, info):
|
||||
user_data = {
|
||||
"username": info.get("screen_name"),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""Core OAauth Views"""
|
||||
from typing import Callable, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@ -23,7 +24,7 @@ LOGGER = get_logger()
|
||||
class OAuthClientMixin:
|
||||
"Mixin for getting OAuth client for a source."
|
||||
|
||||
client_class = None
|
||||
client_class: Optional[Callable] = None
|
||||
|
||||
def get_client(self, source):
|
||||
"Get instance of the OAuth client for this source."
|
||||
@ -124,9 +125,9 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||
source=self.source, identifier=identifier, request=request
|
||||
)
|
||||
if user is None:
|
||||
LOGGER.debug("Handling new user")
|
||||
LOGGER.debug("Handling new user", source=self.source)
|
||||
return self.handle_new_user(self.source, connection, info)
|
||||
LOGGER.debug("Handling existing user")
|
||||
LOGGER.debug("Handling existing user", source=self.source)
|
||||
return self.handle_existing_user(self.source, user, connection, info)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@ -179,7 +180,7 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||
|
||||
def handle_login_failure(self, source, reason):
|
||||
"Message user and redirect on error."
|
||||
LOGGER.warning("Authentication Failure: %s", reason)
|
||||
LOGGER.warning("Authentication Failure", reason=reason)
|
||||
messages.error(self.request, _("Authentication Failed."))
|
||||
return redirect(self.get_error_redirect(source, reason))
|
||||
|
||||
|
||||
@ -21,13 +21,15 @@ class SAMLSource(Source):
|
||||
|
||||
@property
|
||||
def login_button(self):
|
||||
url = reverse_lazy("passbook_sources_saml:login", kwargs={"source": self.slug})
|
||||
url = reverse_lazy(
|
||||
"passbook_sources_saml:login", kwargs={"source_slug": self.slug}
|
||||
)
|
||||
return url, "", self.name
|
||||
|
||||
@property
|
||||
def additional_info(self):
|
||||
metadata_url = reverse_lazy(
|
||||
"passbook_sources_saml:metadata", kwargs={"source": self}
|
||||
"passbook_sources_saml:metadata", kwargs={"source_slug": self}
|
||||
)
|
||||
return f'<a href="{metadata_url}" class="btn btn-default btn-sm">Metadata Download</a>'
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user