Compare commits

...

22 Commits

Author SHA1 Message Date
4eaa46e717 new release: 0.10.6-stable 2020-09-21 22:07:59 +02:00
59e8dca499 sources/ldap: divide connector into password, sync and auth, add unittests for password 2020-09-21 21:40:41 +02:00
945d5bfaf6 *: use Audit custom event action, add SOURCE_LINKED event action 2020-09-21 20:40:45 +02:00
dbcdab05ff audit: create audit logs for model creation/updating/deletion 2020-09-21 20:26:30 +02:00
e2cc2843d8 core: add X-passbook-id to every request with unique ID 2020-09-21 19:37:44 +02:00
241d59be8d ci: test migration from last released version to current branch (#224)
* ci: test migration test from last released version to current branch

* ci: fix typo

* ci: remove hyphens

* ci: checkout Build.SourceBranchName

* ci: attempt to fix Build.SourceBranchName

https://github.com/microsoft/azure-pipelines-tasks/issues/8793

* ci: fix duplicate variables entry

* ci: fix quoting for docker jobs

* ci: attempt to access branchName directly

* ci: attempt to extract branch name via sed

* ci: fix escaping for Build.SourceBranch

* ci: different bash substitution

* ci: replace /refs/pulls

* ci: attempt to save previous branch as variable

* ci: fix indent

* ci: try compile-time variables for docker

* ci: always use Build.SourceBranch

* ci: use compile-time template expression

* ci: use Build.SourceBranchName

* ci: attempt to get branch name from System.PullRequest.SourceBranch
2020-09-21 17:55:57 +02:00
74251a8883 audit: update swagger for event 2020-09-21 13:41:53 +02:00
585afd1bcd core: remove migration dependency on ldap 2020-09-21 13:21:03 +02:00
8358574484 audit: remove foreign key to user, save user data as json 2020-09-21 13:20:50 +02:00
cbcdaaf532 providers/oauth2: fix creation of new refresh token 2020-09-21 11:48:23 +02:00
f99eaa85ac sources/ldap: implement LDAP password validation and syncing 2020-09-21 11:46:35 +02:00
5007a6befe stages/prompt: integrate password comparison when multiple password fields are given 2020-09-21 11:04:31 +02:00
50c75087b8 lifecycle: fix startup logs not being full json 2020-09-21 11:04:31 +02:00
438e4efd49 build(deps-dev): bump django-debug-toolbar from 2.2 to 3.0 (#223)
Bumps [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) from 2.2 to 3.0.
- [Release notes](https://github.com/jazzband/django-debug-toolbar/releases)
- [Changelog](https://github.com/jazzband/django-debug-toolbar/blob/master/docs/changes.rst)
- [Commits](https://github.com/jazzband/django-debug-toolbar/compare/2.2...3.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>

Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
2020-09-21 10:59:43 +02:00
c7ca95ff2b new release: 0.10.5-stable 2020-09-20 13:58:33 +02:00
9f403a71ed root: fix IP detection when using multiple reverse proxies 2020-09-20 13:36:23 +02:00
2f4139df65 docs: add notice to use https when using external reverse proxy 2020-09-20 13:36:07 +02:00
f3ee8f7d9c admin: fix permissions not being checked for policybinding list 2020-09-19 23:07:39 +02:00
5fa3729702 audit: fix fields for events from impersonation being swapped 2020-09-19 22:54:36 +02:00
87f44fada4 providers/oauth2: fix refreshtoken being initialised wrong 2020-09-19 22:23:11 +02:00
c0026f3e16 admin: move pf-m-success to base css 2020-09-19 21:12:39 +02:00
c1051059f4 proxy: fix empty regex field being interpreted as regex 2020-09-19 21:05:41 +02:00
62 changed files with 1152 additions and 404 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.10.4-stable current_version = 0.10.6-stable
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*) parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)

View File

@ -18,11 +18,11 @@ jobs:
- name: Building Docker Image - name: Building Docker Image
run: docker build run: docker build
--no-cache --no-cache
-t beryju/passbook:0.10.4-stable -t beryju/passbook:0.10.6-stable
-t beryju/passbook:latest -t beryju/passbook:latest
-f Dockerfile . -f Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook:0.10.4-stable run: docker push beryju/passbook:0.10.6-stable
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook:latest run: docker push beryju/passbook:latest
build-proxy: build-proxy:
@ -48,11 +48,11 @@ jobs:
cd proxy cd proxy
docker build \ docker build \
--no-cache \ --no-cache \
-t beryju/passbook-proxy:0.10.4-stable \ -t beryju/passbook-proxy:0.10.6-stable \
-t beryju/passbook-proxy:latest \ -t beryju/passbook-proxy:latest \
-f Dockerfile . -f Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-proxy:0.10.4-stable run: docker push beryju/passbook-proxy:0.10.6-stable
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-proxy:latest run: docker push beryju/passbook-proxy:latest
build-static: build-static:
@ -77,11 +77,11 @@ jobs:
run: docker build run: docker build
--no-cache --no-cache
--network=$(docker network ls | grep github | awk '{print $1}') --network=$(docker network ls | grep github | awk '{print $1}')
-t beryju/passbook-static:0.10.4-stable -t beryju/passbook-static:0.10.6-stable
-t beryju/passbook-static:latest -t beryju/passbook-static:latest
-f static.Dockerfile . -f static.Dockerfile .
- name: Push Docker Container to Registry (versioned) - name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-static:0.10.4-stable run: docker push beryju/passbook-static:0.10.6-stable
- name: Push Docker Container to Registry (latest) - name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-static:latest run: docker push beryju/passbook-static:latest
test-release: test-release:
@ -114,5 +114,5 @@ jobs:
SENTRY_PROJECT: passbook SENTRY_PROJECT: passbook
SENTRY_URL: https://sentry.beryju.org SENTRY_URL: https://sentry.beryju.org
with: with:
tagName: 0.10.4-stable tagName: 0.10.6-stable
environment: beryjuorg-prod environment: beryjuorg-prod

14
Pipfile.lock generated
View File

@ -74,7 +74,8 @@
}, },
"boto3": { "boto3": {
"hashes": [ "hashes": [
"sha256:44073b1b1823ffc9edcf9027afbca908dad6bd5000f512ca73f929f6a604ae24" "sha256:44073b1b1823ffc9edcf9027afbca908dad6bd5000f512ca73f929f6a604ae24",
"sha256:888be45e289ba56c4e47cfae5d6b08f097bc981d077fbe6521a6d3dc7a4d757e"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.15.1" "version": "==1.15.1"
@ -748,20 +749,25 @@
"sha256:54bdedd28476dea8a3cd86cb67c0df1f0e3d71cae8022354b0f879c41a3d27b2", "sha256:54bdedd28476dea8a3cd86cb67c0df1f0e3d71cae8022354b0f879c41a3d27b2",
"sha256:55eb61aca2c883db770999f50d091ff7c14016f2769ad7bca3d9b75d1d7c1b68", "sha256:55eb61aca2c883db770999f50d091ff7c14016f2769ad7bca3d9b75d1d7c1b68",
"sha256:6276478ada411aca97c0d5104916354b3d740d368407912722bd4d11aa9ee4c2", "sha256:6276478ada411aca97c0d5104916354b3d740d368407912722bd4d11aa9ee4c2",
"sha256:663f8de2b3df2e744d6e1610506e0ea4e213bde906795953c1e82279c169f0a7",
"sha256:67dcad1b8b201308586a8ca2ffe89df1e4f731d5a4cdd0610cc4ea790351c739", "sha256:67dcad1b8b201308586a8ca2ffe89df1e4f731d5a4cdd0610cc4ea790351c739",
"sha256:709b9f144d23e290b9863121d1ace14a72e01f66ea9c903fbdc690520dfdfcf0", "sha256:709b9f144d23e290b9863121d1ace14a72e01f66ea9c903fbdc690520dfdfcf0",
"sha256:8063a712fba642f78d3c506b0896846601b6de7f5c3d534e388ad0cc07f5a149", "sha256:8063a712fba642f78d3c506b0896846601b6de7f5c3d534e388ad0cc07f5a149",
"sha256:80d57177a0b7c14d4594c62bbb47fe2f6309ad3b0a34348a291d570925c97a82", "sha256:80d57177a0b7c14d4594c62bbb47fe2f6309ad3b0a34348a291d570925c97a82",
"sha256:87006cf0d81505408f1ae4f55cf8a5d95a8e029a4793360720ae17c6500f7ecc",
"sha256:9f62d21bc693f3d7d444f17ed2ad7a913b4c37c15cd807895d013c39c0517dfd",
"sha256:a207231a52426de3ff20f5608f0687261a3329d97a036c51f7d4c606a6f30c23", "sha256:a207231a52426de3ff20f5608f0687261a3329d97a036c51f7d4c606a6f30c23",
"sha256:abc2e126c9490e58a36a0f83516479e781d83adfb134576a5cbe5c6af2a3e93c", "sha256:abc2e126c9490e58a36a0f83516479e781d83adfb134576a5cbe5c6af2a3e93c",
"sha256:b56638d58a3a4be13229c6a815cd448f9e3ce40c00880a5398471b42ee86f50e", "sha256:b56638d58a3a4be13229c6a815cd448f9e3ce40c00880a5398471b42ee86f50e",
"sha256:bcd5b8416e73e4b0d48afba3704d8c826414764dafaed7a1a93c442188d90ccc", "sha256:bcd5b8416e73e4b0d48afba3704d8c826414764dafaed7a1a93c442188d90ccc",
"sha256:bec2bcdf7c9ce7f04d718e51887f3b05dc5c1cfaf5d2c2e9065ecddd1b2f6c9a", "sha256:bec2bcdf7c9ce7f04d718e51887f3b05dc5c1cfaf5d2c2e9065ecddd1b2f6c9a",
"sha256:c8bf40cf6e281a4378e25846924327e728a887e8bf0ee83b2604a0f4b61692e8", "sha256:c8bf40cf6e281a4378e25846924327e728a887e8bf0ee83b2604a0f4b61692e8",
"sha256:cecbf67e81d6144a50dc615629772859463b2e4f815d0c082fa421db362f040e",
"sha256:d8074c8448cfd0705dfa71ca333277fce9786d0b9cac75d120545de6253f996a", "sha256:d8074c8448cfd0705dfa71ca333277fce9786d0b9cac75d120545de6253f996a",
"sha256:dd302b6ae3965afeb5ef1b0d92486f986c0e65183cd7835973f0b593800590e6", "sha256:dd302b6ae3965afeb5ef1b0d92486f986c0e65183cd7835973f0b593800590e6",
"sha256:de6e1cd75677423ff64712c337521e62e3a7a4fc84caabbd93207752e831a85a", "sha256:de6e1cd75677423ff64712c337521e62e3a7a4fc84caabbd93207752e831a85a",
"sha256:ef39c98d9b8c0736d91937d193653e47c3b19ddf4fc3bccdc5e09aaa4b0c5d21", "sha256:ef39c98d9b8c0736d91937d193653e47c3b19ddf4fc3bccdc5e09aaa4b0c5d21",
"sha256:f2e045224074d5664dc9cbabbf4f4d4d46f1ee90f24780e3a9a668fd096ff17f",
"sha256:f521178e5a991ffd04182ed08f552daca1affcb826aeda0e1945cd989a9d4345", "sha256:f521178e5a991ffd04182ed08f552daca1affcb826aeda0e1945cd989a9d4345",
"sha256:f78a68c2c820e4731e510a2df3eef0322f24fde1781ced970bf497b6c7d92982", "sha256:f78a68c2c820e4731e510a2df3eef0322f24fde1781ced970bf497b6c7d92982",
"sha256:fbe65d5cfe04ff2f7684160d50f5118bdefb01e3af4718eeb618bfed40f19d94" "sha256:fbe65d5cfe04ff2f7684160d50f5118bdefb01e3af4718eeb618bfed40f19d94"
@ -1316,11 +1322,11 @@
}, },
"django-debug-toolbar": { "django-debug-toolbar": {
"hashes": [ "hashes": [
"sha256:eabbefe89881bbe4ca7c980ff102e3c35c8e8ad6eb725041f538988f2f39a943", "sha256:23129b4f771605c7ccf8733cc53558a68c5d463d60cdc83408d34b713acf4f5f",
"sha256:ff94725e7aae74b133d0599b9bf89bd4eb8f5d2c964106e61d11750228c8774c" "sha256:7c9bf93eabb1e745fe1fca830242d49f3c839d35163e5b53914009ed111209b1"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.2" "version": "==3.0"
}, },
"docker": { "docker": {
"hashes": [ "hashes": [

View File

@ -8,6 +8,10 @@ variables:
POSTGRES_DB: passbook POSTGRES_DB: passbook
POSTGRES_USER: passbook POSTGRES_USER: passbook
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77" 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: stages:
- stage: Lint - stage: Lint
@ -117,6 +121,41 @@ stages:
- task: CmdLine@2 - task: CmdLine@2
inputs: inputs:
script: pipenv run ./manage.py migrate 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 - job: coverage_unittest
pool: pool:
vmImage: 'ubuntu-latest' vmImage: 'ubuntu-latest'
@ -265,7 +304,7 @@ stages:
repository: 'beryju/passbook' repository: 'beryju/passbook'
command: 'buildAndPush' command: 'buildAndPush'
Dockerfile: 'Dockerfile' Dockerfile: 'Dockerfile'
tags: 'gh-$(Build.SourceBranchName)' tags: "gh-${{ variables.branchName }}"
- job: build_static - job: build_static
pool: pool:
vmImage: 'ubuntu-latest' vmImage: 'ubuntu-latest'
@ -282,14 +321,14 @@ stages:
repository: 'beryju/passbook-static' repository: 'beryju/passbook-static'
command: 'build' command: 'build'
Dockerfile: 'static.Dockerfile' Dockerfile: 'static.Dockerfile'
tags: 'gh-$(Build.SourceBranchName)' tags: "gh-${{ variables.branchName }}"
arguments: "--network=beryjupassbook_default" arguments: "--network=beryjupassbook_default"
- task: Docker@2 - task: Docker@2
inputs: inputs:
containerRegistry: 'dockerhub' containerRegistry: 'dockerhub'
repository: 'beryju/passbook-static' repository: 'beryju/passbook-static'
command: 'push' command: 'push'
tags: 'gh-$(Build.SourceBranchName)' tags: "gh-${{ variables.branchName }}"
- stage: Deploy - stage: Deploy
jobs: jobs:
- job: deploy_dev - job: deploy_dev

View File

@ -23,7 +23,7 @@ services:
labels: labels:
- traefik.enable=false - traefik.enable=false
server: server:
image: beryju/passbook:${PASSBOOK_TAG:-0.10.4-stable} image: beryju/passbook:${PASSBOOK_TAG:-0.10.6-stable}
command: server command: server
environment: environment:
PASSBOOK_REDIS__HOST: redis PASSBOOK_REDIS__HOST: redis
@ -41,7 +41,7 @@ services:
env_file: env_file:
- .env - .env
worker: worker:
image: beryju/passbook:${PASSBOOK_TAG:-0.10.4-stable} image: beryju/passbook:${PASSBOOK_TAG:-0.10.6-stable}
command: worker command: worker
networks: networks:
- internal - internal
@ -55,7 +55,7 @@ services:
env_file: env_file:
- .env - .env
static: static:
image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.4-stable} image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.6-stable}
networks: networks:
- internal - internal
labels: labels:

View File

@ -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": { "identifiers": {
"pk": "096e6282-6b30-4695-bd03-3b143eab5580", "pk": "096e6282-6b30-4695-bd03-3b143eab5580",
"name": "default-enrollment-email-verficiation" "name": "default-enrollment-email-verficiation"
@ -135,9 +126,6 @@
"cb954fd4-65a5-4ad9-b1ee-180ee9559cf4", "cb954fd4-65a5-4ad9-b1ee-180ee9559cf4",
"7db91ee8-4290-4e08-8d39-63f132402515", "7db91ee8-4290-4e08-8d39-63f132402515",
"d30b5eb4-7787-4072-b1ba-65b46e928920" "d30b5eb4-7787-4072-b1ba-65b46e928920"
],
"validation_policies": [
"9922212c-47a2-475a-9905-abeb5e621652"
] ]
} }
}, },

View File

@ -55,16 +55,6 @@
"order": 1 "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": { "identifiers": {
"pk": "e54045a7-6ecb-4ad9-ad37-28e72d8e565e", "pk": "e54045a7-6ecb-4ad9-ad37-28e72d8e565e",
@ -118,9 +108,6 @@
"fields": [ "fields": [
"7db91ee8-4290-4e08-8d39-63f132402515", "7db91ee8-4290-4e08-8d39-63f132402515",
"d30b5eb4-7787-4072-b1ba-65b46e928920" "d30b5eb4-7787-4072-b1ba-65b46e928920"
],
"validation_policies": [
"cd042fc6-cc92-4b98-b7e6-f4729df798d8"
] ]
} }
}, },

View File

@ -13,7 +13,7 @@ Download the latest `docker-compose.yml` from [here](https://raw.githubuserconte
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING__ENABLED=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.4-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: 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. 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. 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.

View File

@ -11,7 +11,7 @@ This installation automatically applies database migrations on startup. After th
image: image:
name: beryju/passbook name: beryju/passbook
name_static: beryju/passbook-static name_static: beryju/passbook-static
tag: 0.10.4-stable tag: 0.10.6-stable
nameOverride: "" nameOverride: ""

View File

@ -10,7 +10,6 @@ from selenium.webdriver.support import expected_conditions as ec
from e2e.utils import USER, SeleniumTestCase from e2e.utils import USER, SeleniumTestCase
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding 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.email.models import EmailStage, EmailTemplates
from passbook.stages.identification.models import IdentificationStage from passbook.stages.identification.models import IdentificationStage
from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage 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 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 # Stages
first_stage = PromptStage.objects.create(name="prompt-stage-first") first_stage = PromptStage.objects.create(name="prompt-stage-first")
first_stage.fields.set([username_prompt, password, password_repeat]) first_stage.fields.set([username_prompt, password, password_repeat])
first_stage.validation_policies.set([password_policy])
first_stage.save() first_stage.save()
second_stage = PromptStage.objects.create(name="prompt-stage-second") second_stage = PromptStage.objects.create(name="prompt-stage-second")
second_stage.fields.set([name_field, email]) 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 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 # Stages
first_stage = PromptStage.objects.create(name="prompt-stage-first") first_stage = PromptStage.objects.create(name="prompt-stage-first")
first_stage.fields.set([username_prompt, password, password_repeat]) first_stage.fields.set([username_prompt, password, password_repeat])
first_stage.validation_policies.set([password_policy])
first_stage.save() first_stage.save()
second_stage = PromptStage.objects.create(name="prompt-stage-second") second_stage = PromptStage.objects.create(name="prompt-stage-second")
second_stage.fields.set([name_field, email]) second_stage.fields.set([name_field, email])

View File

@ -1,8 +1,8 @@
apiVersion: v2 apiVersion: v2
appVersion: "0.10.4-stable" appVersion: "0.10.6-stable"
description: A Helm chart for passbook. description: A Helm chart for passbook.
name: passbook name: passbook
version: "0.10.4-stable" version: "0.10.6-stable"
icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
dependencies: dependencies:
- name: postgresql - name: postgresql

View File

@ -4,7 +4,7 @@
image: image:
name: beryju/passbook name: beryju/passbook
name_static: beryju/passbook-static name_static: beryju/passbook-static
tag: 0.10.4-stable tag: 0.10.6-stable
nameOverride: "" nameOverride: ""

View File

@ -1,16 +1,28 @@
#!/usr/bin/env python #!/usr/bin/env python
"""This file needs to be run from the root of the project to correctly """This file needs to be run from the root of the project to correctly
import passbook. This is done by the dockerfile.""" import passbook. This is done by the dockerfile."""
from json import dumps
from sys import stderr
from time import sleep from time import sleep
from psycopg2 import OperationalError, connect from psycopg2 import OperationalError, connect
from redis import Redis from redis import Redis
from redis.exceptions import RedisError from redis.exceptions import RedisError
from structlog import get_logger
from passbook.lib.config import CONFIG 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: while True:
try: try:
@ -24,7 +36,7 @@ while True:
break break
except OperationalError: except OperationalError:
sleep(1) sleep(1)
LOGGER.warning("PostgreSQL Connection failed, retrying...") j_print("PostgreSQL Connection failed, retrying...")
while True: while True:
try: try:
@ -38,4 +50,4 @@ while True:
break break
except RedisError: except RedisError:
sleep(1) sleep(1)
LOGGER.warning("Redis Connection failed, retrying...") j_print("Redis Connection failed, retrying...")

View File

@ -1,2 +1,2 @@
"""passbook""" """passbook"""
__version__ = "0.10.4-stable" __version__ = "0.10.6-stable"

View File

@ -5,18 +5,6 @@
{% load passbook_utils %} {% load passbook_utils %}
{% load admin_reflection %} {% load admin_reflection %}
{% 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 %}
{% block content %} {% block content %}
<section class="pf-c-page__main-section pf-m-light"> <section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content"> <div class="pf-c-content">

View File

@ -9,11 +9,12 @@ from django.urls import reverse_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from guardian.shortcuts import get_objects_for_user
from passbook.admin.views.utils import DeleteMessageView from passbook.admin.views.utils import DeleteMessageView
from passbook.lib.views import CreateAssignPermView from passbook.lib.views import CreateAssignPermView
from passbook.policies.forms import PolicyBindingForm from passbook.policies.forms import PolicyBindingForm
from passbook.policies.models import PolicyBinding, PolicyBindingModel from passbook.policies.models import PolicyBinding
class PolicyBindingListView(LoginRequiredMixin, PermissionListMixin, ListView): 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 # 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 # First, get all pbm objects that have bindings attached
objects = ( 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_subclasses()
.select_related() .select_related()
.order_by("pk") .order_by("pk")
) )
for pbm in objects: 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 return objects

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

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

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

View File

@ -1,7 +1,6 @@
"""passbook audit models""" """passbook audit models"""
from enum import Enum
from inspect import getmodule, stack from inspect import getmodule, stack
from typing import Any, Dict, Optional from typing import Any, Dict, Optional, Union
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from django.conf import settings from django.conf import settings
@ -12,13 +11,17 @@ from django.db.models.base import Model
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.debug import SafeExceptionReporterFilter 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 structlog import get_logger
from passbook.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER 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 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]: def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
@ -50,6 +53,22 @@ def model_to_dict(model: Model) -> Dict[str, Any]:
} }
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]: def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
"""clean source of all Models that would interfere with the JSONField. """clean source of all Models that would interfere with the JSONField.
Models are replaced with a dictionary of { Models are replaced with a dictionary of {
@ -70,38 +89,39 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
return final_dict return final_dict
class EventAction(Enum): class EventAction(models.TextChoices):
"""All possible actions to save into the audit log""" """All possible actions to save into the audit log"""
LOGIN = "login" LOGIN = "login"
LOGIN_FAILED = "login_failed" LOGIN_FAILED = "login_failed"
LOGOUT = "logout" LOGOUT = "logout"
SIGN_UP = "sign_up"
AUTHORIZE_APPLICATION = "authorize_application" AUTHORIZE_APPLICATION = "authorize_application"
SUSPICIOUS_REQUEST = "suspicious_request" SUSPICIOUS_REQUEST = "suspicious_request"
SIGN_UP = "sign_up" PASSWORD_SET = "password_set" # noqa # nosec
PASSWORD_RESET = "password_reset" # noqa # nosec
INVITE_CREATED = "invitation_created" INVITE_CREATED = "invitation_created"
INVITE_USED = "invitation_used" INVITE_USED = "invitation_used"
SOURCE_LINKED = "source_linked"
IMPERSONATION_STARTED = "impersonation_started" IMPERSONATION_STARTED = "impersonation_started"
IMPERSONATION_ENDED = "impersonation_ended" IMPERSONATION_ENDED = "impersonation_ended"
CUSTOM = "custom"
@staticmethod MODEL_CREATED = "model_created"
def as_choices(): MODEL_UPDATED = "model_updated"
"""Generate choices of actions used for database""" MODEL_DELETED = "model_deleted"
return tuple(
(x, y.value) for x, y in getattr(EventAction, "__members__").items() CUSTOM_PREFIX = "custom_"
)
class Event(models.Model): class Event(models.Model):
"""An individual audit log event""" """An individual audit log event"""
event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
user = models.ForeignKey( user = models.JSONField(default=dict)
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL action = models.TextField(choices=EventAction.choices)
)
action = models.TextField(choices=EventAction.as_choices())
date = models.DateTimeField(auto_now_add=True) date = models.DateTimeField(auto_now_add=True)
app = models.TextField() app = models.TextField()
context = models.JSONField(default=dict, blank=True) context = models.JSONField(default=dict, blank=True)
@ -116,20 +136,18 @@ class Event(models.Model):
@staticmethod @staticmethod
def new( def new(
action: EventAction, action: Union[str, EventAction],
app: Optional[str] = None, app: Optional[str] = None,
_inspect_offset: int = 1, _inspect_offset: int = 1,
**kwargs, **kwargs,
) -> "Event": ) -> "Event":
"""Create new Event instance from arguments. Instance is NOT saved.""" """Create new Event instance from arguments. Instance is NOT saved."""
if not isinstance(action, EventAction): if not isinstance(action, EventAction):
raise ValueError( action = EventAction.CUSTOM_PREFIX + action
f"action must be EventAction instance but was {type(action)}"
)
if not app: if not app:
app = getmodule(stack()[_inspect_offset][0]).__name__ app = getmodule(stack()[_inspect_offset][0]).__name__
cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs)) 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 return event
def from_http( def from_http(
@ -139,17 +157,18 @@ class Event(models.Model):
Events independently from requests. Events independently from requests.
`user` arguments optionally overrides user from requests.""" `user` arguments optionally overrides user from requests."""
if hasattr(request, "user"): if hasattr(request, "user"):
if isinstance(request.user, AnonymousUser): self.user = get_user(
self.user = get_anonymous_user() request.user,
else: request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None),
self.user = request.user )
if user: if user:
self.user = user self.user = get_user(user)
# Check if we're currently impersonating, and add that user # Check if we're currently impersonating, and add that user
if hasattr(request, "session"): if hasattr(request, "session"):
if SESSION_IMPERSONATE_ORIGINAL_USER in request.session: if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
self.context["on_behalf_of"] = model_to_dict( self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_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 # User 255.255.255.255 as fallback if IP cannot be determined
self.client_ip = get_client_ip(request) or "255.255.255.255" self.client_ip = get_client_ip(request) or "255.255.255.255"

View File

@ -20,12 +20,12 @@ from passbook.stages.user_write.signals import user_write
class EventNewThread(Thread): class EventNewThread(Thread):
"""Create Event in background thread""" """Create Event in background thread"""
action: EventAction action: str
request: HttpRequest request: HttpRequest
kwargs: Dict[str, Any] kwargs: Dict[str, Any]
user: Optional[User] = None user: Optional[User] = None
def __init__(self, action: EventAction, request: HttpRequest, **kwargs): def __init__(self, action: str, request: HttpRequest, **kwargs):
super().__init__() super().__init__()
self.action = action self.action = action
self.request = request self.request = request
@ -57,7 +57,7 @@ def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
# pylint: disable=unused-argument # pylint: disable=unused-argument
def on_user_write(sender, request: HttpRequest, user: User, data: Dict[str, Any], **_): def on_user_write(sender, request: HttpRequest, user: User, data: Dict[str, Any], **_):
"""Log User write""" """Log User write"""
thread = EventNewThread(EventAction.CUSTOM, request, **data) thread = EventNewThread("stages/user_write", request, **data)
thread.user = user thread.user = user
thread.run() thread.run()

View File

@ -40,12 +40,28 @@
</div> </div>
</th> </th>
<td role="cell"> <td role="cell">
<div>
<div>
<code>{{ entry.context }}</code> <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>
<td role="cell"> <td role="cell">
<span> <div>
{{ entry.user }} <div>{{ entry.user.username }}</div>
</span> <small>
{% blocktrans with pk=entry.user.pk %}
ID: {{ pk }}
{% endblocktrans %}
</small>
</div>
</td> </td>
<td role="cell"> <td role="cell">
<span> <span>

View File

@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import TestCase
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from passbook.audit.models import Event, EventAction from passbook.audit.models import Event
from passbook.policies.dummy.models import DummyPolicy from passbook.policies.dummy.models import DummyPolicy
@ -13,7 +13,7 @@ class TestAuditEvent(TestCase):
def test_new_with_model(self): def test_new_with_model(self):
"""Create a new Event passing a model as kwarg""" """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 event.save() # We save to ensure nothing is un-saveable
model_content_type = ContentType.objects.get_for_model(get_anonymous_user()) model_content_type = ContentType.objects.get_for_model(get_anonymous_user())
self.assertEqual( self.assertEqual(
@ -24,7 +24,7 @@ class TestAuditEvent(TestCase):
def test_new_with_uuid_model(self): def test_new_with_uuid_model(self):
"""Create a new Event passing a model (with UUID PK) as kwarg""" """Create a new Event passing a model (with UUID PK) as kwarg"""
temp_model = DummyPolicy.objects.create(name="test", result=True) 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 event.save() # We save to ensure nothing is un-saveable
model_content_type = ContentType.objects.get_for_model(temp_model) model_content_type = ContentType.objects.get_for_model(temp_model)
self.assertEqual( self.assertEqual(

View File

@ -1,11 +1,14 @@
"""passbook admin Middleware to impersonate users""" """passbook admin Middleware to impersonate users"""
from logging import Logger
from threading import local
from typing import Callable from typing import Callable
from uuid import uuid4
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
SESSION_IMPERSONATE_USER = "passbook_impersonate_user" SESSION_IMPERSONATE_USER = "passbook_impersonate_user"
SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user" SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user"
LOCAL = local()
class ImpersonateMiddleware: class ImpersonateMiddleware:
@ -24,3 +27,30 @@ class ImpersonateMiddleware:
request.user = request.session[SESSION_IMPERSONATE_USER] request.user = request.session[SESSION_IMPERSONATE_USER]
return self.get_response(request) 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

View File

@ -14,7 +14,7 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
pbadmin, _ = User.objects.using(db_alias).get_or_create( pbadmin, _ = User.objects.using(db_alias).get_or_create(
username="pbadmin", email="root@localhost", name="passbook Default Admin" 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() pbadmin.save()

View File

@ -90,8 +90,8 @@ class User(GuardianUserMixin, AbstractUser):
"""superuser == staff user""" """superuser == staff user"""
return self.is_superuser return self.is_superuser
def set_password(self, password): def set_password(self, password, signal=True):
if self.pk: if self.pk and signal:
password_changed.send(sender=self, user=self, password=password) password_changed.send(sender=self, user=self, password=password)
self.password_change_date = now() self.password_change_date = now()
return super().set_password(password) return super().set_password(password)

View File

@ -31,7 +31,7 @@ class ImpersonateInitView(View):
request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user
request.session[SESSION_IMPERSONATE_USER] = user_to_be request.session[SESSION_IMPERSONATE_USER] = user_to_be
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request) Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
return redirect("passbook_core:overview") return redirect("passbook_core:overview")
@ -48,9 +48,11 @@ class ImpersonateEndView(View):
LOGGER.debug("Can't end impersonation", user=request.user) LOGGER.debug("Can't end impersonation", user=request.user)
return redirect("passbook_core:overview") return redirect("passbook_core:overview")
original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
del request.session[SESSION_IMPERSONATE_USER] del request.session[SESSION_IMPERSONATE_USER]
del request.session[SESSION_IMPERSONATE_ORIGINAL_USER] del request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request) Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
return redirect("passbook_core:overview") return redirect("passbook_core:overview")

View File

@ -105,15 +105,10 @@ class TestFlowTransfer(TransactionTestCase):
order=2, order=2,
type=FieldTypes.PASSWORD, type=FieldTypes.PASSWORD,
) )
# Password checking policy
password_policy = ExpressionPolicy.objects.create(
name=generate_client_id(), expression="return True",
)
# Stages # Stages
first_stage = PromptStage.objects.create(name=generate_client_id()) first_stage = PromptStage.objects.create(name=generate_client_id())
first_stage.fields.set([username_prompt, password, password_repeat]) first_stage.fields.set([username_prompt, password, password_repeat])
first_stage.validation_policies.set([password_policy])
first_stage.save() first_stage.save()
flow = Flow.objects.create( flow = Flow.objects.create(

View File

@ -1,9 +1,23 @@
"""logging helpers""" """logging helpers"""
from logging import Logger
from os import getpid from os import getpid
from typing import Callable
# pylint: disable=unused-argument # 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""" """Add the current process ID"""
event_dict["pid"] = getpid() event_dict["pid"] = getpid()
return event_dict 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

View File

@ -14,7 +14,7 @@ def _get_client_ip_from_meta(meta: Dict[str, Any]) -> Optional[str]:
) )
for _header in headers: for _header in headers:
if _header in meta: if _header in meta:
return meta.get(_header) return meta.get(_header).split(", ")[0]
return None return None

