Compare commits

..

38 Commits

Author SHA1 Message Date
3e2375f970 new release: 0.8.1-beta 2020-02-19 11:31:05 +01:00
38ad8e5fd3 policies/expression: fix pb_is_sso_flow 2020-02-19 11:01:20 +01:00
c481558a46 helm: fix error that FLUSHDB Command is not available 2020-02-19 10:57:57 +01:00
e27a05a7fc lib/sentry: ignore django validation error 2020-02-19 10:54:29 +01:00
e4886f0c6f new release: 0.8.0-beta 2020-02-19 10:29:52 +01:00
8b2ce5476a policies/expression: add annotation to update docs, name jinja filters/funcs more clearly 2020-02-19 10:23:42 +01:00
1b82283a20 docs: update policy types, add docs for expression policies 2020-02-19 10:21:28 +01:00
7f3d0113c2 policies: remove redundant policies which can be easily implemented with expressions 2020-02-19 09:51:15 +01:00
0f6dd33a6b api: add expression policy to API URLs 2020-02-19 09:49:57 +01:00
5b79b3fd22 policies/expression: move evaluation code into separate class 2020-02-19 09:49:38 +01:00
d68c72f1fa lib: remove method_decorator Mixins 2020-02-18 22:28:47 +01:00
9267d0c1dd all: general maintenance, prepare for pyright 2020-02-18 22:12:51 +01:00
865abc005a sources/oauth: remove leading spaces in default URLs 2020-02-18 21:49:53 +01:00
a2725d5b82 sources/oauth: remove redundant OAuth2Clients 2020-02-18 21:49:40 +01:00
4a05bc6e02 sources/oauth: improve default OAuth2 Client, send access_token as Bearer Authz 2020-02-18 21:49:23 +01:00
4e8238603a all: cleanup logging to be structured 2020-02-18 21:35:58 +01:00
ff25c1c057 admin: load custom policy templates 2020-02-18 21:35:21 +01:00
78cddca0d7 admin: fix user object being overwritten when deleting a user 2020-02-18 21:35:06 +01:00
4742ee1d93 docs: add aws integration 2020-02-18 20:14:54 +01:00
0c2dc309e7 providers/saml: fix metadata URLs using incorrect params 2020-02-18 20:14:28 +01:00
144935d10f docs: add ansible tower/awx integration guide 2020-02-18 17:33:31 +01:00
74ad1b6759 factors: strip port for domain check 2020-02-18 17:05:30 +01:00
591d2f89a1 audit: log event creation on save 2020-02-18 17:05:11 +01:00
7c353f9297 sources/oauth: remove supervisr 2020-02-18 17:01:08 +01:00
cd1af15c56 core: sort applications by name 2020-02-18 17:00:56 +01:00
878169ea2e core: only show icon on login page if defined 2020-02-18 17:00:26 +01:00
38dfb03668 new release: 0.7.17-beta 2020-02-18 16:29:23 +01:00
e2631cec0e factors/view: show concise error message when domain is mis-configured 2020-02-18 16:29:04 +01:00
5dad853f8a docs: use note blocks instead of code blocks for product description 2020-02-18 15:34:41 +01:00
9f00843441 policies/expression: add Expression based policy 2020-02-18 15:12:50 +01:00
f31cd7dec6 core: check PropertyMapping's expression syntax before save 2020-02-18 15:12:05 +01:00
1c1afca31f providers/saml: fix linting error 2020-02-18 11:34:04 +01:00
fbd4bdef33 providers/saml: add modal to show metadata without download 2020-02-18 10:57:43 +01:00
5b22f9b6c3 providers/saml: transition to dataclass from dict, cleanup unused templates, add missing autosubmit_form 2020-02-18 10:57:30 +01:00
083e317028 lib: add helper method for 400 response with message 2020-02-18 10:13:53 +01:00
95416623b3 sources/ldap: better handle property mapping evaluation errors 2020-02-18 10:13:05 +01:00
813b2676de providers/saml: better handle PropertyMapping evaluation errors 2020-02-18 10:12:42 +01:00
aeca66a288 providers/saml: change assertion_valid_not_before default to -5 minutes 2020-02-17 21:32:23 +01:00
103 changed files with 877 additions and 957 deletions

View File

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

View File

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

View File

@ -0,0 +1,32 @@
# Amazon Web Services Integration
## What is AWS
!!! note ""
Amazon Web Services (AWS) is the worlds 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:
![](./property-mapping-role.png)
![](./property-mapping-role-session-name.png)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

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

View File

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

View File

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

View File

@ -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. Wont you join them?
```
One million developers at over fifty thousand companies already ship
better software faster with Sentry. Wont you join them?
## Preparation

View File

@ -0,0 +1,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. Its 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.

View 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`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +1,2 @@
"""passbook"""
__version__ = "0.7.16-beta"
__version__ = "0.8.1-beta"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,17 @@
"""passbook multi-factor authentication engine"""
from typing import List, Tuple
from typing import List, Optional, Tuple
from django.contrib.auth import login
from django.contrib.auth.mixins import UserPassesTestMixin
from django.shortcuts import get_object_or_404, redirect, reverse
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.utils.http import urlencode
from django.views.generic import View
from structlog import get_logger
from passbook.core.models import Factor, User
from passbook.core.views.utils import PermissionDeniedView
from passbook.lib.config import CONFIG
from passbook.lib.utils.reflection import class_to_path, path_to_class
from passbook.lib.utils.urls import is_url_absolute
from passbook.policies.engine import PolicyEngine
@ -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)

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
"""passbook helper views"""
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.views.generic import CreateView
from guardian.shortcuts import assign_perm
@ -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)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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

View File

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

View File

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

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

View File

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

View File

@ -1,4 +0,0 @@
"""autodiscover admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister("passbook_policies_group")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +0,0 @@
"""autodiscover admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister("passbook_policies_matcher")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +0,0 @@
"""autodiscover admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister("passbook_policies_sso")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,9 @@
{% extends "saml/idp/base.html" %}
{% extends "login/base.html" %}
{% load i18n %}
{% block content %}
{% 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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,8 +5,9 @@ import ldap3
import ldap3.core.exceptions
from structlog import get_logger
from passbook.core.exceptions import PropertyMappingExpressionException
from passbook.core.models import Group, User
from passbook.sources.ldap.models import LDAPSource, 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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