Compare commits

...

56 Commits

Author SHA1 Message Date
b290bbf6d7 new release: 0.8.4-beta 2020-02-20 16:17:23 +01:00
8d875cb01d providers/saml: fix /login/ pointing to wrong view 2020-02-20 16:13:55 +01:00
36b1f8ba36 new release: 0.8.3-beta 2020-02-20 15:14:49 +01:00
6c889eff27 core: fix application icons not loading, fix with_sources being broken 2020-02-20 14:30:06 +01:00
9d8675e54b new release: 0.8.2-beta 2020-02-20 13:57:46 +01:00
22ae986c0b root: add logger name to log output 2020-02-20 13:52:14 +01:00
2bef5f3911 policies: struct -> types to match core 2020-02-20 13:52:05 +01:00
3c2b8e5ee1 all: prefix all UI related methods with ui_, switch to property and return dataclass 2020-02-20 13:51:41 +01:00
c96571bdba core: fix discord logo being hard to see 2020-02-20 13:50:05 +01:00
2dfd93afb1 core: add more fields for metadata of applications 2020-02-20 13:45:22 +01:00
1d22e30c70 lib: sentry ignore Redis and OSError 2020-02-19 17:13:44 +01:00
07b7951390 sources/ldap: handle user_sync errors better, show warning when user exists already 2020-02-19 16:20:33 +01:00
995615d0a0 policies/expression: Return False if Policy returns Undefined and log warning 2020-02-19 16:19:02 +01:00
ac273aab75 core: raise PropertyMappingExpressionException when PropertyMapping returns Undefined 2020-02-19 16:18:31 +01:00
44cd03654d core: base set maximum-scale to 1 2020-02-19 15:11:25 +01:00
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
04a5428148 new release: 0.7.16-beta 2020-02-17 21:02:54 +01:00
73b173b92a admin: fix form missing on update pages 2020-02-17 21:02:47 +01:00
7cbf20a71c admin: fix CodeMirror field not loading correctly 2020-02-17 21:02:35 +01:00
123 changed files with 1147 additions and 1105 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.7.15-beta
current_version = 0.8.4-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.15-beta
-t beryju/passbook:0.8.4-beta
-t beryju/passbook:latest
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook:0.7.15-beta
run: docker push beryju/passbook:0.8.4-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.15-beta \
-t beryju/passbook-gatekeeper:0.8.4-beta \
-t beryju/passbook-gatekeeper:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-gatekeeper:0.7.15-beta
run: docker push beryju/passbook-gatekeeper:0.8.4-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.15-beta
-t beryju/passbook-static:0.8.4-beta
-t beryju/passbook-static:latest
-f static.Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-static:0.7.15-beta
run: docker push beryju/passbook-static:0.8.4-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.15-beta"
appVersion: "0.8.4-beta"
description: A Helm chart for passbook.
name: passbook
version: "0.7.15-beta"
version: "0.8.4-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.15-beta
tag: 0.8.4-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.15-beta"
__version__ = "0.8.4-beta"

View File

@ -36,7 +36,7 @@
<tr>
<td>{{ source.name }}</td>
<td>{{ source|fieldtype }}</td>
<td>{{ source.additional_info|safe }}</td>
<td>{{ source.ui_additional_info|safe|default:"" }}</td>
<td>
<a class="btn btn-default btn-sm"
href="{% url 'passbook_admin:source-update' pk=source.uuid %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>

View File

@ -47,11 +47,11 @@
lineNumbers: true,
});
}
const templates = document.getElementsByName('template');
if (templates.length > 0) {
const expressions = document.getElementsByName('expression');
if (expressions.length > 0) {
// https://github.com/codemirror/CodeMirror/issues/5092
templates[0].removeAttribute("required");
const templateCM = CodeMirror.fromTextArea(templates[0], {
expressions[0].removeAttribute("required");
const expressionCM = CodeMirror.fromTextArea(expressions[0], {
mode: 'jinja2',
theme: 'monokai',
lineNumbers: true,

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

@ -96,6 +96,7 @@ class PropertyMappingUpdateView(
success_message = _("Successfully updated Property Mapping")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
form_cls = self.get_form_class()
if hasattr(form_cls, "template_name"):
kwargs["base_template"] = form_cls.template_name

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

@ -15,11 +15,13 @@ class ApplicationSerializer(ModelSerializer):
"pk",
"name",
"slug",
"launch_url",
"icon_url",
"provider",
"policies",
"skip_authorization",
"provider",
"meta_launch_url",
"meta_icon_url",
"meta_description",
"meta_publisher",
"policies",
]

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

@ -19,19 +19,25 @@ class ApplicationForm(forms.ModelForm):
fields = [
"name",
"slug",
"launch_url",
"icon_url",
"provider",
"policies",
"skip_authorization",
"provider",
"meta_launch_url",
"meta_icon_url",
"meta_description",
"meta_publisher",
"policies",
]
widgets = {
"name": forms.TextInput(),
"launch_url": forms.TextInput(),
"icon_url": forms.TextInput(),
"meta_launch_url": forms.TextInput(),
"meta_icon_url": forms.TextInput(),
"meta_publisher": forms.TextInput(),
"policies": FilteredSelectMultiple(_("policies"), False),
}
labels = {
"launch_url": _("Launch URL"),
"icon_url": _("Icon URL"),
"meta_launch_url": _("Launch URL"),
"meta_icon_url": _("Icon URL"),
"meta_description": _("Description"),
"meta_publisher": _("Publisher"),
}
help_texts = {"policies": _("Policies required to access this Application.")}

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

@ -0,0 +1,29 @@
# Generated by Django 3.0.3 on 2020-02-20 12:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0007_auto_20200217_1934"),
]
operations = [
migrations.RenameField(
model_name="application", old_name="icon_url", new_name="meta_icon_url",
),
migrations.RenameField(
model_name="application", old_name="launch_url", new_name="meta_launch_url",
),
migrations.AddField(
model_name="application",
name="meta_description",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="application",
name="meta_publisher",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -2,26 +2,31 @@
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 import Undefined
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.types import UIUserSettings, UILoginButton
from passbook.core.exceptions import PropertyMappingExpressionException
from passbook.core.signals import password_changed
from passbook.lib.models import CreatedUpdatedModel, UUIDModel
from passbook.policies.exceptions import PolicyException
from passbook.policies.struct import PolicyRequest, PolicyResult
from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
NATIVE_ENVIRONMENT = NativeEnvironment()
@ -98,19 +103,6 @@ class PolicyModel(UUIDModel, CreatedUpdatedModel):
policies = models.ManyToManyField("Policy", blank=True)
class UserSettings:
"""Dataclass for Factor and Source's user_settings"""
name: str
icon: str
view_name: str
def __init__(self, name: str, icon: str, view_name: str):
self.name = name
self.icon = icon
self.view_name = view_name
class Factor(ExportModelOperationsMixin("factor"), PolicyModel):
"""Authentication factor, multiple instances of the same Factor can be used"""
@ -123,9 +115,10 @@ class Factor(ExportModelOperationsMixin("factor"), PolicyModel):
type = ""
form = ""
def user_settings(self) -> Optional[UserSettings]:
@property
def ui_user_settings(self) -> Optional[UIUserSettings]:
"""Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or an instanace of UserSettings."""
user settings are available, or an instanace of UIUserSettings."""
return None
def __str__(self):
@ -139,16 +132,19 @@ class Application(ExportModelOperationsMixin("application"), PolicyModel):
name = models.TextField()
slug = models.SlugField()
launch_url = models.URLField(null=True, blank=True)
icon_url = models.TextField(null=True, blank=True)
skip_authorization = models.BooleanField(default=False)
provider = models.OneToOneField(
"Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
)
skip_authorization = models.BooleanField(default=False)
meta_launch_url = models.URLField(null=True, blank=True)
meta_icon_url = models.TextField(null=True, blank=True)
meta_description = models.TextField(null=True, blank=True)
meta_publisher = models.TextField(null=True, blank=True)
objects = InheritanceManager()
def get_provider(self):
def get_provider(self) -> Optional[Provider]:
"""Get casted provider instance"""
if not self.provider:
return None
@ -163,6 +159,7 @@ class Source(ExportModelOperationsMixin("source"), PolicyModel):
name = models.TextField()
slug = models.SlugField()
enabled = models.BooleanField(default=True)
property_mappings = models.ManyToManyField(
"PropertyMapping", default=None, blank=True
@ -173,19 +170,20 @@ class Source(ExportModelOperationsMixin("source"), PolicyModel):
objects = InheritanceManager()
@property
def login_button(self):
"""Return a tuple of URL, Icon name and Name
if Source should get a link on the login page"""
def ui_login_button(self) -> Optional[UILoginButton]:
"""If source uses a http-based flow, return UI Information about the login
button. If source doesn't use http-based flow, return None."""
return None
@property
def additional_info(self):
def ui_additional_info(self) -> Optional[str]:
"""Return additional Info, such as a callback URL. Show in the administration interface."""
return None
def user_settings(self) -> Optional[UserSettings]:
@property
def ui_user_settings(self) -> Optional[UIUserSettings]:
"""Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or an instanace of UserSettings."""
user settings are available, or an instanace of UIUserSettings."""
return None
def __str__(self):
@ -301,10 +299,28 @@ 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."""
try:
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
return expression.render(user=user, request=request, **kwargs)
except TemplateSyntaxError as exc:
raise PropertyMappingExpressionException from exc
try:
response = expression.render(user=user, request=request, **kwargs)
if isinstance(response, Undefined):
raise PropertyMappingExpressionException("Response was 'Undefined'")
return response
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

@ -1 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 245 240"><style>.st0{fill:#FFFFFF;}</style><path class="st0" d="M104.4 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1.1-6.1-4.5-11.1-10.2-11.1zM140.9 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z"/><path class="st0" d="M189.5 20h-134C44.2 20 35 29.2 35 40.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19V40.6c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-.9-4.8-2-7.3-3.4-.3-.2-.6-.3-.9-.5-.2-.1-.3-.2-.4-.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3.6-.1 1.1-.2 1.7-.2 6.1-.8 13-1 20.2-.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 245 240"><path d="M104.4 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1.1-6.1-4.5-11.1-10.2-11.1zM140.9 103.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z"/><path d="M189.5 20h-134C44.2 20 35 29.2 35 40.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19V40.6c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-.9-4.8-2-7.3-3.4-.3-.2-.6-.3-.9-.5-.2-.1-.3-.2-.4-.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3.6-.1 1.1-.2 1.7-.2 6.1-.8 13-1 20.2-.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -7,7 +7,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>
{% block title %}
{% title %}

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

@ -48,10 +48,16 @@
<!--login-pf-section-->
<section class="login-pf-social-section" role="contentinfo" aria-label="Log in with third party account">
<ul class="login-pf-social login-pf-social-double-col list-unstyled">
{% for url, icon, name in sources %}
{% for source in sources %}
<li class="login-pf-social-link">
<a href="{{ url }}">
<img src="{% static 'img/logos/' %}{{ icon }}.svg" alt="{{ name }}"> {{ name }}
<a href="{{ source.url }}">
{% if source.icon_path %}
<img src="{% static source.icon_path %}" alt="{{ source.name }}">
{% endif %}
{% if source.icon_url %}
<img src="icon_url" alt="{{ source.name }}">
{% endif %}
{{ source.name }}
</a>
</li>
{% endfor %}

View File

@ -2,42 +2,44 @@
{% load i18n %}
{% block head %}
{{ block.super }}
<style>
img.app-icon {
max-height: 72px;
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="row row-cards-pf">
{% for app in applications %}
<div class="col-xs-12 col-sm-6 col-md-3">
<div class="card-pf card-pf-accented card-pf-aggregate-status">
<h2 class="card-pf-title">
<span class="fa fa-shield"></span> {{ app.name }}
</h2>
<div class="card-pf card-pf-view card-pf-view-select card-pf-view-single-select">
<div class="card-pf-body">
<p class="card-pf-aggregate-status-notifications">
<span class="card-pf-aggregate-status-notification">
<a href="{{ app.launch_url }}" class="add" data-toggle="tooltip" data-placement="top" title="{% trans 'Open App...' %}">
{% if not app.icon_url %}
<span class="pficon pficon-arrow"></span>
<div class="card-pf-top-element">
<a href="{{ app.meta_launch_url }}">
{% if not app.meta_icon_url %}
<span class="pficon pficon-arrow card-pf-icon-circle"></span>
{% else %}
<img class="app-icon" src="{{ app.icon_url }}" alt="{% trans 'Application Icon' %}">
<img class="app-icon card-pf-icon-circle" src="{{ app.meta_icon_url }}" alt="{% trans 'Application Icon' %}">
{% endif %}
</a>
</span>
</p>
</div>
<h2 class="card-pf-title text-center">
<a href="{{ app.meta_launch_url }}">
{{ app.name }}
</a>
</h2>
{% if app.meta_publisher %}
<div class="card-pf-items text-center">
<small>{{ app.meta_publisher }}</small>
</div>
{% endif %}
{% if app.meta_description %}
<div class="card-pf-items text-center">
<p>{{ app.meta_description }}</p>
</div>
{% endif %}
</div>
<div class="card-pf-view-checkbox">
<a href="{{ app.meta_launch_url }}"></a>
</div>
</div>
</div>
{% empty %}
<h1>{% trans 'No Applications available.' %}</h1>
{% endfor %}
</div><!-- /row -->
</div>
</div>
{% endblock %}

View File

@ -1,46 +1,53 @@
"""passbook user settings template tags"""
from typing import List
from typing import List, Iterable
from django import template
from django.template.context import RequestContext
from passbook.core.models import Factor, Source, UserSettings
from passbook.core.types import UIUserSettings
from passbook.core.models import Factor, Source
from passbook.policies.engine import PolicyEngine
register = template.Library()
@register.simple_tag(takes_context=True)
def user_factors(context: RequestContext) -> List[UserSettings]:
def user_factors(context: RequestContext) -> List[UIUserSettings]:
"""Return list of all factors which apply to user"""
user = context.get("request").user
_all_factors = (
_all_factors: Iterable[Factor] = (
Factor.objects.filter(enabled=True).order_by("order").select_subclasses()
)
matching_factors: List[UserSettings] = []
matching_factors: List[UIUserSettings] = []
for factor in _all_factors:
user_settings = factor.user_settings()
user_settings = factor.ui_user_settings
if not user_settings:
continue
policy_engine = PolicyEngine(
factor.policies.all(), user, context.get("request")
)
policy_engine.build()
if policy_engine.passing and user_settings:
if policy_engine.passing:
matching_factors.append(user_settings)
return matching_factors
@register.simple_tag(takes_context=True)
def user_sources(context: RequestContext) -> List[UserSettings]:
def user_sources(context: RequestContext) -> List[UIUserSettings]:
"""Return a list of all sources which are enabled for the user"""
user = context.get("request").user
_all_sources = Source.objects.filter(enabled=True).select_subclasses()
matching_sources: List[UserSettings] = []
_all_sources: Iterable[Source] = (
Source.objects.filter(enabled=True).select_subclasses()
)
matching_sources: List[UIUserSettings] = []
for factor in _all_sources:
user_settings = factor.user_settings()
user_settings = factor.ui_user_settings
if not user_settings:
continue
policy_engine = PolicyEngine(
factor.policies.all(), user, context.get("request")
)
policy_engine.build()
if policy_engine.passing and user_settings:
if policy_engine.passing:
matching_sources.append(user_settings)
return matching_sources

29
passbook/core/types.py Normal file
View File

@ -0,0 +1,29 @@
"""passbook core dataclasses"""
from typing import Optional
from dataclasses import dataclass
@dataclass
class UIUserSettings:
"""Dataclass for Factor and Source's user_settings"""
name: str
icon: str
view_name: str
@dataclass
class UILoginButton:
"""Dataclass for Source's ui_ui_login_button"""
# Name, ran through i18n
name: str
# URL Which Button points to
url: str
# Icon name, ran through django's static
icon_path: Optional[str] = None
# Icon URL, used as-is
icon_url: Optional[str] = None

View File

@ -47,9 +47,9 @@ class LoginView(UserPassesTestMixin, FormView):
kwargs["sources"] = []
sources = Source.objects.filter(enabled=True).select_subclasses()
for source in sources:
login_button = source.login_button
if login_button:
kwargs["sources"].append(login_button)
ui_login_button = source.ui_login_button
if ui_login_button:
kwargs["sources"].append(ui_login_button)
if kwargs["sources"]:
self.template_name = "login/with_sources.html"
return super().get_context_data(**kwargs)
@ -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

@ -1,9 +1,9 @@
"""OTP Factor"""
from django.db import models
from django.utils.translation import gettext as _
from passbook.core.models import Factor, UserSettings
from passbook.core.types import UIUserSettings
from passbook.core.models import Factor
class OTPFactor(Factor):
@ -17,9 +17,12 @@ class OTPFactor(Factor):
type = "passbook.factors.otp.factors.OTPFactor"
form = "passbook.factors.otp.forms.OTPFactorForm"
def user_settings(self) -> UserSettings:
return UserSettings(
_("OTP"), "pficon-locked", "passbook_factors_otp:otp-user-settings"
@property
def ui_user_settings(self) -> UIUserSettings:
return UIUserSettings(
name="OTP",
icon="pficon-locked",
view_name="passbook_factors_otp:otp-user-settings",
)
def __str__(self):

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

@ -3,7 +3,8 @@ from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.utils.translation import gettext_lazy as _
from passbook.core.models import Factor, Policy, User, UserSettings
from passbook.core.types import UIUserSettings
from passbook.core.models import Factor, Policy, User
class PasswordFactor(Factor):
@ -18,9 +19,12 @@ class PasswordFactor(Factor):
type = "passbook.factors.password.factor.PasswordFactor"
form = "passbook.factors.password.forms.PasswordFactorForm"
def user_settings(self):
return UserSettings(
_("Change Password"), "pficon-key", "passbook_core:user-change-password"
@property
def ui_user_settings(self) -> UIUserSettings:
return UIUserSettings(
name="Change Password",
icon="pficon-key",
view_name="passbook_core:user-change-password",
)
def password_passes(self, user: User) -> bool:

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,10 +8,12 @@ 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
from botocore.client import ClientError
from redis.exceptions import RedisError
ignored_classes = (
OperationalError,
@ -24,6 +26,9 @@ def before_send(event, hint):
ConnectionResetError,
KeyboardInterrupt,
ClientError,
ValidationError,
OSError,
RedisError,
)
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

@ -9,7 +9,7 @@ from structlog import get_logger
from passbook.core.models import Policy, User
from passbook.policies.process import PolicyProcess, cache_key
from passbook.policies.struct import PolicyRequest, PolicyResult
from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
# This is only really needed for macOS, because Python 3.8 changed the default to spawn
@ -63,13 +63,13 @@ class PolicyEngine:
for policy in self._select_subclasses():
cached_policy = cache.get(cache_key(policy, self.request.user), None)
if cached_policy and self.use_cache:
LOGGER.debug("Taking result from cache", policy=policy)
LOGGER.debug("P_ENG: Taking result from cache", policy=policy)
self.__cached_policies.append(cached_policy)
continue
LOGGER.debug("Evaluating policy", policy=policy)
LOGGER.debug("P_ENG: Evaluating policy", policy=policy)
our_end, task_end = Pipe(False)
task = PolicyProcess(policy, self.request, task_end)
LOGGER.debug("Starting Process", policy=policy)
LOGGER.debug("P_ENG: Starting Process", policy=policy)
task.start()
self.__processes.append(
PolicyProcessInfo(process=task, connection=our_end, policy=policy)
@ -90,7 +90,7 @@ class PolicyEngine:
x.result for x in self.__processes if x.result
]
for result in process_results + self.__cached_policies:
LOGGER.debug("result", passing=result.passing)
LOGGER.debug("P_ENG: result", passing=result.passing)
if result.messages:
messages += result.messages
if not result.passing:

View File

@ -7,7 +7,7 @@ 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
from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()

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,94 @@
"""passbook expression policy evaluator"""
import re
from typing import TYPE_CHECKING, Any, Dict
from django.core.exceptions import ValidationError
from jinja2 import Undefined
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.types import PolicyRequest, PolicyResult
if TYPE_CHECKING:
from passbook.core.models import User
LOGGER = get_logger()
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, Undefined):
LOGGER.warning(
"Expression policy returned undefined",
src=expression_source,
req=request,
)
return PolicyResult(False)
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.types 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

@ -6,7 +6,7 @@ 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
from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()

View File

@ -7,7 +7,7 @@ from structlog import get_logger
from passbook.core.models import Policy
from passbook.policies.exceptions import PolicyException
from passbook.policies.struct import PolicyRequest, PolicyResult
from passbook.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()

View File

@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
from passbook.core.models import Policy, User
from passbook.lib.utils.http import get_client_ip
from passbook.policies.struct import PolicyRequest, PolicyResult
from passbook.policies.types import PolicyRequest, PolicyResult
class ReputationPolicy(Policy):

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

@ -3,7 +3,7 @@ from django.db import models
from django.utils.translation import gettext as _
from passbook.core.models import Policy
from passbook.policies.struct import PolicyRequest, PolicyResult
from passbook.policies.types import PolicyRequest, PolicyResult
class WebhookPolicy(Policy):

View File

@ -15,13 +15,8 @@ from passbook.policies.engine import PolicyEngine
LOGGER = get_logger()
def check_permissions(
request: HttpRequest, user: User, client: Client
) -> Optional[HttpResponse]:
"""Check permissions, used for
https://django-oidc-provider.readthedocs.io/en/latest/
sections/settings.html#oidc-after-userlogin-hook"""
try:
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
@ -31,8 +26,21 @@ def check_permissions(
for _, related in collector.data.items():
related_object = next(iter(related))
if isinstance(related_object, Provider):
application = related_object.application
break
return related_object
return None
def check_permissions(
request: HttpRequest, user: User, client: Client
) -> Optional[HttpResponse]:
"""Check permissions, used for
https://django-oidc-provider.readthedocs.io/en/latest/
sections/settings.html#oidc-after-userlogin-hook"""
provider = client_related_provider(client)
if not provider:
return redirect("passbook_providers_oauth:oauth2-permission-denied")
try:
application = 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,20 +183,18 @@ class Processor:
# Read the request.
try:
self._extract_saml_request()
except Exception as exc:
raise CannotHandleAssertion(
f"can't find SAML request in user session: {exc}"
) from exc
except KeyError:
raise CannotHandleAssertion(f"Couldn't find SAML request in user session:")
try:
self._decode_and_parse_request()
except Exception as exc:
raise CannotHandleAssertion(f"can't parse SAML request: {exc}") from exc
raise CannotHandleAssertion(f"Couldn't parse SAML request: {exc}") from exc
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 +208,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 +225,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 %}

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