View File

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

View File

@ -91,7 +91,7 @@ class TokenParams:
try: try:
self.refresh_token = RefreshToken.objects.get( self.refresh_token = RefreshToken.objects.get(
refresh_token=raw_token, client=self.provider refresh_token=raw_token, provider=self.provider
) )
except RefreshToken.DoesNotExist: except RefreshToken.DoesNotExist:
@ -218,10 +218,10 @@ class TokenView(View):
if unauthorized_scopes: if unauthorized_scopes:
raise TokenError("invalid_scope") raise TokenError("invalid_scope")
refresh_token = self.params.refresh_token.provider.create_token( provider: OAuth2Provider = self.params.refresh_token.provider
user=self.params.refresh_token.user,
provider=self.params.refresh_token.provider, refresh_token: RefreshToken = provider.create_refresh_token(
scope=self.params.scope, user=self.params.refresh_token.user, scope=self.params.scope,
) )
# If the Token has an id_token it's an Authentication request. # If the Token has an id_token it's an Authentication request.

View File

@ -102,11 +102,14 @@ class ASGILogger:
await self.app(scope, receive, send_hooked) await self.app(scope, receive, send_hooked)
def _get_ip(self) -> str: def _get_ip(self) -> str:
client_ip = None
for header in ASGI_IP_HEADERS: for header in ASGI_IP_HEADERS:
if header in self.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)) 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): def log(self, runtime: float):
"""Outpot access logs in a structured format""" """Outpot access logs in a structured format"""

View File

@ -22,8 +22,9 @@ from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
from passbook import __version__ from passbook import __version__
from passbook.core.middleware import structlog_add_request_id
from passbook.lib.config import CONFIG 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 from passbook.lib.sentry import before_send
@ -175,6 +176,8 @@ MIDDLEWARE = [
"django_prometheus.middleware.PrometheusBeforeMiddleware", "django_prometheus.middleware.PrometheusBeforeMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"passbook.core.middleware.RequestIDMiddleware",
"passbook.audit.middleware.AuditMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@ -330,6 +333,8 @@ structlog.configure_once(
structlog.stdlib.add_log_level, structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name, structlog.stdlib.add_logger_name,
add_process_id, add_process_id,
add_common_fields(CONFIG.y("error_reporting.environment", "customer")),
structlog_add_request_id,
structlog.stdlib.PositionalArgumentsFormatter(), structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(), structlog.processors.TimeStamper(),
structlog.processors.StackInfoRenderer(), structlog.processors.StackInfoRenderer(),

View File

@ -24,6 +24,7 @@ class LDAPSourceSerializer(ModelSerializer):
"user_group_membership_field", "user_group_membership_field",
"object_uniqueness_field", "object_uniqueness_field",
"sync_users", "sync_users",
"sync_users_password",
"sync_groups", "sync_groups",
"sync_parent_group", "sync_parent_group",
"property_mappings", "property_mappings",

View File

@ -1,9 +1,12 @@
"""passbook LDAP Authentication Backend""" """passbook LDAP Authentication Backend"""
from typing import Optional
import ldap3
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.http import HttpRequest from django.http import HttpRequest
from structlog import get_logger 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 from passbook.sources.ldap.models import LDAPSource
LOGGER = get_logger() LOGGER = get_logger()
@ -18,7 +21,56 @@ class LDAPBackend(ModelBackend):
return None return None
for source in LDAPSource.objects.filter(enabled=True): for source in LDAPSource.objects.filter(enabled=True):
LOGGER.debug("LDAP Auth attempt", source=source) LOGGER.debug("LDAP Auth attempt", source=source)
user = Connector(source).auth_user(**kwargs) user = self.auth_user(source, **kwargs)
if user: if user:
return user return user
return None 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

View File

@ -37,6 +37,7 @@ class LDAPSourceForm(forms.ModelForm):
"user_group_membership_field", "user_group_membership_field",
"object_uniqueness_field", "object_uniqueness_field",
"sync_users", "sync_users",
"sync_users_password",
"sync_groups", "sync_groups",
"sync_parent_group", "sync_parent_group",
"property_mappings", "property_mappings",

View File

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

View File

@ -6,7 +6,7 @@ from django.core.cache import cache
from django.db import models from django.db import models
from django.forms import ModelForm from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _ 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.core.models import Group, PropertyMapping, Source
from passbook.lib.models import DomainlessURLValidator from passbook.lib.models import DomainlessURLValidator
@ -52,6 +52,16 @@ class LDAPSource(Source):
) )
sync_users = models.BooleanField(default=True) 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_groups = models.BooleanField(default=True)
sync_parent_group = models.ForeignKey( sync_parent_group = models.ForeignKey(
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
@ -82,7 +92,7 @@ class LDAPSource(Source):
def connection(self) -> Connection: def connection(self) -> Connection:
"""Get a fully connected and bound LDAP Connection""" """Get a fully connected and bound LDAP Connection"""
if not self._connection: if not self._connection:
server = Server(self.server_uri) server = Server(self.server_uri, get_info=ALL)
self._connection = Connection( self._connection = Connection(
server, server,
raise_exceptions=True, raise_exceptions=True,
@ -112,7 +122,7 @@ class LDAPPropertyMapping(PropertyMapping):
return LDAPPropertyMappingForm return LDAPPropertyMappingForm
def __str__(self): def __str__(self):
return f"LDAP Property Mapping {self.expression} -> {self.object_field}" return self.name
class Meta: class Meta:

View 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

View File

@ -1,9 +1,19 @@
"""passbook ldap source signals""" """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.db.models.signals import post_save
from django.dispatch import receiver 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.models import LDAPSource
from passbook.sources.ldap.password import LDAPPasswordChanger
from passbook.sources.ldap.tasks import sync_single from passbook.sources.ldap.tasks import sync_single
from passbook.stages.prompt.signals import password_validate
@receiver(post_save, sender=LDAPSource) @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)""" """Ensure that source is synced on save (if enabled)"""
if instance.enabled: if instance.enabled:
sync_single.delay(instance.pk) 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

View File

@ -1,5 +1,5 @@
"""Wrapper for ldap3 to easily manage user""" """Sync LDAP Users and groups into passbook"""
from typing import Any, Dict, Optional from typing import Any, Dict
import ldap3 import ldap3
import ldap3.core.exceptions import ldap3.core.exceptions
@ -13,19 +13,14 @@ from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
LOGGER = get_logger() LOGGER = get_logger()
class Connector: class LDAPSynchronizer:
"""Wrapper for ldap3 to easily manage user authentication and creation""" """Sync LDAP Users and groups into passbook"""
_source: LDAPSource _source: LDAPSource
def __init__(self, source: LDAPSource): def __init__(self, source: LDAPSource):
self._source = source 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 @property
def base_dn_users(self) -> str: def base_dn_users(self) -> str:
"""Shortcut to get full base_dn for user lookups""" """Shortcut to get full base_dn for user lookups"""
@ -187,48 +182,3 @@ class Connector:
"distinguishedName" "distinguishedName"
) )
return properties 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

View File

@ -4,8 +4,8 @@ from time import time
from django.core.cache import cache from django.core.cache import cache
from passbook.root.celery import CELERY_APP 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.models import LDAPSource
from passbook.sources.ldap.sync import LDAPSynchronizer
@CELERY_APP.task() @CELERY_APP.task()
@ -19,9 +19,9 @@ def sync():
def sync_single(source_pk): def sync_single(source_pk):
"""Sync a single source""" """Sync a single source"""
source: LDAPSource = LDAPSource.objects.get(pk=source_pk) source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
connector = Connector(source) syncer = LDAPSynchronizer(source)
connector.sync_users() syncer.sync_users()
connector.sync_groups() syncer.sync_groups()
connector.sync_membership() syncer.sync_membership()
cache_key = source.state_cache_prefix("last_sync") cache_key = source.state_cache_prefix("last_sync")
cache.set(cache_key, time(), timeout=60 * 60) cache.set(cache_key, time(), timeout=60 * 60)

View File

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

View File

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

View 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

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

View File

@ -0,0 +1,93 @@
"""ldap testing utils"""
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
def _build_mock_connection(password: str) -> 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,DC=AD2012,DC=LAB",
password=_pass,
client_strategy=MOCK_SYNC,
)
# Entry for password checking
connection.strategy.add_entry(
"cn=user,ou=users,DC=AD2012,DC=LAB",
{
"name": "test-user",
"objectSid": "unique-test-group",
"objectCategory": "Person",
"displayName": "Erin M. Hagens",
"sAMAccountName": "sAMAccountName",
"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB",
},
)
connection.strategy.add_entry(
"cn=group1,ou=groups,DC=AD2012,DC=LAB",
{
"name": "test-group",
"objectSid": "unique-test-group",
"objectCategory": "Group",
"distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
},
)
# Group without SID
connection.strategy.add_entry(
"cn=group2,ou=groups,DC=AD2012,DC=LAB",
{
"name": "test-group",
"objectCategory": "Group",
"distinguishedName": "cn=group2,ou=groups,DC=AD2012,DC=LAB",
},
)
connection.strategy.add_entry(
"cn=user0,ou=users,DC=AD2012,DC=LAB",
{
"userPassword": password,
"sAMAccountName": "user0_sn",
"name": "user0_sn",
"revision": 0,
"objectSid": "user0",
"objectCategory": "Person",
"memberOf": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
},
)
# User without SID
connection.strategy.add_entry(
"cn=user1,ou=users,DC=AD2012,DC=LAB",
{
"userPassword": "test1111",
"sAMAccountName": "user2_sn",
"name": "user1_sn",
"revision": 0,
"objectCategory": "Person",
},
)
# Duplicate users
connection.strategy.add_entry(
"cn=user2,ou=users,DC=AD2012,DC=LAB",
{
"userPassword": "test2222",
"sAMAccountName": "user2_sn",
"name": "user2_sn",
"revision": 0,
"objectSid": "unique-test2222",
"objectCategory": "Person",
},
)
connection.strategy.add_entry(
"cn=user3,ou=users,DC=AD2012,DC=LAB",
{
"userPassword": "test2222",
"sAMAccountName": "user2_sn",
"name": "user2_sn",
"revision": 0,
"objectSid": "unique-test2222",
"objectCategory": "Person",
},
)
connection.bind()
return connection

View File

@ -182,7 +182,7 @@ class OAuthCallback(OAuthClientMixin, View):
access.save() access.save()
UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
Event.new( Event.new(
EventAction.CUSTOM, message="Linked OAuth Source", source=source EventAction.SOURCE_LINKED, message="Linked OAuth Source", source=source
).from_http(self.request) ).from_http(self.request)
messages.success( messages.success(
self.request, self.request,

View File

@ -23,6 +23,8 @@ class PostUserEnrollmentStage(StageView):
access.save() access.save()
UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user) UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
Event.new( Event.new(
EventAction.CUSTOM, message="Linked OAuth Source", source=access.source EventAction.SOURCE_LINKED,
message="Linked OAuth Source",
source=access.source,
).from_http(self.request) ).from_http(self.request)
return self.executor.stage_ok() return self.executor.stage_ok()

View File

@ -7,7 +7,7 @@ from django.views import View
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from passbook.audit.models import Event, EventAction from passbook.audit.models import Event
class UserSettingsView(LoginRequiredMixin, TemplateView): class UserSettingsView(LoginRequiredMixin, TemplateView):
@ -36,6 +36,6 @@ class DisableView(LoginRequiredMixin, View):
messages.success(request, "Successfully disabled Static OTP Tokens") messages.success(request, "Successfully disabled Static OTP Tokens")
# Create event with email notification # Create event with email notification
Event.new( Event.new(
EventAction.CUSTOM, message="User disabled Static OTP Tokens." "static_otp_disable", message="User disabled Static OTP Tokens."
).from_http(request) ).from_http(request)
return redirect("passbook_stages_otp:otp-user-settings") return redirect("passbook_stages_otp:otp-user-settings")

View File

@ -7,7 +7,7 @@ from django.views import View
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django_otp.plugins.otp_totp.models import TOTPDevice from django_otp.plugins.otp_totp.models import TOTPDevice
from passbook.audit.models import Event, EventAction from passbook.audit.models import Event
class UserSettingsView(LoginRequiredMixin, TemplateView): class UserSettingsView(LoginRequiredMixin, TemplateView):
@ -32,7 +32,7 @@ class DisableView(LoginRequiredMixin, View):
totp.delete() totp.delete()
messages.success(request, "Successfully disabled Time-based OTP") messages.success(request, "Successfully disabled Time-based OTP")
# Create event with email notification # Create event with email notification
Event.new( Event.new("totp_disable", message="User disabled Time-based OTP.").from_http(
EventAction.CUSTOM, message="User disabled Time-based OTP." request
).from_http(request) )
return redirect("passbook_stages_otp:otp-user-settings") return redirect("passbook_stages_otp:otp-user-settings")

View File

@ -8,18 +8,11 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from passbook.flows.models import FlowDesignation from passbook.flows.models import FlowDesignation
from passbook.stages.prompt.models import FieldTypes from passbook.stages.prompt.models import FieldTypes
PROMPT_POLICY_EXPRESSION = """# Check that both passwords are equal.
return request.context['password'] == request.context['password_repeat']"""
def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Flow = apps.get_model("passbook_flows", "Flow") Flow = apps.get_model("passbook_flows", "Flow")
FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding") FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
ExpressionPolicy = apps.get_model(
"passbook_policies_expression", "ExpressionPolicy"
)
PromptStage = apps.get_model("passbook_stages_prompt", "PromptStage") PromptStage = apps.get_model("passbook_stages_prompt", "PromptStage")
Prompt = apps.get_model("passbook_stages_prompt", "Prompt") Prompt = apps.get_model("passbook_stages_prompt", "Prompt")
@ -57,15 +50,8 @@ def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchema
}, },
) )
# Policy to only trigger prompt when no username is given
prompt_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
name="default-password-change-password-equal",
defaults={"expression": PROMPT_POLICY_EXPRESSION},
)
prompt_stage.fields.add(password_prompt) prompt_stage.fields.add(password_prompt)
prompt_stage.fields.add(password_rep_prompt) prompt_stage.fields.add(password_rep_prompt)
prompt_stage.validation_policies.add(prompt_policy)
prompt_stage.save() prompt_stage.save()
user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create( user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create(
@ -100,7 +86,6 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
("passbook_flows", "0006_auto_20200629_0857"), ("passbook_flows", "0006_auto_20200629_0857"),
("passbook_policies_expression", "0001_initial"),
("passbook_stages_password", "0001_initial"), ("passbook_stages_password", "0001_initial"),
("passbook_stages_prompt", "0001_initial"), ("passbook_stages_prompt", "0001_initial"),
("passbook_stages_user_write", "0001_initial"), ("passbook_stages_user_write", "0001_initial"),

View File

@ -1,9 +1,11 @@
"""Prompt forms""" """Prompt forms"""
from email.policy import Policy from email.policy import Policy
from typing import Callable, Iterator, List from types import MethodType
from typing import Any, Callable, Iterator, List
from django import forms from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.admin.widgets import FilteredSelectMultiple
from django.db.models.query import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
@ -13,6 +15,7 @@ from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
from passbook.policies.models import PolicyBinding, PolicyBindingModel from passbook.policies.models import PolicyBinding, PolicyBindingModel
from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
from passbook.stages.prompt.signals import password_validate
class PromptStageForm(forms.ModelForm): class PromptStageForm(forms.ModelForm):
@ -86,12 +89,35 @@ class PromptForm(forms.Form):
setattr( setattr(
self, self,
f"clean_{field.field_key}", f"clean_{field.field_key}",
username_field_cleaner_generator(field), MethodType(username_field_cleaner_factory(field), self),
) )
# Check if we have a password field, add a handler that sends a signal
# to validate it
if field.type == FieldTypes.PASSWORD:
setattr(
self,
f"clean_{field.field_key}",
MethodType(password_single_cleaner_factory(field), self),
)
self.field_order = sorted(fields, key=lambda x: x.order) self.field_order = sorted(fields, key=lambda x: x.order)
def _clean_password_fields(self, *field_names):
"""Check if the value of all password fields match by merging them into a set
and checking the length"""
all_passwords = {self.cleaned_data[x] for x in field_names}
if len(all_passwords) > 1:
raise forms.ValidationError(_("Passwords don't match."))
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
# Check if we have two password fields, and make sure they are the same
password_fields: QuerySet[Prompt] = self.stage.fields.filter(
type=FieldTypes.PASSWORD
)
if password_fields.exists() and password_fields.count() == 2:
self._clean_password_fields(*[field.field_key for field in password_fields])
user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user()) user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user())
engine = ListPolicyEngine(self.stage.validation_policies.all(), user) engine = ListPolicyEngine(self.stage.validation_policies.all(), user)
engine.request.context = cleaned_data engine.request.context = cleaned_data
@ -101,13 +127,28 @@ class PromptForm(forms.Form):
raise forms.ValidationError(list(result.messages)) raise forms.ValidationError(list(result.messages))
def username_field_cleaner_generator(field: Prompt) -> Callable: def username_field_cleaner_factory(field: Prompt) -> Callable:
"""Return a `clean_` method for `field`. Clean method checks if username is taken already.""" """Return a `clean_` method for `field`. Clean method checks if username is taken already."""
def username_field_cleaner(self: PromptForm): def username_field_cleaner(self: PromptForm) -> Any:
"""Check for duplicate usernames""" """Check for duplicate usernames"""
username = self.cleaned_data.get(field.field_key) username = self.cleaned_data.get(field.field_key)
if User.objects.filter(username=username).exists(): if User.objects.filter(username=username).exists():
raise forms.ValidationError("Username is already taken.") raise forms.ValidationError("Username is already taken.")
return username
return username_field_cleaner return username_field_cleaner
def password_single_cleaner_factory(field: Prompt) -> Callable[[PromptForm], Any]:
"""Return a `clean_` method for `field`. Clean method checks if username is taken already."""
def password_single_clean(self: PromptForm) -> Any:
"""Send password validation signals for e.g. LDAP Source"""
password = self.cleaned_data[field.field_key]
password_validate.send(
sender=self, password=password, plan_context=self.plan.context
)
return password
return password_single_clean

