Merge branch 'master' into e2e
This commit is contained in:
commit
aa440c17b7
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -62,6 +62,8 @@ jobs:
|
||||||
python-version: '3.8'
|
python-version: '3.8'
|
||||||
- name: Install pyright
|
- name: Install pyright
|
||||||
run: npm install -g pyright
|
run: npm install -g pyright
|
||||||
|
- name: Show pyright version
|
||||||
|
run: pyright --version
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo pip install -U wheel pipenv && pipenv install --dev
|
run: sudo pip install -U wheel pipenv && pipenv install --dev
|
||||||
- name: Lint with pyright
|
- name: Lint with pyright
|
||||||
|
|
1
Pipfile
1
Pipfile
|
@ -40,7 +40,6 @@ signxml = "*"
|
||||||
structlog = "*"
|
structlog = "*"
|
||||||
swagger-spec-validator = "*"
|
swagger-spec-validator = "*"
|
||||||
urllib3 = {extras = ["secure"],version = "*"}
|
urllib3 = {extras = ["secure"],version = "*"}
|
||||||
jinja2 = "*"
|
|
||||||
|
|
||||||
[requires]
|
[requires]
|
||||||
python_version = "3.8"
|
python_version = "3.8"
|
||||||
|
|
65
Pipfile.lock
generated
65
Pipfile.lock
generated
|
@ -18,10 +18,10 @@
|
||||||
"default": {
|
"default": {
|
||||||
"amqp": {
|
"amqp": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
|
"sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b",
|
||||||
"sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
|
"sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139"
|
||||||
],
|
],
|
||||||
"version": "==2.5.2"
|
"version": "==2.6.0"
|
||||||
},
|
},
|
||||||
"asgiref": {
|
"asgiref": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -53,26 +53,26 @@
|
||||||
},
|
},
|
||||||
"boto3": {
|
"boto3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1bdab4f87ff39d5aab59b0aae69965bf604fa5608984c673877f4c62c1f16240",
|
"sha256:bcaa88b2f81b88741c47da52f3414c876236700441df87b6198f860e6a200d6f",
|
||||||
"sha256:2b4924ccc1603d562969b9f3c8c74ff4a1f3bdbafe857c990422c73d8e2e229e"
|
"sha256:e974e7a3bbdbd6a73ffc07bea5fa0c0744a5a8b87dcca94702597176e3de465e"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.13.18"
|
"version": "==1.13.23"
|
||||||
},
|
},
|
||||||
"botocore": {
|
"botocore": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:93574cf95a64c71d35c12c93a23f6214cf2f4b461be3bda3a436381cbe126a84",
|
"sha256:5831068c9b49b4c91b0733e0ec784a7733d8732359d73c67a07a0b0868433cae",
|
||||||
"sha256:e65eb27cae262a510e335bc0c0e286e9e42381b1da0aafaa79fa13c1d8d74a95"
|
"sha256:7778957bdc9a25dd33bb4383ebd6d45a8570a2cbff03d1edf430fdacec2b7437"
|
||||||
],
|
],
|
||||||
"version": "==1.16.18"
|
"version": "==1.16.23"
|
||||||
},
|
},
|
||||||
"celery": {
|
"celery": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:108a0bf9018a871620936c33a3ee9f6336a89f8ef0a0f567a9001f4aa361415f",
|
"sha256:9ae2e73b93cc7d6b48b56aaf49a68c91752d0ffd7dfdcc47f842ca79a6f13eae",
|
||||||
"sha256:5b4b37e276033fe47575107a2775469f0b721646a08c96ec2c61531e4fe45f2a"
|
"sha256:c2037b6a8463da43b19969a0fc13f9023ceca6352b4dd51be01c66fbbb13647e"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==4.4.2"
|
"version": "==4.4.4"
|
||||||
},
|
},
|
||||||
"certifi": {
|
"certifi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -169,11 +169,11 @@
|
||||||
},
|
},
|
||||||
"django": {
|
"django": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:051ba55d42daa3eeda3944a8e4df2bc96d4c62f94316dea217248a22563c3621",
|
"sha256:5052b34b34b3425233c682e0e11d658fd6efd587d11335a0203d827224ada8f2",
|
||||||
"sha256:9aaa6a09678e1b8f0d98a948c56482eac3e3dd2ddbfb8de70a868135ef3b5e01"
|
"sha256:e1630333248c9b3d4e38f02093a26f1e07b271ca896d73097457996e0fae12e8"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.0.6"
|
"version": "==3.0.7"
|
||||||
},
|
},
|
||||||
"django-cors-middleware": {
|
"django-cors-middleware": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -345,7 +345,6 @@
|
||||||
"sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484",
|
"sha256:c10142f819c2d22bdcd17548c46fa9b77cf4fda45097854c689666bf425e7484",
|
||||||
"sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668"
|
"sha256:c922560ac46888d47384de1dbdc3daaa2ea993af4b26a436dec31fa2c19ec668"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
|
||||||
"version": "==3.0.0a1"
|
"version": "==3.0.0a1"
|
||||||
},
|
},
|
||||||
"jmespath": {
|
"jmespath": {
|
||||||
|
@ -364,11 +363,11 @@
|
||||||
},
|
},
|
||||||
"kombu": {
|
"kombu": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76",
|
"sha256:437b9cdea193cc2ed0b8044c85fd0f126bb3615ca2f4d4a35b39de7cacfa3c1a",
|
||||||
"sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2"
|
"sha256:dc282bb277197d723bccda1a9ba30a27a28c9672d0ab93e9e51bb05a37bd29c3"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==4.6.8"
|
"version": "==4.6.10"
|
||||||
},
|
},
|
||||||
"ldap3": {
|
"ldap3": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -688,10 +687,10 @@
|
||||||
},
|
},
|
||||||
"redis": {
|
"redis": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242",
|
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
|
||||||
"sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251"
|
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
|
||||||
],
|
],
|
||||||
"version": "==3.5.2"
|
"version": "==3.5.3"
|
||||||
},
|
},
|
||||||
"requests": {
|
"requests": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -858,10 +857,10 @@
|
||||||
},
|
},
|
||||||
"autopep8": {
|
"autopep8": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:152fd8fe47d02082be86e05001ec23d6f420086db56b17fc883f3f965fb34954"
|
"sha256:60fd8c4341bab59963dafd5d2a566e94f547e660b9b396f772afe67d8481dbf0"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.5.2"
|
"version": "==1.5.3"
|
||||||
},
|
},
|
||||||
"bandit": {
|
"bandit": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -948,11 +947,11 @@
|
||||||
},
|
},
|
||||||
"django": {
|
"django": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:051ba55d42daa3eeda3944a8e4df2bc96d4c62f94316dea217248a22563c3621",
|
"sha256:5052b34b34b3425233c682e0e11d658fd6efd587d11335a0203d827224ada8f2",
|
||||||
"sha256:9aaa6a09678e1b8f0d98a948c56482eac3e3dd2ddbfb8de70a868135ef3b5e01"
|
"sha256:e1630333248c9b3d4e38f02093a26f1e07b271ca896d73097457996e0fae12e8"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.0.6"
|
"version": "==3.0.7"
|
||||||
},
|
},
|
||||||
"django-debug-toolbar": {
|
"django-debug-toolbar": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -971,10 +970,10 @@
|
||||||
},
|
},
|
||||||
"gitpython": {
|
"gitpython": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:864a47472548f3ba716ca202e034c1900f197c0fb3a08f641c20c3cafd15ed94",
|
"sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a",
|
||||||
"sha256:da3b2cf819974789da34f95ac218ef99f515a928685db141327c09b73dd69c09"
|
"sha256:ef1d60b01b5ce0040ad3ec20bc64f783362d41fa0822a2742d3586e1f49bb8ac"
|
||||||
],
|
],
|
||||||
"version": "==3.1.2"
|
"version": "==3.1.3"
|
||||||
},
|
},
|
||||||
"isort": {
|
"isort": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
@ -1133,10 +1132,10 @@
|
||||||
},
|
},
|
||||||
"stevedore": {
|
"stevedore": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:18afaf1d623af5950cc0f7e75e70f917784c73b652a34a12d90b309451b5500b",
|
"sha256:001e90cd704be6470d46cc9076434e2d0d566c1379187e7013eb296d3a6032d9",
|
||||||
"sha256:a4e7dc759fb0f2e3e2f7d8ffe2358c19d45b9b8297f393ef1256858d82f69c9b"
|
"sha256:471c920412265cc809540ae6fb01f3f02aba89c79bbc7091372f4745a50f9691"
|
||||||
],
|
],
|
||||||
"version": "==1.32.0"
|
"version": "==2.0.0"
|
||||||
},
|
},
|
||||||
"toml": {
|
"toml": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
|
|
55
docs/expressions/index.md
Normal file
55
docs/expressions/index.md
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
# Expressions
|
||||||
|
|
||||||
|
Expressions allow you to write custom Logic using Python code.
|
||||||
|
|
||||||
|
Expressions are used in different places throughout passbook, and can do different things.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
These functions/objects are available wherever expressions are used. For more specific information, see [Expression Policies](../policies/expression.md) and [Property Mappings](../property-mappings/expression.md)
|
||||||
|
|
||||||
|
## Global objects
|
||||||
|
|
||||||
|
- `pb_logger`: structlog BoundLogger. ([ref](https://www.structlog.org/en/stable/api.html#structlog.BoundLogger))
|
||||||
|
- `requests`: requests Session object. ([ref](https://requests.readthedocs.io/en/master/user/advanced/))
|
||||||
|
|
||||||
|
## Generally available functions
|
||||||
|
|
||||||
|
### `regex_match(value: Any, regex: str) -> bool`
|
||||||
|
|
||||||
|
Check if `value` matches Regular Expression `regex`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return regex_match(request.user.username, '.*admin.*')
|
||||||
|
```
|
||||||
|
|
||||||
|
### `regex_replace(value: Any, regex: str, repl: str) -> str`
|
||||||
|
|
||||||
|
Replace anything matching `regex` within `value` with `repl` and return it.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user_email_local = regex_replace(request.user.email, '(.+)@.+', '')
|
||||||
|
```
|
||||||
|
|
||||||
|
### `pb_is_group_member(user: User, **group_filters) -> bool`
|
||||||
|
|
||||||
|
Check if `user` is member of a group matching `**group_filters`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return pb_is_group_member(request.user, name="test_group")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `pb_user_by(**filters) -> Optional[User]`
|
||||||
|
|
||||||
|
Fetch a user matching `**filters`. Returns None if no user was found.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
other_user = pb_user_by(username="other_user")
|
||||||
|
```
|
|
@ -15,6 +15,7 @@ The User object has the following attributes:
|
||||||
|
|
||||||
List all the User's Group Names
|
List all the User's Group Names
|
||||||
|
|
||||||
```jinja2
|
```python
|
||||||
[{% for group in user.groups.all() %}'{{ group.name }}',{% endfor %}]
|
for group in user.groups.all():
|
||||||
|
yield group.name
|
||||||
```
|
```
|
27
docs/policies/expression.md
Normal file
27
docs/policies/expression.md
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# Expression Policies
|
||||||
|
|
||||||
|
The passing of the policy is determined by the return value of the code. Use `return True` to pass a policy and `return False` to fail it.
|
||||||
|
|
||||||
|
### Available Functions
|
||||||
|
|
||||||
|
#### `pb_message(message: str)`
|
||||||
|
|
||||||
|
Add a message, visible by the end user. This can be used to show the reason why they were denied.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
pb_message("Access denied")
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context variables
|
||||||
|
|
||||||
|
- `request`: A PolicyRequest object, which has the following properties:
|
||||||
|
- `request.user`: The current User, which the Policy is applied against. ([ref](../expressions/reference/user-object.md))
|
||||||
|
- `request.http_request`: The Django HTTP Request. ([ref](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.
|
||||||
|
- `request.context`: A dictionary with dynamic data. This depends on the origin of the execution.
|
||||||
|
- `pb_is_sso_flow`: Boolean which is true if request was initiated by authenticating through an external Provider.
|
||||||
|
- `pb_client_ip`: Client's IP Address or '255.255.255.255' if no IP Address could be extracted.
|
||||||
|
- `pb_flow_plan`: Current Plan if Policy is called from the Flow Planner.
|
|
@ -1,22 +0,0 @@
|
||||||
# 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_flow_plan`: Current Plan if Policy is called while a flow is active.
|
|
||||||
- `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`.
|
|
||||||
- `pb_logger`: Standard Python Logger Object, which can be used to debug expressions.
|
|
||||||
- `pb_client_ip`: Client's IP Address.
|
|
||||||
|
|
||||||
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`
|
|
|
@ -8,10 +8,6 @@ There are two different Kind of policies, a Standard Policy and a Password Polic
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Group-Membership Policy
|
|
||||||
|
|
||||||
This policy evaluates to True if the current user is a Member of the selected group.
|
|
||||||
|
|
||||||
### Reputation Policy
|
### Reputation Policy
|
||||||
|
|
||||||
passbook keeps track of failed login attempts by Source IP and Attempted Username. These values are saved as scores. Each failed login decreases the Score for the Client IP as well as the targeted Username by one.
|
passbook keeps track of failed login attempts by Source IP and Attempted Username. These values are saved as scores. Each failed login decreases the Score for the Client IP as well as the targeted Username by one.
|
||||||
|
@ -20,11 +16,7 @@ This policy can be used to for example prompt Clients with a low score to pass a
|
||||||
|
|
||||||
## Expression Policy
|
## Expression Policy
|
||||||
|
|
||||||
See [Expression Policy](expression/index.md).
|
See [Expression Policy](expression.md).
|
||||||
|
|
||||||
### Webhook Policy
|
|
||||||
|
|
||||||
This policy allows you to send an arbitrary HTTP Request to any URL. You can then use JSONPath to extract the result you need.
|
|
||||||
|
|
||||||
## Password Policies
|
## Password Policies
|
||||||
|
|
||||||
|
|
9
docs/property-mappings/expression.md
Normal file
9
docs/property-mappings/expression.md
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# Property Mapping Expressions
|
||||||
|
|
||||||
|
The property mapping should return a value that is expected by the Provider/Source. What types are supported, is documented in the individual Provider/Source. Returning `None` is always accepted, this simply skips this mapping.
|
||||||
|
|
||||||
|
### Context Variables
|
||||||
|
|
||||||
|
- `user`: The current user, this might be `None` if there is no contextual user. ([ref](../expressions/reference/user-object.md))
|
||||||
|
- `request`: The current request, this might be `None` if there is no contextual request. ([ref](https://docs.djangoproject.com/en/3.0/ref/request-response/#httprequest-objects))
|
||||||
|
- Arbitrary other arguments given by the provider, this is documented on the Provider/Source.
|
|
@ -12,10 +12,10 @@ You can find examples [here](integrations/)
|
||||||
|
|
||||||
LDAP Property Mappings are used when you define a LDAP Source. These Mappings define which LDAP Property maps to which passbook Property. By default, these mappings are created:
|
LDAP Property Mappings are used when you define a LDAP Source. These Mappings define which LDAP Property maps to which passbook Property. By default, these mappings are created:
|
||||||
|
|
||||||
- Autogenerated LDAP Mapping: givenName -> first_name
|
- Autogenerated LDAP Mapping: givenName -> first_name
|
||||||
- Autogenerated LDAP Mapping: mail -> email
|
- Autogenerated LDAP Mapping: mail -> email
|
||||||
- Autogenerated LDAP Mapping: name -> name
|
- Autogenerated LDAP Mapping: name -> name
|
||||||
- Autogenerated LDAP Mapping: sAMAccountName -> username
|
- Autogenerated LDAP Mapping: sAMAccountName -> username
|
||||||
- Autogenerated LDAP Mapping: sn -> last_name
|
- Autogenerated LDAP Mapping: sn -> last_name
|
||||||
|
|
||||||
These are configured for the most common LDAP Setups.
|
These are configured for the most common LDAP Setups.
|
||||||
|
|
17
mkdocs.yml
17
mkdocs.yml
|
@ -10,14 +10,17 @@ nav:
|
||||||
- Kubernetes: installation/kubernetes.md
|
- Kubernetes: installation/kubernetes.md
|
||||||
- Sources: sources.md
|
- Sources: sources.md
|
||||||
- Providers: providers.md
|
- Providers: providers.md
|
||||||
|
- Expressions:
|
||||||
|
- Overview: expressions/index.md
|
||||||
|
- Reference:
|
||||||
|
- User Object: expressions/reference/user-object.md
|
||||||
- Property Mappings:
|
- Property Mappings:
|
||||||
- Overview: property-mappings/index.md
|
- Overview: property-mappings/index.md
|
||||||
- Reference:
|
- Expressions: property-mappings/expression.md
|
||||||
- User Object: property-mappings/reference/user-object.md
|
|
||||||
- Factors: factors.md
|
- Factors: factors.md
|
||||||
- Policies:
|
- Policies:
|
||||||
- Overview: policies/index.md
|
- Overview: policies/index.md
|
||||||
- Expression: policies/expression/index.md
|
- Expression: policies/expression.md
|
||||||
- Integrations:
|
- Integrations:
|
||||||
- as Provider:
|
- as Provider:
|
||||||
- Amazon Web Services: integrations/services/aws/index.md
|
- Amazon Web Services: integrations/services/aws/index.md
|
||||||
|
@ -38,3 +41,11 @@ markdown_extensions:
|
||||||
- toc:
|
- toc:
|
||||||
permalink: "¶"
|
permalink: "¶"
|
||||||
- admonition
|
- admonition
|
||||||
|
- codehilite
|
||||||
|
- pymdownx.betterem:
|
||||||
|
smart_enable: all
|
||||||
|
- pymdownx.inlinehilite
|
||||||
|
- pymdownx.magiclink
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- search
|
||||||
|
|
|
@ -1,4 +1,17 @@
|
||||||
"""passbook core source form fields"""
|
"""passbook core source form fields"""
|
||||||
|
|
||||||
SOURCE_FORM_FIELDS = ["name", "slug", "enabled"]
|
SOURCE_FORM_FIELDS = [
|
||||||
SOURCE_SERIALIZER_FIELDS = ["pk", "name", "slug", "enabled"]
|
"name",
|
||||||
|
"slug",
|
||||||
|
"enabled",
|
||||||
|
"authentication_flow",
|
||||||
|
"enrollment_flow",
|
||||||
|
]
|
||||||
|
SOURCE_SERIALIZER_FIELDS = [
|
||||||
|
"pk",
|
||||||
|
"name",
|
||||||
|
"slug",
|
||||||
|
"enabled",
|
||||||
|
"authentication_flow",
|
||||||
|
"enrollment_flow",
|
||||||
|
]
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
<link rel="stylesheet" href="{% static 'node_modules/codemirror/theme/monokai.css' %}">
|
<link rel="stylesheet" href="{% static 'node_modules/codemirror/theme/monokai.css' %}">
|
||||||
<script src="{% static 'node_modules/codemirror/mode/xml/xml.js' %}"></script>
|
<script src="{% static 'node_modules/codemirror/mode/xml/xml.js' %}"></script>
|
||||||
<script src="{% static 'node_modules/codemirror/mode/yaml/yaml.js' %}"></script>
|
<script src="{% static 'node_modules/codemirror/mode/yaml/yaml.js' %}"></script>
|
||||||
<script src="{% static 'node_modules/codemirror/mode/jinja2/jinja2.js' %}"></script>
|
<script src="{% static 'node_modules/codemirror/mode/python/python.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block page_content %}
|
{% block page_content %}
|
||||||
|
|
|
@ -55,15 +55,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf-c-card__body">
|
<div class="pf-c-card__body">
|
||||||
{% if factor_count < 1 %}
|
{% if stage_count < 1 %}
|
||||||
<i class="pficon-error-circle-o"></i> {{ factor_count }}
|
<i class="pficon-error-circle-o"></i> {{ stage_count }}
|
||||||
<p>{% trans 'No Stages configured. No Users will be able to login.' %}"></p>
|
<p>{% trans 'No Stages configured. No Users will be able to login.' %}"></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="pf-icon pf-icon-ok"></i> {{ factor_count }}
|
<i class="pf-icon pf-icon-ok"></i> {{ stage_count }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="{% url 'passbook_admin:stages' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
|
||||||
|
<div class="pf-c-card__head">
|
||||||
|
<div class="pf-c-card__head-main">
|
||||||
|
<i class="pf-icon pf-icon-topology"></i> {% trans 'Flows' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-c-card__body">
|
||||||
|
<i class="pf-icon pf-icon-ok"></i> {{ flow_count }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="{% url 'passbook_admin:policies' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
|
<a href="{% url 'passbook_admin:policies' %}" class="pf-c-card pf-m-hoverable pf-m-compact">
|
||||||
<div class="pf-c-card__head">
|
<div class="pf-c-card__head">
|
||||||
<div class="pf-c-card__head-main">
|
<div class="pf-c-card__head-main">
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
{% for type, name in types.items %}
|
{% for type, name in types.items %}
|
||||||
<li>
|
<li>
|
||||||
<a class="pf-c-dropdown__menu-item" href="{% url 'passbook_admin:provider-create' %}?type={{ type }}&back={{ request.get_full_path }}">
|
<a class="pf-c-dropdown__menu-item" href="{% url 'passbook_admin:provider-create' %}?type={{ type }}&back={{ request.get_full_path }}">
|
||||||
{{ name|verbose_name }}
|
{{ name|verbose_name }}<br>
|
||||||
<small>
|
<small>
|
||||||
{{ name|doc }}
|
{{ name|doc }}
|
||||||
</small>
|
</small>
|
||||||
|
|
|
@ -5,14 +5,14 @@
|
||||||
|
|
||||||
{% block above_form %}
|
{% block above_form %}
|
||||||
<h1>
|
<h1>
|
||||||
{% blocktrans with type=form|form_verbose_name|title %}
|
{% blocktrans with type=form|form_verbose_name %}
|
||||||
Create {{ type }}
|
Create {{ type }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</h1>
|
</h1>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block action %}
|
{% block action %}
|
||||||
{% blocktrans with type=form|form_verbose_name|title %}
|
{% blocktrans with type=form|form_verbose_name %}
|
||||||
Create {{ type }}
|
Create {{ type }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -15,7 +15,6 @@ class ApplicationSerializer(ModelSerializer):
|
||||||
"pk",
|
"pk",
|
||||||
"name",
|
"name",
|
||||||
"slug",
|
"slug",
|
||||||
"skip_authorization",
|
|
||||||
"provider",
|
"provider",
|
||||||
"meta_launch_url",
|
"meta_launch_url",
|
||||||
"meta_icon_url",
|
"meta_icon_url",
|
||||||
|
|
|
@ -17,7 +17,7 @@ class ProviderSerializer(ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = Provider
|
model = Provider
|
||||||
fields = ["pk", "property_mappings", "__type__"]
|
fields = ["pk", "authorization_flow", "property_mappings", "__type__"]
|
||||||
|
|
||||||
|
|
||||||
class ProviderViewSet(ReadOnlyModelViewSet):
|
class ProviderViewSet(ReadOnlyModelViewSet):
|
||||||
|
|
21
passbook/core/expression.py
Normal file
21
passbook/core/expression.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
"""Property Mapping Evaluator"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.http import HttpRequest
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
from passbook.lib.expression.evaluator import BaseEvaluator
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyMappingEvaluator(BaseEvaluator):
|
||||||
|
"""Custom Evalautor that adds some different context variables."""
|
||||||
|
|
||||||
|
def set_context(
|
||||||
|
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
|
||||||
|
):
|
||||||
|
"""Update context with context from PropertyMapping's evaluate"""
|
||||||
|
if user:
|
||||||
|
self._context["user"] = user
|
||||||
|
if request:
|
||||||
|
self._context["request"] = request
|
||||||
|
self._context.update(**kwargs)
|
|
@ -19,7 +19,6 @@ class ApplicationForm(forms.ModelForm):
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
"slug",
|
"slug",
|
||||||
"skip_authorization",
|
|
||||||
"provider",
|
"provider",
|
||||||
"meta_launch_url",
|
"meta_launch_url",
|
||||||
"meta_icon_url",
|
"meta_icon_url",
|
||||||
|
|
52
passbook/core/migrations/0002_auto_20200523_1133.py
Normal file
52
passbook/core/migrations/0002_auto_20200523_1133.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-23 11:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_flows", "0003_auto_20200523_1133"),
|
||||||
|
("passbook_core", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(model_name="application", name="skip_authorization",),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="source",
|
||||||
|
name="authentication_flow",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Flow to use when authenticating existing users.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="source_authentication",
|
||||||
|
to="passbook_flows.Flow",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="source",
|
||||||
|
name="enrollment_flow",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Flow to use when enrolling new users.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="source_enrollment",
|
||||||
|
to="passbook_flows.Flow",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="provider",
|
||||||
|
name="authorization_flow",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text="Flow used when authorizing this provider.",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="provider_authorization",
|
||||||
|
to="passbook_flows.Flow",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
29
passbook/core/migrations/0003_default_user.py
Normal file
29
passbook/core/migrations/0003_default_user.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-23 16:40
|
||||||
|
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
|
# User = apps.get_model("passbook_core", "User")
|
||||||
|
from passbook.core.models import User
|
||||||
|
|
||||||
|
pbadmin = User.objects.create(
|
||||||
|
username="pbadmin", email="root@localhost", name="passbook Default Admin"
|
||||||
|
)
|
||||||
|
pbadmin.set_password("pbadmin") # noqa # nosec
|
||||||
|
pbadmin.is_superuser = True
|
||||||
|
pbadmin.is_staff = True
|
||||||
|
pbadmin.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_core", "0002_auto_20200523_1133"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_default_user),
|
||||||
|
]
|
|
@ -5,26 +5,22 @@ from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.contrib.postgres.fields import JSONField
|
from django.contrib.postgres.fields import JSONField
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from guardian.mixins import GuardianUserMixin
|
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 model_utils.managers import InheritanceManager
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.exceptions import PropertyMappingExpressionException
|
from passbook.core.exceptions import PropertyMappingExpressionException
|
||||||
from passbook.core.signals import password_changed
|
from passbook.core.signals import password_changed
|
||||||
from passbook.core.types import UILoginButton, UIUserSettings
|
from passbook.core.types import UILoginButton, UIUserSettings
|
||||||
|
from passbook.flows.models import Flow
|
||||||
from passbook.lib.models import CreatedUpdatedModel
|
from passbook.lib.models import CreatedUpdatedModel
|
||||||
from passbook.policies.models import PolicyBindingModel
|
from passbook.policies.models import PolicyBindingModel
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
NATIVE_ENVIRONMENT = NativeEnvironment()
|
|
||||||
|
|
||||||
|
|
||||||
def default_token_duration():
|
def default_token_duration():
|
||||||
|
@ -80,6 +76,13 @@ class User(GuardianUserMixin, AbstractUser):
|
||||||
class Provider(models.Model):
|
class Provider(models.Model):
|
||||||
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
|
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
|
||||||
|
|
||||||
|
authorization_flow = models.ForeignKey(
|
||||||
|
Flow,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
help_text=_("Flow used when authorizing this provider."),
|
||||||
|
related_name="provider_authorization",
|
||||||
|
)
|
||||||
|
|
||||||
property_mappings = models.ManyToManyField(
|
property_mappings = models.ManyToManyField(
|
||||||
"PropertyMapping", default=None, blank=True
|
"PropertyMapping", default=None, blank=True
|
||||||
)
|
)
|
||||||
|
@ -100,7 +103,6 @@ class Application(PolicyBindingModel):
|
||||||
|
|
||||||
name = models.TextField(help_text=_("Application's display Name."))
|
name = models.TextField(help_text=_("Application's display Name."))
|
||||||
slug = models.SlugField(help_text=_("Internal application name, used in URLs."))
|
slug = models.SlugField(help_text=_("Internal application name, used in URLs."))
|
||||||
skip_authorization = models.BooleanField(default=False)
|
|
||||||
provider = models.OneToOneField(
|
provider = models.OneToOneField(
|
||||||
"Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
|
"Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
|
||||||
)
|
)
|
||||||
|
@ -133,6 +135,25 @@ class Source(PolicyBindingModel):
|
||||||
"PropertyMapping", default=None, blank=True
|
"PropertyMapping", default=None, blank=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
authentication_flow = models.ForeignKey(
|
||||||
|
Flow,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
help_text=_("Flow to use when authenticating existing users."),
|
||||||
|
related_name="source_authentication",
|
||||||
|
)
|
||||||
|
enrollment_flow = models.ForeignKey(
|
||||||
|
Flow,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
help_text=_("Flow to use when enrolling new users."),
|
||||||
|
related_name="source_enrollment",
|
||||||
|
)
|
||||||
|
|
||||||
form = "" # ModelForm-based class ued to create/edit instance
|
form = "" # ModelForm-based class ued to create/edit instance
|
||||||
|
|
||||||
objects = InheritanceManager()
|
objects = InheritanceManager()
|
||||||
|
@ -208,24 +229,14 @@ class PropertyMapping(models.Model):
|
||||||
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
|
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Evaluate `self.expression` using `**kwargs` as Context."""
|
"""Evaluate `self.expression` using `**kwargs` as Context."""
|
||||||
try:
|
from passbook.core.expression import PropertyMappingEvaluator
|
||||||
expression = NATIVE_ENVIRONMENT.from_string(self.expression)
|
|
||||||
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):
|
evaluator = PropertyMappingEvaluator()
|
||||||
|
evaluator.set_context(user, request, **kwargs)
|
||||||
try:
|
try:
|
||||||
NATIVE_ENVIRONMENT.from_string(self.expression)
|
return evaluator.evaluate(self.expression)
|
||||||
except TemplateSyntaxError as exc:
|
except (ValueError, SyntaxError) as exc:
|
||||||
raise ValidationError("Expression Syntax Error") from exc
|
raise PropertyMappingExpressionException from exc
|
||||||
return super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Property Mapping {self.name}"
|
return f"Property Mapping {self.name}"
|
||||||
|
|
|
@ -1,31 +1,7 @@
|
||||||
"""passbook core signals"""
|
"""passbook core signals"""
|
||||||
from django.core.cache import cache
|
|
||||||
from django.core.signals import Signal
|
from django.core.signals import Signal
|
||||||
from django.db.models.signals import post_save
|
|
||||||
from django.dispatch import receiver
|
|
||||||
from structlog import get_logger
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
user_signed_up = Signal(providing_args=["request", "user"])
|
user_signed_up = Signal(providing_args=["request", "user"])
|
||||||
invitation_created = Signal(providing_args=["request", "invitation"])
|
invitation_created = Signal(providing_args=["request", "invitation"])
|
||||||
invitation_used = Signal(providing_args=["request", "invitation", "user"])
|
invitation_used = Signal(providing_args=["request", "invitation", "user"])
|
||||||
password_changed = Signal(providing_args=["user", "password"])
|
password_changed = Signal(providing_args=["user", "password"])
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def invalidate_policy_cache(sender, instance, **_):
|
|
||||||
"""Invalidate Policy cache when policy is updated"""
|
|
||||||
from passbook.policies.models import Policy, PolicyBinding
|
|
||||||
from passbook.policies.process import cache_key
|
|
||||||
|
|
||||||
if isinstance(instance, Policy):
|
|
||||||
LOGGER.debug("Invalidating policy cache", policy=instance)
|
|
||||||
total = 0
|
|
||||||
for binding in PolicyBinding.objects.filter(policy=instance):
|
|
||||||
prefix = cache_key(binding) + "*"
|
|
||||||
keys = cache.keys(prefix)
|
|
||||||
total += len(keys)
|
|
||||||
cache.delete_many(keys)
|
|
||||||
LOGGER.debug("Deleted keys", len=total)
|
|
||||||
|
|
|
@ -20,3 +20,40 @@
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
<footer class="pf-c-login__main-footer">
|
||||||
|
{% if config.login.subtext %}
|
||||||
|
<p>{{ config.login.subtext }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<ul class="pf-c-login__main-footer-links">
|
||||||
|
{% for source in sources %}
|
||||||
|
<li class="pf-c-login__main-footer-links-item">
|
||||||
|
<a href="{{ source.url }}" class="pf-c-login__main-footer-links-item-link">
|
||||||
|
{% if source.icon_path %}
|
||||||
|
<img src="{% static source.icon_path %}" alt="{{ source.name }}">
|
||||||
|
{% elif source.icon_url %}
|
||||||
|
<img src="icon_url" alt="{{ source.name }}">
|
||||||
|
{% else %}
|
||||||
|
<i class="pf-icon pf-icon-arrow" title="{{ source.name }}"></i>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if enroll_url or recovery_url %}
|
||||||
|
<div class="pf-c-login__main-footer-band">
|
||||||
|
{% if enroll_url %}
|
||||||
|
<p class="pf-c-login__main-footer-band-item">
|
||||||
|
{% trans 'Need an account?' %}
|
||||||
|
<a href="{{ enroll_url }}">{% trans 'Sign up.' %}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if recovery_url %}
|
||||||
|
<p class="pf-c-login__main-footer-band-item">
|
||||||
|
<a href="{{ recovery_url }}">
|
||||||
|
{% trans 'Forgot username or password?' %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</footer>
|
||||||
|
|
|
@ -2,6 +2,13 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="pf-c-form__group has-error">
|
||||||
|
<p class="pf-c-form__helper-text pf-m-error">
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
|
<div class="pf-c-form__group {% if field.errors %} has-error {% endif %}">
|
||||||
{% if field.field.widget|fieldtype == 'RadioSelect' %}
|
{% if field.field.widget|fieldtype == 'RadioSelect' %}
|
||||||
|
|
|
@ -28,6 +28,9 @@
|
||||||
</label>
|
</label>
|
||||||
<div class="pf-c-form__horizontal-group">
|
<div class="pf-c-form__horizontal-group">
|
||||||
{{ field|css_class:"pf-c-form-control" }}
|
{{ field|css_class:"pf-c-form-control" }}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% elif field.field.widget|fieldtype == 'CheckboxInput' %}
|
{% elif field.field.widget|fieldtype == 'CheckboxInput' %}
|
||||||
<div class="pf-c-form__horizontal-group">
|
<div class="pf-c-form__horizontal-group">
|
||||||
|
@ -36,7 +39,7 @@
|
||||||
<label class="pf-c-check__label" for="{{ field.name }}-{{ forloop.counter0 }}">{{ field.label }}</label>
|
<label class="pf-c-check__label" for="{{ field.name }}-{{ forloop.counter0 }}">{{ field.label }}</label>
|
||||||
</div>
|
</div>
|
||||||
{% if field.help_text %}
|
{% if field.help_text %}
|
||||||
<p class="pf-c-form__helper-text">{{ field.help_text }}</p>
|
<p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -49,7 +52,7 @@
|
||||||
<div class="c-form__horizontal-group">
|
<div class="c-form__horizontal-group">
|
||||||
{{ field|css_class:'pf-c-form-control' }}
|
{{ field|css_class:'pf-c-form-control' }}
|
||||||
{% if field.help_text %}
|
{% if field.help_text %}
|
||||||
<p class="pf-c-form__helper-text">{{ field.help_text }}</p>
|
<p class="pf-c-form__helper-text">{{ field.help_text|safe }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
<div class="pf-c-form__horizontal-group">
|
<div class="pf-c-form__horizontal-group">
|
||||||
<div class="pf-c-form__actions">
|
<div class="pf-c-form__actions">
|
||||||
<input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
|
<input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
|
||||||
|
{% if unenrollment_enabled %}
|
||||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{% trans "Delete account" %}</a>
|
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{% trans "Delete account" %}</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -35,6 +35,6 @@ class AccessMixin:
|
||||||
def user_has_access(self, application: Application, user: User) -> PolicyResult:
|
def user_has_access(self, application: Application, user: User) -> PolicyResult:
|
||||||
"""Check if user has access to application."""
|
"""Check if user has access to application."""
|
||||||
LOGGER.debug("Checking permissions", user=user, application=application)
|
LOGGER.debug("Checking permissions", user=user, application=application)
|
||||||
policy_engine = PolicyEngine(application.policies.all(), user, self.request)
|
policy_engine = PolicyEngine(application, user, self.request)
|
||||||
policy_engine.build()
|
policy_engine.build()
|
||||||
return policy_engine.result
|
return policy_engine.result
|
||||||
|
|
|
@ -16,9 +16,7 @@ class OverviewView(LoginRequiredMixin, TemplateView):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs["applications"] = []
|
kwargs["applications"] = []
|
||||||
for application in Application.objects.all().order_by("name"):
|
for application in Application.objects.all().order_by("name"):
|
||||||
engine = PolicyEngine(
|
engine = PolicyEngine(application, self.request.user, self.request)
|
||||||
application.policies.all(), self.request.user, self.request
|
|
||||||
)
|
|
||||||
engine.build()
|
engine.build()
|
||||||
if engine.passing:
|
if engine.passing:
|
||||||
kwargs["applications"].append(application)
|
kwargs["applications"].append(application)
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
"""passbook core user views"""
|
"""passbook core user views"""
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
|
@ -6,6 +8,7 @@ from django.utils.translation import gettext as _
|
||||||
from django.views.generic import UpdateView
|
from django.views.generic import UpdateView
|
||||||
|
|
||||||
from passbook.core.forms.users import UserDetailForm
|
from passbook.core.forms.users import UserDetailForm
|
||||||
|
from passbook.flows.models import Flow, FlowDesignation
|
||||||
|
|
||||||
|
|
||||||
class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
|
class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
@ -19,3 +22,11 @@ class UserSettingsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
return self.request.user
|
return self.request.user
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
kwargs = super().get_context_data(**kwargs)
|
||||||
|
unenrollment_flow = Flow.with_policy(
|
||||||
|
self.request, designation=FlowDesignation.UNRENOLLMENT
|
||||||
|
)
|
||||||
|
kwargs["unenrollment_enabled"] = bool(unenrollment_flow)
|
||||||
|
return kwargs
|
||||||
|
|
|
@ -36,11 +36,11 @@ class CertificateBuilder:
|
||||||
x509.Name(
|
x509.Name(
|
||||||
[
|
[
|
||||||
x509.NameAttribute(
|
x509.NameAttribute(
|
||||||
NameOID.COMMON_NAME, u"passbook Self-signed Certificate",
|
NameOID.COMMON_NAME, "passbook Self-signed Certificate",
|
||||||
),
|
),
|
||||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"passbook"),
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "passbook"),
|
||||||
x509.NameAttribute(
|
x509.NameAttribute(
|
||||||
NameOID.ORGANIZATIONAL_UNIT_NAME, u"Self-signed"
|
NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -49,7 +49,7 @@ class CertificateBuilder:
|
||||||
x509.Name(
|
x509.Name(
|
||||||
[
|
[
|
||||||
x509.NameAttribute(
|
x509.NameAttribute(
|
||||||
NameOID.COMMON_NAME, u"passbook Self-signed Certificate",
|
NameOID.COMMON_NAME, "passbook Self-signed Certificate",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -34,7 +34,6 @@ class CertificateKeyPairForm(forms.ModelForm):
|
||||||
password=None,
|
password=None,
|
||||||
backend=default_backend(),
|
backend=default_backend(),
|
||||||
)
|
)
|
||||||
load_pem_x509_certificate(key_data.encode("utf-8"), default_backend())
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise forms.ValidationError("Unable to load private key.")
|
raise forms.ValidationError("Unable to load private key.")
|
||||||
return key_data
|
return key_data
|
||||||
|
|
26
passbook/crypto/migrations/0002_create_self_signed_kp.py
Normal file
26
passbook/crypto/migrations/0002_create_self_signed_kp.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-23 23:07
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def create_self_signed(apps, schema_editor):
|
||||||
|
CertificateKeyPair = apps.get_model("passbook_crypto", "CertificateKeyPair")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
from passbook.crypto.builder import CertificateBuilder
|
||||||
|
|
||||||
|
builder = CertificateBuilder()
|
||||||
|
builder.build()
|
||||||
|
CertificateKeyPair.objects.using(db_alias).create(
|
||||||
|
name="passbook Self-signed Certificate",
|
||||||
|
certificate_data=builder.certificate,
|
||||||
|
key_data=builder.private_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_crypto", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(create_self_signed)]
|
|
@ -31,7 +31,7 @@ def create_default_authentication_flow(
|
||||||
if not IdentificationStage.objects.using(db_alias).exists():
|
if not IdentificationStage.objects.using(db_alias).exists():
|
||||||
IdentificationStage.objects.using(db_alias).create(
|
IdentificationStage.objects.using(db_alias).create(
|
||||||
name="identification",
|
name="identification",
|
||||||
user_fields=[UserFields.E_MAIL],
|
user_fields=[UserFields.E_MAIL, UserFields.USERNAME],
|
||||||
template=Templates.DEFAULT_LOGIN,
|
template=Templates.DEFAULT_LOGIN,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ def create_default_authentication_flow(
|
||||||
UserLoginStage.objects.using(db_alias).create(name="authentication")
|
UserLoginStage.objects.using(db_alias).create(name="authentication")
|
||||||
|
|
||||||
flow = Flow.objects.using(db_alias).create(
|
flow = Flow.objects.using(db_alias).create(
|
||||||
name="default-authentication-flow",
|
name="Welcome to passbook!",
|
||||||
slug="default-authentication-flow",
|
slug="default-authentication-flow",
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
)
|
)
|
||||||
|
|
29
passbook/flows/migrations/0003_auto_20200523_1133.py
Normal file
29
passbook/flows/migrations/0003_auto_20200523_1133.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-23 11:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_flows", "0002_default_flows"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="flow",
|
||||||
|
name="designation",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("authentication", "Authentication"),
|
||||||
|
("authorization", "Authorization"),
|
||||||
|
("invalidation", "Invalidation"),
|
||||||
|
("enrollment", "Enrollment"),
|
||||||
|
("unenrollment", "Unrenollment"),
|
||||||
|
("recovery", "Recovery"),
|
||||||
|
("password_change", "Password Change"),
|
||||||
|
],
|
||||||
|
max_length=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
131
passbook/flows/migrations/0004_source_flows.py
Normal file
131
passbook/flows/migrations/0004_source_flows.py
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-23 15:47
|
||||||
|
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
from passbook.flows.models import FlowDesignation
|
||||||
|
from passbook.stages.prompt.models import FieldTypes
|
||||||
|
|
||||||
|
FLOW_POLICY_EXPRESSION = """{{ pb_is_sso_flow }}"""
|
||||||
|
|
||||||
|
PROMPT_POLICY_EXPRESSION = """
|
||||||
|
{% if pb_flow_plan.context.prompt_data.username %}
|
||||||
|
False
|
||||||
|
{% else %}
|
||||||
|
True
|
||||||
|
{% endif %}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_source_enrollment_flow(
|
||||||
|
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
|
||||||
|
):
|
||||||
|
Flow = apps.get_model("passbook_flows", "Flow")
|
||||||
|
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
|
||||||
|
PolicyBinding = apps.get_model("passbook_policies", "PolicyBinding")
|
||||||
|
|
||||||
|
ExpressionPolicy = apps.get_model(
|
||||||
|
"passbook_policies_expression", "ExpressionPolicy"
|
||||||
|
)
|
||||||
|
|
||||||
|
PromptStage = apps.get_model("passbook_stages_prompt", "PromptStage")
|
||||||
|
Prompt = apps.get_model("passbook_stages_prompt", "Prompt")
|
||||||
|
UserWriteStage = apps.get_model("passbook_stages_user_write", "UserWriteStage")
|
||||||
|
UserLoginStage = apps.get_model("passbook_stages_user_login", "UserLoginStage")
|
||||||
|
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
# Create a policy that only allows this flow when doing an SSO Request
|
||||||
|
flow_policy = ExpressionPolicy.objects.create(
|
||||||
|
name="default-source-enrollment-if-sso", expression=FLOW_POLICY_EXPRESSION
|
||||||
|
)
|
||||||
|
|
||||||
|
# This creates a Flow used by sources to enroll users
|
||||||
|
# It makes sure that a username is set, and if not, prompts the user for a Username
|
||||||
|
flow = Flow.objects.create(
|
||||||
|
name="default-source-enrollment",
|
||||||
|
slug="default-source-enrollment",
|
||||||
|
designation=FlowDesignation.ENROLLMENT,
|
||||||
|
)
|
||||||
|
PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0)
|
||||||
|
|
||||||
|
# PromptStage to ask user for their username
|
||||||
|
prompt_stage = PromptStage.objects.create(
|
||||||
|
name="default-source-enrollment-username-prompt",
|
||||||
|
)
|
||||||
|
prompt_stage.fields.add(
|
||||||
|
Prompt.objects.create(
|
||||||
|
field_key="username",
|
||||||
|
label="Username",
|
||||||
|
type=FieldTypes.TEXT,
|
||||||
|
required=True,
|
||||||
|
placeholder="Username",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Policy to only trigger prompt when no username is given
|
||||||
|
prompt_policy = ExpressionPolicy.objects.create(
|
||||||
|
name="default-source-enrollment-if-username",
|
||||||
|
expression=PROMPT_POLICY_EXPRESSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
# UserWrite stage to create the user, and login stage to log user in
|
||||||
|
user_write = UserWriteStage.objects.create(name="default-source-enrollment-write")
|
||||||
|
user_login = UserLoginStage.objects.create(name="default-source-enrollment-login")
|
||||||
|
|
||||||
|
binding = FlowStageBinding.objects.create(flow=flow, stage=prompt_stage, order=0)
|
||||||
|
PolicyBinding.objects.create(policy=prompt_policy, target=binding)
|
||||||
|
|
||||||
|
FlowStageBinding.objects.create(flow=flow, stage=user_write, order=1)
|
||||||
|
FlowStageBinding.objects.create(flow=flow, stage=user_login, order=2)
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_source_authentication_flow(
|
||||||
|
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
|
||||||
|
):
|
||||||
|
Flow = apps.get_model("passbook_flows", "Flow")
|
||||||
|
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
|
||||||
|
PolicyBinding = apps.get_model("passbook_policies", "PolicyBinding")
|
||||||
|
|
||||||
|
ExpressionPolicy = apps.get_model(
|
||||||
|
"passbook_policies_expression", "ExpressionPolicy"
|
||||||
|
)
|
||||||
|
|
||||||
|
UserLoginStage = apps.get_model("passbook_stages_user_login", "UserLoginStage")
|
||||||
|
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
# Create a policy that only allows this flow when doing an SSO Request
|
||||||
|
flow_policy = ExpressionPolicy.objects.create(
|
||||||
|
name="default-source-authentication-if-sso", expression=FLOW_POLICY_EXPRESSION
|
||||||
|
)
|
||||||
|
|
||||||
|
# This creates a Flow used by sources to authenticate users
|
||||||
|
flow = Flow.objects.create(
|
||||||
|
name="default-source-authentication",
|
||||||
|
slug="default-source-authentication",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
PolicyBinding.objects.create(policy=flow_policy, target=flow, order=0)
|
||||||
|
|
||||||
|
user_login = UserLoginStage.objects.create(
|
||||||
|
name="default-source-authentication-login"
|
||||||
|
)
|
||||||
|
FlowStageBinding.objects.create(flow=flow, stage=user_login, order=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_flows", "0003_auto_20200523_1133"),
|
||||||
|
("passbook_policies", "0001_initial"),
|
||||||
|
("passbook_policies_expression", "0001_initial"),
|
||||||
|
("passbook_stages_prompt", "0001_initial"),
|
||||||
|
("passbook_stages_user_write", "0001_initial"),
|
||||||
|
("passbook_stages_user_login", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_default_source_enrollment_flow),
|
||||||
|
migrations.RunPython(create_default_source_authentication_flow),
|
||||||
|
]
|
44
passbook/flows/migrations/0005_provider_flows.py
Normal file
44
passbook/flows/migrations/0005_provider_flows.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-24 11:34
|
||||||
|
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
from passbook.flows.models import FlowDesignation
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_provider_authz_flow(
|
||||||
|
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
|
||||||
|
):
|
||||||
|
Flow = apps.get_model("passbook_flows", "Flow")
|
||||||
|
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
|
||||||
|
|
||||||
|
ConsentStage = apps.get_model("passbook_stages_consent", "ConsentStage")
|
||||||
|
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
# Empty flow for providers where no consent is needed
|
||||||
|
Flow.objects.create(
|
||||||
|
name="default-provider-authorization",
|
||||||
|
slug="default-provider-authorization",
|
||||||
|
designation=FlowDesignation.AUTHORIZATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flow with consent form to obtain user consent for authorization
|
||||||
|
flow = Flow.objects.create(
|
||||||
|
name="default-provider-authorization-consent",
|
||||||
|
slug="default-provider-authorization-consent",
|
||||||
|
designation=FlowDesignation.AUTHORIZATION,
|
||||||
|
)
|
||||||
|
stage = ConsentStage.objects.create(name="default-provider-authorization-consent")
|
||||||
|
FlowStageBinding.objects.create(flow=flow, stage=stage, order=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_flows", "0004_source_flows"),
|
||||||
|
("passbook_stages_consent", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(create_default_provider_authz_flow)]
|
|
@ -1,20 +1,26 @@
|
||||||
"""Flow models"""
|
"""Flow models"""
|
||||||
from typing import Optional
|
from typing import Callable, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.http import HttpRequest
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.types import UIUserSettings
|
from passbook.core.types import UIUserSettings
|
||||||
|
from passbook.lib.utils.reflection import class_to_path
|
||||||
from passbook.policies.models import PolicyBindingModel
|
from passbook.policies.models import PolicyBindingModel
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class FlowDesignation(models.TextChoices):
|
class FlowDesignation(models.TextChoices):
|
||||||
"""Designation of what a Flow should be used for. At a later point, this
|
"""Designation of what a Flow should be used for. At a later point, this
|
||||||
should be replaced by a database entry."""
|
should be replaced by a database entry."""
|
||||||
|
|
||||||
AUTHENTICATION = "authentication"
|
AUTHENTICATION = "authentication"
|
||||||
|
AUTHORIZATION = "authorization"
|
||||||
INVALIDATION = "invalidation"
|
INVALIDATION = "invalidation"
|
||||||
ENROLLMENT = "enrollment"
|
ENROLLMENT = "enrollment"
|
||||||
UNRENOLLMENT = "unenrollment"
|
UNRENOLLMENT = "unenrollment"
|
||||||
|
@ -44,6 +50,14 @@ class Stage(models.Model):
|
||||||
return f"Stage {self.name}"
|
return f"Stage {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def in_memory_stage(_type: Callable) -> Stage:
|
||||||
|
"""Creates an in-memory stage instance, based on a `_type` as view."""
|
||||||
|
class_path = class_to_path(_type)
|
||||||
|
stage = Stage()
|
||||||
|
stage.type = class_path
|
||||||
|
return stage
|
||||||
|
|
||||||
|
|
||||||
class Flow(PolicyBindingModel):
|
class Flow(PolicyBindingModel):
|
||||||
"""Flow describes how a series of Stages should be executed to authenticate/enroll/recover
|
"""Flow describes how a series of Stages should be executed to authenticate/enroll/recover
|
||||||
a user. Additionally, policies can be applied, to specify which users
|
a user. Additionally, policies can be applied, to specify which users
|
||||||
|
@ -62,10 +76,29 @@ class Flow(PolicyBindingModel):
|
||||||
PolicyBindingModel, parent_link=True, on_delete=models.CASCADE, related_name="+"
|
PolicyBindingModel, parent_link=True, on_delete=models.CASCADE, related_name="+"
|
||||||
)
|
)
|
||||||
|
|
||||||
def related_flow(self, designation: str) -> Optional["Flow"]:
|
@staticmethod
|
||||||
|
def with_policy(request: HttpRequest, **flow_filter) -> Optional["Flow"]:
|
||||||
|
"""Get a Flow by `**flow_filter` and check if the request from `request` can access it."""
|
||||||
|
from passbook.policies.engine import PolicyEngine
|
||||||
|
|
||||||
|
flows = Flow.objects.filter(**flow_filter)
|
||||||
|
for flow in flows:
|
||||||
|
engine = PolicyEngine(flow, request.user, request)
|
||||||
|
engine.build()
|
||||||
|
result = engine.result
|
||||||
|
if result.passing:
|
||||||
|
LOGGER.debug("with_policy: flow passing", flow=flow)
|
||||||
|
return flow
|
||||||
|
LOGGER.warning(
|
||||||
|
"with_policy: flow not passing", flow=flow, messages=result.messages
|
||||||
|
)
|
||||||
|
LOGGER.debug("with_policy: no flow found", filters=flow_filter)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def related_flow(self, designation: str, request: HttpRequest) -> Optional["Flow"]:
|
||||||
"""Get a related flow with `designation`. Currently this only queries
|
"""Get a related flow with `designation`. Currently this only queries
|
||||||
Flows by `designation`, but will eventually use `self` for related lookups."""
|
Flows by `designation`, but will eventually use `self` for related lookups."""
|
||||||
return Flow.objects.filter(designation=designation).first()
|
return Flow.with_policy(request, designation=designation)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Flow {self.name} ({self.slug})"
|
return f"Flow {self.name} ({self.slug})"
|
||||||
|
|
|
@ -11,12 +11,12 @@ from passbook.core.models import User
|
||||||
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||||
from passbook.flows.models import Flow, Stage
|
from passbook.flows.models import Flow, Stage
|
||||||
from passbook.policies.engine import PolicyEngine
|
from passbook.policies.engine import PolicyEngine
|
||||||
from passbook.policies.types import PolicyResult
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
PLAN_CONTEXT_PENDING_USER = "pending_user"
|
PLAN_CONTEXT_PENDING_USER = "pending_user"
|
||||||
PLAN_CONTEXT_SSO = "is_sso"
|
PLAN_CONTEXT_SSO = "is_sso"
|
||||||
|
PLAN_CONTEXT_APPLICATION = "application"
|
||||||
|
|
||||||
|
|
||||||
def cache_key(flow: Flow, user: Optional[User] = None) -> str:
|
def cache_key(flow: Flow, user: Optional[User] = None) -> str:
|
||||||
|
@ -46,28 +46,21 @@ class FlowPlanner:
|
||||||
that should be applied."""
|
that should be applied."""
|
||||||
|
|
||||||
use_cache: bool
|
use_cache: bool
|
||||||
|
allow_empty_flows: bool
|
||||||
|
|
||||||
flow: Flow
|
flow: Flow
|
||||||
|
|
||||||
def __init__(self, flow: Flow):
|
def __init__(self, flow: Flow):
|
||||||
self.use_cache = True
|
self.use_cache = True
|
||||||
|
self.allow_empty_flows = False
|
||||||
self.flow = flow
|
self.flow = flow
|
||||||
|
|
||||||
def _check_flow_root_policies(self, request: HttpRequest) -> PolicyResult:
|
|
||||||
engine = PolicyEngine(self.flow, request.user, request)
|
|
||||||
engine.build()
|
|
||||||
return engine.result
|
|
||||||
|
|
||||||
def plan(
|
def plan(
|
||||||
self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
|
self, request: HttpRequest, default_context: Optional[Dict[str, Any]] = None
|
||||||
) -> FlowPlan:
|
) -> FlowPlan:
|
||||||
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
|
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
|
||||||
and return ordered list"""
|
and return ordered list"""
|
||||||
LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
|
LOGGER.debug("f(plan): Starting planning process", flow=self.flow)
|
||||||
# First off, check the flow's direct policy bindings
|
|
||||||
# to make sure the user even has access to the flow
|
|
||||||
root_result = self._check_flow_root_policies(request)
|
|
||||||
if not root_result.passing:
|
|
||||||
raise FlowNonApplicableException(*root_result.messages)
|
|
||||||
# Bit of a workaround here, if there is a pending user set in the default context
|
# Bit of a workaround here, if there is a pending user set in the default context
|
||||||
# we use that user for our cache key
|
# we use that user for our cache key
|
||||||
# to make sure they don't get the generic response
|
# to make sure they don't get the generic response
|
||||||
|
@ -75,16 +68,29 @@ class FlowPlanner:
|
||||||
user = default_context[PLAN_CONTEXT_PENDING_USER]
|
user = default_context[PLAN_CONTEXT_PENDING_USER]
|
||||||
else:
|
else:
|
||||||
user = request.user
|
user = request.user
|
||||||
|
# First off, check the flow's direct policy bindings
|
||||||
|
# to make sure the user even has access to the flow
|
||||||
|
engine = PolicyEngine(self.flow, user, request)
|
||||||
|
if default_context:
|
||||||
|
engine.request.context = default_context
|
||||||
|
engine.build()
|
||||||
|
result = engine.result
|
||||||
|
if not result.passing:
|
||||||
|
raise FlowNonApplicableException(result.messages)
|
||||||
|
# User is passing so far, check if we have a cached plan
|
||||||
cached_plan_key = cache_key(self.flow, user)
|
cached_plan_key = cache_key(self.flow, user)
|
||||||
cached_plan = cache.get(cached_plan_key, None)
|
cached_plan = cache.get(cached_plan_key, None)
|
||||||
if cached_plan and self.use_cache:
|
if cached_plan and self.use_cache:
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"f(plan): Taking plan from cache", flow=self.flow, key=cached_plan_key
|
"f(plan): Taking plan from cache", flow=self.flow, key=cached_plan_key
|
||||||
)
|
)
|
||||||
|
# Reset the context as this isn't factored into caching
|
||||||
|
cached_plan.context = default_context or {}
|
||||||
return cached_plan
|
return cached_plan
|
||||||
|
LOGGER.debug("f(plan): building plan", flow=self.flow)
|
||||||
plan = self._build_plan(user, request, default_context)
|
plan = self._build_plan(user, request, default_context)
|
||||||
cache.set(cache_key(self.flow, user), plan)
|
cache.set(cache_key(self.flow, user), plan)
|
||||||
if not plan.stages:
|
if not plan.stages and not self.allow_empty_flows:
|
||||||
raise EmptyFlowException()
|
raise EmptyFlowException()
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
|
|
@ -113,19 +113,18 @@ const updateMessages = () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const updateCard = (response) => {
|
const updateCard = (data) => {
|
||||||
if (!response.ok) {
|
switch (data.type) {
|
||||||
console.log("well");
|
case "redirect":
|
||||||
}
|
window.location = data.to
|
||||||
if (response.redirected && !response.url.endsWith(flowBodyUrl)) {
|
break;
|
||||||
window.location = response.url;
|
case "template":
|
||||||
} else {
|
flowBody.innerHTML = data.body;
|
||||||
response.text().then(text => {
|
|
||||||
flowBody.innerHTML = text;
|
|
||||||
updateMessages();
|
updateMessages();
|
||||||
loadFormCode();
|
loadFormCode();
|
||||||
setFormSubmitHandlers();
|
setFormSubmitHandlers();
|
||||||
});
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const showSpinner = () => {
|
const showSpinner = () => {
|
||||||
|
@ -139,10 +138,28 @@ const loadFormCode = () => {
|
||||||
document.head.appendChild(newScript);
|
document.head.appendChild(newScript);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const updateFormAction = (form) => {
|
||||||
|
for (let index = 0; index < form.elements.length; index++) {
|
||||||
|
const element = form.elements[index];
|
||||||
|
if (element.value === form.action) {
|
||||||
|
console.log("Found Form action URL in form elements, not changing form action.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form.action = flowBodyUrl;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const checkAutosubmit = (form) => {
|
||||||
|
if ("autosubmit" in form.attributes) {
|
||||||
|
return form.submit();
|
||||||
|
}
|
||||||
|
};
|
||||||
const setFormSubmitHandlers = () => {
|
const setFormSubmitHandlers = () => {
|
||||||
document.querySelectorAll("#flow-body form").forEach(form => {
|
document.querySelectorAll("#flow-body form").forEach(form => {
|
||||||
|
console.log(`Checking for autosubmit attribute ${form}`);
|
||||||
|
checkAutosubmit(form);
|
||||||
console.log(`Setting action for form ${form}`);
|
console.log(`Setting action for form ${form}`);
|
||||||
form.action = flowBodyUrl;
|
updateFormAction(form);
|
||||||
console.log(`Adding handler for form ${form}`);
|
console.log(`Adding handler for form ${form}`);
|
||||||
form.addEventListener('submit', (e) => {
|
form.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -150,19 +167,13 @@ const setFormSubmitHandlers = () => {
|
||||||
fetch(flowBodyUrl, {
|
fetch(flowBodyUrl, {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: formData,
|
body: formData,
|
||||||
}).then((response) => {
|
}).then(response => response.json()).then(data => {
|
||||||
showSpinner();
|
updateCard(data);
|
||||||
if (!response.url.endsWith(flowBodyUrl)) {
|
|
||||||
window.location = response.url;
|
|
||||||
} else {
|
|
||||||
updateCard(response);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
fetch(flowBodyUrl).then(updateCard);
|
fetch(flowBodyUrl).then(response => response.json()).then(data => updateCard(data));
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
"""flow planner tests"""
|
"""flow planner tests"""
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, PropertyMock, patch
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import RequestFactory, TestCase
|
from django.test import RequestFactory, TestCase
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||||
from passbook.flows.planner import FlowPlanner
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
|
||||||
from passbook.policies.types import PolicyResult
|
from passbook.policies.types import PolicyResult
|
||||||
from passbook.stages.dummy.models import DummyStage
|
from passbook.stages.dummy.models import DummyStage
|
||||||
|
|
||||||
POLICY_RESULT_MOCK = MagicMock(return_value=PolicyResult(False))
|
POLICY_RESULT_MOCK = PropertyMock(return_value=PolicyResult(False))
|
||||||
TIME_NOW_MOCK = MagicMock(return_value=3)
|
TIME_NOW_MOCK = MagicMock(return_value=3)
|
||||||
|
|
||||||
|
|
||||||
|
@ -38,8 +40,7 @@ class TestFlowPlanner(TestCase):
|
||||||
planner.plan(request)
|
planner.plan(request)
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"passbook.flows.planner.FlowPlanner._check_flow_root_policies",
|
"passbook.policies.engine.PolicyEngine.result", POLICY_RESULT_MOCK,
|
||||||
POLICY_RESULT_MOCK,
|
|
||||||
)
|
)
|
||||||
def test_non_applicable_plan(self):
|
def test_non_applicable_plan(self):
|
||||||
"""Test that empty plan raises exception"""
|
"""Test that empty plan raises exception"""
|
||||||
|
@ -81,3 +82,24 @@ class TestFlowPlanner(TestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
TIME_NOW_MOCK.call_count, 2
|
TIME_NOW_MOCK.call_count, 2
|
||||||
) # When taking from cache, time is not measured
|
) # When taking from cache, time is not measured
|
||||||
|
|
||||||
|
def test_planner_default_context(self):
|
||||||
|
"""Test planner with default_context"""
|
||||||
|
flow = Flow.objects.create(
|
||||||
|
name="test-default-context",
|
||||||
|
slug="test-default-context",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
FlowStageBinding.objects.create(
|
||||||
|
flow=flow, stage=DummyStage.objects.create(name="dummy"), order=0
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User.objects.create(username="test-user")
|
||||||
|
request = self.request_factory.get(
|
||||||
|
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
|
)
|
||||||
|
request.user = user
|
||||||
|
planner = FlowPlanner(flow)
|
||||||
|
planner.plan(request, default_context={PLAN_CONTEXT_PENDING_USER: user})
|
||||||
|
key = cache_key(flow, user)
|
||||||
|
self.assertTrue(cache.get(key) is not None)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
"""flow views tests"""
|
"""flow views tests"""
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, PropertyMock, patch
|
||||||
|
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
|
@ -12,7 +12,7 @@ from passbook.lib.config import CONFIG
|
||||||
from passbook.policies.types import PolicyResult
|
from passbook.policies.types import PolicyResult
|
||||||
from passbook.stages.dummy.models import DummyStage
|
from passbook.stages.dummy.models import DummyStage
|
||||||
|
|
||||||
POLICY_RESULT_MOCK = MagicMock(return_value=PolicyResult(False))
|
POLICY_RESULT_MOCK = PropertyMock(return_value=PolicyResult(False))
|
||||||
|
|
||||||
|
|
||||||
class TestFlowExecutor(TestCase):
|
class TestFlowExecutor(TestCase):
|
||||||
|
@ -45,8 +45,7 @@ class TestFlowExecutor(TestCase):
|
||||||
self.assertEqual(cancel_mock.call_count, 1)
|
self.assertEqual(cancel_mock.call_count, 1)
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"passbook.flows.planner.FlowPlanner._check_flow_root_policies",
|
"passbook.policies.engine.PolicyEngine.result", POLICY_RESULT_MOCK,
|
||||||
POLICY_RESULT_MOCK,
|
|
||||||
)
|
)
|
||||||
def test_invalid_non_applicable_flow(self):
|
def test_invalid_non_applicable_flow(self):
|
||||||
"""Tests that a non-applicable flow returns the correct error message"""
|
"""Tests that a non-applicable flow returns the correct error message"""
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
"""passbook multi-stage authentication engine"""
|
"""passbook multi-stage authentication engine"""
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import (
|
||||||
|
Http404,
|
||||||
|
HttpRequest,
|
||||||
|
HttpResponse,
|
||||||
|
HttpResponseRedirect,
|
||||||
|
JsonResponse,
|
||||||
|
)
|
||||||
from django.shortcuts import get_object_or_404, redirect, reverse
|
from django.shortcuts import get_object_or_404, redirect, reverse
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||||
from django.views.generic import TemplateView, View
|
from django.views.generic import TemplateView, View
|
||||||
|
@ -34,7 +41,7 @@ class FlowExecutorView(View):
|
||||||
|
|
||||||
def setup(self, request: HttpRequest, flow_slug: str):
|
def setup(self, request: HttpRequest, flow_slug: str):
|
||||||
super().setup(request, flow_slug=flow_slug)
|
super().setup(request, flow_slug=flow_slug)
|
||||||
self.flow = get_object_or_404(Flow, slug=flow_slug)
|
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
|
||||||
|
|
||||||
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
|
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
|
||||||
"""When a flow is non-applicable check if user is on the correct domain"""
|
"""When a flow is non-applicable check if user is on the correct domain"""
|
||||||
|
@ -81,6 +88,8 @@ class FlowExecutorView(View):
|
||||||
)
|
)
|
||||||
stage_cls = path_to_class(self.current_stage.type)
|
stage_cls = path_to_class(self.current_stage.type)
|
||||||
self.current_stage_view = stage_cls(self)
|
self.current_stage_view = stage_cls(self)
|
||||||
|
self.current_stage_view.args = self.args
|
||||||
|
self.current_stage_view.kwargs = self.kwargs
|
||||||
self.current_stage_view.request = request
|
self.current_stage_view.request = request
|
||||||
return super().dispatch(request)
|
return super().dispatch(request)
|
||||||
|
|
||||||
|
@ -91,7 +100,8 @@ class FlowExecutorView(View):
|
||||||
view_class=class_to_path(self.current_stage_view.__class__),
|
view_class=class_to_path(self.current_stage_view.__class__),
|
||||||
flow_slug=self.flow.slug,
|
flow_slug=self.flow.slug,
|
||||||
)
|
)
|
||||||
return self.current_stage_view.get(request, *args, **kwargs)
|
stage_response = self.current_stage_view.get(request, *args, **kwargs)
|
||||||
|
return to_stage_response(request, stage_response)
|
||||||
|
|
||||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""pass post request to current stage"""
|
"""pass post request to current stage"""
|
||||||
|
@ -100,7 +110,8 @@ class FlowExecutorView(View):
|
||||||
view_class=class_to_path(self.current_stage_view.__class__),
|
view_class=class_to_path(self.current_stage_view.__class__),
|
||||||
flow_slug=self.flow.slug,
|
flow_slug=self.flow.slug,
|
||||||
)
|
)
|
||||||
return self.current_stage_view.post(request, *args, **kwargs)
|
stage_response = self.current_stage_view.post(request, *args, **kwargs)
|
||||||
|
return to_stage_response(request, stage_response)
|
||||||
|
|
||||||
def _initiate_plan(self) -> FlowPlan:
|
def _initiate_plan(self) -> FlowPlan:
|
||||||
planner = FlowPlanner(self.flow)
|
planner = FlowPlanner(self.flow)
|
||||||
|
@ -164,7 +175,9 @@ class ToDefaultFlow(View):
|
||||||
designation: Optional[FlowDesignation] = None
|
designation: Optional[FlowDesignation] = None
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||||
flow = get_object_or_404(Flow, designation=self.designation)
|
flow = Flow.with_policy(request, designation=self.designation)
|
||||||
|
if not flow:
|
||||||
|
raise Http404
|
||||||
# If user already has a pending plan, clear it so we don't have to later.
|
# If user already has a pending plan, clear it so we don't have to later.
|
||||||
if SESSION_KEY_PLAN in self.request.session:
|
if SESSION_KEY_PLAN in self.request.session:
|
||||||
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
||||||
|
@ -189,3 +202,22 @@ class FlowExecutorShellView(TemplateView):
|
||||||
kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs)
|
kwargs["exec_url"] = reverse("passbook_flows:flow-executor", kwargs=self.kwargs)
|
||||||
kwargs["msg_url"] = reverse("passbook_api:messages-list")
|
kwargs["msg_url"] = reverse("passbook_api:messages-list")
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse:
|
||||||
|
"""Convert normal HttpResponse into JSON Response"""
|
||||||
|
if isinstance(source, HttpResponseRedirect) or source.status_code == 302:
|
||||||
|
redirect_url = source["Location"]
|
||||||
|
if request.path != redirect_url:
|
||||||
|
return JsonResponse({"type": "redirect", "to": redirect_url})
|
||||||
|
return source
|
||||||
|
if isinstance(source, TemplateResponse):
|
||||||
|
return JsonResponse(
|
||||||
|
{"type": "template", "body": source.render().content.decode("utf-8")}
|
||||||
|
)
|
||||||
|
# Check for actual HttpResponse (without isinstance as we dont want to check inheritance)
|
||||||
|
if source.__class__ == HttpResponse:
|
||||||
|
return JsonResponse(
|
||||||
|
{"type": "template", "body": source.content.decode("utf-8")}
|
||||||
|
)
|
||||||
|
return source
|
||||||
|
|
0
passbook/lib/expression/__init__.py
Normal file
0
passbook/lib/expression/__init__.py
Normal file
101
passbook/lib/expression/evaluator.py
Normal file
101
passbook/lib/expression/evaluator.py
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
"""passbook expression policy evaluator"""
|
||||||
|
import re
|
||||||
|
from textwrap import indent
|
||||||
|
from typing import Any, Dict, Iterable, Optional
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from requests import Session
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseEvaluator:
|
||||||
|
"""Validate and evaluate python-based expressions"""
|
||||||
|
|
||||||
|
# Globals that can be used by function
|
||||||
|
_globals: Dict[str, Any]
|
||||||
|
# Context passed as locals to exec()
|
||||||
|
_context: Dict[str, Any]
|
||||||
|
|
||||||
|
# Filename used for exec
|
||||||
|
_filename: str
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||||
|
# update docs/policies/expression/index.md
|
||||||
|
self._globals = {
|
||||||
|
"regex_match": BaseEvaluator.expr_filter_regex_match,
|
||||||
|
"regex_replace": BaseEvaluator.expr_filter_regex_replace,
|
||||||
|
"pb_is_group_member": BaseEvaluator.expr_func_is_group_member,
|
||||||
|
"pb_user_by": BaseEvaluator.expr_func_user_by,
|
||||||
|
"pb_logger": get_logger(),
|
||||||
|
"requests": Session(),
|
||||||
|
}
|
||||||
|
self._context = {}
|
||||||
|
self._filename = "BaseEvalautor"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def expr_filter_regex_match(value: Any, regex: str) -> bool:
|
||||||
|
"""Expression Filter to run re.search"""
|
||||||
|
return re.search(regex, value) is None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def expr_filter_regex_replace(value: Any, regex: str, repl: str) -> str:
|
||||||
|
"""Expression Filter to run re.sub"""
|
||||||
|
return re.sub(regex, repl, value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def expr_func_user_by(**filters) -> Optional[User]:
|
||||||
|
"""Get user by filters"""
|
||||||
|
users = User.objects.filter(**filters)
|
||||||
|
if users:
|
||||||
|
return users.first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def expr_func_is_group_member(user: User, **group_filters) -> bool:
|
||||||
|
"""Check if `user` is member of group with name `group_name`"""
|
||||||
|
return user.groups.filter(**group_filters).exists()
|
||||||
|
|
||||||
|
def wrap_expression(self, expression: str, params: Iterable[str]) -> str:
|
||||||
|
"""Wrap expression in a function, call it, and save the result as `result`"""
|
||||||
|
handler_signature = ",".join(params)
|
||||||
|
full_expression = f"def handler({handler_signature}):\n"
|
||||||
|
full_expression += indent(expression, " ")
|
||||||
|
full_expression += f"\nresult = handler({handler_signature})"
|
||||||
|
return full_expression
|
||||||
|
|
||||||
|
def evaluate(self, expression_source: str) -> Any:
|
||||||
|
"""Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised.
|
||||||
|
If any exception is raised during execution, it is raised.
|
||||||
|
The result is returned without any type-checking."""
|
||||||
|
param_keys = self._context.keys()
|
||||||
|
ast_obj = compile(
|
||||||
|
self.wrap_expression(expression_source, param_keys), self._filename, "exec",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_locals = self._context
|
||||||
|
# Yes this is an exec, yes it is potentially bad. Since we limit what variables are
|
||||||
|
# available here, and these policies can only be edited by admins, this is a risk
|
||||||
|
# we're willing to take.
|
||||||
|
# pylint: disable=exec-used
|
||||||
|
exec(ast_obj, self._globals, _locals) # nosec # noqa
|
||||||
|
result = _locals["result"]
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("Expression error", exc=exc)
|
||||||
|
raise
|
||||||
|
return result
|
||||||
|
|
||||||
|
def validate(self, expression: str) -> bool:
|
||||||
|
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
|
||||||
|
param_keys = self._context.keys()
|
||||||
|
try:
|
||||||
|
compile(
|
||||||
|
self.wrap_expression(expression, param_keys), self._filename, "exec",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (ValueError, SyntaxError) as exc:
|
||||||
|
raise ValidationError(f"Expression Syntax Error: {str(exc)}") from exc
|
|
@ -1,4 +1,6 @@
|
||||||
"""passbook policies app config"""
|
"""passbook policies app config"""
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,3 +10,7 @@ class PassbookPoliciesConfig(AppConfig):
|
||||||
name = "passbook.policies"
|
name = "passbook.policies"
|
||||||
label = "passbook_policies"
|
label = "passbook_policies"
|
||||||
verbose_name = "passbook Policies"
|
verbose_name = "passbook Policies"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
"""Load source_types from config file"""
|
||||||
|
import_module("passbook.policies.signals")
|
||||||
|
|
|
@ -73,16 +73,20 @@ class PolicyEngine:
|
||||||
"""Build task group"""
|
"""Build task group"""
|
||||||
for binding in self._iter_bindings():
|
for binding in self._iter_bindings():
|
||||||
self._check_policy_type(binding.policy)
|
self._check_policy_type(binding.policy)
|
||||||
policy = binding.policy
|
key = cache_key(binding, self.request)
|
||||||
cached_policy = cache.get(cache_key(binding, self.request.user), None)
|
cached_policy = cache.get(key, None)
|
||||||
if cached_policy and self.use_cache:
|
if cached_policy and self.use_cache:
|
||||||
LOGGER.debug("P_ENG: Taking result from cache", policy=policy)
|
LOGGER.debug(
|
||||||
|
"P_ENG: Taking result from cache",
|
||||||
|
policy=binding.policy,
|
||||||
|
cache_key=key,
|
||||||
|
)
|
||||||
self.__cached_policies.append(cached_policy)
|
self.__cached_policies.append(cached_policy)
|
||||||
continue
|
continue
|
||||||
LOGGER.debug("P_ENG: Evaluating policy", policy=policy)
|
LOGGER.debug("P_ENG: Evaluating policy", policy=binding.policy)
|
||||||
our_end, task_end = Pipe(False)
|
our_end, task_end = Pipe(False)
|
||||||
task = PolicyProcess(binding, self.request, task_end)
|
task = PolicyProcess(binding, self.request, task_end)
|
||||||
LOGGER.debug("P_ENG: Starting Process", policy=policy)
|
LOGGER.debug("P_ENG: Starting Process", policy=binding.policy)
|
||||||
task.start()
|
task.start()
|
||||||
self.__processes.append(
|
self.__processes.append(
|
||||||
PolicyProcessInfo(process=task, connection=our_end, binding=binding)
|
PolicyProcessInfo(process=task, connection=our_end, binding=binding)
|
||||||
|
@ -103,7 +107,9 @@ class PolicyEngine:
|
||||||
x.result for x in self.__processes if x.result
|
x.result for x in self.__processes if x.result
|
||||||
]
|
]
|
||||||
for result in process_results + self.__cached_policies:
|
for result in process_results + self.__cached_policies:
|
||||||
LOGGER.debug("P_ENG: result", passing=result.passing)
|
LOGGER.debug(
|
||||||
|
"P_ENG: result", passing=result.passing, messages=result.messages
|
||||||
|
)
|
||||||
if result.messages:
|
if result.messages:
|
||||||
messages += result.messages
|
messages += result.messages
|
||||||
if not result.passing:
|
if not result.passing:
|
||||||
|
|
|
@ -1,103 +1,71 @@
|
||||||
"""passbook expression policy evaluator"""
|
"""passbook expression policy evaluator"""
|
||||||
import re
|
from typing import List
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.http import HttpRequest
|
||||||
from jinja2 import Undefined
|
|
||||||
from jinja2.exceptions import TemplateSyntaxError
|
|
||||||
from jinja2.nativetypes import NativeEnvironment
|
|
||||||
from requests import Session
|
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.flows.planner import PLAN_CONTEXT_SSO
|
from passbook.flows.planner import PLAN_CONTEXT_SSO
|
||||||
from passbook.flows.views import SESSION_KEY_PLAN
|
from passbook.flows.views import SESSION_KEY_PLAN
|
||||||
|
from passbook.lib.expression.evaluator import BaseEvaluator
|
||||||
from passbook.lib.utils.http import get_client_ip
|
from passbook.lib.utils.http import get_client_ip
|
||||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from passbook.core.models import User
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class Evaluator:
|
class PolicyEvaluator(BaseEvaluator):
|
||||||
"""Validate and evaluate jinja2-based expressions"""
|
"""Validate and evaluate python-based expressions"""
|
||||||
|
|
||||||
_env: NativeEnvironment
|
_messages: List[str]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, policy_name: str):
|
||||||
self._env = NativeEnvironment()
|
super().__init__()
|
||||||
|
self._messages = []
|
||||||
|
self._context["pb_message"] = self.expr_func_message
|
||||||
|
self._filename = policy_name
|
||||||
|
|
||||||
|
def expr_func_message(self, message: str):
|
||||||
|
"""Wrapper to append to messages list, which is returned with PolicyResult"""
|
||||||
|
self._messages.append(message)
|
||||||
|
|
||||||
|
def set_policy_request(self, request: PolicyRequest):
|
||||||
|
"""Update context based on policy request (if http request is given, update that too)"""
|
||||||
# update passbook/policies/expression/templates/policy/expression/form.html
|
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||||
# update docs/policies/expression/index.md
|
# update docs/policies/expression/index.md
|
||||||
self._env.filters["regex_match"] = Evaluator.jinja2_filter_regex_match
|
self._context["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
|
||||||
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_group_member"] = Evaluator.jinja2_func_is_group_member
|
|
||||||
kwargs["pb_logger"] = get_logger()
|
|
||||||
kwargs["requests"] = Session()
|
|
||||||
kwargs["pb_is_sso_flow"] = request.context.get(PLAN_CONTEXT_SSO, False)
|
|
||||||
if request.http_request:
|
if request.http_request:
|
||||||
kwargs["pb_client_ip"] = (
|
self.set_http_request(request.http_request)
|
||||||
get_client_ip(request.http_request) or "255.255.255.255"
|
self._context["request"] = request
|
||||||
)
|
|
||||||
if SESSION_KEY_PLAN in request.http_request.session:
|
|
||||||
kwargs["pb_flow_plan"] = request.http_request.session[SESSION_KEY_PLAN]
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
def evaluate(self, expression_source: str, request: PolicyRequest) -> PolicyResult:
|
def set_http_request(self, request: HttpRequest):
|
||||||
"""Parse and evaluate expression.
|
"""Update context based on http request"""
|
||||||
If the Expression evaluates to a list with 2 items, the first is used as passing bool and
|
# update passbook/policies/expression/templates/policy/expression/form.html
|
||||||
the second as messages.
|
# update docs/policies/expression/index.md
|
||||||
If the Expression evaluates to a truthy-object, it is used as passing bool."""
|
self._context["pb_client_ip"] = get_client_ip(request) or "255.255.255.255"
|
||||||
|
self._context["request"] = request
|
||||||
|
if SESSION_KEY_PLAN in request.session:
|
||||||
|
self._context["pb_flow_plan"] = request.session[SESSION_KEY_PLAN]
|
||||||
|
|
||||||
|
def evaluate(self, expression_source: str) -> PolicyResult:
|
||||||
|
"""Parse and evaluate expression. Policy is expected to return a truthy object.
|
||||||
|
Messages can be added using 'do pb_message()'."""
|
||||||
try:
|
try:
|
||||||
expression = self._env.from_string(expression_source)
|
result = super().evaluate(expression_source)
|
||||||
except TemplateSyntaxError as exc:
|
except (ValueError, SyntaxError) as exc:
|
||||||
return PolicyResult(False, str(exc))
|
return PolicyResult(False, str(exc))
|
||||||
try:
|
|
||||||
result: Optional[Any] = 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, tuple)) and len(result) == 2:
|
|
||||||
return PolicyResult(*result)
|
|
||||||
if result:
|
|
||||||
return PolicyResult(bool(result))
|
|
||||||
return PolicyResult(False)
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
LOGGER.warning("Expression error", exc=exc)
|
LOGGER.warning("Expression error", exc=exc)
|
||||||
return PolicyResult(False, str(exc))
|
return PolicyResult(False, str(exc))
|
||||||
|
else:
|
||||||
def validate(self, expression: str):
|
policy_result = PolicyResult(False)
|
||||||
"""Validate expression's syntax, raise ValidationError if Syntax is invalid"""
|
policy_result.messages = tuple(self._messages)
|
||||||
try:
|
if result is None:
|
||||||
self._env.from_string(expression)
|
LOGGER.warning(
|
||||||
return True
|
"Expression policy returned None",
|
||||||
except TemplateSyntaxError as exc:
|
src=expression_source,
|
||||||
raise ValidationError("Expression Syntax Error") from exc
|
req=self._context,
|
||||||
|
)
|
||||||
|
policy_result.passing = False
|
||||||
|
if result:
|
||||||
|
policy_result.passing = bool(result)
|
||||||
|
return policy_result
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from passbook.admin.fields import CodeMirrorWidget
|
from passbook.admin.fields import CodeMirrorWidget
|
||||||
from passbook.policies.expression.evaluator import Evaluator
|
from passbook.policies.expression.evaluator import PolicyEvaluator
|
||||||
from passbook.policies.expression.models import ExpressionPolicy
|
from passbook.policies.expression.models import ExpressionPolicy
|
||||||
from passbook.policies.forms import GENERAL_FIELDS
|
from passbook.policies.forms import GENERAL_FIELDS
|
||||||
|
|
||||||
|
@ -14,9 +14,9 @@ class ExpressionPolicyForm(forms.ModelForm):
|
||||||
template_name = "policy/expression/form.html"
|
template_name = "policy/expression/form.html"
|
||||||
|
|
||||||
def clean_expression(self):
|
def clean_expression(self):
|
||||||
"""Test Jinja2 Syntax"""
|
"""Test Syntax"""
|
||||||
expression = self.cleaned_data.get("expression")
|
expression = self.cleaned_data.get("expression")
|
||||||
Evaluator().validate(expression)
|
PolicyEvaluator(self.instance.name).validate(expression)
|
||||||
return expression
|
return expression
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -27,5 +27,5 @@ class ExpressionPolicyForm(forms.ModelForm):
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"name": forms.TextInput(),
|
"name": forms.TextInput(),
|
||||||
"expression": CodeMirrorWidget(mode="jinja2"),
|
"expression": CodeMirrorWidget(mode="python"),
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,13 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from passbook.policies.expression.evaluator import Evaluator
|
from passbook.policies.expression.evaluator import PolicyEvaluator
|
||||||
from passbook.policies.models import Policy
|
from passbook.policies.models import Policy
|
||||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
|
|
||||||
class ExpressionPolicy(Policy):
|
class ExpressionPolicy(Policy):
|
||||||
"""Jinja2-based Expression policy that allows Admins to write their own logic"""
|
"""Implement custom logic using python."""
|
||||||
|
|
||||||
expression = models.TextField()
|
expression = models.TextField()
|
||||||
|
|
||||||
|
@ -16,10 +16,12 @@ class ExpressionPolicy(Policy):
|
||||||
|
|
||||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
def passes(self, request: PolicyRequest) -> PolicyResult:
|
||||||
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
|
"""Evaluate and render expression. Returns PolicyResult(false) on error."""
|
||||||
return Evaluator().evaluate(self.expression, request)
|
evaluator = PolicyEvaluator(self.name)
|
||||||
|
evaluator.set_policy_request(request)
|
||||||
|
return evaluator.evaluate(self.expression)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
Evaluator().validate(self.expression)
|
PolicyEvaluator(self.name).validate(self.expression)
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
|
||||||
from passbook.policies.expression.evaluator import Evaluator
|
from passbook.policies.expression.evaluator import PolicyEvaluator
|
||||||
from passbook.policies.types import PolicyRequest
|
from passbook.policies.types import PolicyRequest
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,44 +15,48 @@ class TestEvaluator(TestCase):
|
||||||
|
|
||||||
def test_valid(self):
|
def test_valid(self):
|
||||||
"""test simple value expression"""
|
"""test simple value expression"""
|
||||||
template = "True"
|
template = "return True"
|
||||||
evaluator = Evaluator()
|
evaluator = PolicyEvaluator("test")
|
||||||
self.assertEqual(evaluator.evaluate(template, self.request).passing, True)
|
evaluator.set_policy_request(self.request)
|
||||||
|
self.assertEqual(evaluator.evaluate(template).passing, True)
|
||||||
|
|
||||||
def test_messages(self):
|
def test_messages(self):
|
||||||
"""test expression with message return"""
|
"""test expression with message return"""
|
||||||
template = "False, 'some message'"
|
template = 'pb_message("some message");return False'
|
||||||
evaluator = Evaluator()
|
evaluator = PolicyEvaluator("test")
|
||||||
result = evaluator.evaluate(template, self.request)
|
evaluator.set_policy_request(self.request)
|
||||||
|
result = evaluator.evaluate(template)
|
||||||
self.assertEqual(result.passing, False)
|
self.assertEqual(result.passing, False)
|
||||||
self.assertEqual(result.messages, ("some message",))
|
self.assertEqual(result.messages, ("some message",))
|
||||||
|
|
||||||
def test_invalid_syntax(self):
|
def test_invalid_syntax(self):
|
||||||
"""test invalid syntax"""
|
"""test invalid syntax"""
|
||||||
template = "{%"
|
template = ";"
|
||||||
evaluator = Evaluator()
|
evaluator = PolicyEvaluator("test")
|
||||||
result = evaluator.evaluate(template, self.request)
|
evaluator.set_policy_request(self.request)
|
||||||
|
result = evaluator.evaluate(template)
|
||||||
self.assertEqual(result.passing, False)
|
self.assertEqual(result.passing, False)
|
||||||
self.assertEqual(result.messages, ("tag name expected",))
|
self.assertEqual(result.messages, ("invalid syntax (test, line 2)",))
|
||||||
|
|
||||||
def test_undefined(self):
|
def test_undefined(self):
|
||||||
"""test undefined result"""
|
"""test undefined result"""
|
||||||
template = "{{ foo.bar }}"
|
template = "{{ foo.bar }}"
|
||||||
evaluator = Evaluator()
|
evaluator = PolicyEvaluator("test")
|
||||||
result = evaluator.evaluate(template, self.request)
|
evaluator.set_policy_request(self.request)
|
||||||
|
result = evaluator.evaluate(template)
|
||||||
self.assertEqual(result.passing, False)
|
self.assertEqual(result.passing, False)
|
||||||
self.assertEqual(result.messages, ("'foo' is undefined",))
|
self.assertEqual(result.messages, ("name 'foo' is not defined",))
|
||||||
|
|
||||||
def test_validate(self):
|
def test_validate(self):
|
||||||
"""test validate"""
|
"""test validate"""
|
||||||
template = "True"
|
template = "True"
|
||||||
evaluator = Evaluator()
|
evaluator = PolicyEvaluator("test")
|
||||||
result = evaluator.validate(template)
|
result = evaluator.validate(template)
|
||||||
self.assertEqual(result, True)
|
self.assertEqual(result, True)
|
||||||
|
|
||||||
def test_validate_invalid(self):
|
def test_validate_invalid(self):
|
||||||
"""test validate"""
|
"""test validate"""
|
||||||
template = "{%"
|
template = ";"
|
||||||
evaluator = Evaluator()
|
evaluator = PolicyEvaluator("test")
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
evaluator.validate(template)
|
evaluator.validate(template)
|
||||||
|
|
|
@ -6,7 +6,6 @@ from typing import Optional
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.models import User
|
|
||||||
from passbook.policies.exceptions import PolicyException
|
from passbook.policies.exceptions import PolicyException
|
||||||
from passbook.policies.models import PolicyBinding
|
from passbook.policies.models import PolicyBinding
|
||||||
from passbook.policies.types import PolicyRequest, PolicyResult
|
from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
|
@ -14,11 +13,13 @@ from passbook.policies.types import PolicyRequest, PolicyResult
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
def cache_key(binding: PolicyBinding, user: Optional[User] = None) -> str:
|
def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
|
||||||
"""Generate Cache key for policy"""
|
"""Generate Cache key for policy"""
|
||||||
prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}"
|
prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}"
|
||||||
if user:
|
if request.http_request:
|
||||||
prefix += f"#{user.pk}"
|
prefix += f"_{request.http_request.session.session_key}"
|
||||||
|
if request.user:
|
||||||
|
prefix += f"#{request.user.pk}"
|
||||||
return prefix
|
return prefix
|
||||||
|
|
||||||
|
|
||||||
|
@ -38,6 +39,8 @@ class PolicyProcess(Process):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.binding = binding
|
self.binding = binding
|
||||||
self.request = request
|
self.request = request
|
||||||
|
if not isinstance(self.request, PolicyRequest):
|
||||||
|
raise ValueError(f"{self.request} is not a Policy Request.")
|
||||||
if connection:
|
if connection:
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
|
|
||||||
|
@ -65,7 +68,7 @@ class PolicyProcess(Process):
|
||||||
passing=policy_result.passing,
|
passing=policy_result.passing,
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
)
|
)
|
||||||
key = cache_key(self.binding, self.request.user)
|
key = cache_key(self.binding, self.request)
|
||||||
cache.set(key, policy_result)
|
cache.set(key, policy_result)
|
||||||
LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
|
LOGGER.debug("P_ENG(proc): Cached policy evaluation", key=key)
|
||||||
return policy_result
|
return policy_result
|
||||||
|
|
26
passbook/policies/signals.py
Normal file
26
passbook/policies/signals.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
"""passbook policy signals"""
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def invalidate_policy_cache(sender, instance, **_):
|
||||||
|
"""Invalidate Policy cache when policy is updated"""
|
||||||
|
from passbook.policies.models import Policy, PolicyBinding
|
||||||
|
|
||||||
|
if isinstance(instance, Policy):
|
||||||
|
LOGGER.debug("Invalidating policy cache", policy=instance)
|
||||||
|
total = 0
|
||||||
|
for binding in PolicyBinding.objects.filter(policy=instance):
|
||||||
|
prefix = (
|
||||||
|
f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}*"
|
||||||
|
)
|
||||||
|
keys = cache.keys(prefix)
|
||||||
|
total += len(keys)
|
||||||
|
cache.delete_many(keys)
|
||||||
|
LOGGER.debug("Deleted keys", len=total)
|
|
@ -38,5 +38,10 @@ class PolicyResult:
|
||||||
self.passing = passing
|
self.passing = passing
|
||||||
self.messages = messages
|
self.messages = messages
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"<PolicyResult passing={self.passing}>"
|
if self.messages:
|
||||||
|
return f"PolicyResult passing={self.passing} messages={self.messages}"
|
||||||
|
return f"PolicyResult passing={self.passing}"
|
||||||
|
|
|
@ -32,7 +32,7 @@ class ApplicationGatewayProviderForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = ApplicationGatewayProvider
|
model = ApplicationGatewayProvider
|
||||||
fields = ["name", "internal_host", "external_host"]
|
fields = ["name", "authorization_flow", "internal_host", "external_host"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"name": forms.TextInput(),
|
"name": forms.TextInput(),
|
||||||
"internal_host": forms.TextInput(),
|
"internal_host": forms.TextInput(),
|
||||||
|
|
|
@ -14,7 +14,8 @@ from passbook.lib.utils.template import render_to_string
|
||||||
|
|
||||||
|
|
||||||
class ApplicationGatewayProvider(Provider):
|
class ApplicationGatewayProvider(Provider):
|
||||||
"""This provider uses oauth2_proxy with the OIDC Provider."""
|
"""Protect applications that don't support any of the other
|
||||||
|
Protocols by using a Reverse-Proxy."""
|
||||||
|
|
||||||
name = models.TextField()
|
name = models.TextField()
|
||||||
internal_host = models.TextField()
|
internal_host = models.TextField()
|
||||||
|
|
|
@ -1,21 +1,32 @@
|
||||||
"""passbook OAuth2 Provider Forms"""
|
"""passbook OAuth2 Provider Forms"""
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from passbook.flows.models import Flow, FlowDesignation
|
||||||
from passbook.providers.oauth.models import OAuth2Provider
|
from passbook.providers.oauth.models import OAuth2Provider
|
||||||
|
|
||||||
|
|
||||||
class OAuth2ProviderForm(forms.ModelForm):
|
class OAuth2ProviderForm(forms.ModelForm):
|
||||||
"""OAuth2 Provider form"""
|
"""OAuth2 Provider form"""
|
||||||
|
|
||||||
|
authorization_flow = forms.ModelChoiceField(
|
||||||
|
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = OAuth2Provider
|
model = OAuth2Provider
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
|
"authorization_flow",
|
||||||
"redirect_uris",
|
"redirect_uris",
|
||||||
"client_type",
|
"client_type",
|
||||||
"authorization_grant_type",
|
"authorization_grant_type",
|
||||||
"client_id",
|
"client_id",
|
||||||
"client_secret",
|
"client_secret",
|
||||||
]
|
]
|
||||||
|
labels = {
|
||||||
|
"client_id": _("Client ID"),
|
||||||
|
"redirect_uris": _("Redirect URIs"),
|
||||||
|
}
|
||||||
|
|
|
@ -12,7 +12,8 @@ from passbook.lib.utils.template import render_to_string
|
||||||
|
|
||||||
|
|
||||||
class OAuth2Provider(Provider, AbstractApplication):
|
class OAuth2Provider(Provider, AbstractApplication):
|
||||||
"""Associate an OAuth2 Application with a Product"""
|
"""Generic OAuth2 Provider for applications not using OpenID-Connect. This Provider
|
||||||
|
also supports the GitHub-pretend mode."""
|
||||||
|
|
||||||
form = "passbook.providers.oauth.forms.OAuth2ProviderForm"
|
form = "passbook.providers.oauth.forms.OAuth2ProviderForm"
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ OAUTH2_PROVIDER = {
|
||||||
"SCOPES": {
|
"SCOPES": {
|
||||||
"openid": "Access OpenID Userinfo",
|
"openid": "Access OpenID Userinfo",
|
||||||
"openid:userinfo": "Access OpenID Userinfo",
|
"openid:userinfo": "Access OpenID Userinfo",
|
||||||
|
"email": "Access OpenID E-Mail",
|
||||||
# 'write': 'Write scope',
|
# 'write': 'Write scope',
|
||||||
# 'groups': 'Access to your groups',
|
# 'groups': 'Access to your groups',
|
||||||
"user:email": "GitHub Compatibility: User E-Mail",
|
"user:email": "GitHub Compatibility: User E-Mail",
|
||||||
|
|
|
@ -6,17 +6,12 @@ from oauth2_provider import views
|
||||||
from passbook.providers.oauth.views import github, oauth2
|
from passbook.providers.oauth.views import github, oauth2
|
||||||
|
|
||||||
oauth_urlpatterns = [
|
oauth_urlpatterns = [
|
||||||
# Custom OAuth 2 Authorize View
|
# Custom OAuth2 Authorize View
|
||||||
path(
|
path(
|
||||||
"authorize/",
|
"authorize/",
|
||||||
oauth2.PassbookAuthorizationView.as_view(),
|
oauth2.AuthorizationFlowInitView.as_view(),
|
||||||
name="oauth2-authorize",
|
name="oauth2-authorize",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"authorize/permission_denied/",
|
|
||||||
oauth2.OAuthPermissionDenied.as_view(),
|
|
||||||
name="oauth2-permission-denied",
|
|
||||||
),
|
|
||||||
# OAuth API
|
# OAuth API
|
||||||
path("token/", views.TokenView.as_view(), name="token"),
|
path("token/", views.TokenView.as_view(), name="token"),
|
||||||
path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"),
|
path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"),
|
||||||
|
@ -26,7 +21,7 @@ oauth_urlpatterns = [
|
||||||
github_urlpatterns = [
|
github_urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"login/oauth/authorize",
|
"login/oauth/authorize",
|
||||||
oauth2.PassbookAuthorizationView.as_view(),
|
oauth2.AuthorizationFlowInitView.as_view(),
|
||||||
name="github-authorize",
|
name="github-authorize",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
|
@ -35,6 +30,7 @@ github_urlpatterns = [
|
||||||
name="github-access-token",
|
name="github-access-token",
|
||||||
),
|
),
|
||||||
path("user", github.GitHubUserView.as_view(), name="github-user"),
|
path("user", github.GitHubUserView.as_view(), name="github-user"),
|
||||||
|
path("user/teams", github.GitHubUserTeamsView.as_view(), name="github-user-teams"),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
|
@ -1,21 +1,32 @@
|
||||||
"""passbook pretend GitHub Views"""
|
"""passbook pretend GitHub Views"""
|
||||||
from django.http import JsonResponse
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from oauth2_provider.models import AccessToken
|
from oauth2_provider.models import AccessToken
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
|
||||||
class GitHubUserView(View):
|
|
||||||
|
class GitHubPretendView(View):
|
||||||
|
"""Emulate GitHub's API Endpoints"""
|
||||||
|
|
||||||
|
def verify_access_token(self) -> User:
|
||||||
|
"""Verify access token manually since github uses /user?access_token=..."""
|
||||||
|
if "HTTP_AUTHORIZATION" in self.request.META:
|
||||||
|
full_token = self.request.META.get("HTTP_AUTHORIZATION")
|
||||||
|
_, token = full_token.split(" ")
|
||||||
|
elif "access_token" in self.request.GET:
|
||||||
|
token = self.request.GET.get("access_token", "")
|
||||||
|
else:
|
||||||
|
raise PermissionDenied("No access token passed.")
|
||||||
|
return get_object_or_404(AccessToken, token=token).user
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubUserView(GitHubPretendView):
|
||||||
"""Emulate GitHub's /user API Endpoint"""
|
"""Emulate GitHub's /user API Endpoint"""
|
||||||
|
|
||||||
def verify_access_token(self):
|
def get(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""Verify access token manually since github uses /user?access_token=..."""
|
|
||||||
token = get_object_or_404(
|
|
||||||
AccessToken, token=self.request.GET.get("access_token", "")
|
|
||||||
)
|
|
||||||
return token.user
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
"""Emulate GitHub's /user API Endpoint"""
|
"""Emulate GitHub's /user API Endpoint"""
|
||||||
user = self.verify_access_token()
|
user = self.verify_access_token()
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
|
@ -65,3 +76,11 @@ class GitHubUserView(View):
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubUserTeamsView(GitHubPretendView):
|
||||||
|
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||||
|
|
||||||
|
def get(self, request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||||
|
return JsonResponse([], safe=False)
|
||||||
|
|
|
@ -1,76 +1,124 @@
|
||||||
"""passbook OAuth2 Views"""
|
"""passbook OAuth2 Views"""
|
||||||
from typing import Optional
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.forms import Form
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.shortcuts import get_object_or_404, redirect, reverse
|
from django.views import View
|
||||||
|
from oauth2_provider.exceptions import OAuthToolkitError
|
||||||
from oauth2_provider.views.base import AuthorizationView
|
from oauth2_provider.views.base import AuthorizationView
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
from passbook.audit.models import Event, EventAction
|
||||||
from passbook.core.models import Application
|
from passbook.core.models import Application
|
||||||
from passbook.core.views.access import AccessMixin
|
from passbook.core.views.access import AccessMixin
|
||||||
from passbook.core.views.utils import PermissionDeniedView
|
from passbook.flows.models import in_memory_stage
|
||||||
|
from passbook.flows.planner import (
|
||||||
|
PLAN_CONTEXT_APPLICATION,
|
||||||
|
PLAN_CONTEXT_SSO,
|
||||||
|
FlowPlanner,
|
||||||
|
)
|
||||||
|
from passbook.flows.stage import StageView
|
||||||
|
from passbook.flows.views import SESSION_KEY_PLAN
|
||||||
|
from passbook.lib.utils.urls import redirect_with_qs
|
||||||
from passbook.providers.oauth.models import OAuth2Provider
|
from passbook.providers.oauth.models import OAuth2Provider
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
PLAN_CONTEXT_CLIENT_ID = "client_id"
|
||||||
|
PLAN_CONTEXT_REDIRECT_URI = "redirect_uri"
|
||||||
|
PLAN_CONTEXT_RESPONSE_TYPE = "response_type"
|
||||||
|
PLAN_CONTEXT_STATE = "state"
|
||||||
|
|
||||||
class OAuthPermissionDenied(PermissionDeniedView):
|
PLAN_CONTEXT_CODE_CHALLENGE = "code_challenge"
|
||||||
"""Show permission denied view"""
|
PLAN_CONTEXT_CODE_CHALLENGE_METHOD = "code_challenge_method"
|
||||||
|
PLAN_CONTEXT_SCOPE = "scope"
|
||||||
|
PLAN_CONTEXT_NONCE = "nonce"
|
||||||
|
|
||||||
|
|
||||||
class PassbookAuthorizationView(AccessMixin, AuthorizationView):
|
class AuthorizationFlowInitView(AccessMixin, View):
|
||||||
"""Custom OAuth2 Authorization View which checks policies, etc"""
|
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||||
|
|
||||||
_application: Optional[Application] = None
|
# pylint: disable=unused-argument
|
||||||
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
def _inject_response_type(self):
|
"""Check access to application, start FlowPLanner, return to flow executor shell"""
|
||||||
"""Inject response_type into querystring if not set"""
|
|
||||||
LOGGER.debug("response_type not set, defaulting to 'code'")
|
|
||||||
querystring = urlencode(self.request.GET)
|
|
||||||
querystring += "&response_type=code"
|
|
||||||
return redirect(
|
|
||||||
reverse("passbook_providers_oauth:oauth2-ok-authorize") + "?" + querystring
|
|
||||||
)
|
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
|
||||||
"""Update OAuth2Provider's skip_authorization state"""
|
|
||||||
# Get client_id to get provider, so we can update skip_authorization field
|
|
||||||
client_id = request.GET.get("client_id")
|
client_id = request.GET.get("client_id")
|
||||||
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
|
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
|
||||||
try:
|
try:
|
||||||
application = self.provider_to_application(provider)
|
application = self.provider_to_application(provider)
|
||||||
except Application.DoesNotExist:
|
except Application.DoesNotExist:
|
||||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||||
# Update field here so oauth-toolkit does work for us
|
|
||||||
provider.skip_authorization = application.skip_authorization
|
|
||||||
provider.save()
|
|
||||||
self._application = application
|
|
||||||
# Check permissions
|
# Check permissions
|
||||||
result = self.user_has_access(self._application, request.user)
|
result = self.user_has_access(application, request.user)
|
||||||
if not result.passing:
|
if not result.passing:
|
||||||
for policy_message in result.messages:
|
for policy_message in result.messages:
|
||||||
messages.error(request, policy_message)
|
messages.error(request, policy_message)
|
||||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||||
# Some clients don't pass response_type, so we default to code
|
# Regardless, we start the planner and return to it
|
||||||
if "response_type" not in request.GET:
|
planner = FlowPlanner(provider.authorization_flow)
|
||||||
return self._inject_response_type()
|
# planner.use_cache = False
|
||||||
actual_response = AuthorizationView.dispatch(self, request, *args, **kwargs)
|
planner.allow_empty_flows = True
|
||||||
if actual_response.status_code == 400:
|
plan = planner.plan(
|
||||||
LOGGER.debug("Bad request", redirect_uri=request.GET.get("redirect_uri"))
|
self.request,
|
||||||
return actual_response
|
{
|
||||||
|
PLAN_CONTEXT_SSO: True,
|
||||||
def form_valid(self, form: Form):
|
PLAN_CONTEXT_APPLICATION: application,
|
||||||
# User has clicked on "Authorize"
|
PLAN_CONTEXT_CLIENT_ID: client_id,
|
||||||
Event.new(
|
PLAN_CONTEXT_REDIRECT_URI: request.GET.get(PLAN_CONTEXT_REDIRECT_URI),
|
||||||
EventAction.AUTHORIZE_APPLICATION, authorized_application=self._application,
|
PLAN_CONTEXT_RESPONSE_TYPE: request.GET.get(PLAN_CONTEXT_RESPONSE_TYPE),
|
||||||
).from_http(self.request)
|
PLAN_CONTEXT_STATE: request.GET.get(PLAN_CONTEXT_STATE),
|
||||||
LOGGER.debug(
|
PLAN_CONTEXT_SCOPE: request.GET.get(PLAN_CONTEXT_SCOPE),
|
||||||
"User authorized Application",
|
PLAN_CONTEXT_NONCE: request.GET.get(PLAN_CONTEXT_NONCE),
|
||||||
user=self.request.user,
|
},
|
||||||
application=self._application,
|
|
||||||
)
|
)
|
||||||
return AuthorizationView.form_valid(self, form)
|
plan.stages.append(in_memory_stage(OAuth2Stage))
|
||||||
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
|
return redirect_with_qs(
|
||||||
|
"passbook_flows:flow-executor-shell",
|
||||||
|
self.request.GET,
|
||||||
|
flow_slug=provider.authorization_flow.slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth2Stage(AuthorizationView, StageView):
|
||||||
|
"""OAuth2 Stage, dynamically injected into the plan"""
|
||||||
|
|
||||||
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
|
"""Last stage in flow, finalizes OAuth Response and redirects to Client"""
|
||||||
|
application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
|
||||||
|
provider: OAuth2Provider = application.provider
|
||||||
|
|
||||||
|
Event.new(
|
||||||
|
EventAction.AUTHORIZE_APPLICATION, authorized_application=application,
|
||||||
|
).from_http(self.request)
|
||||||
|
|
||||||
|
credentials = {
|
||||||
|
"client_id": self.executor.plan.context[PLAN_CONTEXT_CLIENT_ID],
|
||||||
|
"redirect_uri": self.executor.plan.context[PLAN_CONTEXT_REDIRECT_URI],
|
||||||
|
"response_type": self.executor.plan.context.get(
|
||||||
|
PLAN_CONTEXT_RESPONSE_TYPE, None
|
||||||
|
),
|
||||||
|
"state": self.executor.plan.context.get(PLAN_CONTEXT_STATE, None),
|
||||||
|
"nonce": self.executor.plan.context.get(PLAN_CONTEXT_NONCE, None),
|
||||||
|
}
|
||||||
|
if self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE, False):
|
||||||
|
credentials[PLAN_CONTEXT_CODE_CHALLENGE] = self.executor.plan.context.get(
|
||||||
|
PLAN_CONTEXT_CODE_CHALLENGE
|
||||||
|
)
|
||||||
|
if self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE_METHOD, False):
|
||||||
|
credentials[
|
||||||
|
PLAN_CONTEXT_CODE_CHALLENGE_METHOD
|
||||||
|
] = self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE_METHOD)
|
||||||
|
scopes = self.executor.plan.context.get(PLAN_CONTEXT_SCOPE)
|
||||||
|
|
||||||
|
try:
|
||||||
|
uri, _headers, _body, _status = self.create_authorization_response(
|
||||||
|
request=self.request,
|
||||||
|
scopes=scopes,
|
||||||
|
credentials=credentials,
|
||||||
|
allow=True,
|
||||||
|
)
|
||||||
|
LOGGER.debug("Success url for the request: {0}".format(uri))
|
||||||
|
except OAuthToolkitError as error:
|
||||||
|
return self.error_response(error, provider)
|
||||||
|
|
||||||
|
self.executor.stage_ok()
|
||||||
|
return HttpResponseRedirect(self.redirect(uri, provider).url)
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
"""passbook auth oidc provider app config"""
|
"""passbook auth oidc provider app config"""
|
||||||
from importlib import import_module
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from django.db.utils import InternalError, OperationalError, ProgrammingError
|
from django.db.utils import InternalError, OperationalError, ProgrammingError
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
@ -15,6 +13,7 @@ class PassbookProviderOIDCConfig(AppConfig):
|
||||||
name = "passbook.providers.oidc"
|
name = "passbook.providers.oidc"
|
||||||
label = "passbook_providers_oidc"
|
label = "passbook_providers_oidc"
|
||||||
verbose_name = "passbook Providers.OIDC"
|
verbose_name = "passbook Providers.OIDC"
|
||||||
|
mountpoint = "application/oidc/"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
try:
|
try:
|
||||||
|
@ -36,5 +35,3 @@ class PassbookProviderOIDCConfig(AppConfig):
|
||||||
include("oidc_provider.urls", namespace="oidc_provider"),
|
include("oidc_provider.urls", namespace="oidc_provider"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
import_module("passbook.providers.oidc.signals")
|
|
||||||
|
|
|
@ -10,6 +10,8 @@ from structlog import get_logger
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
from passbook.audit.models import Event, EventAction
|
||||||
from passbook.core.models import Application, Provider, User
|
from passbook.core.models import Application, Provider, User
|
||||||
|
from passbook.flows.planner import FlowPlan
|
||||||
|
from passbook.flows.views import SESSION_KEY_PLAN
|
||||||
from passbook.policies.engine import PolicyEngine
|
from passbook.policies.engine import PolicyEngine
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
@ -46,7 +48,7 @@ def check_permissions(
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Checking permissions for application", user=user, application=application
|
"Checking permissions for application", user=user, application=application
|
||||||
)
|
)
|
||||||
policy_engine = PolicyEngine(application.policies.all(), user, request)
|
policy_engine = PolicyEngine(application, user, request)
|
||||||
policy_engine.build()
|
policy_engine.build()
|
||||||
|
|
||||||
# Check permissions
|
# Check permissions
|
||||||
|
@ -56,9 +58,10 @@ def check_permissions(
|
||||||
messages.error(request, policy_message)
|
messages.error(request, policy_message)
|
||||||
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||||
|
|
||||||
|
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||||
Event.new(
|
Event.new(
|
||||||
EventAction.AUTHORIZE_APPLICATION,
|
EventAction.AUTHORIZE_APPLICATION,
|
||||||
authorized_application=application,
|
authorized_application=application,
|
||||||
skipped_authorization=False,
|
flow=plan.flow_pk,
|
||||||
).from_http(request)
|
).from_http(request)
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -4,12 +4,17 @@ from django import forms
|
||||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||||
from oidc_provider.models import Client
|
from oidc_provider.models import Client
|
||||||
|
|
||||||
|
from passbook.flows.models import Flow, FlowDesignation
|
||||||
from passbook.providers.oidc.models import OpenIDProvider
|
from passbook.providers.oidc.models import OpenIDProvider
|
||||||
|
|
||||||
|
|
||||||
class OIDCProviderForm(forms.ModelForm):
|
class OIDCProviderForm(forms.ModelForm):
|
||||||
"""OpenID Client form"""
|
"""OpenID Client form"""
|
||||||
|
|
||||||
|
authorization_flow = forms.ModelChoiceField(
|
||||||
|
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Correctly load data from 1:1 rel
|
# Correctly load data from 1:1 rel
|
||||||
if "instance" in kwargs and kwargs["instance"]:
|
if "instance" in kwargs and kwargs["instance"]:
|
||||||
|
@ -17,20 +22,35 @@ class OIDCProviderForm(forms.ModelForm):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["client_id"].initial = generate_client_id()
|
self.fields["client_id"].initial = generate_client_id()
|
||||||
self.fields["client_secret"].initial = generate_client_secret()
|
self.fields["client_secret"].initial = generate_client_secret()
|
||||||
|
try:
|
||||||
|
self.fields[
|
||||||
|
"authorization_flow"
|
||||||
|
].initial = self.instance.openidprovider.authorization_flow
|
||||||
|
# pylint: disable=no-member
|
||||||
|
except Client.openidprovider.RelatedObjectDoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
self.instance.reuse_consent = False # This is managed by passbook
|
self.instance.reuse_consent = False # This is managed by passbook
|
||||||
self.instance.require_consent = True # This is managed by passbook
|
self.instance.require_consent = False # This is managed by passbook
|
||||||
response = super().save(*args, **kwargs)
|
response = super().save(*args, **kwargs)
|
||||||
# Check if openidprovider class instance exists
|
# Check if openidprovider class instance exists
|
||||||
if not OpenIDProvider.objects.filter(oidc_client=self.instance).exists():
|
if not OpenIDProvider.objects.filter(oidc_client=self.instance).exists():
|
||||||
OpenIDProvider.objects.create(oidc_client=self.instance)
|
OpenIDProvider.objects.create(
|
||||||
|
oidc_client=self.instance,
|
||||||
|
authorization_flow=self.cleaned_data.get("authorization_flow"),
|
||||||
|
)
|
||||||
|
self.instance.openidprovider.authorization_flow = self.cleaned_data.get(
|
||||||
|
"authorization_flow"
|
||||||
|
)
|
||||||
|
self.instance.openidprovider.save()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Client
|
model = Client
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
|
"authorization_flow",
|
||||||
"client_type",
|
"client_type",
|
||||||
"client_id",
|
"client_id",
|
||||||
"client_secret",
|
"client_secret",
|
||||||
|
|
|
@ -12,7 +12,7 @@ from passbook.lib.utils.template import render_to_string
|
||||||
|
|
||||||
|
|
||||||
class OpenIDProvider(Provider):
|
class OpenIDProvider(Provider):
|
||||||
"""Proxy model for OIDC Client"""
|
"""OpenID Connect Provider for applications that support OIDC."""
|
||||||
|
|
||||||
# Since oidc_provider doesn't currently support swappable models
|
# Since oidc_provider doesn't currently support swappable models
|
||||||
# (https://github.com/juanifioren/django-oidc-provider/pull/305)
|
# (https://github.com/juanifioren/django-oidc-provider/pull/305)
|
||||||
|
@ -28,7 +28,7 @@ class OpenIDProvider(Provider):
|
||||||
return self.oidc_client.name
|
return self.oidc_client.name
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "OpenID Connect Provider %s" % self.oidc_client.__str__()
|
return f"OpenID Connect Provider {self.oidc_client.__str__()}"
|
||||||
|
|
||||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||||
|
@ -37,14 +37,14 @@ class OpenIDProvider(Provider):
|
||||||
{
|
{
|
||||||
"provider": self,
|
"provider": self,
|
||||||
"authorize": request.build_absolute_uri(
|
"authorize": request.build_absolute_uri(
|
||||||
reverse("oidc_provider:authorize")
|
reverse("passbook_providers_oidc:authorize")
|
||||||
),
|
),
|
||||||
"token": request.build_absolute_uri(reverse("oidc_provider:token")),
|
"token": request.build_absolute_uri(reverse("oidc_provider:token")),
|
||||||
"userinfo": request.build_absolute_uri(
|
"userinfo": request.build_absolute_uri(
|
||||||
reverse("oidc_provider:userinfo")
|
reverse("oidc_provider:userinfo")
|
||||||
),
|
),
|
||||||
"provider_info": request.build_absolute_uri(
|
"provider_info": request.build_absolute_uri(
|
||||||
reverse("oidc_provider:provider-info")
|
reverse("passbook_providers_oidc:provider-info")
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
"""OIDC Provider signals"""
|
|
||||||
from django.db.models.signals import post_save
|
|
||||||
from django.dispatch import receiver
|
|
||||||
|
|
||||||
from passbook.core.models import Application
|
|
||||||
from passbook.providers.oidc.models import OpenIDProvider
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Application)
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_application_save(sender, instance: Application, **_):
|
|
||||||
"""Synchronize application's skip_authorization with oidc_client's require_consent"""
|
|
||||||
if isinstance(instance.provider, OpenIDProvider):
|
|
||||||
instance.provider.oidc_client.require_consent = not instance.skip_authorization
|
|
||||||
instance.provider.oidc_client.save()
|
|
13
passbook/providers/oidc/urls.py
Normal file
13
passbook/providers/oidc/urls.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
"""oidc provider URLs"""
|
||||||
|
from django.conf.urls import url
|
||||||
|
|
||||||
|
from passbook.providers.oidc.views import AuthorizationFlowInitView, ProviderInfoView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
url(r"^authorize/?$", AuthorizationFlowInitView.as_view(), name="authorize"),
|
||||||
|
url(
|
||||||
|
r"^\.well-known/openid-configuration/?$",
|
||||||
|
ProviderInfoView.as_view(),
|
||||||
|
name="provider-info",
|
||||||
|
),
|
||||||
|
]
|
127
passbook/providers/oidc/views.py
Normal file
127
passbook/providers/oidc/views.py
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
"""passbook OIDC Views"""
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, reverse
|
||||||
|
from django.views import View
|
||||||
|
from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint
|
||||||
|
from oidc_provider.lib.utils.common import get_issuer, get_site_url
|
||||||
|
from oidc_provider.models import ResponseType
|
||||||
|
from oidc_provider.views import AuthorizeView
|
||||||
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from passbook.core.models import Application
|
||||||
|
from passbook.core.views.access import AccessMixin
|
||||||
|
from passbook.flows.models import in_memory_stage
|
||||||
|
from passbook.flows.planner import (
|
||||||
|
PLAN_CONTEXT_APPLICATION,
|
||||||
|
PLAN_CONTEXT_SSO,
|
||||||
|
FlowPlan,
|
||||||
|
FlowPlanner,
|
||||||
|
)
|
||||||
|
from passbook.flows.stage import StageView
|
||||||
|
from passbook.flows.views import SESSION_KEY_PLAN
|
||||||
|
from passbook.lib.utils.urls import redirect_with_qs
|
||||||
|
from passbook.providers.oidc.models import OpenIDProvider
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
PLAN_CONTEXT_PARAMS = "params"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationFlowInitView(AccessMixin, View):
|
||||||
|
"""OIDC Flow initializer, checks access to application and starts flow"""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
|
"""Check access to application, start FlowPLanner, return to flow executor shell"""
|
||||||
|
client_id = request.GET.get("client_id")
|
||||||
|
provider = get_object_or_404(OpenIDProvider, oidc_client__client_id=client_id)
|
||||||
|
try:
|
||||||
|
application = self.provider_to_application(provider)
|
||||||
|
except Application.DoesNotExist:
|
||||||
|
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||||
|
# Check permissions
|
||||||
|
result = self.user_has_access(application, request.user)
|
||||||
|
if not result.passing:
|
||||||
|
for policy_message in result.messages:
|
||||||
|
messages.error(request, policy_message)
|
||||||
|
return redirect("passbook_providers_oauth:oauth2-permission-denied")
|
||||||
|
# Extract params so we can save them in the plan context
|
||||||
|
endpoint = AuthorizeEndpoint(request)
|
||||||
|
# Regardless, we start the planner and return to it
|
||||||
|
planner = FlowPlanner(provider.authorization_flow)
|
||||||
|
# planner.use_cache = False
|
||||||
|
planner.allow_empty_flows = True
|
||||||
|
plan = planner.plan(
|
||||||
|
self.request,
|
||||||
|
{
|
||||||
|
PLAN_CONTEXT_SSO: True,
|
||||||
|
PLAN_CONTEXT_APPLICATION: application,
|
||||||
|
PLAN_CONTEXT_PARAMS: endpoint.params,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
plan.stages.append(in_memory_stage(OIDCStage))
|
||||||
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
|
return redirect_with_qs(
|
||||||
|
"passbook_flows:flow-executor-shell",
|
||||||
|
self.request.GET,
|
||||||
|
flow_slug=provider.authorization_flow.slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FlowAuthorizeEndpoint(AuthorizeEndpoint):
|
||||||
|
"""Restore params from flow context"""
|
||||||
|
|
||||||
|
def _extract_params(self):
|
||||||
|
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
||||||
|
self.params = plan.context[PLAN_CONTEXT_PARAMS]
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCStage(AuthorizeView, StageView):
|
||||||
|
"""Finall stage, restores params from Flow."""
|
||||||
|
|
||||||
|
authorize_endpoint_class = FlowAuthorizeEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderInfoView(View):
|
||||||
|
"""Custom ProviderInfo View which shows our URLs instead"""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Custom ProviderInfo View which shows our URLs instead"""
|
||||||
|
dic = dict()
|
||||||
|
|
||||||
|
site_url = get_site_url(request=request)
|
||||||
|
dic["issuer"] = get_issuer(site_url=site_url, request=request)
|
||||||
|
|
||||||
|
dic["authorization_endpoint"] = site_url + reverse(
|
||||||
|
"passbook_providers_oidc:authorize"
|
||||||
|
)
|
||||||
|
dic["token_endpoint"] = site_url + reverse("oidc_provider:token")
|
||||||
|
dic["userinfo_endpoint"] = site_url + reverse("oidc_provider:userinfo")
|
||||||
|
dic["end_session_endpoint"] = site_url + reverse("oidc_provider:end-session")
|
||||||
|
dic["introspection_endpoint"] = site_url + reverse(
|
||||||
|
"oidc_provider:token-introspection"
|
||||||
|
)
|
||||||
|
|
||||||
|
types_supported = [
|
||||||
|
response_type.value for response_type in ResponseType.objects.all()
|
||||||
|
]
|
||||||
|
dic["response_types_supported"] = types_supported
|
||||||
|
|
||||||
|
dic["jwks_uri"] = site_url + reverse("oidc_provider:jwks")
|
||||||
|
|
||||||
|
dic["id_token_signing_alg_values_supported"] = ["HS256", "RS256"]
|
||||||
|
|
||||||
|
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
||||||
|
dic["subject_types_supported"] = ["public"]
|
||||||
|
|
||||||
|
dic["token_endpoint_auth_methods_supported"] = [
|
||||||
|
"client_secret_post",
|
||||||
|
"client_secret_basic",
|
||||||
|
]
|
||||||
|
|
||||||
|
response = JsonResponse(dic)
|
||||||
|
response["Access-Control-Allow-Origin"] = "*"
|
||||||
|
|
||||||
|
return response
|
|
@ -4,6 +4,8 @@ from django import forms
|
||||||
from django.contrib.admin.widgets import FilteredSelectMultiple
|
from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from passbook.core.expression import PropertyMappingEvaluator
|
||||||
|
from passbook.flows.models import Flow, FlowDesignation
|
||||||
from passbook.providers.saml.models import (
|
from passbook.providers.saml.models import (
|
||||||
SAMLPropertyMapping,
|
SAMLPropertyMapping,
|
||||||
SAMLProvider,
|
SAMLProvider,
|
||||||
|
@ -14,6 +16,9 @@ from passbook.providers.saml.models import (
|
||||||
class SAMLProviderForm(forms.ModelForm):
|
class SAMLProviderForm(forms.ModelForm):
|
||||||
"""SAML Provider form"""
|
"""SAML Provider form"""
|
||||||
|
|
||||||
|
authorization_flow = forms.ModelChoiceField(
|
||||||
|
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
|
||||||
|
)
|
||||||
processor_path = forms.ChoiceField(
|
processor_path = forms.ChoiceField(
|
||||||
choices=get_provider_choices(), label="Processor"
|
choices=get_provider_choices(), label="Processor"
|
||||||
)
|
)
|
||||||
|
@ -23,10 +28,12 @@ class SAMLProviderForm(forms.ModelForm):
|
||||||
model = SAMLProvider
|
model = SAMLProvider
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
|
"authorization_flow",
|
||||||
"processor_path",
|
"processor_path",
|
||||||
"acs_url",
|
"acs_url",
|
||||||
"audience",
|
"audience",
|
||||||
"issuer",
|
"issuer",
|
||||||
|
"sp_binding",
|
||||||
"assertion_valid_not_before",
|
"assertion_valid_not_before",
|
||||||
"assertion_valid_not_on_or_after",
|
"assertion_valid_not_on_or_after",
|
||||||
"session_valid_not_on_or_after",
|
"session_valid_not_on_or_after",
|
||||||
|
@ -52,6 +59,13 @@ class SAMLPropertyMappingForm(forms.ModelForm):
|
||||||
|
|
||||||
template_name = "saml/idp/property_mapping_form.html"
|
template_name = "saml/idp/property_mapping_form.html"
|
||||||
|
|
||||||
|
def clean_expression(self):
|
||||||
|
"""Test Syntax"""
|
||||||
|
expression = self.cleaned_data.get("expression")
|
||||||
|
evaluator = PropertyMappingEvaluator()
|
||||||
|
evaluator.validate(expression)
|
||||||
|
return expression
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = SAMLPropertyMapping
|
model = SAMLPropertyMapping
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-23 19:32
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_property_mappings(apps, schema_editor):
|
||||||
|
"""Create default SAML Property Mappings"""
|
||||||
|
SAMLPropertyMapping = apps.get_model(
|
||||||
|
"passbook_providers_saml", "SAMLPropertyMapping"
|
||||||
|
)
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
defaults = [
|
||||||
|
{
|
||||||
|
"FriendlyName": "eduPersonPrincipalName",
|
||||||
|
"Name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
|
||||||
|
"Expression": "return user.email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"FriendlyName": "cn",
|
||||||
|
"Name": "urn:oid:2.5.4.3",
|
||||||
|
"Expression": "return user.name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"FriendlyName": "mail",
|
||||||
|
"Name": "urn:oid:0.9.2342.19200300.100.1.3",
|
||||||
|
"Expression": "return user.email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"FriendlyName": "displayName",
|
||||||
|
"Name": "urn:oid:2.16.840.1.113730.3.1.241",
|
||||||
|
"Expression": "return user.username",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"FriendlyName": "uid",
|
||||||
|
"Name": "urn:oid:0.9.2342.19200300.100.1.1",
|
||||||
|
"Expression": "return user.pk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"FriendlyName": "member-of",
|
||||||
|
"Name": "member-of",
|
||||||
|
"Expression": "for group in user.groups.all():\n yield group.name",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for default in defaults:
|
||||||
|
SAMLPropertyMapping.objects.using(db_alias).get_or_create(
|
||||||
|
saml_name=default["Name"],
|
||||||
|
friendly_name=default["FriendlyName"],
|
||||||
|
expression=default["Expression"],
|
||||||
|
defaults={
|
||||||
|
"name": f"Autogenerated SAML Mapping: {default['FriendlyName']} -> {default['Expression']}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_providers_saml", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_default_property_mappings),
|
||||||
|
]
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-06-06 13:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_providers_saml", "0002_default_saml_property_mappings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="samlprovider",
|
||||||
|
name="sp_binding",
|
||||||
|
field=models.TextField(
|
||||||
|
choices=[("redirect", "Redirect"), ("post", "Post")], default="redirect"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -17,6 +17,13 @@ from passbook.providers.saml.utils.time import timedelta_string_validator
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class SAMLBindings(models.TextChoices):
|
||||||
|
"""SAML Bindings supported by passbook"""
|
||||||
|
|
||||||
|
REDIRECT = "redirect"
|
||||||
|
POST = "post"
|
||||||
|
|
||||||
|
|
||||||
class SAMLProvider(Provider):
|
class SAMLProvider(Provider):
|
||||||
"""Model to save information about a Remote SAML Endpoint"""
|
"""Model to save information about a Remote SAML Endpoint"""
|
||||||
|
|
||||||
|
@ -26,6 +33,9 @@ class SAMLProvider(Provider):
|
||||||
acs_url = models.URLField(verbose_name=_("ACS URL"))
|
acs_url = models.URLField(verbose_name=_("ACS URL"))
|
||||||
audience = models.TextField(default="")
|
audience = models.TextField(default="")
|
||||||
issuer = models.TextField(help_text=_("Also known as EntityID"))
|
issuer = models.TextField(help_text=_("Also known as EntityID"))
|
||||||
|
sp_binding = models.TextField(
|
||||||
|
choices=SAMLBindings.choices, default=SAMLBindings.REDIRECT
|
||||||
|
)
|
||||||
|
|
||||||
assertion_valid_not_before = models.TextField(
|
assertion_valid_not_before = models.TextField(
|
||||||
default="minutes=-5",
|
default="minutes=-5",
|
||||||
|
@ -118,8 +128,8 @@ class SAMLProvider(Provider):
|
||||||
try:
|
try:
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
return reverse(
|
return reverse(
|
||||||
"passbook_providers_saml:saml-metadata",
|
"passbook_providers_saml:metadata",
|
||||||
kwargs={"application": self.application.slug},
|
kwargs={"application_slug": self.application.slug},
|
||||||
)
|
)
|
||||||
except Provider.application.RelatedObjectDoesNotExist:
|
except Provider.application.RelatedObjectDoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
"""Basic SAML Processor"""
|
"""Basic SAML Processor"""
|
||||||
|
from types import GeneratorType
|
||||||
from typing import TYPE_CHECKING, Dict, List, Union
|
from typing import TYPE_CHECKING, Dict, List, Union
|
||||||
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
@ -21,7 +22,7 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
# pylint: disable=too-many-instance-attributes
|
# pylint: disable=too-many-instance-attributes
|
||||||
class Processor:
|
class Processor:
|
||||||
"""Base SAML 2.0 AuthnRequest to Response Processor.
|
"""Base SAML 2.0 Auth-N-Request to Response Processor.
|
||||||
Sub-classes should provide Service Provider-specific functionality."""
|
Sub-classes should provide Service Provider-specific functionality."""
|
||||||
|
|
||||||
is_idp_initiated = False
|
is_idp_initiated = False
|
||||||
|
@ -111,6 +112,8 @@ class Processor:
|
||||||
request=self._http_request,
|
request=self._http_request,
|
||||||
provider=self._remote,
|
provider=self._remote,
|
||||||
)
|
)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
mapping_payload = {
|
mapping_payload = {
|
||||||
"Name": mapping.saml_name,
|
"Name": mapping.saml_name,
|
||||||
"FriendlyName": mapping.friendly_name,
|
"FriendlyName": mapping.friendly_name,
|
||||||
|
@ -119,6 +122,8 @@ class Processor:
|
||||||
# differently in the template
|
# differently in the template
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
mapping_payload["ValueArray"] = value
|
mapping_payload["ValueArray"] = value
|
||||||
|
elif isinstance(value, GeneratorType):
|
||||||
|
mapping_payload["ValueArray"] = list(value)
|
||||||
else:
|
else:
|
||||||
mapping_payload["Value"] = value
|
mapping_payload["Value"] = value
|
||||||
attributes.append(mapping_payload)
|
attributes.append(mapping_payload)
|
||||||
|
|
|
@ -4,30 +4,26 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block card_title %}
|
{% block card_title %}
|
||||||
{% trans 'Redirecting...' %}
|
{% blocktrans with app=application.name %}
|
||||||
|
Redirecting to {{ app }}...
|
||||||
|
{% endblocktrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block card %}
|
{% block card %}
|
||||||
<form method="POST" action="{{ url }}">
|
<form method="POST" action="{{ url }}" autosubmit>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% for key, value in attrs.items %}
|
{% for key, value in attrs.items %}
|
||||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="login-group">
|
<div class="pf-c-form__group">
|
||||||
<p>
|
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
|
||||||
{% blocktrans with user=user %}
|
<span class="pf-c-spinner__clipper"></span>
|
||||||
You are logged in as {{ user }}.
|
<span class="pf-c-spinner__lead-ball"></span>
|
||||||
{% endblocktrans %}
|
<span class="pf-c-spinner__tail-ball"></span>
|
||||||
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Not you?' %}</a>
|
</span>
|
||||||
</p>
|
</div>
|
||||||
<input class="btn btn-primary btn-block btn-lg" type="submit" value="{% trans 'Continue' %}" />
|
<div class="pf-c-form__group pf-m-action">
|
||||||
|
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans 'Continue' %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
{{ block.super }}
|
|
||||||
<script>
|
|
||||||
document.querySelector("form").submit();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
{% extends "login/base.html" %}
|
|
||||||
|
|
||||||
{% load passbook_utils %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block card %}
|
|
||||||
<form method="POST" class="pf-c-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="pf-c-form__group">
|
|
||||||
<h3>
|
|
||||||
{% blocktrans with provider=provider.application.name %}
|
|
||||||
You're about to sign into {{ provider }}
|
|
||||||
{% endblocktrans %}
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
{% blocktrans with user=user %}
|
|
||||||
You are logged in as {{ user }}.
|
|
||||||
{% endblocktrans %}
|
|
||||||
<a href="{% url 'passbook_flows:default-invalidation' %}">{% trans 'Not you?' %}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="pf-c-form__group pf-m-action">
|
|
||||||
<input class="pf-c-button pf-m-primary pf-m-block" type="submit" value="{% trans 'Continue' %}" />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
|
@ -11,8 +11,7 @@
|
||||||
</md:KeyDescriptor>
|
</md:KeyDescriptor>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<md:NameIDFormat>{{ subject_format }}</md:NameIDFormat>
|
<md:NameIDFormat>{{ subject_format }}</md:NameIDFormat>
|
||||||
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ slo_url }}"/>
|
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ sso_binding_post }}"/>
|
||||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ sso_post_url }}"/>
|
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_binding_redirect }}"/>
|
||||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ sso_redirect_url }}"/>
|
|
||||||
</md:IDPSSODescriptor>
|
</md:IDPSSODescriptor>
|
||||||
</md:EntityDescriptor>
|
</md:EntityDescriptor>
|
||||||
|
|
|
@ -4,31 +4,26 @@ from django.urls import path
|
||||||
from passbook.providers.saml import views
|
from passbook.providers.saml import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# This view is used to initiate a Login-flow from the IDP
|
# SSO Bindings
|
||||||
path(
|
path(
|
||||||
"<slug:application>/login/initiate/",
|
"<slug:application_slug>/sso/binding/redirect/",
|
||||||
views.InitiateLoginView.as_view(),
|
views.SAMLSSOBindingRedirectView.as_view(),
|
||||||
name="saml-login-initiate",
|
name="sso-redirect",
|
||||||
),
|
|
||||||
# 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.LoginBeginView.as_view(), name="saml-login"
|
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"<slug:application>/login/authorize/",
|
"<slug:application_slug>/sso/binding/post/",
|
||||||
views.AuthorizeView.as_view(),
|
views.SAMLSSOBindingPOSTView.as_view(),
|
||||||
name="saml-login-authorize",
|
name="sso-post",
|
||||||
),
|
),
|
||||||
path("<slug:application>/logout/", views.LogoutView.as_view(), name="saml-logout"),
|
# SSO IdP Initiated
|
||||||
path(
|
path(
|
||||||
"<slug:application>/logout/slo/",
|
"<slug:application_slug>/sso/binding/init/",
|
||||||
views.SLOLogout.as_view(),
|
views.SAMLSSOBindingInitView.as_view(),
|
||||||
name="saml-logout-slo",
|
name="sso-init",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"<slug:application>/metadata/",
|
"<slug:application_slug>/metadata/",
|
||||||
views.DescriptorDownloadView.as_view(),
|
views.DescriptorDownloadView.as_view(),
|
||||||
name="saml-metadata",
|
name="metadata",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
"""passbook SAML IDP Views"""
|
"""passbook SAML IDP Views"""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.contrib.auth import logout
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import AccessMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render, reverse
|
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.decorators import method_decorator
|
||||||
from django.utils.html import mark_safe
|
from django.utils.http import urlencode
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
@ -18,11 +17,20 @@ from structlog import get_logger
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
from passbook.audit.models import Event, EventAction
|
||||||
from passbook.core.models import Application, Provider
|
from passbook.core.models import Application, Provider
|
||||||
|
from passbook.flows.models import in_memory_stage
|
||||||
|
from passbook.flows.planner import (
|
||||||
|
PLAN_CONTEXT_APPLICATION,
|
||||||
|
PLAN_CONTEXT_SSO,
|
||||||
|
FlowPlanner,
|
||||||
|
)
|
||||||
|
from passbook.flows.stage import StageView
|
||||||
|
from passbook.flows.views import SESSION_KEY_PLAN
|
||||||
from passbook.lib.utils.template import render_to_string
|
from passbook.lib.utils.template import render_to_string
|
||||||
|
from passbook.lib.utils.urls import redirect_with_qs
|
||||||
from passbook.lib.views import bad_request_message
|
from passbook.lib.views import bad_request_message
|
||||||
from passbook.policies.engine import PolicyEngine
|
from passbook.policies.engine import PolicyEngine
|
||||||
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
from passbook.providers.saml.exceptions import CannotHandleAssertion
|
||||||
from passbook.providers.saml.models import SAMLProvider
|
from passbook.providers.saml.models import SAMLBindings, SAMLProvider
|
||||||
from passbook.providers.saml.processors.types import SAMLResponseParams
|
from passbook.providers.saml.processors.types import SAMLResponseParams
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
@ -33,69 +41,82 @@ SESSION_KEY_RELAY_STATE = "RelayState"
|
||||||
SESSION_KEY_PARAMS = "SAMLParams"
|
SESSION_KEY_PARAMS = "SAMLParams"
|
||||||
|
|
||||||
|
|
||||||
class AccessRequiredView(AccessMixin, View):
|
class SAMLAccessMixin:
|
||||||
"""Mixin class for Views using a provider instance"""
|
"""SAML base access mixin, checks access to an application based on its policies"""
|
||||||
|
|
||||||
_provider: Optional[SAMLProvider] = None
|
request: HttpRequest
|
||||||
|
application: Application
|
||||||
@property
|
provider: SAMLProvider
|
||||||
def provider(self) -> SAMLProvider:
|
|
||||||
"""Get provider instance"""
|
|
||||||
if not self._provider:
|
|
||||||
application = get_object_or_404(
|
|
||||||
Application, slug=self.kwargs["application"]
|
|
||||||
)
|
|
||||||
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:
|
def _has_access(self) -> bool:
|
||||||
"""Check if user has access to application"""
|
"""Check if user has access to application, add an error if not"""
|
||||||
policy_engine = PolicyEngine(
|
policy_engine = PolicyEngine(self.application, self.request.user, self.request)
|
||||||
self.provider.application.policies.all(), self.request.user, self.request
|
|
||||||
)
|
|
||||||
policy_engine.build()
|
policy_engine.build()
|
||||||
passing = policy_engine.passing
|
result = policy_engine.result
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"saml_has_access",
|
"SAMLFlowInit _has_access",
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
app=self.provider.application,
|
app=self.application,
|
||||||
passing=passing,
|
result=result,
|
||||||
)
|
)
|
||||||
return passing
|
if not result.passing:
|
||||||
|
for message in result.messages:
|
||||||
|
messages.error(self.request, _(message))
|
||||||
|
return result.passing
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
|
||||||
if not request.user.is_authenticated:
|
class SAMLSSOView(LoginRequiredMixin, SAMLAccessMixin, View):
|
||||||
return self.handle_no_permission()
|
""""SAML SSO Base View, which plans a flow and injects our final stage.
|
||||||
|
Calls get/post handler."""
|
||||||
|
|
||||||
|
def dispatch(
|
||||||
|
self, request: HttpRequest, *args, application_slug: str, **kwargs
|
||||||
|
) -> HttpResponse:
|
||||||
|
self.application = get_object_or_404(Application, slug=application_slug)
|
||||||
|
self.provider: SAMLProvider = get_object_or_404(
|
||||||
|
SAMLProvider, pk=self.application.provider_id
|
||||||
|
)
|
||||||
if not self._has_access():
|
if not self._has_access():
|
||||||
return render(
|
raise PermissionDenied()
|
||||||
request,
|
# Call the method handler, which checks the SAML Request
|
||||||
"login/denied.html",
|
method_response = super().dispatch(request, *args, application_slug, **kwargs)
|
||||||
{"title": _("You don't have access to this application")},
|
if method_response:
|
||||||
)
|
return method_response
|
||||||
return super().dispatch(request, *args, **kwargs)
|
# Regardless, we start the planner and return to it
|
||||||
|
planner = FlowPlanner(self.provider.authorization_flow)
|
||||||
|
planner.allow_empty_flows = True
|
||||||
|
plan = planner.plan(
|
||||||
|
self.request,
|
||||||
|
{PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_APPLICATION: self.application},
|
||||||
|
)
|
||||||
|
plan.stages.append(in_memory_stage(SAMLFlowFinalView))
|
||||||
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
|
return redirect_with_qs(
|
||||||
|
"passbook_flows:flow-executor-shell",
|
||||||
|
self.request.GET,
|
||||||
|
flow_slug=self.provider.authorization_flow.slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoginBeginView(AccessRequiredView):
|
class SAMLSSOBindingRedirectView(SAMLSSOView):
|
||||||
"""Receives a SAML 2.0 AuthnRequest from a Service Provider and
|
"""SAML Handler for SSO/Redirect bindings, which are sent via GET"""
|
||||||
stores it in the session prior to enforcing login."""
|
|
||||||
|
|
||||||
def handler(self, source, application: str) -> HttpResponse:
|
# pylint: disable=unused-argument
|
||||||
"""Handle SAML Request whether its a POST or a Redirect binding"""
|
def get(
|
||||||
|
self, request: HttpRequest, application_slug: str
|
||||||
|
) -> Optional[HttpResponse]:
|
||||||
|
"""Handle REDIRECT bindings"""
|
||||||
# Store these values now, because Django's login cycle won't preserve them.
|
# Store these values now, because Django's login cycle won't preserve them.
|
||||||
try:
|
if SESSION_KEY_SAML_REQUEST not in request.GET:
|
||||||
self.request.session[SESSION_KEY_SAML_REQUEST] = source[
|
LOGGER.info("handle_saml_request: SAML payload missing")
|
||||||
SESSION_KEY_SAML_REQUEST
|
|
||||||
]
|
|
||||||
except (KeyError, MultiValueDictKeyError):
|
|
||||||
return bad_request_message(
|
return bad_request_message(
|
||||||
self.request, "The SAML request payload is missing."
|
self.request, "The SAML request payload is missing."
|
||||||
)
|
)
|
||||||
|
|
||||||
self.request.session[SESSION_KEY_RELAY_STATE] = source.get(
|
self.request.session[SESSION_KEY_SAML_REQUEST] = request.GET[
|
||||||
|
SESSION_KEY_SAML_REQUEST
|
||||||
|
]
|
||||||
|
self.request.session[SESSION_KEY_RELAY_STATE] = request.GET.get(
|
||||||
SESSION_KEY_RELAY_STATE, ""
|
SESSION_KEY_RELAY_STATE, ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -105,182 +126,131 @@ class LoginBeginView(AccessRequiredView):
|
||||||
self.request.session[SESSION_KEY_PARAMS] = params
|
self.request.session[SESSION_KEY_PARAMS] = params
|
||||||
except CannotHandleAssertion as exc:
|
except CannotHandleAssertion as exc:
|
||||||
LOGGER.info(exc)
|
LOGGER.info(exc)
|
||||||
did_you_mean_link = self.request.build_absolute_uri(
|
return bad_request_message(self.request, str(exc))
|
||||||
reverse(
|
return None
|
||||||
"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(
|
|
||||||
self.request, mark_safe(str(exc) + did_you_mean_message)
|
|
||||||
)
|
|
||||||
|
|
||||||
return redirect(
|
|
||||||
reverse(
|
|
||||||
"passbook_providers_saml:saml-login-authorize",
|
|
||||||
kwargs={"application": application},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt)
|
|
||||||
def dispatch(self, *args, **kwargs):
|
|
||||||
return super().dispatch(*args, **kwargs)
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt)
|
|
||||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Handle REDIRECT bindings"""
|
|
||||||
return self.handler(request.GET, application)
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt)
|
|
||||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Handle POST Bindings"""
|
|
||||||
return self.handler(request.POST, application)
|
|
||||||
|
|
||||||
|
|
||||||
class InitiateLoginView(AccessRequiredView):
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
"""IdP-initiated Login"""
|
class SAMLSSOBindingPOSTView(SAMLSSOView):
|
||||||
|
"""SAML Handler for SSO/POST bindings"""
|
||||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Initiates an IdP-initiated link to a simple SP resource/target URL."""
|
|
||||||
self.provider.processor.is_idp_initiated = True
|
|
||||||
self.provider.processor.init_deep_link(request)
|
|
||||||
params = self.provider.processor.generate_response()
|
|
||||||
request.session[SESSION_KEY_PARAMS] = params
|
|
||||||
return redirect(
|
|
||||||
reverse(
|
|
||||||
"passbook_providers_saml:saml-login-authorize",
|
|
||||||
kwargs={"application": application},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorizeView(AccessRequiredView):
|
|
||||||
"""Ask the user for authorization to continue to the SP.
|
|
||||||
Presents a SAML 2.0 Assertion for POSTing back to the Service Provider."""
|
|
||||||
|
|
||||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Handle get request, i.e. render form"""
|
|
||||||
# User access gets checked in dispatch
|
|
||||||
|
|
||||||
# Otherwise we generate the IdP initiated session
|
|
||||||
try:
|
|
||||||
# application.skip_authorization is set so we directly redirect the user
|
|
||||||
if self.provider.application.skip_authorization:
|
|
||||||
LOGGER.debug("skipping authz", application=self.provider.application)
|
|
||||||
return self.post(request, application)
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"saml/idp/login.html",
|
|
||||||
{"provider": self.provider, "title": "Authorize Application"},
|
|
||||||
)
|
|
||||||
|
|
||||||
except KeyError:
|
|
||||||
return bad_request_message(request, "Missing SAML Payload")
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
def post(
|
||||||
"""Handle post request, return back to ACS"""
|
self, request: HttpRequest, application_slug: str
|
||||||
# User access gets checked in dispatch
|
) -> Optional[HttpResponse]:
|
||||||
|
"""Handle POST bindings"""
|
||||||
|
# Store these values now, because Django's login cycle won't preserve them.
|
||||||
|
if SESSION_KEY_SAML_REQUEST not in request.POST:
|
||||||
|
LOGGER.info("handle_saml_request: SAML payload missing")
|
||||||
|
return bad_request_message(
|
||||||
|
self.request, "The SAML request payload is missing."
|
||||||
|
)
|
||||||
|
|
||||||
# we get here when skip_authorization is True, and after the user accepted
|
self.request.session[SESSION_KEY_SAML_REQUEST] = request.POST[
|
||||||
# the authorization form
|
SESSION_KEY_SAML_REQUEST
|
||||||
|
]
|
||||||
|
self.request.session[SESSION_KEY_RELAY_STATE] = request.POST.get(
|
||||||
|
SESSION_KEY_RELAY_STATE, ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.provider.processor.can_handle(self.request)
|
||||||
|
params = self.provider.processor.generate_response()
|
||||||
|
self.request.session[SESSION_KEY_PARAMS] = params
|
||||||
|
except CannotHandleAssertion as exc:
|
||||||
|
LOGGER.info(exc)
|
||||||
|
return bad_request_message(self.request, str(exc))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SAMLSSOBindingInitView(SAMLSSOView):
|
||||||
|
"""SAML Handler for for IdP Initiated login flows"""
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def get(
|
||||||
|
self, request: HttpRequest, application_slug: str
|
||||||
|
) -> Optional[HttpResponse]:
|
||||||
|
"""Create saml params from scratch"""
|
||||||
|
LOGGER.debug(
|
||||||
|
"handle_saml_no_request: No SAML Request, using IdP-initiated flow."
|
||||||
|
)
|
||||||
|
self.provider.processor.is_idp_initiated = True
|
||||||
|
self.provider.processor.init_deep_link(self.request)
|
||||||
|
params = self.provider.processor.generate_response()
|
||||||
|
self.request.session[SESSION_KEY_PARAMS] = params
|
||||||
|
|
||||||
|
|
||||||
|
# This View doesn't have a URL on purpose, as its called by the FlowExecutor
|
||||||
|
class SAMLFlowFinalView(StageView):
|
||||||
|
"""View used by FlowExecutor after all stages have passed. Logs the authorization,
|
||||||
|
and redirects to the SP (if REDIRECT is configured) or shows and auto-submit for
|
||||||
|
(if POST is configured)."""
|
||||||
|
|
||||||
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
|
application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
|
||||||
|
provider: SAMLProvider = application.provider
|
||||||
# Log Application Authorization
|
# Log Application Authorization
|
||||||
Event.new(
|
Event.new(
|
||||||
EventAction.AUTHORIZE_APPLICATION,
|
EventAction.AUTHORIZE_APPLICATION,
|
||||||
authorized_application=self.provider.application,
|
authorized_application=application,
|
||||||
skipped_authorization=self.provider.application.skip_authorization,
|
flow=self.executor.plan.flow_pk,
|
||||||
).from_http(self.request)
|
).from_http(self.request)
|
||||||
self.request.session.pop(SESSION_KEY_SAML_REQUEST, None)
|
self.request.session.pop(SESSION_KEY_SAML_REQUEST, None)
|
||||||
self.request.session.pop(SESSION_KEY_SAML_RESPONSE, None)
|
self.request.session.pop(SESSION_KEY_SAML_RESPONSE, None)
|
||||||
self.request.session.pop(SESSION_KEY_RELAY_STATE, None)
|
self.request.session.pop(SESSION_KEY_RELAY_STATE, None)
|
||||||
|
if SESSION_KEY_PARAMS not in self.request.session:
|
||||||
|
return self.executor.stage_invalid()
|
||||||
response: SAMLResponseParams = self.request.session.pop(SESSION_KEY_PARAMS)
|
response: SAMLResponseParams = self.request.session.pop(SESSION_KEY_PARAMS)
|
||||||
return render(
|
|
||||||
self.request,
|
if provider.sp_binding == SAMLBindings.POST:
|
||||||
"saml/idp/autosubmit_form.html",
|
return render(
|
||||||
{
|
self.request,
|
||||||
"url": response.acs_url,
|
"saml/idp/autosubmit_form.html",
|
||||||
"attrs": {
|
{
|
||||||
"ACSUrl": response.acs_url,
|
"url": response.acs_url,
|
||||||
|
"application": application,
|
||||||
|
"attrs": {
|
||||||
|
"ACSUrl": response.acs_url,
|
||||||
|
SESSION_KEY_SAML_RESPONSE: response.saml_response,
|
||||||
|
SESSION_KEY_RELAY_STATE: response.relay_state,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if provider.sp_binding == SAMLBindings.REDIRECT:
|
||||||
|
querystring = urlencode(
|
||||||
|
{
|
||||||
SESSION_KEY_SAML_RESPONSE: response.saml_response,
|
SESSION_KEY_SAML_RESPONSE: response.saml_response,
|
||||||
SESSION_KEY_RELAY_STATE: response.relay_state,
|
SESSION_KEY_RELAY_STATE: response.relay_state,
|
||||||
},
|
}
|
||||||
},
|
)
|
||||||
)
|
return redirect(f"{response.acs_url}?{querystring}")
|
||||||
|
return bad_request_message(request, "Invalid sp_binding specified")
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
class DescriptorDownloadView(LoginRequiredMixin, SAMLAccessMixin, View):
|
||||||
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)."""
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Perform logout"""
|
|
||||||
logout(request)
|
|
||||||
|
|
||||||
redirect_url = request.GET.get("redirect_to", "")
|
|
||||||
|
|
||||||
try:
|
|
||||||
URL_VALIDATOR(redirect_url)
|
|
||||||
except ValidationError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
return redirect(redirect_url)
|
|
||||||
|
|
||||||
return render(request, "saml/idp/logged_out.html")
|
|
||||||
|
|
||||||
|
|
||||||
@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."""
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Perform logout"""
|
|
||||||
request.session[SESSION_KEY_SAML_REQUEST] = request.POST[
|
|
||||||
SESSION_KEY_SAML_REQUEST
|
|
||||||
]
|
|
||||||
# TODO: Parse SAML LogoutRequest from POST data, similar to login_process().
|
|
||||||
# TODO: Modify the base processor to handle logouts?
|
|
||||||
# TODO: Combine this with login_process(), since they are so very similar?
|
|
||||||
# TODO: Format a LogoutResponse and return it to the browser.
|
|
||||||
# XXX: For now, simply log out without validating the request.
|
|
||||||
logout(request)
|
|
||||||
return render(request, "saml/idp/logged_out.html")
|
|
||||||
|
|
||||||
|
|
||||||
class DescriptorDownloadView(AccessRequiredView):
|
|
||||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
|
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
|
||||||
"""Return rendered XML Metadata"""
|
"""Return rendered XML Metadata"""
|
||||||
entity_id = provider.issuer
|
entity_id = provider.issuer
|
||||||
slo_url = request.build_absolute_uri(
|
saml_sso_binding_post = request.build_absolute_uri(
|
||||||
reverse(
|
reverse(
|
||||||
"passbook_providers_saml:saml-logout",
|
"passbook_providers_saml:sso-post",
|
||||||
kwargs={"application": provider.application.slug},
|
kwargs={"application_slug": provider.application.slug},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sso_post_url = request.build_absolute_uri(
|
saml_sso_binding_redirect = request.build_absolute_uri(
|
||||||
reverse(
|
reverse(
|
||||||
"passbook_providers_saml:saml-login",
|
"passbook_providers_saml:sso-redirect",
|
||||||
kwargs={"application": provider.application.slug},
|
kwargs={"application_slug": provider.application.slug},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
subject_format = provider.processor.subject_format
|
subject_format = provider.processor.subject_format
|
||||||
ctx = {
|
ctx = {
|
||||||
|
"saml_sso_binding_post": saml_sso_binding_post,
|
||||||
|
"saml_sso_binding_redirect": saml_sso_binding_redirect,
|
||||||
"entity_id": entity_id,
|
"entity_id": entity_id,
|
||||||
"slo_url": slo_url,
|
|
||||||
# Currently, the same endpoint accepts POST and REDIRECT
|
|
||||||
"sso_post_url": sso_post_url,
|
|
||||||
"sso_redirect_url": sso_post_url,
|
|
||||||
"subject_format": subject_format,
|
"subject_format": subject_format,
|
||||||
}
|
}
|
||||||
if provider.signing_kp:
|
if provider.signing_kp:
|
||||||
|
@ -289,9 +259,14 @@ class DescriptorDownloadView(AccessRequiredView):
|
||||||
).replace("\n", "")
|
).replace("\n", "")
|
||||||
return 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_slug: str) -> HttpResponse:
|
||||||
def get(self, request: HttpRequest, application: str) -> HttpResponse:
|
|
||||||
"""Replies with the XML Metadata IDSSODescriptor."""
|
"""Replies with the XML Metadata IDSSODescriptor."""
|
||||||
|
self.application = get_object_or_404(Application, slug=application_slug)
|
||||||
|
self.provider: SAMLProvider = get_object_or_404(
|
||||||
|
SAMLProvider, pk=self.application.provider_id
|
||||||
|
)
|
||||||
|
if not self._has_access():
|
||||||
|
raise PermissionDenied()
|
||||||
try:
|
try:
|
||||||
metadata = DescriptorDownloadView.get_metadata(request, self.provider)
|
metadata = DescriptorDownloadView.get_metadata(request, self.provider)
|
||||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||||
|
|
|
@ -97,6 +97,7 @@ INSTALLED_APPS = [
|
||||||
"passbook.sources.oauth.apps.PassbookSourceOAuthConfig",
|
"passbook.sources.oauth.apps.PassbookSourceOAuthConfig",
|
||||||
"passbook.sources.saml.apps.PassbookSourceSAMLConfig",
|
"passbook.sources.saml.apps.PassbookSourceSAMLConfig",
|
||||||
"passbook.stages.captcha.apps.PassbookStageCaptchaConfig",
|
"passbook.stages.captcha.apps.PassbookStageCaptchaConfig",
|
||||||
|
"passbook.stages.consent.apps.PassbookStageConsentConfig",
|
||||||
"passbook.stages.dummy.apps.PassbookStageDummyConfig",
|
"passbook.stages.dummy.apps.PassbookStageDummyConfig",
|
||||||
"passbook.stages.email.apps.PassbookStageEmailConfig",
|
"passbook.stages.email.apps.PassbookStageEmailConfig",
|
||||||
"passbook.stages.prompt.apps.PassbookStagPromptConfig",
|
"passbook.stages.prompt.apps.PassbookStagPromptConfig",
|
||||||
|
|
|
@ -23,6 +23,7 @@ class LDAPSourceSerializer(ModelSerializer):
|
||||||
"group_object_filter",
|
"group_object_filter",
|
||||||
"user_group_membership_field",
|
"user_group_membership_field",
|
||||||
"object_uniqueness_field",
|
"object_uniqueness_field",
|
||||||
|
"sync_users",
|
||||||
"sync_groups",
|
"sync_groups",
|
||||||
"sync_parent_group",
|
"sync_parent_group",
|
||||||
"property_mappings",
|
"property_mappings",
|
||||||
|
|
|
@ -16,26 +16,10 @@ LOGGER = get_logger()
|
||||||
class Connector:
|
class Connector:
|
||||||
"""Wrapper for ldap3 to easily manage user authentication and creation"""
|
"""Wrapper for ldap3 to easily manage user authentication and creation"""
|
||||||
|
|
||||||
_server: ldap3.Server
|
|
||||||
_connection = ldap3.Connection
|
|
||||||
_source: LDAPSource
|
_source: LDAPSource
|
||||||
|
|
||||||
def __init__(self, source: LDAPSource):
|
def __init__(self, source: LDAPSource):
|
||||||
self._source = source
|
self._source = source
|
||||||
self._server = ldap3.Server(source.server_uri) # Implement URI parsing
|
|
||||||
|
|
||||||
def bind(self):
|
|
||||||
"""Bind using Source's Credentials"""
|
|
||||||
self._connection = ldap3.Connection(
|
|
||||||
self._server,
|
|
||||||
raise_exceptions=True,
|
|
||||||
user=self._source.bind_cn,
|
|
||||||
password=self._source.bind_password,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._connection.bind()
|
|
||||||
if self._source.start_tls:
|
|
||||||
self._connection.start_tls()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encode_pass(password: str) -> bytes:
|
def encode_pass(password: str) -> bytes:
|
||||||
|
@ -45,19 +29,23 @@ class Connector:
|
||||||
@property
|
@property
|
||||||
def base_dn_users(self) -> str:
|
def base_dn_users(self) -> str:
|
||||||
"""Shortcut to get full base_dn for user lookups"""
|
"""Shortcut to get full base_dn for user lookups"""
|
||||||
return ",".join([self._source.additional_user_dn, self._source.base_dn])
|
if self._source.additional_user_dn:
|
||||||
|
return f"{self._source.additional_user_dn},{self._source.base_dn}"
|
||||||
|
return self._source.base_dn
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def base_dn_groups(self) -> str:
|
def base_dn_groups(self) -> str:
|
||||||
"""Shortcut to get full base_dn for group lookups"""
|
"""Shortcut to get full base_dn for group lookups"""
|
||||||
return ",".join([self._source.additional_group_dn, self._source.base_dn])
|
if self._source.additional_group_dn:
|
||||||
|
return f"{self._source.additional_group_dn},{self._source.base_dn}"
|
||||||
|
return self._source.base_dn
|
||||||
|
|
||||||
def sync_groups(self):
|
def sync_groups(self):
|
||||||
"""Iterate over all LDAP Groups and create passbook_core.Group instances"""
|
"""Iterate over all LDAP Groups and create passbook_core.Group instances"""
|
||||||
if not self._source.sync_groups:
|
if not self._source.sync_groups:
|
||||||
LOGGER.debug("Group syncing is disabled for this Source")
|
LOGGER.warning("Group syncing is disabled for this Source")
|
||||||
return
|
return
|
||||||
groups = self._connection.extend.standard.paged_search(
|
groups = self._source.connection.extend.standard.paged_search(
|
||||||
search_base=self.base_dn_groups,
|
search_base=self.base_dn_groups,
|
||||||
search_filter=self._source.group_object_filter,
|
search_filter=self._source.group_object_filter,
|
||||||
search_scope=ldap3.SUBTREE,
|
search_scope=ldap3.SUBTREE,
|
||||||
|
@ -87,7 +75,10 @@ class Connector:
|
||||||
|
|
||||||
def sync_users(self):
|
def sync_users(self):
|
||||||
"""Iterate over all LDAP Users and create passbook_core.User instances"""
|
"""Iterate over all LDAP Users and create passbook_core.User instances"""
|
||||||
users = self._connection.extend.standard.paged_search(
|
if not self._source.sync_users:
|
||||||
|
LOGGER.warning("User syncing is disabled for this Source")
|
||||||
|
return
|
||||||
|
users = self._source.connection.extend.standard.paged_search(
|
||||||
search_base=self.base_dn_users,
|
search_base=self.base_dn_users,
|
||||||
search_filter=self._source.user_object_filter,
|
search_filter=self._source.user_object_filter,
|
||||||
search_scope=ldap3.SUBTREE,
|
search_scope=ldap3.SUBTREE,
|
||||||
|
@ -101,9 +92,9 @@ class Connector:
|
||||||
LOGGER.warning("Cannot find uniqueness Field in attributes")
|
LOGGER.warning("Cannot find uniqueness Field in attributes")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
defaults = self._build_object_properties(attributes)
|
||||||
user, created = User.objects.update_or_create(
|
user, created = User.objects.update_or_create(
|
||||||
attributes__ldap_uniq=uniq,
|
attributes__ldap_uniq=uniq, defaults=defaults,
|
||||||
defaults=self._build_object_properties(attributes),
|
|
||||||
)
|
)
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
LOGGER.warning("Failed to create user", exc=exc)
|
LOGGER.warning("Failed to create user", exc=exc)
|
||||||
|
@ -123,7 +114,7 @@ class Connector:
|
||||||
|
|
||||||
def sync_membership(self):
|
def sync_membership(self):
|
||||||
"""Iterate over all Users and assign Groups using memberOf Field"""
|
"""Iterate over all Users and assign Groups using memberOf Field"""
|
||||||
users = self._connection.extend.standard.paged_search(
|
users = self._source.connection.extend.standard.paged_search(
|
||||||
search_base=self.base_dn_users,
|
search_base=self.base_dn_users,
|
||||||
search_filter=self._source.user_object_filter,
|
search_filter=self._source.user_object_filter,
|
||||||
search_scope=ldap3.SUBTREE,
|
search_scope=ldap3.SUBTREE,
|
||||||
|
@ -173,9 +164,10 @@ class Connector:
|
||||||
continue
|
continue
|
||||||
mapping: LDAPPropertyMapping
|
mapping: LDAPPropertyMapping
|
||||||
try:
|
try:
|
||||||
properties[mapping.object_field] = mapping.evaluate(
|
value = mapping.evaluate(user=None, request=None, ldap=attributes)
|
||||||
user=None, request=None, ldap=attributes
|
if value is None:
|
||||||
)
|
continue
|
||||||
|
properties[mapping.object_field] = value
|
||||||
except PropertyMappingExpressionException as exc:
|
except PropertyMappingExpressionException as exc:
|
||||||
LOGGER.warning("Mapping failed to evaluate", exc=exc, mapping=mapping)
|
LOGGER.warning("Mapping failed to evaluate", exc=exc, mapping=mapping)
|
||||||
continue
|
continue
|
||||||
|
@ -220,7 +212,7 @@ class Connector:
|
||||||
LOGGER.debug("Attempting Binding as user", user=user)
|
LOGGER.debug("Attempting Binding as user", user=user)
|
||||||
try:
|
try:
|
||||||
temp_connection = ldap3.Connection(
|
temp_connection = ldap3.Connection(
|
||||||
self._server,
|
self._source.connection.server,
|
||||||
user=user.attributes.get("distinguishedName"),
|
user=user.attributes.get("distinguishedName"),
|
||||||
password=password,
|
password=password,
|
||||||
raise_exceptions=True,
|
raise_exceptions=True,
|
||||||
|
|
|
@ -5,6 +5,7 @@ from django.contrib.admin.widgets import FilteredSelectMultiple
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
|
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
|
||||||
|
from passbook.core.expression import PropertyMappingEvaluator
|
||||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,6 +27,7 @@ class LDAPSourceForm(forms.ModelForm):
|
||||||
"group_object_filter",
|
"group_object_filter",
|
||||||
"user_group_membership_field",
|
"user_group_membership_field",
|
||||||
"object_uniqueness_field",
|
"object_uniqueness_field",
|
||||||
|
"sync_users",
|
||||||
"sync_groups",
|
"sync_groups",
|
||||||
"sync_parent_group",
|
"sync_parent_group",
|
||||||
"property_mappings",
|
"property_mappings",
|
||||||
|
@ -51,6 +53,13 @@ class LDAPPropertyMappingForm(forms.ModelForm):
|
||||||
|
|
||||||
template_name = "ldap/property_mapping_form.html"
|
template_name = "ldap/property_mapping_form.html"
|
||||||
|
|
||||||
|
def clean_expression(self):
|
||||||
|
"""Test Syntax"""
|
||||||
|
expression = self.cleaned_data.get("expression")
|
||||||
|
evaluator = PropertyMappingEvaluator()
|
||||||
|
evaluator.validate(expression)
|
||||||
|
return expression
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = LDAPPropertyMapping
|
model = LDAPPropertyMapping
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-23 19:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_sources_ldap", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ldapsource",
|
||||||
|
name="sync_users",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-23 19:30
|
||||||
|
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_ad_property_mappings(apps: Apps, schema_editor):
|
||||||
|
LDAPPropertyMapping = apps.get_model("passbook_sources_ldap", "LDAPPropertyMapping")
|
||||||
|
mapping = {
|
||||||
|
"name": "return ldap.get('name')",
|
||||||
|
"first_name": "return ldap.get('givenName')",
|
||||||
|
"last_name": "return ldap.get('sn')",
|
||||||
|
"username": "return ldap.get('sAMAccountName')",
|
||||||
|
"email": "return ldap.get('mail')",
|
||||||
|
}
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
for object_field, expression in mapping.items():
|
||||||
|
LDAPPropertyMapping.objects.using(db_alias).get_or_create(
|
||||||
|
expression=expression,
|
||||||
|
object_field=object_field,
|
||||||
|
defaults={
|
||||||
|
"name": f"Autogenerated LDAP Mapping: {expression} -> {object_field}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_sources_ldap", "0002_ldapsource_sync_users"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_default_ad_property_mappings),
|
||||||
|
]
|
31
passbook/sources/ldap/migrations/0004_auto_20200524_1146.py
Normal file
31
passbook/sources/ldap/migrations/0004_auto_20200524_1146.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-24 11:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_sources_ldap", "0003_default_ldap_property_mappings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="ldapsource",
|
||||||
|
name="additional_group_dn",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Prepended to Base DN for Group-queries.",
|
||||||
|
verbose_name="Addition Group DN",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="ldapsource",
|
||||||
|
name="additional_user_dn",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Prepended to Base DN for User-queries.",
|
||||||
|
verbose_name="Addition User DN",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,8 +1,10 @@
|
||||||
"""passbook LDAP Models"""
|
"""passbook LDAP Models"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from ldap3 import Connection, Server
|
||||||
|
|
||||||
from passbook.core.models import Group, PropertyMapping, Source
|
from passbook.core.models import Group, PropertyMapping, Source
|
||||||
|
|
||||||
|
@ -22,10 +24,12 @@ class LDAPSource(Source):
|
||||||
additional_user_dn = models.TextField(
|
additional_user_dn = models.TextField(
|
||||||
help_text=_("Prepended to Base DN for User-queries."),
|
help_text=_("Prepended to Base DN for User-queries."),
|
||||||
verbose_name=_("Addition User DN"),
|
verbose_name=_("Addition User DN"),
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
additional_group_dn = models.TextField(
|
additional_group_dn = models.TextField(
|
||||||
help_text=_("Prepended to Base DN for Group-queries."),
|
help_text=_("Prepended to Base DN for Group-queries."),
|
||||||
verbose_name=_("Addition Group DN"),
|
verbose_name=_("Addition Group DN"),
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_object_filter = models.TextField(
|
user_object_filter = models.TextField(
|
||||||
|
@ -43,6 +47,7 @@ class LDAPSource(Source):
|
||||||
default="objectSid", help_text=_("Field which contains a unique Identifier.")
|
default="objectSid", help_text=_("Field which contains a unique Identifier.")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sync_users = models.BooleanField(default=True)
|
||||||
sync_groups = models.BooleanField(default=True)
|
sync_groups = models.BooleanField(default=True)
|
||||||
sync_parent_group = models.ForeignKey(
|
sync_parent_group = models.ForeignKey(
|
||||||
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
|
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
|
||||||
|
@ -50,6 +55,25 @@ class LDAPSource(Source):
|
||||||
|
|
||||||
form = "passbook.sources.ldap.forms.LDAPSourceForm"
|
form = "passbook.sources.ldap.forms.LDAPSourceForm"
|
||||||
|
|
||||||
|
_connection: Optional[Connection]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connection(self) -> Connection:
|
||||||
|
"""Get a fully connected and bound LDAP Connection"""
|
||||||
|
if not self._connection:
|
||||||
|
server = Server(self.server_uri)
|
||||||
|
self._connection = Connection(
|
||||||
|
server,
|
||||||
|
raise_exceptions=True,
|
||||||
|
user=self.bind_cn,
|
||||||
|
password=self.bind_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._connection.bind()
|
||||||
|
if self.start_tls:
|
||||||
|
self._connection.start_tls()
|
||||||
|
return self._connection
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
verbose_name = _("LDAP Source")
|
verbose_name = _("LDAP Source")
|
||||||
|
|
|
@ -9,7 +9,6 @@ def sync_groups(source_pk: int):
|
||||||
"""Sync LDAP Groups on background worker"""
|
"""Sync LDAP Groups on background worker"""
|
||||||
source = LDAPSource.objects.get(pk=source_pk)
|
source = LDAPSource.objects.get(pk=source_pk)
|
||||||
connector = Connector(source)
|
connector = Connector(source)
|
||||||
connector.bind()
|
|
||||||
connector.sync_groups()
|
connector.sync_groups()
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,7 +17,6 @@ def sync_users(source_pk: int):
|
||||||
"""Sync LDAP Users on background worker"""
|
"""Sync LDAP Users on background worker"""
|
||||||
source = LDAPSource.objects.get(pk=source_pk)
|
source = LDAPSource.objects.get(pk=source_pk)
|
||||||
connector = Connector(source)
|
connector = Connector(source)
|
||||||
connector.bind()
|
|
||||||
connector.sync_users()
|
connector.sync_users()
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,7 +25,6 @@ def sync():
|
||||||
"""Sync all sources"""
|
"""Sync all sources"""
|
||||||
for source in LDAPSource.objects.filter(enabled=True):
|
for source in LDAPSource.objects.filter(enabled=True):
|
||||||
connector = Connector(source)
|
connector = Connector(source)
|
||||||
connector.bind()
|
|
||||||
connector.sync_users()
|
connector.sync_users()
|
||||||
connector.sync_groups()
|
connector.sync_groups()
|
||||||
connector.sync_membership()
|
connector.sync_membership()
|
||||||
|
|
75
passbook/sources/ldap/tests.py
Normal file
75
passbook/sources/ldap/tests.py
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
"""LDAP Source tests"""
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
from passbook.sources.ldap.connector import Connector
|
||||||
|
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mock_connection() -> Connection:
|
||||||
|
"""Create mock connection"""
|
||||||
|
server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
|
||||||
|
_pass = "foo" # noqa # nosec
|
||||||
|
connection = Connection(
|
||||||
|
server,
|
||||||
|
user="cn=my_user,ou=test,o=lab",
|
||||||
|
password=_pass,
|
||||||
|
client_strategy=MOCK_SYNC,
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user0,ou=test,o=lab",
|
||||||
|
{
|
||||||
|
"userPassword": "test0000",
|
||||||
|
"sAMAccountName": "user0_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectSid": "unique-test0000",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user1,ou=test,o=lab",
|
||||||
|
{
|
||||||
|
"userPassword": "test1111",
|
||||||
|
"sAMAccountName": "user1_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectSid": "unique-test1111",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user2,ou=test,o=lab",
|
||||||
|
{
|
||||||
|
"userPassword": "test2222",
|
||||||
|
"sAMAccountName": "user2_sn",
|
||||||
|
"revision": 0,
|
||||||
|
"objectSid": "unique-test2222",
|
||||||
|
"objectCategory": "Person",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.bind()
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection())
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPSourceTests(TestCase):
|
||||||
|
"""LDAP Source tests"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.source = LDAPSource.objects.create(
|
||||||
|
name="ldap", slug="ldap", base_dn="o=lab"
|
||||||
|
)
|
||||||
|
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||||
|
self.source.save()
|
||||||
|
|
||||||
|
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||||
|
def test_sync_users(self):
|
||||||
|
"""Test user sync"""
|
||||||
|
connector = Connector(self.source)
|
||||||
|
connector.sync_users()
|
||||||
|
user = User.objects.filter(username="user2_sn")
|
||||||
|
self.assertTrue(user.exists())
|
|
@ -1,6 +1,6 @@
|
||||||
"""OAuth Clients"""
|
"""OAuth Clients"""
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Optional
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||||
from urllib.parse import parse_qs, urlencode
|
from urllib.parse import parse_qs, urlencode
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
@ -14,24 +14,29 @@ from structlog import get_logger
|
||||||
from passbook import __version__
|
from passbook import __version__
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from passbook.sources.oauth.models import OAuthSource
|
||||||
|
|
||||||
|
|
||||||
class BaseOAuthClient:
|
class BaseOAuthClient:
|
||||||
"""Base OAuth Client"""
|
"""Base OAuth Client"""
|
||||||
|
|
||||||
session: Session
|
session: Session
|
||||||
|
source: "OAuthSource"
|
||||||
|
|
||||||
def __init__(self, source, token=""): # nosec
|
def __init__(self, source: "OAuthSource", token=""): # nosec
|
||||||
self.source = source
|
self.source = source
|
||||||
self.token = token
|
self.token = token
|
||||||
self.session = Session()
|
self.session = Session()
|
||||||
self.session.headers.update({"User-Agent": "passbook %s" % __version__})
|
self.session.headers.update({"User-Agent": "passbook %s" % __version__})
|
||||||
|
|
||||||
def get_access_token(self, request, callback=None):
|
def get_access_token(
|
||||||
|
self, request: HttpRequest, callback=None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
"Fetch access token from callback request."
|
"Fetch access token from callback request."
|
||||||
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
raise NotImplementedError("Defined in a sub-class") # pragma: no cover
|
||||||
|
|
||||||
def get_profile_info(self, token: Dict[str, str]):
|
def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
||||||
"Fetch user profile information."
|
"Fetch user profile information."
|
||||||
try:
|
try:
|
||||||
headers = {
|
headers = {
|
||||||
|
@ -45,7 +50,7 @@ class BaseOAuthClient:
|
||||||
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
LOGGER.warning("Unable to fetch user profile", exc=exc)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
return response.json() or response.text
|
return response.json()
|
||||||
|
|
||||||
def get_redirect_args(self, request, callback) -> Dict[str, str]:
|
def get_redirect_args(self, request, callback) -> Dict[str, str]:
|
||||||
"Get request parameters for redirect url."
|
"Get request parameters for redirect url."
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
|
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
|
||||||
|
from passbook.flows.models import Flow, FlowDesignation
|
||||||
from passbook.sources.oauth.models import OAuthSource
|
from passbook.sources.oauth.models import OAuthSource
|
||||||
from passbook.sources.oauth.types.manager import MANAGER
|
from passbook.sources.oauth.types.manager import MANAGER
|
||||||
|
|
||||||
|
@ -10,6 +11,13 @@ from passbook.sources.oauth.types.manager import MANAGER
|
||||||
class OAuthSourceForm(forms.ModelForm):
|
class OAuthSourceForm(forms.ModelForm):
|
||||||
"""OAuthSource Form"""
|
"""OAuthSource Form"""
|
||||||
|
|
||||||
|
authentication_flow = forms.ModelChoiceField(
|
||||||
|
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHENTICATION)
|
||||||
|
)
|
||||||
|
enrollment_flow = forms.ModelChoiceField(
|
||||||
|
queryset=Flow.objects.filter(designation=FlowDesignation.ENROLLMENT)
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if hasattr(self.Meta, "overrides"):
|
if hasattr(self.Meta, "overrides"):
|
||||||
|
|
38
passbook/sources/oauth/tests.py
Normal file
38
passbook/sources/oauth/tests.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
"""OAuth Source tests"""
|
||||||
|
from django.shortcuts import reverse
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
|
||||||
|
from passbook.sources.oauth.models import OAuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthSourceTests(TestCase):
|
||||||
|
"""OAuth Source tests"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.source = OAuthSource.objects.create(
|
||||||
|
name="test",
|
||||||
|
slug="test",
|
||||||
|
provider_type="openid-connect",
|
||||||
|
authorization_url="",
|
||||||
|
profile_url="",
|
||||||
|
consumer_key="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_source_redirect(self):
|
||||||
|
"""test redirect view"""
|
||||||
|
self.client.get(
|
||||||
|
reverse(
|
||||||
|
"passbook_sources_oauth:oauth-client-login",
|
||||||
|
kwargs={"source_slug": self.source.slug},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_source_callback(self):
|
||||||
|
"""test callback view"""
|
||||||
|
self.client.get(
|
||||||
|
reverse(
|
||||||
|
"passbook_sources_oauth:oauth-client-callback",
|
||||||
|
kwargs={"source_slug": self.source.slug},
|
||||||
|
)
|
||||||
|
)
|
|
@ -1,6 +1,9 @@
|
||||||
"""AzureAD OAuth2 Views"""
|
"""AzureAD OAuth2 Views"""
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from passbook.core.models import User
|
||||||
|
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||||
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
|
||||||
from passbook.sources.oauth.utils import user_get_or_create
|
from passbook.sources.oauth.utils import user_get_or_create
|
||||||
from passbook.sources.oauth.views.core import OAuthCallback
|
from passbook.sources.oauth.views.core import OAuthCallback
|
||||||
|
@ -10,10 +13,15 @@ from passbook.sources.oauth.views.core import OAuthCallback
|
||||||
class AzureADOAuthCallback(OAuthCallback):
|
class AzureADOAuthCallback(OAuthCallback):
|
||||||
"""AzureAD OAuth2 Callback"""
|
"""AzureAD OAuth2 Callback"""
|
||||||
|
|
||||||
def get_user_id(self, source, info):
|
def get_user_id(self, source: OAuthSource, info: Dict[str, Any]) -> str:
|
||||||
return uuid.UUID(info.get("objectId")).int
|
return str(uuid.UUID(info.get("objectId")).int)
|
||||||
|
|
||||||
def get_or_create_user(self, source, access, info):
|
def get_or_create_user(
|
||||||
|
self,
|
||||||
|
source: OAuthSource,
|
||||||
|
access: UserOAuthSourceConnection,
|
||||||
|
info: Dict[str, Any],
|
||||||
|
) -> User:
|
||||||
user_data = {
|
user_data = {
|
||||||
"username": info.get("displayName"),
|
"username": info.get("displayName"),
|
||||||
"email": info.get("mail", None) or info.get("otherMails")[0],
|
"email": info.get("mail", None) or info.get("otherMails")[0],
|
||||||
|
|
|
@ -54,7 +54,9 @@ class SourceTypeManager:
|
||||||
return OAuthCallback
|
return OAuthCallback
|
||||||
if kind.value == RequestKind.redirect:
|
if kind.value == RequestKind.redirect:
|
||||||
return OAuthRedirect
|
return OAuthRedirect
|
||||||
raise KeyError
|
raise KeyError(
|
||||||
|
f"Provider Type {source.provider_type} (type {kind.value}) not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
MANAGER = SourceTypeManager()
|
MANAGER = SourceTypeManager()
|
||||||
|
|
|
@ -21,8 +21,8 @@ class OpenIDConnectOAuthRedirect(OAuthRedirect):
|
||||||
class OpenIDConnectOAuth2Callback(OAuthCallback):
|
class OpenIDConnectOAuth2Callback(OAuthCallback):
|
||||||
"""OpenIDConnect OAuth2 Callback"""
|
"""OpenIDConnect OAuth2 Callback"""
|
||||||
|
|
||||||
def get_user_id(self, source: OAuthSource, info: Dict[str, str]):
|
def get_user_id(self, source: OAuthSource, info: Dict[str, str]) -> str:
|
||||||
return info.get("sub")
|
return info.get("sub", "")
|
||||||
|
|
||||||
def get_or_create_user(self, source: OAuthSource, access, info: Dict[str, str]):
|
def get_or_create_user(self, source: OAuthSource, access, info: Dict[str, str]):
|
||||||
user_data = {
|
user_data = {
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
"""Core OAauth Views"""
|
"""Core OAauth Views"""
|
||||||
from typing import Callable, Optional
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.http import Http404
|
from django.http import Http404, HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
@ -13,7 +13,8 @@ from django.views.generic import RedirectView, View
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.audit.models import Event, EventAction
|
from passbook.audit.models import Event, EventAction
|
||||||
from passbook.flows.models import Flow, FlowDesignation
|
from passbook.core.models import User
|
||||||
|
from passbook.flows.models import Flow
|
||||||
from passbook.flows.planner import (
|
from passbook.flows.planner import (
|
||||||
PLAN_CONTEXT_PENDING_USER,
|
PLAN_CONTEXT_PENDING_USER,
|
||||||
PLAN_CONTEXT_SSO,
|
PLAN_CONTEXT_SSO,
|
||||||
|
@ -21,7 +22,7 @@ from passbook.flows.planner import (
|
||||||
)
|
)
|
||||||
from passbook.flows.views import SESSION_KEY_PLAN
|
from passbook.flows.views import SESSION_KEY_PLAN
|
||||||
from passbook.lib.utils.urls import redirect_with_qs
|
from passbook.lib.utils.urls import redirect_with_qs
|
||||||
from passbook.sources.oauth.clients import get_client
|
from passbook.sources.oauth.clients import BaseOAuthClient, get_client
|
||||||
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||||
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||||
|
|
||||||
|
@ -34,7 +35,7 @@ class OAuthClientMixin:
|
||||||
|
|
||||||
client_class: Optional[Callable] = None
|
client_class: Optional[Callable] = None
|
||||||
|
|
||||||
def get_client(self, source):
|
def get_client(self, source: OAuthSource) -> BaseOAuthClient:
|
||||||
"Get instance of the OAuth client for this source."
|
"Get instance of the OAuth client for this source."
|
||||||
if self.client_class is not None:
|
if self.client_class is not None:
|
||||||
# pylint: disable=not-callable
|
# pylint: disable=not-callable
|
||||||
|
@ -49,18 +50,18 @@ class OAuthRedirect(OAuthClientMixin, RedirectView):
|
||||||
params = None
|
params = None
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def get_additional_parameters(self, source):
|
def get_additional_parameters(self, source: OAuthSource) -> Dict[str, Any]:
|
||||||
"Return additional redirect parameters for this source."
|
"Return additional redirect parameters for this source."
|
||||||
return self.params or {}
|
return self.params or {}
|
||||||
|
|
||||||
def get_callback_url(self, source):
|
def get_callback_url(self, source: OAuthSource) -> str:
|
||||||
"Return the callback url for this source."
|
"Return the callback url for this source."
|
||||||
return reverse(
|
return reverse(
|
||||||
"passbook_sources_oauth:oauth-client-callback",
|
"passbook_sources_oauth:oauth-client-callback",
|
||||||
kwargs={"source_slug": source.slug},
|
kwargs={"source_slug": source.slug},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_redirect_url(self, **kwargs):
|
def get_redirect_url(self, **kwargs) -> str:
|
||||||
"Build redirect url for a given source."
|
"Build redirect url for a given source."
|
||||||
slug = kwargs.get("source_slug", "")
|
slug = kwargs.get("source_slug", "")
|
||||||
try:
|
try:
|
||||||
|
@ -84,7 +85,7 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
source_id = None
|
source_id = None
|
||||||
source = None
|
source = None
|
||||||
|
|
||||||
def get(self, request, *_, **kwargs):
|
def get(self, request: HttpRequest, *_, **kwargs) -> HttpResponse:
|
||||||
"""View Get handler"""
|
"""View Get handler"""
|
||||||
slug = kwargs.get("source_slug", "")
|
slug = kwargs.get("source_slug", "")
|
||||||
try:
|
try:
|
||||||
|
@ -143,38 +144,38 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
return self.handle_existing_user(self.source, user, connection, info)
|
return self.handle_existing_user(self.source, user, connection, info)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def get_callback_url(self, source):
|
def get_callback_url(self, source: OAuthSource) -> str:
|
||||||
"Return callback url if different than the current url."
|
"Return callback url if different than the current url."
|
||||||
return False
|
return ""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def get_error_redirect(self, source, reason):
|
def get_error_redirect(self, source: OAuthSource, reason: str) -> str:
|
||||||
"Return url to redirect on login failure."
|
"Return url to redirect on login failure."
|
||||||
return settings.LOGIN_URL
|
return settings.LOGIN_URL
|
||||||
|
|
||||||
def get_or_create_user(self, source, access, info):
|
def get_or_create_user(
|
||||||
|
self,
|
||||||
|
source: OAuthSource,
|
||||||
|
access: UserOAuthSourceConnection,
|
||||||
|
info: Dict[str, Any],
|
||||||
|
) -> User:
|
||||||
"Create a shell auth.User."
|
"Create a shell auth.User."
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def get_user_id(self, source, info):
|
def get_user_id(
|
||||||
"Return unique identifier from the profile info."
|
self, source: UserOAuthSourceConnection, info: Dict[str, Any]
|
||||||
id_key = self.source_id or "id"
|
) -> Optional[str]:
|
||||||
result = info
|
"""Return unique identifier from the profile info."""
|
||||||
try:
|
if "id" in info:
|
||||||
for key in id_key.split("."):
|
return info["id"]
|
||||||
result = result[key]
|
return None
|
||||||
return result
|
|
||||||
except KeyError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def handle_login(self, user, source, access):
|
def handle_login_flow(self, flow: Optional[Flow], user: User) -> HttpResponse:
|
||||||
"""Prepare Authentication Plan, redirect user FlowExecutor"""
|
"""Prepare Authentication Plan, redirect user FlowExecutor"""
|
||||||
user = authenticate(
|
if not flow:
|
||||||
source=access.source, identifier=access.identifier, request=self.request
|
raise Http404
|
||||||
)
|
|
||||||
# We run the Flow planner here so we can pass the Pending user in the context
|
# We run the Flow planner here so we can pass the Pending user in the context
|
||||||
flow = get_object_or_404(Flow, designation=FlowDesignation.AUTHENTICATION)
|
|
||||||
planner = FlowPlanner(flow)
|
planner = FlowPlanner(flow)
|
||||||
plan = planner.plan(
|
plan = planner.plan(
|
||||||
self.request,
|
self.request,
|
||||||
|
@ -186,11 +187,17 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
)
|
)
|
||||||
self.request.session[SESSION_KEY_PLAN] = plan
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
return redirect_with_qs(
|
return redirect_with_qs(
|
||||||
"passbook_flows:flow-executor", self.request.GET, flow_slug=flow.slug,
|
"passbook_flows:flow-executor-shell", self.request.GET, flow_slug=flow.slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def handle_existing_user(self, source, user, access, info):
|
def handle_existing_user(
|
||||||
|
self,
|
||||||
|
source: OAuthSource,
|
||||||
|
user: User,
|
||||||
|
access: UserOAuthSourceConnection,
|
||||||
|
info: Dict[str, Any],
|
||||||
|
) -> HttpResponse:
|
||||||
"Login user and redirect."
|
"Login user and redirect."
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
|
@ -199,15 +206,23 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
% {"source": self.source.name}
|
% {"source": self.source.name}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return self.handle_login(user, source, access)
|
user = authenticate(
|
||||||
|
source=access.source, identifier=access.identifier, request=self.request
|
||||||
|
)
|
||||||
|
return self.handle_login_flow(source.authentication_flow, user)
|
||||||
|
|
||||||
def handle_login_failure(self, source, reason):
|
def handle_login_failure(self, source: OAuthSource, reason: str) -> HttpResponse:
|
||||||
"Message user and redirect on error."
|
"Message user and redirect on error."
|
||||||
LOGGER.warning("Authentication Failure", reason=reason)
|
LOGGER.warning("Authentication Failure", reason=reason)
|
||||||
messages.error(self.request, _("Authentication Failed."))
|
messages.error(self.request, _("Authentication Failed."))
|
||||||
return redirect(self.get_error_redirect(source, reason))
|
return redirect(self.get_error_redirect(source, reason))
|
||||||
|
|
||||||
def handle_new_user(self, source, access, info):
|
def handle_new_user(
|
||||||
|
self,
|
||||||
|
source: OAuthSource,
|
||||||
|
access: UserOAuthSourceConnection,
|
||||||
|
info: Dict[str, Any],
|
||||||
|
) -> HttpResponse:
|
||||||
"Create a shell auth.User and redirect."
|
"Create a shell auth.User and redirect."
|
||||||
was_authenticated = False
|
was_authenticated = False
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
|
@ -244,7 +259,7 @@ class OAuthCallback(OAuthClientMixin, View):
|
||||||
% {"source": self.source.name}
|
% {"source": self.source.name}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return self.handle_login(user, source, access)
|
return self.handle_login_flow(source.enrollment_flow, user)
|
||||||
|
|
||||||
|
|
||||||
class DisconnectView(LoginRequiredMixin, View):
|
class DisconnectView(LoginRequiredMixin, View):
|
||||||
|
|
|
@ -28,3 +28,4 @@ class SAMLSourceForm(forms.ModelForm):
|
||||||
"idp_url": forms.TextInput(),
|
"idp_url": forms.TextInput(),
|
||||||
"idp_logout_url": forms.TextInput(),
|
"idp_logout_url": forms.TextInput(),
|
||||||
}
|
}
|
||||||
|
labels = {"signing_kp": _("Singing Keypair")}
|
||||||
|
|
30
passbook/sources/saml/migrations/0002_auto_20200523_2329.py
Normal file
30
passbook/sources/saml/migrations/0002_auto_20200523_2329.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
# Generated by Django 3.0.6 on 2020-05-23 23:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("passbook_sources_saml", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="samlsource",
|
||||||
|
name="binding_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("REDIRECT", "Redirect"), ("POST", "Post")],
|
||||||
|
default="REDIRECT",
|
||||||
|
max_length=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="samlsource",
|
||||||
|
name="idp_url",
|
||||||
|
field=models.URLField(
|
||||||
|
help_text="URL that the initial SAML Request is sent to. Also known as a Binding.",
|
||||||
|
verbose_name="IDP URL",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -8,6 +8,13 @@ from passbook.core.types import UILoginButton
|
||||||
from passbook.crypto.models import CertificateKeyPair
|
from passbook.crypto.models import CertificateKeyPair
|
||||||
|
|
||||||
|
|
||||||
|
class SAMLBindingTypes(models.TextChoices):
|
||||||
|
"""SAML Binding types"""
|
||||||
|
|
||||||
|
Redirect = "REDIRECT"
|
||||||
|
POST = "POST"
|
||||||
|
|
||||||
|
|
||||||
class SAMLSource(Source):
|
class SAMLSource(Source):
|
||||||
"""SAML Source"""
|
"""SAML Source"""
|
||||||
|
|
||||||
|
@ -18,7 +25,18 @@ class SAMLSource(Source):
|
||||||
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
|
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
|
||||||
)
|
)
|
||||||
|
|
||||||
idp_url = models.URLField(verbose_name=_("IDP URL"))
|
idp_url = models.URLField(
|
||||||
|
verbose_name=_("IDP URL"),
|
||||||
|
help_text=_(
|
||||||
|
"URL that the initial SAML Request is sent to. Also known as a Binding."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
binding_type = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
choices=SAMLBindingTypes.choices,
|
||||||
|
default=SAMLBindingTypes.Redirect,
|
||||||
|
)
|
||||||
|
|
||||||
idp_logout_url = models.URLField(
|
idp_logout_url = models.URLField(
|
||||||
default=None, blank=True, null=True, verbose_name=_("IDP Logout URL")
|
default=None, blank=True, null=True, verbose_name=_("IDP Logout URL")
|
||||||
)
|
)
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue