Compare commits
69 Commits
version/0.
...
version/0.
| Author | SHA1 | Date | |
|---|---|---|---|
| 4eaa46e717 | |||
| 59e8dca499 | |||
| 945d5bfaf6 | |||
| dbcdab05ff | |||
| e2cc2843d8 | |||
| 241d59be8d | |||
| 74251a8883 | |||
| 585afd1bcd | |||
| 8358574484 | |||
| cbcdaaf532 | |||
| f99eaa85ac | |||
| 5007a6befe | |||
| 50c75087b8 | |||
| 438e4efd49 | |||
| c7ca95ff2b | |||
| 9f403a71ed | |||
| 2f4139df65 | |||
| f3ee8f7d9c | |||
| 5fa3729702 | |||
| 87f44fada4 | |||
| c0026f3e16 | |||
| c1051059f4 | |||
| c25eda63ba | |||
| c90906c968 | |||
| f6b52b9281 | |||
| b04f92c8b4 | |||
| a02fcb0a7a | |||
| c1ea605c7e | |||
| 116be0b3c0 | |||
| 438250b3a9 | |||
| 5e6acee2a5 | |||
| 8b4222e7bb | |||
| 4af563ce89 | |||
| 77842fab58 | |||
| 5689f25c39 | |||
| a69c494feb | |||
| 83408b6ae0 | |||
| d30abc64d0 | |||
| 6674d3e017 | |||
| 4749c3fad0 | |||
| 18886697d6 | |||
| e75c9e9a79 | |||
| 5a3c1137ab | |||
| ddca46e24a | |||
| 22a9abf7bf | |||
| fb16502466 | |||
| 421bd13ddf | |||
| 404c9ef753 | |||
| a57b545093 | |||
| d8530f238d | |||
| fe4a0c3b44 | |||
| e0c104ee5c | |||
| 6ab8794754 | |||
| 316e6cb17f | |||
| 9d5d99290c | |||
| 20ffe833de | |||
| d4d026bf6a | |||
| dfe093b2b9 | |||
| 60739e620e | |||
| d6cc6770b8 | |||
| ddc1022461 | |||
| 2c2226610e | |||
| cba78b4de7 | |||
| 1eeb64ee39 | |||
| 22dea62084 | |||
| 5ff1dd8426 | |||
| da15a8878f | |||
| bf33828ac1 | |||
| 950a1fc77e |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.10.3-stable
|
||||
current_version = 0.10.6-stable
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||
@ -28,3 +28,5 @@ values =
|
||||
[bumpversion:file:.github/workflows/release.yml]
|
||||
|
||||
[bumpversion:file:passbook/__init__.py]
|
||||
|
||||
[bumpversion:file:proxy/pkg/version.go]
|
||||
|
||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@ -18,11 +18,11 @@ jobs:
|
||||
- name: Building Docker Image
|
||||
run: docker build
|
||||
--no-cache
|
||||
-t beryju/passbook:0.10.3-stable
|
||||
-t beryju/passbook:0.10.6-stable
|
||||
-t beryju/passbook:latest
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook:0.10.3-stable
|
||||
run: docker push beryju/passbook:0.10.6-stable
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook:latest
|
||||
build-proxy:
|
||||
@ -48,11 +48,11 @@ jobs:
|
||||
cd proxy
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/passbook-proxy:0.10.3-stable \
|
||||
-t beryju/passbook-proxy:0.10.6-stable \
|
||||
-t beryju/passbook-proxy:latest \
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-proxy:0.10.3-stable
|
||||
run: docker push beryju/passbook-proxy:0.10.6-stable
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-proxy:latest
|
||||
build-static:
|
||||
@ -77,11 +77,11 @@ jobs:
|
||||
run: docker build
|
||||
--no-cache
|
||||
--network=$(docker network ls | grep github | awk '{print $1}')
|
||||
-t beryju/passbook-static:0.10.3-stable
|
||||
-t beryju/passbook-static:0.10.6-stable
|
||||
-t beryju/passbook-static:latest
|
||||
-f static.Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-static:0.10.3-stable
|
||||
run: docker push beryju/passbook-static:0.10.6-stable
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-static:latest
|
||||
test-release:
|
||||
@ -114,5 +114,5 @@ jobs:
|
||||
SENTRY_PROJECT: passbook
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
tagName: 0.10.3-stable
|
||||
tagName: 0.10.6-stable
|
||||
environment: beryjuorg-prod
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
[MASTER]
|
||||
|
||||
disable=arguments-differ,no-self-use,fixme,locally-disabled,too-many-ancestors,too-few-public-methods,import-outside-toplevel,bad-continuation,signature-differs,similarities,cyclic-import
|
||||
|
||||
load-plugins=pylint_django,pylint.extensions.bad_builtin
|
||||
|
||||
extension-pkg-whitelist=lxml
|
||||
|
||||
# Allow constants to be shorter than normal (and lowercase, for settings.py)
|
||||
const-rgx=[a-zA-Z0-9_]{1,40}$
|
||||
|
||||
ignored-modules=django-otp
|
||||
jobs=12
|
||||
ignore=migrations
|
||||
max-attributes=12
|
||||
|
||||
jobs=12
|
||||
|
||||
30
Pipfile.lock
generated
30
Pipfile.lock
generated
@ -74,17 +74,18 @@
|
||||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:25c716b7c01d4664027afc6a6418a06459e311a610c7fd39a030a1ced1b72ce4"
|
||||
"sha256:44073b1b1823ffc9edcf9027afbca908dad6bd5000f512ca73f929f6a604ae24",
|
||||
"sha256:888be45e289ba56c4e47cfae5d6b08f097bc981d077fbe6521a6d3dc7a4d757e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.14.63"
|
||||
"version": "==1.15.1"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:40f13f6c9c29c307a9dc5982739e537ddce55b29787b90c3447b507e3283bcd6",
|
||||
"sha256:aa88eafc6295132f4bc606f1df32b3248e0fa611724c0a216aceda767948ac75"
|
||||
"sha256:6bdf60281c2e80360fe904851a1a07df3dcfe066fe88dc7fba2b5e626ac05c8c",
|
||||
"sha256:d6bdf51c8880aa9974e6b61d2f7d9d1debe407287e2e9e60f36c789fe8ba6790"
|
||||
],
|
||||
"version": "==1.17.63"
|
||||
"version": "==1.18.1"
|
||||
},
|
||||
"cachetools": {
|
||||
"hashes": [
|
||||
@ -355,14 +356,6 @@
|
||||
"index": "pypi",
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"docutils": {
|
||||
"hashes": [
|
||||
"sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0",
|
||||
"sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827",
|
||||
"sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"
|
||||
],
|
||||
"version": "==0.15.2"
|
||||
},
|
||||
"drf-yasg": {
|
||||
"hashes": [
|
||||
"sha256:5572e9d5baab9f6b49318169df9789f7399d0e3c7bdac8fdb8dfccf1d5d2b1ca",
|
||||
@ -756,20 +749,25 @@
|
||||
"sha256:54bdedd28476dea8a3cd86cb67c0df1f0e3d71cae8022354b0f879c41a3d27b2",
|
||||
"sha256:55eb61aca2c883db770999f50d091ff7c14016f2769ad7bca3d9b75d1d7c1b68",
|
||||
"sha256:6276478ada411aca97c0d5104916354b3d740d368407912722bd4d11aa9ee4c2",
|
||||
"sha256:663f8de2b3df2e744d6e1610506e0ea4e213bde906795953c1e82279c169f0a7",
|
||||
"sha256:67dcad1b8b201308586a8ca2ffe89df1e4f731d5a4cdd0610cc4ea790351c739",
|
||||
"sha256:709b9f144d23e290b9863121d1ace14a72e01f66ea9c903fbdc690520dfdfcf0",
|
||||
"sha256:8063a712fba642f78d3c506b0896846601b6de7f5c3d534e388ad0cc07f5a149",
|
||||
"sha256:80d57177a0b7c14d4594c62bbb47fe2f6309ad3b0a34348a291d570925c97a82",
|
||||
"sha256:87006cf0d81505408f1ae4f55cf8a5d95a8e029a4793360720ae17c6500f7ecc",
|
||||
"sha256:9f62d21bc693f3d7d444f17ed2ad7a913b4c37c15cd807895d013c39c0517dfd",
|
||||
"sha256:a207231a52426de3ff20f5608f0687261a3329d97a036c51f7d4c606a6f30c23",
|
||||
"sha256:abc2e126c9490e58a36a0f83516479e781d83adfb134576a5cbe5c6af2a3e93c",
|
||||
"sha256:b56638d58a3a4be13229c6a815cd448f9e3ce40c00880a5398471b42ee86f50e",
|
||||
"sha256:bcd5b8416e73e4b0d48afba3704d8c826414764dafaed7a1a93c442188d90ccc",
|
||||
"sha256:bec2bcdf7c9ce7f04d718e51887f3b05dc5c1cfaf5d2c2e9065ecddd1b2f6c9a",
|
||||
"sha256:c8bf40cf6e281a4378e25846924327e728a887e8bf0ee83b2604a0f4b61692e8",
|
||||
"sha256:cecbf67e81d6144a50dc615629772859463b2e4f815d0c082fa421db362f040e",
|
||||
"sha256:d8074c8448cfd0705dfa71ca333277fce9786d0b9cac75d120545de6253f996a",
|
||||
"sha256:dd302b6ae3965afeb5ef1b0d92486f986c0e65183cd7835973f0b593800590e6",
|
||||
"sha256:de6e1cd75677423ff64712c337521e62e3a7a4fc84caabbd93207752e831a85a",
|
||||
"sha256:ef39c98d9b8c0736d91937d193653e47c3b19ddf4fc3bccdc5e09aaa4b0c5d21",
|
||||
"sha256:f2e045224074d5664dc9cbabbf4f4d4d46f1ee90f24780e3a9a668fd096ff17f",
|
||||
"sha256:f521178e5a991ffd04182ed08f552daca1affcb826aeda0e1945cd989a9d4345",
|
||||
"sha256:f78a68c2c820e4731e510a2df3eef0322f24fde1781ced970bf497b6c7d92982",
|
||||
"sha256:fbe65d5cfe04ff2f7684160d50f5118bdefb01e3af4718eeb618bfed40f19d94"
|
||||
@ -1324,11 +1322,11 @@
|
||||
},
|
||||
"django-debug-toolbar": {
|
||||
"hashes": [
|
||||
"sha256:eabbefe89881bbe4ca7c980ff102e3c35c8e8ad6eb725041f538988f2f39a943",
|
||||
"sha256:ff94725e7aae74b133d0599b9bf89bd4eb8f5d2c964106e61d11750228c8774c"
|
||||
"sha256:23129b4f771605c7ccf8733cc53558a68c5d463d60cdc83408d34b713acf4f5f",
|
||||
"sha256:7c9bf93eabb1e745fe1fca830242d49f3c839d35163e5b53914009ed111209b1"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.2"
|
||||
"version": "==3.0"
|
||||
},
|
||||
"docker": {
|
||||
"hashes": [
|
||||
|
||||
@ -6,7 +6,8 @@ As passbook is currently in a pre-stable, only the latest "stable" version is su
|
||||
|
||||
| Version | Supported |
|
||||
| -------- | ------------------ |
|
||||
| 0.8.15 | :white_check_mark: |
|
||||
| 0.9.x | :white_check_mark: |
|
||||
| 0.10.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@ -8,6 +8,10 @@ variables:
|
||||
POSTGRES_DB: passbook
|
||||
POSTGRES_USER: passbook
|
||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||
${{ if startsWith(variables['Build.SourceBranch'], 'refs/heads/') }}:
|
||||
branchName: ${{ replace(variables['Build.SourceBranchName'], 'refs/heads/', '') }}
|
||||
${{ if startsWith(variables['Build.SourceBranch'], 'refs/pull/') }}:
|
||||
branchName: ${{ replace(variables['System.PullRequest.SourceBranch'], 'refs/heads/', '') }}
|
||||
|
||||
stages:
|
||||
- stage: Lint
|
||||
@ -117,6 +121,41 @@ stages:
|
||||
- task: CmdLine@2
|
||||
inputs:
|
||||
script: pipenv run ./manage.py migrate
|
||||
- job: migrations_from_previous_release
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
inputs:
|
||||
versionSpec: '3.8'
|
||||
- task: DockerCompose@0
|
||||
displayName: Run services
|
||||
inputs:
|
||||
dockerComposeFile: 'scripts/ci.docker-compose.yml'
|
||||
action: 'Run services'
|
||||
buildImages: false
|
||||
- task: CmdLine@2
|
||||
displayName: Prepare Last tagged release
|
||||
inputs:
|
||||
script: |
|
||||
git checkout $(git describe --abbrev=0 --match 'version/*')
|
||||
sudo pip install -U wheel pipenv
|
||||
pipenv install --dev
|
||||
- task: CmdLine@2
|
||||
displayName: Migrate to last tagged release
|
||||
inputs:
|
||||
script: pipenv run ./manage.py migrate
|
||||
- task: CmdLine@2
|
||||
displayName: Install current branch
|
||||
inputs:
|
||||
script: |
|
||||
set -x
|
||||
git checkout ${{ variables.branchName }}
|
||||
pipenv sync --dev
|
||||
- task: CmdLine@2
|
||||
displayName: Migrate to current branch
|
||||
inputs:
|
||||
script: pipenv run ./manage.py migrate
|
||||
- job: coverage_unittest
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
@ -265,7 +304,7 @@ stages:
|
||||
repository: 'beryju/passbook'
|
||||
command: 'buildAndPush'
|
||||
Dockerfile: 'Dockerfile'
|
||||
tags: 'gh-$(Build.SourceBranchName)'
|
||||
tags: "gh-${{ variables.branchName }}"
|
||||
- job: build_static
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
@ -282,14 +321,14 @@ stages:
|
||||
repository: 'beryju/passbook-static'
|
||||
command: 'build'
|
||||
Dockerfile: 'static.Dockerfile'
|
||||
tags: 'gh-$(Build.SourceBranchName)'
|
||||
tags: "gh-${{ variables.branchName }}"
|
||||
arguments: "--network=beryjupassbook_default"
|
||||
- task: Docker@2
|
||||
inputs:
|
||||
containerRegistry: 'dockerhub'
|
||||
repository: 'beryju/passbook-static'
|
||||
command: 'push'
|
||||
tags: 'gh-$(Build.SourceBranchName)'
|
||||
tags: "gh-${{ variables.branchName }}"
|
||||
- stage: Deploy
|
||||
jobs:
|
||||
- job: deploy_dev
|
||||
|
||||
@ -23,7 +23,7 @@ services:
|
||||
labels:
|
||||
- traefik.enable=false
|
||||
server:
|
||||
image: beryju/passbook:${PASSBOOK_TAG:-0.10.3-stable}
|
||||
image: beryju/passbook:${PASSBOOK_TAG:-0.10.6-stable}
|
||||
command: server
|
||||
environment:
|
||||
PASSBOOK_REDIS__HOST: redis
|
||||
@ -41,7 +41,7 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
worker:
|
||||
image: beryju/passbook:${PASSBOOK_TAG:-0.10.3-stable}
|
||||
image: beryju/passbook:${PASSBOOK_TAG:-0.10.6-stable}
|
||||
command: worker
|
||||
networks:
|
||||
- internal
|
||||
@ -55,7 +55,7 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
static:
|
||||
image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.3-stable}
|
||||
image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.6-stable}
|
||||
networks:
|
||||
- internal
|
||||
labels:
|
||||
|
||||
@ -84,15 +84,6 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identifiers": {
|
||||
"pk": "9922212c-47a2-475a-9905-abeb5e621652"
|
||||
},
|
||||
"model": "passbook_policies_expression.expressionpolicy",
|
||||
"attrs": {
|
||||
"name": "policy-enrollment-password-equals",
|
||||
"expression": "# Verifies that the passwords are equal\r\nreturn request.context['password'] == request.context['password_repeat']"
|
||||
}
|
||||
},{
|
||||
"identifiers": {
|
||||
"pk": "096e6282-6b30-4695-bd03-3b143eab5580",
|
||||
"name": "default-enrollment-email-verficiation"
|
||||
@ -135,9 +126,6 @@
|
||||
"cb954fd4-65a5-4ad9-b1ee-180ee9559cf4",
|
||||
"7db91ee8-4290-4e08-8d39-63f132402515",
|
||||
"d30b5eb4-7787-4072-b1ba-65b46e928920"
|
||||
],
|
||||
"validation_policies": [
|
||||
"9922212c-47a2-475a-9905-abeb5e621652"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@ -55,16 +55,6 @@
|
||||
"order": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"identifiers": {
|
||||
"pk": "cd042fc6-cc92-4b98-b7e6-f4729df798d8"
|
||||
},
|
||||
"model": "passbook_policies_expression.expressionpolicy",
|
||||
"attrs": {
|
||||
"name": "default-password-change-password-equal",
|
||||
"expression": "# Check that both passwords are equal.\nreturn request.context['password'] == request.context['password_repeat']"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identifiers": {
|
||||
"pk": "e54045a7-6ecb-4ad9-ad37-28e72d8e565e",
|
||||
@ -118,9 +108,6 @@
|
||||
"fields": [
|
||||
"7db91ee8-4290-4e08-8d39-63f132402515",
|
||||
"d30b5eb4-7787-4072-b1ba-65b46e928920"
|
||||
],
|
||||
"validation_policies": [
|
||||
"cd042fc6-cc92-4b98-b7e6-f4729df798d8"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@ -39,7 +39,6 @@ This designates a flow for unenrollment. This flow can contain any amount of ver
|
||||
This designates a flow for recovery. This flow normally contains an [**identification**](stages/identification/index.md) stage to find the user. It can also contain any amount of verification stages, such as [**email**](stages/email/index.md) or [**captcha**](stages/captcha/index.md).
|
||||
Afterwards, use the [**prompt**](stages/prompt/index.md) stage to ask the user for a new password and the [**user_write**](stages/user_write.md) stage to update the password.
|
||||
|
||||
### Change Password
|
||||
### Setup
|
||||
|
||||
This designates a flow for password changes. This flow can contain any amount of verification stages, such as [**email**](stages/email/index.md) or [**captcha**](stages/captcha/index.md).
|
||||
Afterwards, use the [**prompt**](stages/prompt/index.md) stage to ask the user for a new password and the [**user_write**](stages/user_write.md) stage to update the password.
|
||||
This designates a flow for general setup. This designation doesn't have any constraints in what you can do. For example, by default this designation is used to configure Factors, like change a password and setup TOTP.
|
||||
|
||||
@ -11,9 +11,9 @@ This installation method is for test-setups and small-scale productive setups.
|
||||
|
||||
Download the latest `docker-compose.yml` from [here](https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml). Place it in a directory of your choice.
|
||||
|
||||
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING=true >> .env`
|
||||
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING__ENABLED=true >> .env`
|
||||
|
||||
To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.3-stable >> .env`
|
||||
To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.6-stable >> .env`
|
||||
|
||||
If this is a fresh passbook install run the following commands to generate a password:
|
||||
|
||||
@ -39,4 +39,6 @@ Now you can pull the Docker images needed by running `docker-compose pull`. Afte
|
||||
|
||||
passbook will then be reachable via HTTP on port 80, and HTTPS on port 443. You can optionally configure the packaged traefik to use Let's Encrypt certificates for TLS Encryption.
|
||||
|
||||
If you plan to access passbook via a reverse proxy which does SSL Termination, make sure you use the HTTPS port, so passbook is aware of the SSL connection.
|
||||
|
||||
The initial setup process also creates a default admin user, the username and password for which is `pbadmin`. It is highly recommended to change this password as soon as you log in.
|
||||
|
||||
@ -11,7 +11,7 @@ This installation automatically applies database migrations on startup. After th
|
||||
image:
|
||||
name: beryju/passbook
|
||||
name_static: beryju/passbook-static
|
||||
tag: 0.10.3-stable
|
||||
tag: 0.10.6-stable
|
||||
|
||||
nameOverride: ""
|
||||
|
||||
|
||||
BIN
docs/integrations/services/sentry/auth.png
Normal file
BIN
docs/integrations/services/sentry/auth.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
@ -18,24 +18,28 @@ The following placeholders will be used:
|
||||
- `sentry.company` is the FQDN of the Sentry install.
|
||||
- `passbook.company` is the FQDN of the passbook install.
|
||||
|
||||
Create an application in passbook. Create an OpenID provider with the following parameters:
|
||||
Create an application in passbook. Create a SAML Provider with the following values
|
||||
|
||||
- Client Type: `Confidential`
|
||||
- Response types: `code (Authorization Code Flow)`
|
||||
- JWT Algorithm: `RS256`
|
||||
- Redirect URIs: `https://sentry.company/auth/sso/`
|
||||
- Scopes: `openid email`
|
||||
- ACS URL: `https://sentry.company/saml/acs/<sentry organisation name>/`
|
||||
- Audience: `https://sentry.company/saml/metadata/<sentry organisation name>/`
|
||||
- Issuer: `passbook`
|
||||
- Service Provider Binding: `Post`
|
||||
- Property Mapping: Select all Autogenerated Mappings
|
||||
|
||||
## Sentry
|
||||
|
||||
**This guide assumes you've installed Sentry using [getsentry/onpremise](https://github.com/getsentry/onpremise)**
|
||||
|
||||
- Add `sentry-auth-oidc` to `onpremise/sentry/requirements.txt` (Create the file if it doesn't exist yet)
|
||||
- Add the following block to your `onpremise/sentry/sentry.conf.py`:
|
||||
```
|
||||
OIDC_ISSUER = "passbook"
|
||||
OIDC_CLIENT_ID = "<Client ID from passbook>"
|
||||
OIDC_CLIENT_SECRET = "<Client Secret from passbook>"
|
||||
OIDC_SCOPE = "openid email"
|
||||
OIDC_DOMAIN = "https://passbook.company/application/oidc/"
|
||||
```
|
||||
Navigate to Settings -> Auth, and click on Configure next to SAML2
|
||||
|
||||

|
||||
|
||||
In passbook, get the Metadata URL by right-clicking `Download Metadata` and selecting Copy Link Address, and paste that URL into Sentry.
|
||||
|
||||
On the next screen, input these Values
|
||||
|
||||
IdP User ID: `urn:oid:0.9.2342.19200300.100.1.1`
|
||||
User Email: `urn:oid:0.9.2342.19200300.100.1.3`
|
||||
First Name: `urn:oid:2.5.4.3`
|
||||
|
||||
After confirming, Sentry will authenticate with passbook, and you should be redirected back to a page confirming your settings.
|
||||
|
||||
37
docs/integrations/services/sonarr/index.md
Normal file
37
docs/integrations/services/sonarr/index.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Sonarr Integration
|
||||
|
||||
!!! note
|
||||
These instructions apply to all projects in the *arr Family. If you use multiple of these projects, you can assign them to the same Outpost.
|
||||
|
||||
## What is Sonarr
|
||||
|
||||
From https://github.com/Sonarr/Sonarr
|
||||
|
||||
!!! note ""
|
||||
Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new episodes of your favorite shows and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available.
|
||||
|
||||
|
||||
## Preparation
|
||||
|
||||
The following placeholders will be used:
|
||||
|
||||
- `sonarr.company` is the FQDN of the Sonarr install.
|
||||
- `passbook.company` is the FQDN of the passbook install.
|
||||
|
||||
Create an application in passbook. Create a Proxy Provider with the following values
|
||||
|
||||
- Internal host
|
||||
|
||||
If Sonarr is running in docker, and you're deploying the passbook proxy on the same host, set the value to `http://sonarr:8989`, where sonarr is the name of your container.
|
||||
|
||||
If Sonarr is running on a different server than where you are deploying the passbook proxy, set the value to `http://sonarr.company:8989`.
|
||||
|
||||
- External host
|
||||
|
||||
Set this to the external URL you will be accessing Sonarr from.
|
||||
|
||||
## Deployment
|
||||
|
||||
Create an outpost deployment for the provider you've created above, as described [here](../../../outposts/outposts.md). Deploy this Outpost either on the same host or a different host that can access Sonarr.
|
||||
|
||||
The outpost will connect to passbook and configure itself.
|
||||
@ -23,6 +23,7 @@ Create an application in passbook and note the slug, as this will be used later.
|
||||
|
||||
- ACS URL: `https://awx.company/sso/complete/saml/`
|
||||
- Audience: `awx`
|
||||
- Service Provider Binding: Post
|
||||
- Issuer: `https://awx.company/sso/metadata/saml/`
|
||||
|
||||
You can of course use a custom signing certificate, and adjust durations.
|
||||
|
||||
58
docs/integrations/services/ubuntu-landscape/index.md
Normal file
58
docs/integrations/services/ubuntu-landscape/index.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Ubuntu Landscape Integration
|
||||
|
||||
## What is Ubuntu Landscape
|
||||
|
||||
From https://en.wikipedia.org/wiki/Landscape_(software)
|
||||
|
||||
!!! note ""
|
||||
|
||||
Landscape is a systems management tool developed by Canonical. It can be run on-premises or in the cloud depending on the needs of the user. It is primarily designed for use with Ubuntu derivatives such as Desktop, Server, and Core.
|
||||
|
||||
!!! warning
|
||||
|
||||
This requires passbook 0.10.3 or newer.
|
||||
|
||||
## Preparation
|
||||
|
||||
The following placeholders will be used:
|
||||
|
||||
- `landscape.company` is the FQDN of the Landscape server.
|
||||
- `passbook.company` is the FQDN of the passbook install.
|
||||
|
||||
Landscape uses the OpenID-Connect Protocol for single-sign on.
|
||||
|
||||
## passbook Setup
|
||||
|
||||
Create an OAuth2/OpenID-Connect Provider with the default settings. Set the Redirect URIs to `https://landscape.company/login/handle-openid`. Select all Autogenerated Scopes.
|
||||
|
||||
Keep Note of the Client ID and the Client Secret.
|
||||
|
||||
Create an application and assign access policies to the application. Set the application's provider to the provider you've just created.
|
||||
|
||||
## Landscape Setup
|
||||
|
||||
On the Landscape Server, edit the file `/etc/landscape/service.conf` and add the following snippet under the `[landscape]` section:
|
||||
|
||||
```
|
||||
oidc-issuer = https://passbook.company/application/o/<slug of the application you've created>/
|
||||
oidc-client-id = <client ID of the provider you've created>
|
||||
oidc-client-secret = <client Secret of the provider you've created>
|
||||
```
|
||||
|
||||
Afterwards, run `sudo lsctl restart` to restart the Landscape services.
|
||||
|
||||
## Appendix
|
||||
|
||||
To make an OpenID-Connect User admin, you have to insert some rows into the database.
|
||||
|
||||
First login with your passbook user, and make sure the user is created successfully.
|
||||
|
||||
Run `sudo -u postgres psql landscape-standalone-main` on the Landscape server to open a PostgreSQL Prompt.
|
||||
Then run `select * from person;` to get a list of all users. Take note of the ID given to your new user.
|
||||
|
||||
Run the following commands to make this user an administrator:
|
||||
|
||||
```sql
|
||||
INSERT INTO person_account VALUES (<user id>, 1);
|
||||
INSERT INTO person_access VALUES (<user id>, 1, 1);
|
||||
```
|
||||
@ -47,7 +47,7 @@ Under *Sources*, click *Edit* and ensure that "Autogenerated Active Directory Ma
|
||||
Under *Providers*, create an OAuth2/OpenID Provider with these settings:
|
||||
|
||||
- Client Type: Confidential
|
||||
- Response Type: code
|
||||
- Response Type: code (ADFS Compatibility Mode, sends id_token as access_token)
|
||||
- JWT Algorithm: RS256
|
||||
- Redirect URI: `https://vcenter.company/ui/login/oauth2/authcode`
|
||||
- Post Logout Redirect URIs: `https://vcenter.company/ui/login`
|
||||
|
||||
@ -15,6 +15,6 @@ services:
|
||||
- 4443:4443
|
||||
environment:
|
||||
PASSBOOK_HOST: https://your-passbook.tld
|
||||
PASSBOOK_INSECURE: 'true'
|
||||
PASSBOOK_INSECURE: 'false'
|
||||
PASSBOOK_TOKEN: token-generated-by-passbook
|
||||
```
|
||||
|
||||
9
docs/outposts/upgrading.md
Normal file
9
docs/outposts/upgrading.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Upgrading an Outpost
|
||||
|
||||
In the Outpost Overview list, you'll see if any deployed outposts are out of date.
|
||||
|
||||

|
||||
|
||||
To upgrade the Outpost to the latest version, simple adjust the docker tag of the outpost the the new version.
|
||||
|
||||
Since the configuration is managed by passbook, that's all you have to do.
|
||||
BIN
docs/outposts/upgrading_outdated.png
Normal file
BIN
docs/outposts/upgrading_outdated.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@ -10,7 +10,6 @@ from selenium.webdriver.support import expected_conditions as ec
|
||||
|
||||
from e2e.utils import USER, SeleniumTestCase
|
||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||
from passbook.policies.expression.models import ExpressionPolicy
|
||||
from passbook.stages.email.models import EmailStage, EmailTemplates
|
||||
from passbook.stages.identification.models import IdentificationStage
|
||||
from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
||||
@ -59,16 +58,9 @@ class TestFlowsEnroll(SeleniumTestCase):
|
||||
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
|
||||
)
|
||||
|
||||
# Password checking policy
|
||||
password_policy = ExpressionPolicy.objects.create(
|
||||
name="policy-enrollment-password-equals",
|
||||
expression="return request.context['password'] == request.context['password_repeat']",
|
||||
)
|
||||
|
||||
# Stages
|
||||
first_stage = PromptStage.objects.create(name="prompt-stage-first")
|
||||
first_stage.fields.set([username_prompt, password, password_repeat])
|
||||
first_stage.validation_policies.set([password_policy])
|
||||
first_stage.save()
|
||||
second_stage = PromptStage.objects.create(name="prompt-stage-second")
|
||||
second_stage.fields.set([name_field, email])
|
||||
@ -152,16 +144,9 @@ class TestFlowsEnroll(SeleniumTestCase):
|
||||
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
|
||||
)
|
||||
|
||||
# Password checking policy
|
||||
password_policy = ExpressionPolicy.objects.create(
|
||||
name="policy-enrollment-password-equals",
|
||||
expression="return request.context['password'] == request.context['password_repeat']",
|
||||
)
|
||||
|
||||
# Stages
|
||||
first_stage = PromptStage.objects.create(name="prompt-stage-first")
|
||||
first_stage.fields.set([username_prompt, password, password_repeat])
|
||||
first_stage.validation_policies.set([password_policy])
|
||||
first_stage.save()
|
||||
second_stage = PromptStage.objects.create(name="prompt-stage-second")
|
||||
second_stage.fields.set([name_field, email])
|
||||
|
||||
@ -33,6 +33,7 @@ from passbook.providers.oauth2.models import (
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
APPLICATION_SLUG = "grafana"
|
||||
|
||||
|
||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||
@ -69,6 +70,12 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
"GF_AUTH_GENERIC_OAUTH_API_URL": (
|
||||
self.url("passbook_providers_oauth2:userinfo")
|
||||
),
|
||||
"GF_AUTH_SIGNOUT_REDIRECT_URL": (
|
||||
self.url(
|
||||
"passbook_providers_oauth2:end-session",
|
||||
application_slug=APPLICATION_SLUG,
|
||||
)
|
||||
),
|
||||
"GF_LOG_LEVEL": "debug",
|
||||
},
|
||||
}
|
||||
@ -97,7 +104,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
)
|
||||
provider.save()
|
||||
Application.objects.create(
|
||||
name="Grafana", slug="grafana", provider=provider,
|
||||
name="Grafana", slug=APPLICATION_SLUG, provider=provider,
|
||||
)
|
||||
|
||||
self.driver.get("http://localhost:3000")
|
||||
@ -137,7 +144,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
)
|
||||
provider.save()
|
||||
Application.objects.create(
|
||||
name="Grafana", slug="grafana", provider=provider,
|
||||
name="Grafana", slug=APPLICATION_SLUG, provider=provider,
|
||||
)
|
||||
|
||||
self.driver.get("http://localhost:3000")
|
||||
@ -171,6 +178,72 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
USER().email,
|
||||
)
|
||||
|
||||
def test_authorization_logout(self):
|
||||
"""test OpenID Provider flow with logout"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authorization_flow = Flow.objects.get(
|
||||
slug="default-provider-authorization-implicit-consent"
|
||||
)
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="grafana",
|
||||
client_type=ClientTypes.CONFIDENTIAL,
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
rsa_key=CertificateKeyPair.objects.first(),
|
||||
redirect_uris="http://localhost:3000/login/generic_oauth",
|
||||
authorization_flow=authorization_flow,
|
||||
response_type=ResponseTypes.CODE,
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE]
|
||||
)
|
||||
)
|
||||
provider.save()
|
||||
Application.objects.create(
|
||||
name="Grafana", slug=APPLICATION_SLUG, provider=provider,
|
||||
)
|
||||
|
||||
self.driver.get("http://localhost:3000")
|
||||
self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click()
|
||||
self.driver.find_element(By.ID, "id_uid_field").click()
|
||||
self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username)
|
||||
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
|
||||
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
|
||||
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
||||
self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click()
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.CLASS_NAME, "page-header__title").text,
|
||||
USER().name,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute(
|
||||
"value"
|
||||
),
|
||||
USER().name,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.CSS_SELECTOR, "input[name=email]"
|
||||
).get_attribute("value"),
|
||||
USER().email,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(
|
||||
By.CSS_SELECTOR, "input[name=login]"
|
||||
).get_attribute("value"),
|
||||
USER().email,
|
||||
)
|
||||
self.driver.find_element(By.CSS_SELECTOR, "[href='/logout']").click()
|
||||
self.wait_for_url(
|
||||
self.url(
|
||||
"passbook_providers_oauth2:end-session",
|
||||
application_slug=APPLICATION_SLUG,
|
||||
)
|
||||
)
|
||||
self.driver.find_element(By.ID, "logout").click()
|
||||
|
||||
def test_authorization_consent_explicit(self):
|
||||
"""test OpenID Provider flow (default authorization flow with explicit consent)"""
|
||||
sleep(1)
|
||||
@ -195,7 +268,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
)
|
||||
provider.save()
|
||||
app = Application.objects.create(
|
||||
name="Grafana", slug="grafana", provider=provider,
|
||||
name="Grafana", slug=APPLICATION_SLUG, provider=provider,
|
||||
)
|
||||
|
||||
self.driver.get("http://localhost:3000")
|
||||
@ -271,7 +344,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
)
|
||||
provider.save()
|
||||
app = Application.objects.create(
|
||||
name="Grafana", slug="grafana", provider=provider,
|
||||
name="Grafana", slug=APPLICATION_SLUG, provider=provider,
|
||||
)
|
||||
|
||||
negative_policy = ExpressionPolicy.objects.create(
|
||||
|
||||
@ -77,7 +77,7 @@ class TestProviderProxy(SeleniumTestCase):
|
||||
# Wait until outpost healthcheck succeeds
|
||||
healthcheck_retries = 0
|
||||
while healthcheck_retries < 50:
|
||||
if outpost.health:
|
||||
if outpost.deployment_health:
|
||||
break
|
||||
healthcheck_retries += 1
|
||||
sleep(0.5)
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
apiVersion: v2
|
||||
appVersion: "0.10.3-stable"
|
||||
appVersion: "0.10.6-stable"
|
||||
description: A Helm chart for passbook.
|
||||
name: passbook
|
||||
version: "0.10.3-stable"
|
||||
version: "0.10.6-stable"
|
||||
icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
image:
|
||||
name: beryju/passbook
|
||||
name_static: beryju/passbook-static
|
||||
tag: 0.10.3-stable
|
||||
tag: 0.10.6-stable
|
||||
|
||||
nameOverride: ""
|
||||
|
||||
|
||||
@ -1,16 +1,28 @@
|
||||
#!/usr/bin/env python
|
||||
"""This file needs to be run from the root of the project to correctly
|
||||
import passbook. This is done by the dockerfile."""
|
||||
from json import dumps
|
||||
from sys import stderr
|
||||
from time import sleep
|
||||
|
||||
from psycopg2 import OperationalError, connect
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
def j_print(event: str, log_level: str = "info", **kwargs):
|
||||
"""Print event in the same format as structlog with JSON.
|
||||
Used before structlog is configured."""
|
||||
data = {
|
||||
"event": event,
|
||||
"level": log_level,
|
||||
"logger": __name__,
|
||||
}
|
||||
data.update(**kwargs)
|
||||
print(dumps(data), file=stderr)
|
||||
|
||||
|
||||
while True:
|
||||
try:
|
||||
@ -24,7 +36,7 @@ while True:
|
||||
break
|
||||
except OperationalError:
|
||||
sleep(1)
|
||||
LOGGER.warning("PostgreSQL Connection failed, retrying...")
|
||||
j_print("PostgreSQL Connection failed, retrying...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
@ -38,4 +50,4 @@ while True:
|
||||
break
|
||||
except RedisError:
|
||||
sleep(1)
|
||||
LOGGER.warning("Redis Connection failed, retrying...")
|
||||
j_print("Redis Connection failed, retrying...")
|
||||
|
||||
@ -32,6 +32,7 @@ nav:
|
||||
- Proxy: providers/proxy.md
|
||||
- Outposts:
|
||||
- Overview: outposts/outposts.md
|
||||
- Upgrading: outposts/upgrading.md
|
||||
- Deploy on docker-compose: outposts/deploy-docker-compose.md
|
||||
- Deploy on Kubernetes: outposts/deploy-kubernetes.md
|
||||
- Expressions:
|
||||
@ -53,6 +54,8 @@ nav:
|
||||
- Sentry: integrations/services/sentry/index.md
|
||||
- Ansible Tower/AWX: integrations/services/tower-awx/index.md
|
||||
- VMware vCenter: integrations/services/vmware-vcenter/index.md
|
||||
- Ubuntu Landscape: integrations/services/ubuntu-landscape/index.md
|
||||
- Sonarr: integrations/services/sonarr/index.md
|
||||
- Upgrading:
|
||||
- to 0.9: upgrading/to-0.9.md
|
||||
- to 0.10: upgrading/to-0.10.md
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
"""passbook"""
|
||||
__version__ = "0.10.3-stable"
|
||||
__version__ = "0.10.6-stable"
|
||||
|
||||
@ -53,6 +53,8 @@ class YAMLField(forms.JSONField):
|
||||
)
|
||||
if isinstance(converted, str):
|
||||
return YAMLString(converted)
|
||||
if converted is None:
|
||||
return {}
|
||||
return converted
|
||||
|
||||
def bound_data(self, data, initial):
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
"""passbook admin Middleware to impersonate users"""
|
||||
|
||||
from passbook.core.models import User
|
||||
|
||||
|
||||
def impersonate(get_response):
|
||||
"""Middleware to impersonate users"""
|
||||
|
||||
def middleware(request):
|
||||
"""Middleware to impersonate users"""
|
||||
|
||||
# User is superuser and has __impersonate ID set
|
||||
if request.user.is_superuser and "__impersonate" in request.GET:
|
||||
request.session["impersonate_id"] = request.GET["__impersonate"]
|
||||
# user wants to stop impersonation
|
||||
elif "__unimpersonate" in request.GET and "impersonate_id" in request.session:
|
||||
del request.session["impersonate_id"]
|
||||
|
||||
# Actually impersonate user
|
||||
if request.user.is_superuser and "impersonate_id" in request.session:
|
||||
request.user = User.objects.get(pk=request.session["impersonate_id"])
|
||||
|
||||
response = get_response(request)
|
||||
return response
|
||||
|
||||
return middleware
|
||||
@ -1,5 +0,0 @@
|
||||
"""passbook admin settings"""
|
||||
|
||||
MIDDLEWARE = [
|
||||
"passbook.admin.middleware.impersonate",
|
||||
]
|
||||
@ -3,18 +3,7 @@
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
{% load passbook_utils %}
|
||||
|
||||
{% block head %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.pf-m-success {
|
||||
color: var(--pf-global--success-color--100);
|
||||
}
|
||||
.pf-m-danger {
|
||||
color: var(--pf-global--danger-color--100);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% load admin_reflection %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
@ -32,7 +21,7 @@
|
||||
<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
<div class="pf-c-toolbar__bulk-select">
|
||||
<a href="{% url 'passbook_admin:flow-create' %}?back={{ request.get_full_path }}" class="pf-c-button pf-m-primary" type="button">{% trans 'Create' %}</a>
|
||||
<a href="{% url 'passbook_admin:outpost-create' %}?back={{ request.get_full_path }}" class="pf-c-button pf-m-primary" type="button">{% trans 'Create' %}</a>
|
||||
</div>
|
||||
{% include 'partials/pagination.html' %}
|
||||
</div>
|
||||
@ -43,6 +32,7 @@
|
||||
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Providers' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Health' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Version' %}</th>
|
||||
<th role="cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -50,7 +40,7 @@
|
||||
{% for outpost in object_list %}
|
||||
<tr role="row">
|
||||
<th role="columnheader">
|
||||
<a href="{% url 'passbook_outposts:setup' outpost_pk=outpost.pk %}">{{ outpost.name }}</a>
|
||||
<span>{{ outpost.name }}</span>
|
||||
</th>
|
||||
<td role="cell">
|
||||
<span>
|
||||
@ -58,7 +48,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td role="cell">
|
||||
{% with health=outpost.health %}
|
||||
{% with health=outpost.deployment_health %}
|
||||
{% if health %}
|
||||
<i class="fas fa-check pf-m-success"></i> {{ health|naturaltime }}
|
||||
{% else %}
|
||||
@ -66,10 +56,28 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{% with ver=outpost.deployment_version %}
|
||||
{% if ver.outdated %}
|
||||
{% if ver.version == "" %}
|
||||
<i class="fas fa-times pf-m-danger"></i> -
|
||||
{% else %}
|
||||
<i class="fas fa-times pf-m-danger"></i> {% blocktrans with is=ver.version should=ver.should %}{{ is }}, should be {{ should }}{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<i class="fas fa-check pf-m-success"></i> {{ ver.version }}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:outpost-update' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:outpost-delete' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
|
||||
<a href="https://passbook.beryju.org/outposts/outposts/#deploy">{% trans 'Deploy' %}</a>
|
||||
{% get_htmls outpost as htmls %}
|
||||
{% for html in htmls %}
|
||||
{{ html|safe }}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:user-update' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:user-delete' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
|
||||
<a class="pf-c-button pf-m-tertiary" href="{% url 'passbook_admin:user-password-reset' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Reset Password' %}</a>
|
||||
<a class="pf-c-button pf-m-tertiary" href="{% url 'passbook_core:overview' %}?__impersonate={{ user.pk }}">{% trans 'Impersonate' %}</a>
|
||||
<a class="pf-c-button pf-m-tertiary" href="{% url 'passbook_core:impersonate-init' user_id=user.pk %}">{% trans 'Impersonate' %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"""passbook Outpost administration"""
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Dict
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.mixins import (
|
||||
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
|
||||
@ -12,7 +15,7 @@ from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
from passbook.admin.views.utils import DeleteMessageView
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
from passbook.outposts.forms import OutpostForm
|
||||
from passbook.outposts.models import Outpost
|
||||
from passbook.outposts.models import Outpost, OutpostConfig
|
||||
|
||||
|
||||
class OutpostListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||
@ -41,6 +44,13 @@ class OutpostCreateView(
|
||||
success_url = reverse_lazy("passbook_admin:outposts")
|
||||
success_message = _("Successfully created Outpost")
|
||||
|
||||
def get_initial(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"_config": asdict(
|
||||
OutpostConfig(passbook_host=self.request.build_absolute_uri("/"))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class OutpostUpdateView(
|
||||
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
|
||||
@ -53,7 +63,7 @@ class OutpostUpdateView(
|
||||
|
||||
template_name = "generic/update.html"
|
||||
success_url = reverse_lazy("passbook_admin:outposts")
|
||||
success_message = _("Successfully updated Certificate-Key Pair")
|
||||
success_message = _("Successfully updated Outpost")
|
||||
|
||||
|
||||
class OutpostDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
|
||||
@ -64,4 +74,4 @@ class OutpostDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessa
|
||||
|
||||
template_name = "generic/delete.html"
|
||||
success_url = reverse_lazy("passbook_admin:outposts")
|
||||
success_message = _("Successfully deleted Certificate-Key Pair")
|
||||
success_message = _("Successfully deleted Outpost")
|
||||
|
||||
@ -9,11 +9,12 @@ from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
|
||||
from passbook.admin.views.utils import DeleteMessageView
|
||||
from passbook.lib.views import CreateAssignPermView
|
||||
from passbook.policies.forms import PolicyBindingForm
|
||||
from passbook.policies.models import PolicyBinding, PolicyBindingModel
|
||||
from passbook.policies.models import PolicyBinding
|
||||
|
||||
|
||||
class PolicyBindingListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||
@ -29,13 +30,18 @@ class PolicyBindingListView(LoginRequiredMixin, PermissionListMixin, ListView):
|
||||
# Since `select_subclasses` does not work with a foreign key, we have to do two queries here
|
||||
# First, get all pbm objects that have bindings attached
|
||||
objects = (
|
||||
PolicyBindingModel.objects.filter(policies__isnull=False)
|
||||
get_objects_for_user(
|
||||
self.request.user, "passbook_policies.view_policybindingmodel"
|
||||
)
|
||||
.filter(policies__isnull=False)
|
||||
.select_subclasses()
|
||||
.select_related()
|
||||
.order_by("pk")
|
||||
)
|
||||
for pbm in objects:
|
||||
pbm.bindings = PolicyBinding.objects.filter(target__pk=pbm.pbm_uuid)
|
||||
pbm.bindings = get_objects_for_user(
|
||||
self.request.user, self.permission_required
|
||||
).filter(target__pk=pbm.pbm_uuid)
|
||||
return objects
|
||||
|
||||
|
||||
|
||||
87
passbook/audit/middleware.py
Normal file
87
passbook/audit/middleware.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Audit middleware"""
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from passbook.audit.models import Event, EventAction, model_to_dict
|
||||
from passbook.audit.signals import EventNewThread
|
||||
from passbook.core.middleware import LOCAL
|
||||
|
||||
|
||||
class AuditMiddleware:
|
||||
"""Register handlers for duration of request-response that log creation/update/deletion
|
||||
of models"""
|
||||
|
||||
get_response: Callable[[HttpRequest], HttpResponse]
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
# Connect signal for automatic logging
|
||||
if hasattr(request, "user") and getattr(
|
||||
request.user, "is_authenticated", False
|
||||
):
|
||||
post_save_handler = partial(
|
||||
self.post_save_handler, user=request.user, request=request
|
||||
)
|
||||
pre_delete_handler = partial(
|
||||
self.pre_delete_handler, user=request.user, request=request
|
||||
)
|
||||
post_save.connect(
|
||||
post_save_handler,
|
||||
dispatch_uid=LOCAL.passbook["request_id"],
|
||||
weak=False,
|
||||
)
|
||||
pre_delete.connect(
|
||||
pre_delete_handler,
|
||||
dispatch_uid=LOCAL.passbook["request_id"],
|
||||
weak=False,
|
||||
)
|
||||
|
||||
response = self.get_response(request)
|
||||
|
||||
post_save.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
|
||||
pre_delete.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
|
||||
|
||||
return response
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def process_exception(self, request: HttpRequest, exception: Exception):
|
||||
"""Unregister handlers in case of exception"""
|
||||
post_save.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
|
||||
pre_delete.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
|
||||
|
||||
@staticmethod
|
||||
# pylint: disable=unused-argument
|
||||
def post_save_handler(
|
||||
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
||||
):
|
||||
"""Signal handler for all object's post_save"""
|
||||
if isinstance(instance, Event):
|
||||
return
|
||||
|
||||
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
|
||||
EventNewThread(
|
||||
action, request, user=user, kwargs={"model": model_to_dict(instance)}
|
||||
).run()
|
||||
|
||||
@staticmethod
|
||||
# pylint: disable=unused-argument
|
||||
def pre_delete_handler(
|
||||
user: User, request: HttpRequest, sender, instance: Model, **_
|
||||
):
|
||||
"""Signal handler for all object's pre_delete"""
|
||||
if isinstance(instance, Event):
|
||||
return
|
||||
|
||||
EventNewThread(
|
||||
EventAction.MODEL_DELETED,
|
||||
request,
|
||||
user=user,
|
||||
kwargs={"model": model_to_dict(instance)},
|
||||
).run()
|
||||
33
passbook/audit/migrations/0002_auto_20200918_2116.py
Normal file
33
passbook/audit/migrations/0002_auto_20200918_2116.py
Normal file
@ -0,0 +1,33 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-18 21:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_audit", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("LOGIN", "login"),
|
||||
("LOGIN_FAILED", "login_failed"),
|
||||
("LOGOUT", "logout"),
|
||||
("AUTHORIZE_APPLICATION", "authorize_application"),
|
||||
("SUSPICIOUS_REQUEST", "suspicious_request"),
|
||||
("SIGN_UP", "sign_up"),
|
||||
("PASSWORD_RESET", "password_reset"),
|
||||
("INVITE_CREATED", "invitation_created"),
|
||||
("INVITE_USED", "invitation_used"),
|
||||
("IMPERSONATION_STARTED", "impersonation_started"),
|
||||
("IMPERSONATION_ENDED", "impersonation_ended"),
|
||||
("CUSTOM", "custom"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
59
passbook/audit/migrations/0003_auto_20200917_1155.py
Normal file
59
passbook/audit/migrations/0003_auto_20200917_1155.py
Normal file
@ -0,0 +1,59 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-17 11:55
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
import passbook.audit.models
|
||||
|
||||
|
||||
def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
Event = apps.get_model("passbook_audit", "Event")
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
for event in Event.objects.all():
|
||||
event.delete()
|
||||
# Because event objects cannot be updated, we have to re-create them
|
||||
event.pk = None
|
||||
event.user_json = (
|
||||
passbook.audit.models.get_user(event.user) if event.user else {}
|
||||
)
|
||||
event._state.adding = True
|
||||
event.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_audit", "0002_auto_20200918_2116"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("LOGIN", "login"),
|
||||
("LOGIN_FAILED", "login_failed"),
|
||||
("LOGOUT", "logout"),
|
||||
("AUTHORIZE_APPLICATION", "authorize_application"),
|
||||
("SUSPICIOUS_REQUEST", "suspicious_request"),
|
||||
("SIGN_UP", "sign_up"),
|
||||
("PASSWORD_RESET", "password_reset"),
|
||||
("INVITE_CREATED", "invitation_created"),
|
||||
("INVITE_USED", "invitation_used"),
|
||||
("IMPERSONATION_STARTED", "impersonation_started"),
|
||||
("IMPERSONATION_ENDED", "impersonation_ended"),
|
||||
("CUSTOM", "custom"),
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="event", name="user_json", field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.RunPython(convert_user_to_json),
|
||||
migrations.RemoveField(model_name="event", name="user",),
|
||||
migrations.RenameField(
|
||||
model_name="event", old_name="user_json", new_name="user"
|
||||
),
|
||||
]
|
||||
37
passbook/audit/migrations/0004_auto_20200921_1829.py
Normal file
37
passbook/audit/migrations/0004_auto_20200921_1829.py
Normal file
@ -0,0 +1,37 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-21 18:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_audit", "0003_auto_20200917_1155"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("sign_up", "Sign Up"),
|
||||
("authorize_application", "Authorize Application"),
|
||||
("suspicious_request", "Suspicious Request"),
|
||||
("password_set", "Password Set"),
|
||||
("invitation_created", "Invite Created"),
|
||||
("invitation_used", "Invite Used"),
|
||||
("source_linked", "Source Linked"),
|
||||
("impersonation_started", "Impersonation Started"),
|
||||
("impersonation_ended", "Impersonation Ended"),
|
||||
("model_created", "Model Created"),
|
||||
("model_updated", "Model Updated"),
|
||||
("model_deleted", "Model Deleted"),
|
||||
("custom_", "Custom Prefix"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,23 +1,27 @@
|
||||
"""passbook audit models"""
|
||||
from enum import Enum
|
||||
from inspect import getmodule, stack
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models.base import Model
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.debug import SafeExceptionReporterFilter
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from guardian.utils import get_anonymous_user
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.middleware import (
|
||||
SESSION_IMPERSONATE_ORIGINAL_USER,
|
||||
SESSION_IMPERSONATE_USER,
|
||||
)
|
||||
from passbook.core.models import User
|
||||
from passbook.lib.utils.http import get_client_ip
|
||||
|
||||
LOGGER = get_logger()
|
||||
LOGGER = get_logger("passbook.audit")
|
||||
|
||||
|
||||
def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
@ -36,6 +40,35 @@ def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
return final_dict
|
||||
|
||||
|
||||
def model_to_dict(model: Model) -> Dict[str, Any]:
|
||||
"""Convert model to dict"""
|
||||
name = str(model)
|
||||
if hasattr(model, "name"):
|
||||
name = model.name
|
||||
return {
|
||||
"app": model._meta.app_label,
|
||||
"model_name": model._meta.model_name,
|
||||
"pk": model.pk,
|
||||
"name": name,
|
||||
}
|
||||
|
||||
|
||||
def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]:
|
||||
"""Convert user object to dictionary, optionally including the original user"""
|
||||
if isinstance(user, AnonymousUser):
|
||||
user = get_anonymous_user()
|
||||
user_data = {
|
||||
"username": user.username,
|
||||
"pk": user.pk,
|
||||
"email": user.email,
|
||||
}
|
||||
if original_user:
|
||||
original_data = get_user(original_user)
|
||||
original_data["on_behalf_of"] = user_data
|
||||
return original_data
|
||||
return user_data
|
||||
|
||||
|
||||
def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
"""clean source of all Models that would interfere with the JSONField.
|
||||
Models are replaced with a dictionary of {
|
||||
@ -48,18 +81,7 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
if isinstance(value, dict):
|
||||
final_dict[key] = sanitize_dict(value)
|
||||
elif isinstance(value, models.Model):
|
||||
model_content_type = ContentType.objects.get_for_model(value)
|
||||
name = str(value)
|
||||
if hasattr(value, "name"):
|
||||
name = value.name
|
||||
final_dict[key] = sanitize_dict(
|
||||
{
|
||||
"app": model_content_type.app_label,
|
||||
"model_name": model_content_type.model,
|
||||
"pk": value.pk,
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
final_dict[key] = sanitize_dict(model_to_dict(value))
|
||||
elif isinstance(value, UUID):
|
||||
final_dict[key] = value.hex
|
||||
else:
|
||||
@ -67,36 +89,39 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
|
||||
return final_dict
|
||||
|
||||
|
||||
class EventAction(Enum):
|
||||
class EventAction(models.TextChoices):
|
||||
"""All possible actions to save into the audit log"""
|
||||
|
||||
LOGIN = "login"
|
||||
LOGIN_FAILED = "login_failed"
|
||||
LOGOUT = "logout"
|
||||
|
||||
SIGN_UP = "sign_up"
|
||||
AUTHORIZE_APPLICATION = "authorize_application"
|
||||
SUSPICIOUS_REQUEST = "suspicious_request"
|
||||
SIGN_UP = "sign_up"
|
||||
PASSWORD_RESET = "password_reset" # noqa # nosec
|
||||
PASSWORD_SET = "password_set" # noqa # nosec
|
||||
|
||||
INVITE_CREATED = "invitation_created"
|
||||
INVITE_USED = "invitation_used"
|
||||
CUSTOM = "custom"
|
||||
|
||||
@staticmethod
|
||||
def as_choices():
|
||||
"""Generate choices of actions used for database"""
|
||||
return tuple(
|
||||
(x, y.value) for x, y in getattr(EventAction, "__members__").items()
|
||||
)
|
||||
SOURCE_LINKED = "source_linked"
|
||||
|
||||
IMPERSONATION_STARTED = "impersonation_started"
|
||||
IMPERSONATION_ENDED = "impersonation_ended"
|
||||
|
||||
MODEL_CREATED = "model_created"
|
||||
MODEL_UPDATED = "model_updated"
|
||||
MODEL_DELETED = "model_deleted"
|
||||
|
||||
CUSTOM_PREFIX = "custom_"
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
"""An individual audit log event"""
|
||||
|
||||
event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
|
||||
)
|
||||
action = models.TextField(choices=EventAction.as_choices())
|
||||
user = models.JSONField(default=dict)
|
||||
action = models.TextField(choices=EventAction.choices)
|
||||
date = models.DateTimeField(auto_now_add=True)
|
||||
app = models.TextField()
|
||||
context = models.JSONField(default=dict, blank=True)
|
||||
@ -111,20 +136,18 @@ class Event(models.Model):
|
||||
|
||||
@staticmethod
|
||||
def new(
|
||||
action: EventAction,
|
||||
action: Union[str, EventAction],
|
||||
app: Optional[str] = None,
|
||||
_inspect_offset: int = 1,
|
||||
**kwargs,
|
||||
) -> "Event":
|
||||
"""Create new Event instance from arguments. Instance is NOT saved."""
|
||||
if not isinstance(action, EventAction):
|
||||
raise ValueError(
|
||||
f"action must be EventAction instance but was {type(action)}"
|
||||
)
|
||||
action = EventAction.CUSTOM_PREFIX + action
|
||||
if not app:
|
||||
app = getmodule(stack()[_inspect_offset][0]).__name__
|
||||
cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
|
||||
event = Event(action=action.value, app=app, context=cleaned_kwargs)
|
||||
event = Event(action=action, app=app, context=cleaned_kwargs)
|
||||
return event
|
||||
|
||||
def from_http(
|
||||
@ -134,12 +157,19 @@ class Event(models.Model):
|
||||
Events independently from requests.
|
||||
`user` arguments optionally overrides user from requests."""
|
||||
if hasattr(request, "user"):
|
||||
if isinstance(request.user, AnonymousUser):
|
||||
self.user = get_anonymous_user()
|
||||
else:
|
||||
self.user = request.user
|
||||
self.user = get_user(
|
||||
request.user,
|
||||
request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None),
|
||||
)
|
||||
if user:
|
||||
self.user = user
|
||||
self.user = get_user(user)
|
||||
# Check if we're currently impersonating, and add that user
|
||||
if hasattr(request, "session"):
|
||||
if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
|
||||
self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER])
|
||||
self.user["on_behalf_of"] = get_user(
|
||||
request.session[SESSION_IMPERSONATE_USER]
|
||||
)
|
||||
# User 255.255.255.255 as fallback if IP cannot be determined
|
||||
self.client_ip = get_client_ip(request) or "255.255.255.255"
|
||||
# If there's no app set, we get it from the requests too
|
||||
|
||||
@ -20,12 +20,12 @@ from passbook.stages.user_write.signals import user_write
|
||||
class EventNewThread(Thread):
|
||||
"""Create Event in background thread"""
|
||||
|
||||
action: EventAction
|
||||
action: str
|
||||
request: HttpRequest
|
||||
kwargs: Dict[str, Any]
|
||||
user: Optional[User] = None
|
||||
|
||||
def __init__(self, action: EventAction, request: HttpRequest, **kwargs):
|
||||
def __init__(self, action: str, request: HttpRequest, **kwargs):
|
||||
super().__init__()
|
||||
self.action = action
|
||||
self.request = request
|
||||
@ -57,7 +57,7 @@ def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||
# pylint: disable=unused-argument
|
||||
def on_user_write(sender, request: HttpRequest, user: User, data: Dict[str, Any], **_):
|
||||
"""Log User write"""
|
||||
thread = EventNewThread(EventAction.CUSTOM, request, **data)
|
||||
thread = EventNewThread("stages/user_write", request, **data)
|
||||
thread.user = user
|
||||
thread.run()
|
||||
|
||||
|
||||
@ -40,12 +40,28 @@
|
||||
</div>
|
||||
</th>
|
||||
<td role="cell">
|
||||
<div>
|
||||
<div>
|
||||
<code>{{ entry.context }}</code>
|
||||
</div>
|
||||
{% if entry.user.on_behalf_of %}
|
||||
<small>
|
||||
{% blocktrans with username=entry.user.on_behalf_of.username %}
|
||||
On behalf of {{ username }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
{{ entry.user }}
|
||||
</span>
|
||||
<div>
|
||||
<div>{{ entry.user.username }}</div>
|
||||
<small>
|
||||
{% blocktrans with pk=entry.user.pk %}
|
||||
ID: {{ pk }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
</div>
|
||||
</td>
|
||||
<td role="cell">
|
||||
<span>
|
||||
|
||||
@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.audit.models import Event
|
||||
from passbook.policies.dummy.models import DummyPolicy
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ class TestAuditEvent(TestCase):
|
||||
|
||||
def test_new_with_model(self):
|
||||
"""Create a new Event passing a model as kwarg"""
|
||||
event = Event.new(EventAction.CUSTOM, test={"model": get_anonymous_user()})
|
||||
event = Event.new("unittest", test={"model": get_anonymous_user()})
|
||||
event.save() # We save to ensure nothing is un-saveable
|
||||
model_content_type = ContentType.objects.get_for_model(get_anonymous_user())
|
||||
self.assertEqual(
|
||||
@ -24,7 +24,7 @@ class TestAuditEvent(TestCase):
|
||||
def test_new_with_uuid_model(self):
|
||||
"""Create a new Event passing a model (with UUID PK) as kwarg"""
|
||||
temp_model = DummyPolicy.objects.create(name="test", result=True)
|
||||
event = Event.new(EventAction.CUSTOM, model=temp_model)
|
||||
event = Event.new("unittest", model=temp_model)
|
||||
event.save() # We save to ensure nothing is un-saveable
|
||||
model_content_type = ContentType.objects.get_for_model(temp_model)
|
||||
self.assertEqual(
|
||||
|
||||
56
passbook/core/middleware.py
Normal file
56
passbook/core/middleware.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""passbook admin Middleware to impersonate users"""
|
||||
from logging import Logger
|
||||
from threading import local
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
SESSION_IMPERSONATE_USER = "passbook_impersonate_user"
|
||||
SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user"
|
||||
LOCAL = local()
|
||||
|
||||
|
||||
class ImpersonateMiddleware:
|
||||
"""Middleware to impersonate users"""
|
||||
|
||||
get_response: Callable[[HttpRequest], HttpResponse]
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
# No permission checks are done here, they need to be checked before
|
||||
# SESSION_IMPERSONATE_USER is set.
|
||||
|
||||
if SESSION_IMPERSONATE_USER in request.session:
|
||||
request.user = request.session[SESSION_IMPERSONATE_USER]
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
class RequestIDMiddleware:
|
||||
"""Add a unique ID to every request"""
|
||||
|
||||
get_response: Callable[[HttpRequest], HttpResponse]
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
if not hasattr(request, "request_id"):
|
||||
request_id = uuid4().hex
|
||||
setattr(request, "request_id", request_id)
|
||||
LOCAL.passbook = {"request_id": request_id}
|
||||
response = self.get_response(request)
|
||||
response["X-passbook-id"] = request.request_id
|
||||
del LOCAL.passbook["request_id"]
|
||||
return response
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def structlog_add_request_id(logger: Logger, method_name: str, event_dict):
|
||||
"""If threadlocal has passbook defined, add request_id to log"""
|
||||
if hasattr(LOCAL, "passbook"):
|
||||
event_dict["request_id"] = LOCAL.passbook.get("request_id", "")
|
||||
return event_dict
|
||||
@ -14,7 +14,7 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
pbadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||
username="pbadmin", email="root@localhost", name="passbook Default Admin"
|
||||
)
|
||||
pbadmin.set_password("pbadmin") # noqa # nosec
|
||||
pbadmin.set_password("pbadmin", signal=False) # noqa # nosec
|
||||
pbadmin.save()
|
||||
|
||||
|
||||
|
||||
24
passbook/core/migrations/0010_auto_20200917_1021.py
Normal file
24
passbook/core/migrations/0010_auto_20200917_1021.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-17 10:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_core", "0009_group_is_superuser"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="user",
|
||||
options={
|
||||
"permissions": (
|
||||
("reset_user_password", "Reset Password"),
|
||||
("impersonate", "Can impersonate other users"),
|
||||
),
|
||||
"verbose_name": "User",
|
||||
"verbose_name_plural": "Users",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -90,15 +90,18 @@ class User(GuardianUserMixin, AbstractUser):
|
||||
"""superuser == staff user"""
|
||||
return self.is_superuser
|
||||
|
||||
def set_password(self, password):
|
||||
if self.pk:
|
||||
def set_password(self, password, signal=True):
|
||||
if self.pk and signal:
|
||||
password_changed.send(sender=self, user=self, password=password)
|
||||
self.password_change_date = now()
|
||||
return super().set_password(password)
|
||||
|
||||
class Meta:
|
||||
|
||||
permissions = (("reset_user_password", "Reset Password"),)
|
||||
permissions = (
|
||||
("reset_user_password", "Reset Password"),
|
||||
("impersonate", "Can impersonate other users"),
|
||||
)
|
||||
verbose_name = _("User")
|
||||
verbose_name_plural = _("Users")
|
||||
|
||||
@ -157,7 +160,7 @@ class Application(PolicyBindingModel):
|
||||
if self.meta_launch_url:
|
||||
return self.meta_launch_url
|
||||
if self.provider:
|
||||
return self.provider.launch_url
|
||||
return self.get_provider().launch_url
|
||||
return None
|
||||
|
||||
def get_provider(self) -> Optional[Provider]:
|
||||
|
||||
@ -21,13 +21,13 @@
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% if 'impersonate_id' in request.session %}
|
||||
{% if 'passbook_impersonate_user' in request.session %}
|
||||
<div class="pf-c-banner pf-m-warning pf-c-alert pf-m-sticky">
|
||||
<div class="pf-l-flex pf-m-justify-content-center pf-m-justify-content-space-between-on-lg pf-m-nowrap" style="height: 100%;">
|
||||
<div class=""></div>
|
||||
<div class="pf-u-display-none pf-u-display-block-on-lg">
|
||||
{% blocktrans with user=user %}You're currently impersonating {{ user }}.{% endblocktrans %}
|
||||
<a href="?__unimpersonate=True" id="acceptMessage">{% trans 'Stop impersonation' %}</a>
|
||||
<a href="{% url 'passbook_core:impersonate-end' %}?back={{ request.get_full_path }}" id="acceptMessage">{% trans 'Stop impersonation' %}</a>
|
||||
</div>
|
||||
<div class=""></div>
|
||||
</div>
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
<style>
|
||||
img.app-icon {
|
||||
max-height: 72px;
|
||||
width: auto !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
55
passbook/core/tests/test_impersonation.py
Normal file
55
passbook/core/tests/test_impersonation.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""impersonation tests"""
|
||||
from django.shortcuts import reverse
|
||||
from django.test.testcases import TestCase
|
||||
|
||||
from passbook.core.models import User
|
||||
|
||||
|
||||
class TestImpersonation(TestCase):
|
||||
"""impersonation tests"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.other_user = User.objects.create(username="to-impersonate")
|
||||
self.pbadmin = User.objects.get(username="pbadmin")
|
||||
|
||||
def test_impersonate_simple(self):
|
||||
"""test simple impersonation and un-impersonation"""
|
||||
self.client.force_login(self.pbadmin)
|
||||
|
||||
self.client.get(
|
||||
reverse(
|
||||
"passbook_core:impersonate-init", kwargs={"user_id": self.other_user.pk}
|
||||
)
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("passbook_core:overview"))
|
||||
self.assertIn(self.other_user.username, response.content.decode())
|
||||
self.assertNotIn(self.pbadmin.username, response.content.decode())
|
||||
|
||||
self.client.get(reverse("passbook_core:impersonate-end"))
|
||||
|
||||
response = self.client.get(reverse("passbook_core:overview"))
|
||||
self.assertNotIn(self.other_user.username, response.content.decode())
|
||||
self.assertIn(self.pbadmin.username, response.content.decode())
|
||||
|
||||
def test_impersonate_denied(self):
|
||||
"""test impersonation without permissions"""
|
||||
self.client.force_login(self.other_user)
|
||||
|
||||
self.client.get(
|
||||
reverse(
|
||||
"passbook_core:impersonate-init", kwargs={"user_id": self.pbadmin.pk}
|
||||
)
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("passbook_core:overview"))
|
||||
self.assertIn(self.other_user.username, response.content.decode())
|
||||
self.assertNotIn(self.pbadmin.username, response.content.decode())
|
||||
|
||||
def test_un_impersonate_empty(self):
|
||||
"""test un-impersonation without impersonating first"""
|
||||
self.client.force_login(self.other_user)
|
||||
|
||||
response = self.client.get(reverse("passbook_core:impersonate-end"))
|
||||
self.assertRedirects(response, reverse("passbook_core:overview"))
|
||||
@ -1,11 +1,22 @@
|
||||
"""passbook URL Configuration"""
|
||||
from django.urls import path
|
||||
|
||||
from passbook.core.views import overview, user
|
||||
from passbook.core.views import impersonate, overview, user
|
||||
|
||||
urlpatterns = [
|
||||
# User views
|
||||
path("-/user/", user.UserSettingsView.as_view(), name="user-settings"),
|
||||
# Overview
|
||||
path("", overview.OverviewView.as_view(), name="overview"),
|
||||
# Impersonation
|
||||
path(
|
||||
"-/impersonation/<int:user_id>/",
|
||||
impersonate.ImpersonateInitView.as_view(),
|
||||
name="impersonate-init",
|
||||
),
|
||||
path(
|
||||
"-/impersonation/end/",
|
||||
impersonate.ImpersonateEndView.as_view(),
|
||||
name="impersonate-end",
|
||||
),
|
||||
]
|
||||
|
||||
58
passbook/core/views/impersonate.py
Normal file
58
passbook/core/views/impersonate.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""passbook impersonation views"""
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.core.middleware import (
|
||||
SESSION_IMPERSONATE_ORIGINAL_USER,
|
||||
SESSION_IMPERSONATE_USER,
|
||||
)
|
||||
from passbook.core.models import User
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class ImpersonateInitView(View):
|
||||
"""Initiate Impersonation"""
|
||||
|
||||
def get(self, request: HttpRequest, user_id: int) -> HttpResponse:
|
||||
"""Impersonation handler, checks permissions"""
|
||||
if not request.user.has_perm("impersonate"):
|
||||
LOGGER.debug(
|
||||
"User attempted to impersonate without permissions", user=request.user
|
||||
)
|
||||
return HttpResponse("Unauthorized", status=401)
|
||||
|
||||
user_to_be = get_object_or_404(User, pk=user_id)
|
||||
|
||||
request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user
|
||||
request.session[SESSION_IMPERSONATE_USER] = user_to_be
|
||||
|
||||
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
||||
|
||||
return redirect("passbook_core:overview")
|
||||
|
||||
|
||||
class ImpersonateEndView(View):
|
||||
"""End User impersonation"""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""End Impersonation handler"""
|
||||
if (
|
||||
SESSION_IMPERSONATE_USER not in request.session
|
||||
or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session
|
||||
):
|
||||
LOGGER.debug("Can't end impersonation", user=request.user)
|
||||
return redirect("passbook_core:overview")
|
||||
|
||||
original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
||||
|
||||
del request.session[SESSION_IMPERSONATE_USER]
|
||||
del request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
||||
|
||||
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
|
||||
|
||||
return redirect("passbook_core:overview")
|
||||
@ -177,6 +177,6 @@ class FlowPlanner:
|
||||
marker = ReevaluateMarker(binding=binding, user=user)
|
||||
plan.markers.append(marker)
|
||||
LOGGER.debug(
|
||||
"f(plan): Finished building", flow=self.flow, duration_s=span.timestamp,
|
||||
"f(plan): Finished building", flow=self.flow,
|
||||
)
|
||||
return plan
|
||||
|
||||
57
passbook/flows/templates/flows/denied_shell.html
Normal file
57
passbook/flows/templates/flows/denied_shell.html
Normal file
@ -0,0 +1,57 @@
|
||||
{% extends 'login/base.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load passbook_utils %}
|
||||
|
||||
{% block card_title %}
|
||||
{% trans 'Permission denied' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
{% trans 'Permission denied' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% csrf_token %}
|
||||
{% include 'partials/form.html' %}
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
<i class="pf-icon pf-icon-error-circle-o"></i>
|
||||
{% trans 'Request has been denied.' %}
|
||||
</p>
|
||||
{% if error %}
|
||||
<hr>
|
||||
<p>
|
||||
{{ error }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if policy_result %}
|
||||
<hr>
|
||||
<em>
|
||||
{% trans 'Explanation:' %}
|
||||
</em>
|
||||
<ul class="pf-c-list">
|
||||
{% for source_result in policy_result.source_results %}
|
||||
<li>
|
||||
{% blocktrans with name=source_result.source_policy.name result=source_result.passing %}
|
||||
Policy '{{ name }}' returned result '{{ result }}'
|
||||
{% endblocktrans %}
|
||||
{% if source_result.messages %}
|
||||
<ul class="pf-c-list">
|
||||
{% for message in source_result.messages %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if 'back' in request.GET %}
|
||||
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@ -105,15 +105,10 @@ class TestFlowTransfer(TransactionTestCase):
|
||||
order=2,
|
||||
type=FieldTypes.PASSWORD,
|
||||
)
|
||||
# Password checking policy
|
||||
password_policy = ExpressionPolicy.objects.create(
|
||||
name=generate_client_id(), expression="return True",
|
||||
)
|
||||
|
||||
# Stages
|
||||
first_stage = PromptStage.objects.create(name=generate_client_id())
|
||||
first_stage.fields.set([username_prompt, password, password_repeat])
|
||||
first_stage.validation_policies.set([password_policy])
|
||||
first_stage.save()
|
||||
|
||||
flow = Flow.objects.create(
|
||||
|
||||
@ -187,9 +187,11 @@ class FlowExecutorView(View):
|
||||
is a superuser."""
|
||||
LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug)
|
||||
self.cancel()
|
||||
response = AccessDeniedResponse(self.request)
|
||||
response = AccessDeniedResponse(
|
||||
self.request, template="flows/denied_shell.html"
|
||||
)
|
||||
response.error_message = error_message
|
||||
return response
|
||||
return to_stage_response(self.request, response)
|
||||
|
||||
def cancel(self):
|
||||
"""Cancel current execution and return a redirect"""
|
||||
|
||||
@ -1,9 +1,23 @@
|
||||
"""logging helpers"""
|
||||
from logging import Logger
|
||||
from os import getpid
|
||||
from typing import Callable
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def add_process_id(logger, method_name, event_dict):
|
||||
def add_process_id(logger: Logger, method_name: str, event_dict):
|
||||
"""Add the current process ID"""
|
||||
event_dict["pid"] = getpid()
|
||||
return event_dict
|
||||
|
||||
|
||||
def add_common_fields(environment: str) -> Callable:
|
||||
"""Add a common field to easily search for passbook logs"""
|
||||
|
||||
def add_common_field(logger: Logger, method_name: str, event_dict):
|
||||
"""Add a common field to easily search for passbook logs"""
|
||||
event_dict["app"] = "passbook"
|
||||
event_dict["app_environment"] = environment
|
||||
return event_dict
|
||||
|
||||
return add_common_field
|
||||
|
||||
@ -5,6 +5,7 @@ from celery.exceptions import CeleryError
|
||||
from django.core.exceptions import DisallowedHost, ValidationError
|
||||
from django.db import InternalError, OperationalError, ProgrammingError
|
||||
from django_redis.exceptions import ConnectionInterrupted
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
from redis.exceptions import RedisError
|
||||
from rest_framework.exceptions import APIException
|
||||
@ -39,6 +40,7 @@ def before_send(event, hint):
|
||||
SentryIgnoredException,
|
||||
WebSocketException,
|
||||
CeleryError,
|
||||
LDAPException,
|
||||
)
|
||||
if "exc_info" in hint:
|
||||
_, exc_value, _ = hint["exc_info"]
|
||||
|
||||
@ -14,7 +14,7 @@ def _get_client_ip_from_meta(meta: Dict[str, Any]) -> Optional[str]:
|
||||
)
|
||||
for _header in headers:
|
||||
if _header in meta:
|
||||
return meta.get(_header)
|
||||
return meta.get(_header).split(", ")[0]
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@ -83,7 +83,11 @@ class OutpostConsumer(JsonWebsocketConsumer):
|
||||
def receive_json(self, content: Data):
|
||||
msg = from_dict(WebsocketMessage, content)
|
||||
if msg.instruction == WebsocketMessageInstruction.HELLO:
|
||||
cache.set(self.outpost.health_cache_key, time(), timeout=60)
|
||||
cache.set(self.outpost.state_cache_prefix("health"), time(), timeout=60)
|
||||
if "version" in msg.args:
|
||||
cache.set(
|
||||
self.outpost.state_cache_prefix("version"), msg.args["version"]
|
||||
)
|
||||
elif msg.instruction == WebsocketMessageInstruction.ACK:
|
||||
return
|
||||
|
||||
|
||||
@ -4,8 +4,8 @@ from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passbook.admin.fields import CodeMirrorWidget, YAMLField
|
||||
from passbook.core.models import Provider
|
||||
from passbook.outposts.models import Outpost
|
||||
from passbook.providers.proxy.models import ProxyProvider
|
||||
|
||||
|
||||
class OutpostForm(forms.ModelForm):
|
||||
@ -13,7 +13,7 @@ class OutpostForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["providers"].queryset = Provider.objects.all().select_subclasses()
|
||||
self.fields["providers"].queryset = ProxyProvider.objects.all()
|
||||
|
||||
class Meta:
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""Outpost models"""
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from dacite import from_dict
|
||||
@ -9,12 +9,19 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.cache import cache
|
||||
from django.db import models, transaction
|
||||
from django.db.models.base import Model
|
||||
from django.http import HttpRequest
|
||||
from django.utils import version
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
from packaging.version import InvalidVersion, parse
|
||||
|
||||
from passbook import __version__
|
||||
from passbook.core.models import Provider, Token, TokenIntents, User
|
||||
from passbook.lib.config import CONFIG
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
OUR_VERSION = parse(__version__)
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -91,20 +98,37 @@ class Outpost(models.Model):
|
||||
"""Dump config into json"""
|
||||
self._config = asdict(value)
|
||||
|
||||
@property
|
||||
def health_cache_key(self) -> str:
|
||||
"""Key by which the outposts health status is saved"""
|
||||
return f"outpost_{self.uuid.hex}_health"
|
||||
def state_cache_prefix(self, suffix: str) -> str:
|
||||
"""Key by which the outposts status is saved"""
|
||||
return f"outpost_{self.uuid.hex}_state_{suffix}"
|
||||
|
||||
@property
|
||||
def health(self) -> Optional[datetime]:
|
||||
def deployment_health(self) -> Optional[datetime]:
|
||||
"""Get outpost's health status"""
|
||||
key = self.health_cache_key
|
||||
key = self.state_cache_prefix("health")
|
||||
value = cache.get(key, None)
|
||||
if value:
|
||||
return datetime.fromtimestamp(value)
|
||||
return None
|
||||
|
||||
@property
|
||||
def deployment_version(self) -> Dict[str, Any]:
|
||||
"""Get deployed outposts version, and if the version is behind ours.
|
||||
Returns a dict with keys version and outdated."""
|
||||
key = self.state_cache_prefix("version")
|
||||
value = cache.get(key, None)
|
||||
if not value:
|
||||
return {"version": "", "outdated": False, "should": OUR_VERSION}
|
||||
try:
|
||||
outpost_version = parse(value)
|
||||
return {
|
||||
"version": value,
|
||||
"outdated": outpost_version < OUR_VERSION,
|
||||
"should": OUR_VERSION,
|
||||
}
|
||||
except InvalidVersion:
|
||||
return {"version": version, "outdated": False, "should": OUR_VERSION}
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
"""Get/create user with access to all required objects"""
|
||||
@ -149,5 +173,12 @@ class Outpost(models.Model):
|
||||
objects.append(provider)
|
||||
return objects
|
||||
|
||||
def html_deployment_view(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal to view token and other config info"""
|
||||
return render_to_string(
|
||||
"outposts/deployment_modal.html",
|
||||
{"outpost": self, "full_url": request.build_absolute_uri("/")},
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Outpost {self.name}"
|
||||
|
||||
43
passbook/outposts/templates/outposts/deployment_modal.html
Normal file
43
passbook/outposts/templates/outposts/deployment_modal.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="saml-{{ provider.pk }}">{% trans 'View Deployment Info' %}</button>
|
||||
|
||||
<div class="pf-c-backdrop" id="saml-{{ provider.pk }}" hidden>
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
||||
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="pf-c-modal-box__header">
|
||||
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Outpost Deployment Info' %}</h1>
|
||||
</div>
|
||||
<div class="pf-c-modal-box__body" id="modal-description">
|
||||
<p><a href="https://passbook.beryju.org/outposts/outposts/#deploy">{% trans 'View deployment documentation' %}</a></p>
|
||||
<form class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">PASSBOOK_HOST</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ full_url }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">PASSBOOK_TOKEN</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ outpost.token.token_uuid.hex }}" />
|
||||
</div>
|
||||
<h3>{% trans 'If your passbook Instance is using a self-signed certificate, set this value.' %}</h3>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">PASSBOOK_INSECURE</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="true" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -18,10 +18,9 @@ class AccessDeniedResponse(TemplateResponse):
|
||||
error_message: Optional[str] = None
|
||||
policy_result: Optional[PolicyResult] = None
|
||||
|
||||
def __init__(self, request: HttpRequest) -> None:
|
||||
# For some reason pyright complains about keyword argument usage here
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
super().__init__(request=request, template="policies/denied.html")
|
||||
def __init__(self, request: HttpRequest, template="policies/denied.html") -> None:
|
||||
super().__init__(request, template)
|
||||
self.title = _("Access denied")
|
||||
|
||||
def resolve_context(
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
<i class="pf-icon pf-icon-error-circle-o"></i>
|
||||
{% trans 'Access denied' %}
|
||||
{% trans 'Request has been denied.' %}
|
||||
</p>
|
||||
{% if error %}
|
||||
<hr>
|
||||
|
||||
@ -22,7 +22,6 @@ class OAuth2ProviderSerializer(ModelSerializer):
|
||||
"jwt_alg",
|
||||
"rsa_key",
|
||||
"redirect_uris",
|
||||
"post_logout_redirect_uris",
|
||||
"sub_mode",
|
||||
"property_mappings",
|
||||
]
|
||||
|
||||
@ -41,7 +41,6 @@ class OAuth2ProviderForm(forms.ModelForm):
|
||||
"jwt_alg",
|
||||
"rsa_key",
|
||||
"redirect_uris",
|
||||
"post_logout_redirect_uris",
|
||||
"sub_mode",
|
||||
"property_mappings",
|
||||
]
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-18 21:16
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_oauth2", "0003_auto_20200916_2129"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="oauth2provider", name="post_logout_redirect_uris",
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,36 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-20 12:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"passbook_providers_oauth2",
|
||||
"0004_remove_oauth2provider_post_logout_redirect_uris",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="response_type",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("code", "code (Authorization Code Flow)"),
|
||||
(
|
||||
"code#adfs",
|
||||
"code (ADFS Compatibility Mode, sends id_token as access_token)",
|
||||
),
|
||||
("id_token", "id_token (Implicit Flow)"),
|
||||
("id_token token", "id_token token (Implicit Flow)"),
|
||||
("code token", "code token (Hybrid Flow)"),
|
||||
("code id_token", "code id_token (Hybrid Flow)"),
|
||||
("code id_token token", "code id_token token (Hybrid Flow)"),
|
||||
],
|
||||
default="code",
|
||||
help_text="Response Type required by the client.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -71,7 +71,7 @@ class ResponseTypes(models.TextChoices):
|
||||
|
||||
CODE = "code", _("code (Authorization Code Flow)")
|
||||
CODE_ADFS = (
|
||||
"code_adfs",
|
||||
"code#adfs",
|
||||
_("code (ADFS Compatibility Mode, sends id_token as access_token)"),
|
||||
)
|
||||
ID_TOKEN = "id_token", _("id_token (Implicit Flow)")
|
||||
@ -157,12 +157,6 @@ class OAuth2Provider(Provider):
|
||||
verbose_name=_("Redirect URIs"),
|
||||
help_text=_("Enter each URI on a new line."),
|
||||
)
|
||||
post_logout_redirect_uris = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name=_("Post Logout Redirect URIs"),
|
||||
help_text=_("Enter each URI on a new line."),
|
||||
)
|
||||
|
||||
include_claims_in_id_token = models.BooleanField(
|
||||
default=True,
|
||||
@ -269,12 +263,11 @@ class OAuth2Provider(Provider):
|
||||
@property
|
||||
def launch_url(self) -> Optional[str]:
|
||||
"""Guess launch_url based on first redirect_uri"""
|
||||
if not self.redirect_uris:
|
||||
if self.redirect_uris == "":
|
||||
return None
|
||||
main_url = self.redirect_uris[0]
|
||||
main_url = self.redirect_uris.split("\n")[0]
|
||||
launch_url = urlparse(main_url)
|
||||
launch_url.path = ""
|
||||
return launch_url.geturl()
|
||||
return main_url.replace(launch_url.path, "")
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.oauth2.forms import OAuth2ProviderForm
|
||||
@ -300,6 +293,7 @@ class OAuth2Provider(Provider):
|
||||
"providers/oauth2/setup_url_modal.html",
|
||||
{
|
||||
"provider": self,
|
||||
"issuer": self.get_issuer(request),
|
||||
"authorize": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:authorize",)
|
||||
),
|
||||
@ -346,7 +340,6 @@ class BaseGrantModel(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class AuthorizationCode(ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Authorization Code"""
|
||||
|
||||
@ -373,7 +366,6 @@ class AuthorizationCode(ExpiringModel, BaseGrantModel):
|
||||
|
||||
|
||||
@dataclass
|
||||
# plyint: disable=too-many-instance-attributes
|
||||
class IDToken:
|
||||
"""The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be
|
||||
Authenticated is the ID Token data structure. The ID Token is a security token that contains
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
{% extends 'login/base_full.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load passbook_utils %}
|
||||
|
||||
{% block title %}
|
||||
{% trans 'End session' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
{% blocktrans with application=application.name %}
|
||||
You've logged out of {{ application }}.
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
<p>
|
||||
{% blocktrans with application=application.name %}
|
||||
You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your passbook account.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<a id="pb-back-home" href="{% url 'passbook_core:overview' %}" class="pf-c-button pf-m-primary">{% trans 'Go back to overview' %}</a>
|
||||
|
||||
<a id="logout" href="{% url 'passbook_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">{% trans 'Log out of passbook' %}</a>
|
||||
|
||||
{% if application.get_launch_url %}
|
||||
<a href="{{ application.get_launch_url }}" class="pf-c-button pf-m-secondary">
|
||||
{% blocktrans with application=application.name %}
|
||||
Log back into {{ application }}
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
@ -13,6 +13,19 @@
|
||||
</div>
|
||||
<div class="pf-c-modal-box__body" id="modal-description">
|
||||
<form class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ provider_info }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration Issuer' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ issuer }}" />
|
||||
</div>
|
||||
<hr>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Authorize URL' %}</span>
|
||||
@ -31,13 +44,6 @@
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ userinfo }}" />
|
||||
</div>
|
||||
<hr>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ provider_info }}" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
|
||||
@ -20,12 +20,16 @@ urlpatterns = [
|
||||
csrf_exempt(protected_resource_view([SCOPE_OPENID])(UserInfoView.as_view())),
|
||||
name="userinfo",
|
||||
),
|
||||
path("end-session/", EndSessionView.as_view(), name="end-session",),
|
||||
path(
|
||||
"introspect/",
|
||||
csrf_exempt(TokenIntrospectionView.as_view()),
|
||||
name="token-introspection",
|
||||
),
|
||||
path(
|
||||
"<slug:application_slug>/end-session/",
|
||||
EndSessionView.as_view(),
|
||||
name="end-session",
|
||||
),
|
||||
path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"),
|
||||
path(
|
||||
"<slug:application_slug>/.well-known/openid-configuration",
|
||||
|
||||
@ -57,7 +57,6 @@ ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET}
|
||||
|
||||
|
||||
@dataclass
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class OAuthAuthorizationParams:
|
||||
"""Parameteres required to authorize an OAuth Client"""
|
||||
|
||||
@ -164,8 +163,15 @@ class OAuthAuthorizationParams:
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
|
||||
|
||||
# Response type parameter validation.
|
||||
if is_open_id and self.response_type != self.provider.response_type:
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
|
||||
if is_open_id:
|
||||
actual_response_type = self.provider.response_type
|
||||
if "#" in self.provider.response_type:
|
||||
hash_index = actual_response_type.index("#")
|
||||
actual_response_type = actual_response_type[:hash_index]
|
||||
if self.response_type != actual_response_type:
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "invalid_request", self.grant_type
|
||||
)
|
||||
|
||||
# PKCE validation of the transformation method.
|
||||
if self.code_challenge:
|
||||
|
||||
@ -32,7 +32,10 @@ class ProviderInfoView(View):
|
||||
reverse("passbook_providers_oauth2:userinfo")
|
||||
),
|
||||
"end_session_endpoint": self.request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:end-session")
|
||||
reverse(
|
||||
"passbook_providers_oauth2:end-session",
|
||||
kwargs={"application_slug": provider.application.slug},
|
||||
)
|
||||
),
|
||||
"introspection_endpoint": self.request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:token-introspection")
|
||||
@ -63,7 +66,9 @@ class ProviderInfoView(View):
|
||||
provider: OAuth2Provider = get_object_or_404(
|
||||
OAuth2Provider, pk=application.provider_id
|
||||
)
|
||||
response = JsonResponse(self.get_info(provider))
|
||||
response = JsonResponse(
|
||||
self.get_info(provider), json_dumps_params={"indent": 2}
|
||||
)
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
return response
|
||||
|
||||
@ -1,45 +1,22 @@
|
||||
"""passbook OAuth2 Session Views"""
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||
from typing import Any, Dict
|
||||
|
||||
from django.contrib.auth.views import LogoutView
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.generic.base import TemplateView
|
||||
|
||||
from passbook.core.models import Application
|
||||
from passbook.providers.oauth2.models import OAuth2Provider
|
||||
from passbook.providers.oauth2.utils import client_id_from_id_token
|
||||
|
||||
|
||||
class EndSessionView(LogoutView):
|
||||
class EndSessionView(TemplateView):
|
||||
"""Allow the client to end the Session"""
|
||||
|
||||
def dispatch(
|
||||
self, request: HttpRequest, application_slug: str, *args, **kwargs
|
||||
) -> HttpResponse:
|
||||
template_name = "providers/oauth2/end_session.html"
|
||||
|
||||
application = get_object_or_404(Application, slug=application_slug)
|
||||
provider: OAuth2Provider = get_object_or_404(
|
||||
OAuth2Provider, pk=application.provider_id
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context["application"] = get_object_or_404(
|
||||
Application, slug=self.kwargs["application_slug"]
|
||||
)
|
||||
|
||||
id_token_hint = request.GET.get("id_token_hint", "")
|
||||
post_logout_redirect_uri = request.GET.get("post_logout_redirect_uri", "")
|
||||
state = request.GET.get("state", "")
|
||||
|
||||
if id_token_hint:
|
||||
client_id = client_id_from_id_token(id_token_hint)
|
||||
try:
|
||||
provider = OAuth2Provider.objects.get(client_id=client_id)
|
||||
if post_logout_redirect_uri in provider.post_logout_redirect_uris:
|
||||
if state:
|
||||
uri = urlsplit(post_logout_redirect_uri)
|
||||
query_params = parse_qs(uri.query)
|
||||
query_params["state"] = state
|
||||
uri = uri._replace(query=urlencode(query_params, doseq=True))
|
||||
self.next_page = urlunsplit(uri)
|
||||
else:
|
||||
self.next_page = post_logout_redirect_uri
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
return context
|
||||
|
||||
@ -26,7 +26,6 @@ LOGGER = get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class TokenParams:
|
||||
"""Token params"""
|
||||
|
||||
@ -92,7 +91,7 @@ class TokenParams:
|
||||
|
||||
try:
|
||||
self.refresh_token = RefreshToken.objects.get(
|
||||
refresh_token=raw_token, client=self.provider
|
||||
refresh_token=raw_token, provider=self.provider
|
||||
)
|
||||
|
||||
except RefreshToken.DoesNotExist:
|
||||
@ -219,10 +218,10 @@ class TokenView(View):
|
||||
if unauthorized_scopes:
|
||||
raise TokenError("invalid_scope")
|
||||
|
||||
refresh_token = self.params.refresh_token.provider.create_token(
|
||||
user=self.params.refresh_token.user,
|
||||
provider=self.params.refresh_token.provider,
|
||||
scope=self.params.scope,
|
||||
provider: OAuth2Provider = self.params.refresh_token.provider
|
||||
|
||||
refresh_token: RefreshToken = provider.create_refresh_token(
|
||||
user=self.params.refresh_token.user, scope=self.params.scope,
|
||||
)
|
||||
|
||||
# If the Token has an id_token it's an Authentication request.
|
||||
|
||||
@ -55,6 +55,7 @@ class ProxyProviderSerializer(ModelSerializer):
|
||||
"internal_host",
|
||||
"external_host",
|
||||
"certificate",
|
||||
"skip_path_regex",
|
||||
]
|
||||
|
||||
|
||||
@ -93,6 +94,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
||||
"oidc_configuration",
|
||||
"cookie_secret",
|
||||
"certificate",
|
||||
"skip_path_regex",
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=OpenIDConnectConfigurationSerializer)
|
||||
|
||||
@ -35,6 +35,7 @@ class ProxyProviderForm(forms.ModelForm):
|
||||
"internal_host",
|
||||
"external_host",
|
||||
"certificate",
|
||||
"skip_path_regex",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-19 09:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_proxy", "0005_auto_20200914_1536"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="proxyprovider",
|
||||
name="skip_path_regex",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Regular expression for which authentication is not required. Each new line is interpreted as a new Regular Expression.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -49,6 +49,17 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
|
||||
cookie_secret = models.TextField(default=get_cookie_secret)
|
||||
|
||||
skip_path_regex = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
help_text=_(
|
||||
(
|
||||
"Regular expression for which authentication is not required. "
|
||||
"Each new line is interpreted as a new Regular Expression."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
certificate = models.ForeignKey(
|
||||
CertificateKeyPair, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
)
|
||||
|
||||
@ -107,8 +107,7 @@ class SAMLProvider(Provider):
|
||||
def launch_url(self) -> Optional[str]:
|
||||
"""Guess launch_url based on acs URL"""
|
||||
launch_url = urlparse(self.acs_url)
|
||||
launch_url.path = ""
|
||||
return launch_url.geturl()
|
||||
return self.acs_url.replace(launch_url.path, "")
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.saml.forms import SAMLProviderForm
|
||||
@ -130,7 +129,7 @@ class SAMLProvider(Provider):
|
||||
return None
|
||||
|
||||
def html_metadata_view(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with to view Metadata without downloading it"""
|
||||
"""return template and context modal to view Metadata without downloading it"""
|
||||
from passbook.providers.saml.views import DescriptorDownloadView
|
||||
|
||||
try:
|
||||
@ -161,7 +160,8 @@ class SAMLPropertyMapping(PropertyMapping):
|
||||
return SAMLPropertyMappingForm
|
||||
|
||||
def __str__(self):
|
||||
return f"SAML Property Mapping {self.saml_name}"
|
||||
name = self.friendly_name if self.friendly_name != "" else self.saml_name
|
||||
return f"SAML Property Mapping {self.name} ({name})"
|
||||
|
||||
class Meta:
|
||||
|
||||
|
||||
@ -102,11 +102,14 @@ class ASGILogger:
|
||||
await self.app(scope, receive, send_hooked)
|
||||
|
||||
def _get_ip(self) -> str:
|
||||
client_ip = None
|
||||
for header in ASGI_IP_HEADERS:
|
||||
if header in self.headers:
|
||||
return self.headers[header].decode()
|
||||
client_ip = self.headers[header].decode()
|
||||
if not client_ip:
|
||||
client_ip, _ = self.scope.get("client", ("", 0))
|
||||
return client_ip
|
||||
# Check if header has multiple values, and use the first one
|
||||
return client_ip.split(", ")[0]
|
||||
|
||||
def log(self, runtime: float):
|
||||
"""Outpot access logs in a structured format"""
|
||||
|
||||
@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/2.1/ref/settings/
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from json import dumps
|
||||
|
||||
import structlog
|
||||
@ -21,8 +22,9 @@ from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
|
||||
from passbook import __version__
|
||||
from passbook.core.middleware import structlog_add_request_id
|
||||
from passbook.lib.config import CONFIG
|
||||
from passbook.lib.logging import add_process_id
|
||||
from passbook.lib.logging import add_common_fields, add_process_id
|
||||
from passbook.lib.sentry import before_send
|
||||
|
||||
|
||||
@ -35,7 +37,7 @@ def j_print(event: str, log_level: str = "info", **kwargs):
|
||||
"logger": __name__,
|
||||
}
|
||||
data.update(**kwargs)
|
||||
print(dumps(data))
|
||||
print(dumps(data), file=sys.stderr)
|
||||
|
||||
|
||||
LOGGER = structlog.get_logger()
|
||||
@ -174,11 +176,14 @@ MIDDLEWARE = [
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"passbook.core.middleware.RequestIDMiddleware",
|
||||
"passbook.audit.middleware.AuditMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"passbook.core.middleware.ImpersonateMiddleware",
|
||||
"django_prometheus.middleware.PrometheusAfterMiddleware",
|
||||
]
|
||||
|
||||
@ -328,6 +333,8 @@ structlog.configure_once(
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
add_process_id,
|
||||
add_common_fields(CONFIG.y("error_reporting.environment", "customer")),
|
||||
structlog_add_request_id,
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
|
||||
@ -24,6 +24,7 @@ class LDAPSourceSerializer(ModelSerializer):
|
||||
"user_group_membership_field",
|
||||
"object_uniqueness_field",
|
||||
"sync_users",
|
||||
"sync_users_password",
|
||||
"sync_groups",
|
||||
"sync_parent_group",
|
||||
"property_mappings",
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
"""passbook LDAP Authentication Backend"""
|
||||
from typing import Optional
|
||||
|
||||
import ldap3
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.http import HttpRequest
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.sources.ldap.connector import Connector
|
||||
from passbook.core.models import User
|
||||
from passbook.sources.ldap.models import LDAPSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -18,7 +21,56 @@ class LDAPBackend(ModelBackend):
|
||||
return None
|
||||
for source in LDAPSource.objects.filter(enabled=True):
|
||||
LOGGER.debug("LDAP Auth attempt", source=source)
|
||||
user = Connector(source).auth_user(**kwargs)
|
||||
user = self.auth_user(source, **kwargs)
|
||||
if user:
|
||||
return user
|
||||
return None
|
||||
|
||||
def auth_user(
|
||||
self, source: LDAPSource, password: str, **filters: str
|
||||
) -> Optional[User]:
|
||||
"""Try to bind as either user_dn or mail with password.
|
||||
Returns True on success, otherwise False"""
|
||||
users = User.objects.filter(**filters)
|
||||
if not users.exists():
|
||||
return None
|
||||
user: User = users.first()
|
||||
if "distinguishedName" not in user.attributes:
|
||||
LOGGER.debug(
|
||||
"User doesn't have DN set, assuming not LDAP imported.", user=user
|
||||
)
|
||||
return None
|
||||
# Either has unusable password,
|
||||
# or has a password, but couldn't be authenticated by ModelBackend.
|
||||
# This means we check with a bind to see if the LDAP password has changed
|
||||
if self.auth_user_by_bind(source, user, password):
|
||||
# Password given successfully binds to LDAP, so we save it in our Database
|
||||
LOGGER.debug("Updating user's password in DB", user=user)
|
||||
user.set_password(password, signal=False)
|
||||
user.save()
|
||||
return user
|
||||
# Password doesn't match
|
||||
LOGGER.debug("Failed to bind, password invalid")
|
||||
return None
|
||||
|
||||
def auth_user_by_bind(
|
||||
self, source: LDAPSource, user: User, password: str
|
||||
) -> Optional[User]:
|
||||
"""Attempt authentication by binding to the LDAP server as `user`. This
|
||||
method should be avoided as its slow to do the bind."""
|
||||
# Try to bind as new user
|
||||
LOGGER.debug("Attempting Binding as user", user=user)
|
||||
try:
|
||||
temp_connection = ldap3.Connection(
|
||||
source.connection.server,
|
||||
user=user.attributes.get("distinguishedName"),
|
||||
password=password,
|
||||
raise_exceptions=True,
|
||||
)
|
||||
temp_connection.bind()
|
||||
return user
|
||||
except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
|
||||
LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
|
||||
except ldap3.core.exceptions.LDAPException as exception:
|
||||
LOGGER.warning(exception)
|
||||
return None
|
||||
|
||||
@ -37,6 +37,7 @@ class LDAPSourceForm(forms.ModelForm):
|
||||
"user_group_membership_field",
|
||||
"object_uniqueness_field",
|
||||
"sync_users",
|
||||
"sync_users_password",
|
||||
"sync_groups",
|
||||
"sync_parent_group",
|
||||
"property_mappings",
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-21 09:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_sources_ldap", "0006_auto_20200915_1919"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ldapsource",
|
||||
name="sync_users_password",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="When a user changes their password, sync it back to LDAP. This can only be enabled on a single LDAP source.",
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,13 +1,16 @@
|
||||
"""passbook LDAP Models"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Type
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from ldap3 import Connection, Server
|
||||
from ldap3 import ALL, Connection, Server
|
||||
|
||||
from passbook.core.models import Group, PropertyMapping, Source
|
||||
from passbook.lib.models import DomainlessURLValidator
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
|
||||
class LDAPSource(Source):
|
||||
@ -49,6 +52,16 @@ class LDAPSource(Source):
|
||||
)
|
||||
|
||||
sync_users = models.BooleanField(default=True)
|
||||
sync_users_password = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_(
|
||||
(
|
||||
"When a user changes their password, sync it back to LDAP. "
|
||||
"This can only be enabled on a single LDAP source."
|
||||
)
|
||||
),
|
||||
unique=True,
|
||||
)
|
||||
sync_groups = models.BooleanField(default=True)
|
||||
sync_parent_group = models.ForeignKey(
|
||||
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
|
||||
@ -59,13 +72,27 @@ class LDAPSource(Source):
|
||||
|
||||
return LDAPSourceForm
|
||||
|
||||
def state_cache_prefix(self, suffix: str) -> str:
|
||||
"""Key by which the ldap source status is saved"""
|
||||
return f"source_ldap_{self.pk}_state_{suffix}"
|
||||
|
||||
@property
|
||||
def ui_additional_info(self) -> str:
|
||||
last_sync = cache.get(self.state_cache_prefix("last_sync"), None)
|
||||
if last_sync:
|
||||
last_sync = datetime.fromtimestamp(last_sync)
|
||||
|
||||
return render_to_string(
|
||||
"ldap/source_list_status.html", {"source": self, "last_sync": last_sync}
|
||||
)
|
||||
|
||||
_connection: Optional[Connection] = None
|
||||
|
||||
@property
|
||||
def connection(self) -> Connection:
|
||||
"""Get a fully connected and bound LDAP Connection"""
|
||||
if not self._connection:
|
||||
server = Server(self.server_uri)
|
||||
server = Server(self.server_uri, get_info=ALL)
|
||||
self._connection = Connection(
|
||||
server,
|
||||
raise_exceptions=True,
|
||||
@ -95,7 +122,7 @@ class LDAPPropertyMapping(PropertyMapping):
|
||||
return LDAPPropertyMappingForm
|
||||
|
||||
def __str__(self):
|
||||
return f"LDAP Property Mapping {self.expression} -> {self.object_field}"
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
|
||||
|
||||
155
passbook/sources/ldap/password.py
Normal file
155
passbook/sources/ldap/password.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""Help validate and update passwords in LDAP"""
|
||||
from enum import IntFlag
|
||||
from re import split
|
||||
from typing import Optional
|
||||
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.sources.ldap.models import LDAPSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
NON_ALPHA = r"~!@#$%^&*_-+=`|\(){}[]:;\"'<>,.?/"
|
||||
RE_DISPLAYNAME_SEPARATORS = r",\.–—_\s#\t"
|
||||
|
||||
|
||||
class PwdProperties(IntFlag):
|
||||
"""Possible values for the pwdProperties attribute"""
|
||||
|
||||
DOMAIN_PASSWORD_COMPLEX = 1
|
||||
DOMAIN_PASSWORD_NO_ANON_CHANGE = 2
|
||||
DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4
|
||||
DOMAIN_LOCKOUT_ADMINS = 8
|
||||
DOMAIN_PASSWORD_STORE_CLEARTEXT = 16
|
||||
DOMAIN_REFUSE_PASSWORD_CHANGE = 32
|
||||
|
||||
|
||||
class PasswordCategories(IntFlag):
|
||||
"""Password categories as defined by Microsoft, a category can only be counted
|
||||
once, hence intflag."""
|
||||
|
||||
NONE = 0
|
||||
ALPHA_LOWER = 1
|
||||
ALPHA_UPPER = 2
|
||||
ALPHA_OTHER = 4
|
||||
NUMERIC = 8
|
||||
SYMBOL = 16
|
||||
|
||||
|
||||
class LDAPPasswordChanger:
|
||||
"""Help validate and update passwords in LDAP"""
|
||||
|
||||
_source: LDAPSource
|
||||
|
||||
def __init__(self, source: LDAPSource) -> None:
|
||||
self._source = source
|
||||
|
||||
def get_domain_root_dn(self) -> str:
|
||||
"""Attempt to get root DN via MS specific fields or generic LDAP fields"""
|
||||
info = self._source.connection.server.info
|
||||
if "rootDomainNamingContext" in info.other:
|
||||
return info.other["rootDomainNamingContext"][0]
|
||||
naming_contexts = info.naming_contexts
|
||||
naming_contexts.sort(key=len)
|
||||
return naming_contexts[0]
|
||||
|
||||
def check_ad_password_complexity_enabled(self) -> bool:
|
||||
"""Check if DOMAIN_PASSWORD_COMPLEX is enabled"""
|
||||
root_dn = self.get_domain_root_dn()
|
||||
root_attrs = self._source.connection.extend.standard.paged_search(
|
||||
search_base=root_dn,
|
||||
search_filter="(objectClass=*)",
|
||||
search_scope=ldap3.BASE,
|
||||
attributes=["pwdProperties"],
|
||||
)
|
||||
root_attrs = list(root_attrs)[0]
|
||||
pwd_properties = PwdProperties(root_attrs["attributes"]["pwdProperties"])
|
||||
if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def change_password(self, user: User, password: str):
|
||||
"""Change user's password"""
|
||||
user_dn = user.attributes.get("distinguishedName", None)
|
||||
if not user_dn:
|
||||
raise AttributeError("User has no distinguishedName set.")
|
||||
self._source.connection.extend.microsoft.modify_password(user_dn, password)
|
||||
|
||||
def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
|
||||
"""Check if a password contains sAMAccount or displayName"""
|
||||
users = list(
|
||||
self._source.connection.extend.standard.paged_search(
|
||||
search_base=user_dn,
|
||||
search_filter=self._source.user_object_filter,
|
||||
search_scope=ldap3.BASE,
|
||||
attributes=["displayName", "sAMAccountName"],
|
||||
)
|
||||
)
|
||||
if len(users) != 1:
|
||||
raise AssertionError()
|
||||
user_attributes = users[0]["attributes"]
|
||||
# If sAMAccountName is longer than 3 chars, check if its contained in password
|
||||
if len(user_attributes["sAMAccountName"]) >= 3:
|
||||
if password.lower() in user_attributes["sAMAccountName"].lower():
|
||||
return False
|
||||
display_name_tokens = split(
|
||||
RE_DISPLAYNAME_SEPARATORS, user_attributes["displayName"]
|
||||
)
|
||||
for token in display_name_tokens:
|
||||
# Ignore tokens under 3 chars
|
||||
if len(token) < 3:
|
||||
continue
|
||||
if token.lower() in password.lower():
|
||||
return False
|
||||
return True
|
||||
|
||||
def ad_password_complexity(
|
||||
self, password: str, user: Optional[User] = None
|
||||
) -> bool:
|
||||
"""Check if password matches Active direcotry password policies
|
||||
|
||||
https://docs.microsoft.com/en-us/windows/security/threat-protection/
|
||||
security-policy-settings/password-must-meet-complexity-requirements
|
||||
"""
|
||||
if user:
|
||||
# Check if password contains sAMAccountName or displayNames
|
||||
if "distinguishedName" in user.attributes:
|
||||
existing_user_check = self._ad_check_password_existing(
|
||||
password, user.attributes.get("distinguishedName")
|
||||
)
|
||||
if not existing_user_check:
|
||||
LOGGER.debug("Password failed name check", user=user)
|
||||
return existing_user_check
|
||||
|
||||
# Step 2, match at least 3 of 5 categories
|
||||
matched_categories = PasswordCategories.NONE
|
||||
required = 3
|
||||
for letter in password:
|
||||
# Only match one category per letter,
|
||||
if letter.islower():
|
||||
matched_categories |= PasswordCategories.ALPHA_LOWER
|
||||
elif letter.isupper():
|
||||
matched_categories |= PasswordCategories.ALPHA_UPPER
|
||||
elif not letter.isascii() and letter.isalpha():
|
||||
# Not exactly matching microsoft's policy, but count it as "Other unicode" char
|
||||
# when its alpha and not ascii
|
||||
matched_categories |= PasswordCategories.ALPHA_OTHER
|
||||
elif letter.isnumeric():
|
||||
matched_categories |= PasswordCategories.NUMERIC
|
||||
elif letter in NON_ALPHA:
|
||||
matched_categories |= PasswordCategories.SYMBOL
|
||||
if bin(matched_categories).count("1") < required:
|
||||
LOGGER.debug(
|
||||
"Password didn't match enough categories",
|
||||
has=matched_categories,
|
||||
must=required,
|
||||
)
|
||||
return False
|
||||
LOGGER.debug(
|
||||
"Password matched categories", has=matched_categories, must=required
|
||||
)
|
||||
return True
|
||||
@ -1,9 +1,19 @@
|
||||
"""passbook ldap source signals"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.core.signals import password_changed
|
||||
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from passbook.sources.ldap.models import LDAPSource
|
||||
from passbook.sources.ldap.password import LDAPPasswordChanger
|
||||
from passbook.sources.ldap.tasks import sync_single
|
||||
from passbook.stages.prompt.signals import password_validate
|
||||
|
||||
|
||||
@receiver(post_save, sender=LDAPSource)
|
||||
@ -12,3 +22,38 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
|
||||
"""Ensure that source is synced on save (if enabled)"""
|
||||
if instance.enabled:
|
||||
sync_single.delay(instance.pk)
|
||||
|
||||
|
||||
@receiver(password_validate)
|
||||
# pylint: disable=unused-argument
|
||||
def ldap_password_validate(sender, password: str, plan_context: Dict[str, Any], **__):
|
||||
"""if there's an LDAP Source with enabled password sync, check the password"""
|
||||
sources = LDAPSource.objects.filter(sync_users_password=True)
|
||||
if not sources.exists():
|
||||
return
|
||||
source = sources.first()
|
||||
changer = LDAPPasswordChanger(source)
|
||||
if changer.check_ad_password_complexity_enabled():
|
||||
passing = changer.ad_password_complexity(
|
||||
password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
|
||||
)
|
||||
if not passing:
|
||||
raise ValidationError(
|
||||
_("Password does not match Active Direcory Complexity.")
|
||||
)
|
||||
|
||||
|
||||
@receiver(password_changed)
|
||||
# pylint: disable=unused-argument
|
||||
def ldap_sync_password(sender, user: User, password: str, **_):
|
||||
"""Connect to ldap and update password. We do this in the background to get
|
||||
automatic retries on error."""
|
||||
sources = LDAPSource.objects.filter(sync_users_password=True)
|
||||
if not sources.exists():
|
||||
return
|
||||
source = sources.first()
|
||||
changer = LDAPPasswordChanger(source)
|
||||
try:
|
||||
changer.change_password(user, password)
|
||||
except LDAPException as exc:
|
||||
raise ValidationError("Failed to set password") from exc
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""Wrapper for ldap3 to easily manage user"""
|
||||
from typing import Any, Dict, Optional
|
||||
"""Sync LDAP Users and groups into passbook"""
|
||||
from typing import Any, Dict
|
||||
|
||||
import ldap3
|
||||
import ldap3.core.exceptions
|
||||
@ -13,19 +13,14 @@ from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class Connector:
|
||||
"""Wrapper for ldap3 to easily manage user authentication and creation"""
|
||||
class LDAPSynchronizer:
|
||||
"""Sync LDAP Users and groups into passbook"""
|
||||
|
||||
_source: LDAPSource
|
||||
|
||||
def __init__(self, source: LDAPSource):
|
||||
self._source = source
|
||||
|
||||
@staticmethod
|
||||
def encode_pass(password: str) -> bytes:
|
||||
"""Encodes a plain-text password so it can be used by AD"""
|
||||
return '"{}"'.format(password).encode("utf-16-le")
|
||||
|
||||
@property
|
||||
def base_dn_users(self) -> str:
|
||||
"""Shortcut to get full base_dn for user lookups"""
|
||||
@ -187,48 +182,3 @@ class Connector:
|
||||
"distinguishedName"
|
||||
)
|
||||
return properties
|
||||
|
||||
def auth_user(self, password: str, **filters: str) -> Optional[User]:
|
||||
"""Try to bind as either user_dn or mail with password.
|
||||
Returns True on success, otherwise False"""
|
||||
users = User.objects.filter(**filters)
|
||||
if not users.exists():
|
||||
return None
|
||||
user: User = users.first()
|
||||
if "distinguishedName" not in user.attributes:
|
||||
LOGGER.debug(
|
||||
"User doesn't have DN set, assuming not LDAP imported.", user=user
|
||||
)
|
||||
return None
|
||||
# Either has unusable password,
|
||||
# or has a password, but couldn't be authenticated by ModelBackend.
|
||||
# This means we check with a bind to see if the LDAP password has changed
|
||||
if self.auth_user_by_bind(user, password):
|
||||
# Password given successfully binds to LDAP, so we save it in our Database
|
||||
LOGGER.debug("Updating user's password in DB", user=user)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
return user
|
||||
# Password doesn't match
|
||||
LOGGER.debug("Failed to bind, password invalid")
|
||||
return None
|
||||
|
||||
def auth_user_by_bind(self, user: User, password: str) -> Optional[User]:
|
||||
"""Attempt authentication by binding to the LDAP server as `user`. This
|
||||
method should be avoided as its slow to do the bind."""
|
||||
# Try to bind as new user
|
||||
LOGGER.debug("Attempting Binding as user", user=user)
|
||||
try:
|
||||
temp_connection = ldap3.Connection(
|
||||
self._source.connection.server,
|
||||
user=user.attributes.get("distinguishedName"),
|
||||
password=password,
|
||||
raise_exceptions=True,
|
||||
)
|
||||
temp_connection.bind()
|
||||
return user
|
||||
except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
|
||||
LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
|
||||
except ldap3.core.exceptions.LDAPException as exception:
|
||||
LOGGER.warning(exception)
|
||||
return None
|
||||
@ -1,7 +1,11 @@
|
||||
"""LDAP Sync tasks"""
|
||||
from time import time
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
from passbook.root.celery import CELERY_APP
|
||||
from passbook.sources.ldap.connector import Connector
|
||||
from passbook.sources.ldap.models import LDAPSource
|
||||
from passbook.sources.ldap.sync import LDAPSynchronizer
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
@ -14,8 +18,10 @@ def sync():
|
||||
@CELERY_APP.task()
|
||||
def sync_single(source_pk):
|
||||
"""Sync a single source"""
|
||||
source = LDAPSource.objects.get(pk=source_pk)
|
||||
connector = Connector(source)
|
||||
connector.sync_users()
|
||||
connector.sync_groups()
|
||||
connector.sync_membership()
|
||||
source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
|
||||
syncer = LDAPSynchronizer(source)
|
||||
syncer.sync_users()
|
||||
syncer.sync_groups()
|
||||
syncer.sync_membership()
|
||||
cache_key = source.state_cache_prefix("last_sync")
|
||||
cache.set(cache_key, time(), timeout=60 * 60)
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
{% load humanize %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if last_sync %}
|
||||
<i class="fas fa-check pf-m-success"></i> {% blocktrans with last_sync=last_sync|naturaltime %}Synced {{ last_sync }}.{% endblocktrans %}
|
||||
{% else %}
|
||||
<i class="fas fa-times pf-m-danger"></i> Not synced yet/Sync in Progress
|
||||
{% endif %}
|
||||
@ -1,149 +0,0 @@
|
||||
"""LDAP Source tests"""
|
||||
from unittest.mock import Mock, PropertyMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
|
||||
|
||||
from passbook.core.models import Group, User
|
||||
from passbook.providers.oauth2.generators import generate_client_secret
|
||||
from passbook.sources.ldap.auth import LDAPBackend
|
||||
from passbook.sources.ldap.connector import Connector
|
||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from passbook.sources.ldap.tasks import sync
|
||||
|
||||
|
||||
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=group1,ou=groups,ou=test,o=lab",
|
||||
{
|
||||
"name": "test-group",
|
||||
"objectSid": "unique-test-group",
|
||||
"objectCategory": "Group",
|
||||
"distinguishedName": "cn=group1,ou=groups,ou=test,o=lab",
|
||||
},
|
||||
)
|
||||
# Group without SID
|
||||
connection.strategy.add_entry(
|
||||
"cn=group2,ou=groups,ou=test,o=lab",
|
||||
{
|
||||
"name": "test-group",
|
||||
"objectCategory": "Group",
|
||||
"distinguishedName": "cn=group2,ou=groups,ou=test,o=lab",
|
||||
},
|
||||
)
|
||||
connection.strategy.add_entry(
|
||||
"cn=user0,ou=users,ou=test,o=lab",
|
||||
{
|
||||
"userPassword": LDAP_PASSWORD,
|
||||
"sAMAccountName": "user0_sn",
|
||||
"name": "user0_sn",
|
||||
"revision": 0,
|
||||
"objectSid": "user0",
|
||||
"objectCategory": "Person",
|
||||
"memberOf": "cn=group1,ou=groups,ou=test,o=lab",
|
||||
},
|
||||
)
|
||||
# User without SID
|
||||
connection.strategy.add_entry(
|
||||
"cn=user1,ou=users,ou=test,o=lab",
|
||||
{
|
||||
"userPassword": "test1111",
|
||||
"sAMAccountName": "user2_sn",
|
||||
"name": "user1_sn",
|
||||
"revision": 0,
|
||||
"objectCategory": "Person",
|
||||
},
|
||||
)
|
||||
# Duplicate users
|
||||
connection.strategy.add_entry(
|
||||
"cn=user2,ou=users,ou=test,o=lab",
|
||||
{
|
||||
"userPassword": "test2222",
|
||||
"sAMAccountName": "user2_sn",
|
||||
"name": "user2_sn",
|
||||
"revision": 0,
|
||||
"objectSid": "unique-test2222",
|
||||
"objectCategory": "Person",
|
||||
},
|
||||
)
|
||||
connection.strategy.add_entry(
|
||||
"cn=user3,ou=users,ou=test,o=lab",
|
||||
{
|
||||
"userPassword": "test2222",
|
||||
"sAMAccountName": "user2_sn",
|
||||
"name": "user2_sn",
|
||||
"revision": 0,
|
||||
"objectSid": "unique-test2222",
|
||||
"objectCategory": "Person",
|
||||
},
|
||||
)
|
||||
connection.bind()
|
||||
return connection
|
||||
|
||||
|
||||
LDAP_PASSWORD = generate_client_secret()
|
||||
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="ou=test,o=lab",
|
||||
additional_user_dn="ou=users",
|
||||
additional_group_dn="ou=groups",
|
||||
)
|
||||
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()
|
||||
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||
|
||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_sync_groups(self):
|
||||
"""Test group sync"""
|
||||
connector = Connector(self.source)
|
||||
connector.sync_groups()
|
||||
connector.sync_membership()
|
||||
group = Group.objects.filter(name="test-group")
|
||||
self.assertTrue(group.exists())
|
||||
|
||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_auth(self):
|
||||
"""Test Cached auth"""
|
||||
connector = Connector(self.source)
|
||||
connector.sync_users()
|
||||
|
||||
user = User.objects.get(username="user0_sn")
|
||||
auth_user_by_bind = Mock(return_value=user)
|
||||
with patch(
|
||||
"passbook.sources.ldap.connector.Connector.auth_user_by_bind",
|
||||
auth_user_by_bind,
|
||||
):
|
||||
backend = LDAPBackend()
|
||||
self.assertEqual(
|
||||
backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
|
||||
user,
|
||||
)
|
||||
|
||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_tasks(self):
|
||||
"""Test Scheduled tasks"""
|
||||
sync()
|
||||
0
passbook/sources/ldap/tests/__init__.py
Normal file
0
passbook/sources/ldap/tests/__init__.py
Normal file
47
passbook/sources/ldap/tests/test_auth.py
Normal file
47
passbook/sources/ldap/tests/test_auth.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""LDAP Source tests"""
|
||||
from unittest.mock import Mock, PropertyMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.providers.oauth2.generators import generate_client_secret
|
||||
from passbook.sources.ldap.auth import LDAPBackend
|
||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from passbook.sources.ldap.sync import LDAPSynchronizer
|
||||
from passbook.sources.ldap.tests.utils import _build_mock_connection
|
||||
|
||||
LDAP_PASSWORD = generate_client_secret()
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||
|
||||
|
||||
class LDAPSyncTests(TestCase):
|
||||
"""LDAP Sync tests"""
|
||||
|
||||
def setUp(self):
|
||||
self.source = LDAPSource.objects.create(
|
||||
name="ldap",
|
||||
slug="ldap",
|
||||
base_dn="DC=AD2012,DC=LAB",
|
||||
additional_user_dn="ou=users",
|
||||
additional_group_dn="ou=groups",
|
||||
)
|
||||
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||
self.source.save()
|
||||
|
||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_auth_synced_user(self):
|
||||
"""Test Cached auth"""
|
||||
syncer = LDAPSynchronizer(self.source)
|
||||
syncer.sync_users()
|
||||
|
||||
user = User.objects.get(username="user0_sn")
|
||||
auth_user_by_bind = Mock(return_value=user)
|
||||
with patch(
|
||||
"passbook.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
|
||||
auth_user_by_bind,
|
||||
):
|
||||
backend = LDAPBackend()
|
||||
self.assertEqual(
|
||||
backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
|
||||
user,
|
||||
)
|
||||
54
passbook/sources/ldap/tests/test_password.py
Normal file
54
passbook/sources/ldap/tests/test_password.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""LDAP Source tests"""
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.providers.oauth2.generators import generate_client_secret
|
||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from passbook.sources.ldap.password import LDAPPasswordChanger
|
||||
from passbook.sources.ldap.tests.utils import _build_mock_connection
|
||||
|
||||
LDAP_PASSWORD = generate_client_secret()
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||
|
||||
|
||||
class LDAPPasswordTests(TestCase):
|
||||
"""LDAP Password tests"""
|
||||
|
||||
def setUp(self):
|
||||
self.source = LDAPSource.objects.create(
|
||||
name="ldap",
|
||||
slug="ldap",
|
||||
base_dn="DC=AD2012,DC=LAB",
|
||||
additional_user_dn="ou=users",
|
||||
additional_group_dn="ou=groups",
|
||||
)
|
||||
self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
|
||||
self.source.save()
|
||||
|
||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_password_complexity(self):
|
||||
"""Test password without user"""
|
||||
pwc = LDAPPasswordChanger(self.source)
|
||||
self.assertFalse(pwc.ad_password_complexity("test")) # 1 category
|
||||
self.assertFalse(pwc.ad_password_complexity("test1")) # 2 categories
|
||||
self.assertTrue(pwc.ad_password_complexity("test1!")) # 2 categories
|
||||
|
||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_password_complexity_user(self):
|
||||
"""test password with user"""
|
||||
pwc = LDAPPasswordChanger(self.source)
|
||||
user = User.objects.create(
|
||||
username="test",
|
||||
attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"},
|
||||
)
|
||||
self.assertFalse(pwc.ad_password_complexity("test", user)) # 1 category
|
||||
self.assertFalse(pwc.ad_password_complexity("test1", user)) # 2 categories
|
||||
self.assertTrue(pwc.ad_password_complexity("test1!", user)) # 2 categories
|
||||
self.assertFalse(
|
||||
pwc.ad_password_complexity("erin!qewrqewr", user)
|
||||
) # displayName token
|
||||
self.assertFalse(
|
||||
pwc.ad_password_complexity("hagens!qewrqewr", user)
|
||||
) # displayName token
|
||||
51
passbook/sources/ldap/tests/test_sync.py
Normal file
51
passbook/sources/ldap/tests/test_sync.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""LDAP Source tests"""
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from passbook.core.models import Group, User
|
||||
from passbook.providers.oauth2.generators import generate_client_secret
|
||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
|
||||
from passbook.sources.ldap.sync import LDAPSynchronizer
|
||||
from passbook.sources.ldap.tasks import sync
|
||||
from passbook.sources.ldap.tests.utils import _build_mock_connection
|
||||
|
||||
LDAP_PASSWORD = generate_client_secret()
|
||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
|
||||
|
||||
|
||||
class LDAPSyncTests(TestCase):
|
||||
"""LDAP Sync tests"""
|
||||
|
||||
def setUp(self):
|
||||
self.source = LDAPSource.objects.create(
|
||||
name="ldap",
|
||||
slug="ldap",
|
||||
base_dn="DC=AD2012,DC=LAB",
|
||||
additional_user_dn="ou=users",
|
||||
additional_group_dn="ou=groups",
|
||||
)
|
||||
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"""
|
||||
syncer = LDAPSynchronizer(self.source)
|
||||
syncer.sync_users()
|
||||
self.assertTrue(User.objects.filter(username="user0_sn").exists())
|
||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||
|
||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_sync_groups(self):
|
||||
"""Test group sync"""
|
||||
syncer = LDAPSynchronizer(self.source)
|
||||
syncer.sync_groups()
|
||||
syncer.sync_membership()
|
||||
group = Group.objects.filter(name="test-group")
|
||||
self.assertTrue(group.exists())
|
||||
|
||||
@patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
|
||||
def test_tasks(self):
|
||||
"""Test Scheduled tasks"""
|
||||
sync()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user