View File

@ -0,0 +1,42 @@
# Generated by Django 3.1.1 on 2020-09-20 18:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_stages_prompt", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="prompt",
name="type",
field=models.CharField(
choices=[
("text", "Text: Simple Text input"),
(
"username",
"Username: Same as Text input, but checks for and prevents duplicate usernames.",
),
("email", "Email: Text field with Email type."),
(
"password",
"Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.",
),
("number", "Number"),
("checkbox", "Checkbox"),
("data", "Date"),
("data-time", "Date Time"),
("separator", "Separator: Static Separator Line"),
(
"hidden",
"Hidden: Hidden field, can be used to insert data into form.",
),
("static", "Static: Static value, displayed as-is."),
],
max_length=100,
),
),
]

View File

@ -31,7 +31,16 @@ class FieldTypes(models.TextChoices):
), ),
) )
EMAIL = "email", _("Email: Text field with Email type.") EMAIL = "email", _("Email: Text field with Email type.")
PASSWORD = "password" # noqa # nosec PASSWORD = (
"password", # noqa # nosec
_(
(
"Password: Masked input, password is validated against sources. Policies still "
"have to be applied to this Stage. If two of these are used in the same stage, "
"they are ensured to be identical."
)
),
)
NUMBER = "number" NUMBER = "number"
CHECKBOX = "checkbox" CHECKBOX = "checkbox"
DATE = "data" DATE = "data"

View File

@ -0,0 +1,4 @@
"""passbook prompt stage signals"""
from django.core.signals import Signal
password_validate = Signal(providing_args=["password", "plan_context"])

View File

@ -49,6 +49,13 @@
max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height); max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height);
} }
.pf-m-success {
color: var(--pf-global--success-color--100);
}
.pf-m-danger {
color: var(--pf-global--danger-color--100);
}
/* fix multiple selects height */ /* fix multiple selects height */
select[multiple] { select[multiple] {
height: initial; height: initial;

View File

@ -51,8 +51,10 @@ func (pb *providerBundle) prepareOpts(provider *models.ProxyOutpostConfig) *opti
providerOpts.OIDCJwksURL = *provider.OidcConfiguration.JwksURI providerOpts.OIDCJwksURL = *provider.OidcConfiguration.JwksURI
providerOpts.ProfileURL = *provider.OidcConfiguration.UserinfoEndpoint providerOpts.ProfileURL = *provider.OidcConfiguration.UserinfoEndpoint
if provider.SkipPathRegex != "" {
skipRegexes := strings.Split(provider.SkipPathRegex, "\n") skipRegexes := strings.Split(provider.SkipPathRegex, "\n")
providerOpts.SkipAuthRegex = skipRegexes providerOpts.SkipAuthRegex = skipRegexes
}
providerOpts.UpstreamServers = []options.Upstream{ providerOpts.UpstreamServers = []options.Upstream{
{ {

View File

@ -1,3 +1,3 @@
package pkg package pkg
const VERSION = "0.10.4-stable" const VERSION = "0.10.6-stable"

View File

@ -5831,24 +5831,27 @@ definitions:
readOnly: true readOnly: true
user: user:
title: User title: User
type: integer type: string
x-nullable: true
action: action:
title: Action title: Action
type: string type: string
enum: enum:
- LOGIN - login
- LOGIN_FAILED - login_failed
- LOGOUT - logout
- AUTHORIZE_APPLICATION - sign_up
- SUSPICIOUS_REQUEST - authorize_application
- SIGN_UP - suspicious_request
- PASSWORD_RESET - password_set
- INVITE_CREATED - invitation_created
- INVITE_USED - invitation_used
- IMPERSONATION_STARTED - source_linked
- IMPERSONATION_ENDED - impersonation_started
- CUSTOM - impersonation_ended
- model_created
- model_updated
- model_deleted
- custom_
date: date:
title: Date title: Date
type: string type: string
@ -6945,6 +6948,11 @@ definitions:
sync_users: sync_users:
title: Sync users title: Sync users
type: boolean type: boolean
sync_users_password:
title: Sync users password
description: When a user changes their password, sync it back to LDAP. This
can only be enabled on a single LDAP source.
type: boolean
sync_groups: sync_groups:
title: Sync groups title: Sync groups
type: boolean type: boolean