Compare commits

...

69 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
c25eda63ba new release: 0.10.4-stable 2020-09-19 19:40:58 +02:00
c90906c968 outposts: fix formatting 2020-09-19 19:12:49 +02:00
f6b52b9281 docs: add outpost upgrading docs 2020-09-19 19:04:04 +02:00
b04f92c8b4 admin: outposts show should-be version 2020-09-19 19:03:54 +02:00
a02fcb0a7a providers/oauth2: use # as separate for code#adfs, check if # exists in response_type and trim 2020-09-19 18:37:50 +02:00
c1ea605c7e build(deps): bump @patternfly/patternfly from 4.35.2 to 4.42.2 in /passbook/static/static (#222)
Bumps [@patternfly/patternfly](https://github.com/patternfly/patternfly) from 4.35.2 to 4.42.2.
- [Release notes](https://github.com/patternfly/patternfly/releases)
- [Changelog](https://github.com/patternfly/patternfly/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/patternfly/patternfly/compare/prerelease-v4.35.2...prerelease-v4.42.2)

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-19 18:30:23 +02:00
116be0b3c0 sources/ldap: add status display to show last sync 2020-09-19 17:50:39 +02:00
438250b3a9 policies: improve wording on denied tempaltes 2020-09-19 15:24:52 +02:00
5e6acee2a5 root: increase limit of max-attributes in pylint 2020-09-19 13:40:23 +02:00
8b4222e7bb providers/proxy: fix formatting 2020-09-19 12:21:31 +02:00
4af563ce89 proxy: implement simple healthcheck 2020-09-19 11:43:22 +02:00
77842fab58 proxy: implement SkipAuthRegex 2020-09-19 11:32:21 +02:00
5689f25c39 providers/proxy: add option to skip authentication for paths matching regular expressions 2020-09-19 11:32:04 +02:00
a69c494feb stages/password: update swagger 2020-09-19 02:20:38 +02:00
83408b6ae0 stages/password: add failed_attempts_before_cancel to cancel a flow after x failed entries 2020-09-19 02:18:43 +02:00
d30abc64d0 flows: improve _full template being used for stage_invalid 2020-09-19 02:15:15 +02:00
6674d3e017 e2e: fix tests for proxy provider/outpost 2020-09-19 01:54:54 +02:00
4749c3fad0 proxy: improve reconnect logic, send version, properly version proxy 2020-09-19 01:37:08 +02:00
18886697d6 outposts: add support for version checking 2020-09-19 01:34:11 +02:00
e75c9e9a79 providers/oauth2: make openid-configuration easily readable 2020-09-19 01:34:11 +02:00
5a3c1137ab providers/oauth2: add more info to configuration modal 2020-09-19 01:34:11 +02:00
ddca46e24a outposts: add modal to show setup information 2020-09-19 01:34:11 +02:00
22a9abf7bf docs: add docs for sonarr 2020-09-19 01:34:11 +02:00
fb16502466 build(deps): bump boto3 from 1.14.63 to 1.15.1 (#221)
Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
2020-09-19 00:46:09 +02:00
421bd13ddf admin: make YAMLField return empty dict when empty yaml is given 2020-09-19 00:00:55 +02:00
404c9ef753 providers/saml: improve __str__ of SAMLPropertyMapping 2020-09-18 23:50:31 +02:00
a57b545093 docs: add landscape integration 2020-09-18 23:50:16 +02:00
d8530f238d docs: update sentry and awx integrations 2020-09-18 23:50:00 +02:00
fe4a0c3b44 core: add impersonation start/end to audit log
also add impersonated user as context to other logs
2020-09-18 23:39:37 +02:00
e0c104ee5c providers/oauth2: remove post_logout_redirect_uris 2020-09-18 23:37:40 +02:00
6ab8794754 proxy: improve logging and reconnecting 2020-09-18 21:54:23 +02:00
316e6cb17f admin: set default host for outposts based on HTTP host 2020-09-18 21:51:08 +02:00
9d5d99290c outposts: only show proxy providers 2020-09-18 21:50:49 +02:00
20ffe833de admin: fix create link for outposts 2020-09-18 21:28:48 +02:00
d4d026bf6a stages/user_write: add migration that removes unintended data 2020-09-18 18:58:07 +02:00
dfe093b2b9 stages/user_write: fix unittests 2020-09-18 18:52:19 +02:00
60739e620e stages/user_write: fix formatting 2020-09-18 18:41:11 +02:00
d6cc6770b8 stages/user_write: fix data being saved as attributes without intent 2020-09-18 18:15:33 +02:00
ddc1022461 stages/user_write: check if session hash should be updated early 2020-09-18 18:15:25 +02:00
2c2226610e providers/oauth2: fix end-session view not working, add tests 2020-09-17 21:55:01 +02:00
cba78b4de7 providers/*: fix launch_url not working 2020-09-17 21:53:57 +02:00
1eeb64ee39 docs: fix environment variable for error reporting 2020-09-17 21:22:46 +02:00
22dea62084 root: fix startup log not showing in docker 2020-09-17 21:16:31 +02:00
5ff1dd8426 core: move impersonation to core, add tests, add better permission checks 2020-09-17 16:24:53 +02:00
da15a8878f stages/password: improve labelling of LDAP backend 2020-09-17 15:54:48 +02:00
bf33828ac1 core: fix overview template for non-rectangular icons 2020-09-17 10:44:10 +02:00
950a1fc77e docs: add vsphere note for code (ADFS) 2020-09-17 10:43:59 +02:00
132 changed files with 2228 additions and 626 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.10.3-stable
current_version = 0.10.6-stable
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
@ -28,3 +28,5 @@ values =
[bumpversion:file:.github/workflows/release.yml]
[bumpversion:file:passbook/__init__.py]
[bumpversion:file:proxy/pkg/version.go]

View File

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

View File

@ -1,9 +1,16 @@
[MASTER]
disable=arguments-differ,no-self-use,fixme,locally-disabled,too-many-ancestors,too-few-public-methods,import-outside-toplevel,bad-continuation,signature-differs,similarities,cyclic-import
load-plugins=pylint_django,pylint.extensions.bad_builtin
extension-pkg-whitelist=lxml
# Allow constants to be shorter than normal (and lowercase, for settings.py)
const-rgx=[a-zA-Z0-9_]{1,40}$
ignored-modules=django-otp
jobs=12
ignore=migrations
max-attributes=12
jobs=12

30
Pipfile.lock generated
View File

@ -74,17 +74,18 @@
},
"boto3": {
"hashes": [
"sha256:25c716b7c01d4664027afc6a6418a06459e311a610c7fd39a030a1ced1b72ce4"
"sha256:44073b1b1823ffc9edcf9027afbca908dad6bd5000f512ca73f929f6a604ae24",
"sha256:888be45e289ba56c4e47cfae5d6b08f097bc981d077fbe6521a6d3dc7a4d757e"
],
"index": "pypi",
"version": "==1.14.63"
"version": "==1.15.1"
},
"botocore": {
"hashes": [
"sha256:40f13f6c9c29c307a9dc5982739e537ddce55b29787b90c3447b507e3283bcd6",
"sha256:aa88eafc6295132f4bc606f1df32b3248e0fa611724c0a216aceda767948ac75"
"sha256:6bdf60281c2e80360fe904851a1a07df3dcfe066fe88dc7fba2b5e626ac05c8c",
"sha256:d6bdf51c8880aa9974e6b61d2f7d9d1debe407287e2e9e60f36c789fe8ba6790"
],
"version": "==1.17.63"
"version": "==1.18.1"
},
"cachetools": {
"hashes": [
@ -355,14 +356,6 @@
"index": "pypi",
"version": "==0.3.0"
},
"docutils": {
"hashes": [
"sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0",
"sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827",
"sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"
],
"version": "==0.15.2"
},
"drf-yasg": {
"hashes": [
"sha256:5572e9d5baab9f6b49318169df9789f7399d0e3c7bdac8fdb8dfccf1d5d2b1ca",
@ -756,20 +749,25 @@
"sha256:54bdedd28476dea8a3cd86cb67c0df1f0e3d71cae8022354b0f879c41a3d27b2",
"sha256:55eb61aca2c883db770999f50d091ff7c14016f2769ad7bca3d9b75d1d7c1b68",
"sha256:6276478ada411aca97c0d5104916354b3d740d368407912722bd4d11aa9ee4c2",
"sha256:663f8de2b3df2e744d6e1610506e0ea4e213bde906795953c1e82279c169f0a7",
"sha256:67dcad1b8b201308586a8ca2ffe89df1e4f731d5a4cdd0610cc4ea790351c739",
"sha256:709b9f144d23e290b9863121d1ace14a72e01f66ea9c903fbdc690520dfdfcf0",
"sha256:8063a712fba642f78d3c506b0896846601b6de7f5c3d534e388ad0cc07f5a149",
"sha256:80d57177a0b7c14d4594c62bbb47fe2f6309ad3b0a34348a291d570925c97a82",
"sha256:87006cf0d81505408f1ae4f55cf8a5d95a8e029a4793360720ae17c6500f7ecc",
"sha256:9f62d21bc693f3d7d444f17ed2ad7a913b4c37c15cd807895d013c39c0517dfd",
"sha256:a207231a52426de3ff20f5608f0687261a3329d97a036c51f7d4c606a6f30c23",
"sha256:abc2e126c9490e58a36a0f83516479e781d83adfb134576a5cbe5c6af2a3e93c",
"sha256:b56638d58a3a4be13229c6a815cd448f9e3ce40c00880a5398471b42ee86f50e",
"sha256:bcd5b8416e73e4b0d48afba3704d8c826414764dafaed7a1a93c442188d90ccc",
"sha256:bec2bcdf7c9ce7f04d718e51887f3b05dc5c1cfaf5d2c2e9065ecddd1b2f6c9a",
"sha256:c8bf40cf6e281a4378e25846924327e728a887e8bf0ee83b2604a0f4b61692e8",
"sha256:cecbf67e81d6144a50dc615629772859463b2e4f815d0c082fa421db362f040e",
"sha256:d8074c8448cfd0705dfa71ca333277fce9786d0b9cac75d120545de6253f996a",
"sha256:dd302b6ae3965afeb5ef1b0d92486f986c0e65183cd7835973f0b593800590e6",
"sha256:de6e1cd75677423ff64712c337521e62e3a7a4fc84caabbd93207752e831a85a",
"sha256:ef39c98d9b8c0736d91937d193653e47c3b19ddf4fc3bccdc5e09aaa4b0c5d21",
"sha256:f2e045224074d5664dc9cbabbf4f4d4d46f1ee90f24780e3a9a668fd096ff17f",
"sha256:f521178e5a991ffd04182ed08f552daca1affcb826aeda0e1945cd989a9d4345",
"sha256:f78a68c2c820e4731e510a2df3eef0322f24fde1781ced970bf497b6c7d92982",
"sha256:fbe65d5cfe04ff2f7684160d50f5118bdefb01e3af4718eeb618bfed40f19d94"
@ -1324,11 +1322,11 @@
},
"django-debug-toolbar": {
"hashes": [
"sha256:eabbefe89881bbe4ca7c980ff102e3c35c8e8ad6eb725041f538988f2f39a943",
"sha256:ff94725e7aae74b133d0599b9bf89bd4eb8f5d2c964106e61d11750228c8774c"
"sha256:23129b4f771605c7ccf8733cc53558a68c5d463d60cdc83408d34b713acf4f5f",
"sha256:7c9bf93eabb1e745fe1fca830242d49f3c839d35163e5b53914009ed111209b1"
],
"index": "pypi",
"version": "==2.2"
"version": "==3.0"
},
"docker": {
"hashes": [

View File

@ -6,7 +6,8 @@ As passbook is currently in a pre-stable, only the latest "stable" version is su
| Version | Supported |
| -------- | ------------------ |
| 0.8.15 | :white_check_mark: |
| 0.9.x | :white_check_mark: |
| 0.10.x | :white_check_mark: |
## Reporting a Vulnerability

View File

@ -8,6 +8,10 @@ variables:
POSTGRES_DB: passbook
POSTGRES_USER: passbook
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
${{ if startsWith(variables['Build.SourceBranch'], 'refs/heads/') }}:
branchName: ${{ replace(variables['Build.SourceBranchName'], 'refs/heads/', '') }}
${{ if startsWith(variables['Build.SourceBranch'], 'refs/pull/') }}:
branchName: ${{ replace(variables['System.PullRequest.SourceBranch'], 'refs/heads/', '') }}
stages:
- stage: Lint
@ -117,6 +121,41 @@ stages:
- task: CmdLine@2
inputs:
script: pipenv run ./manage.py migrate
- job: migrations_from_previous_release
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.8'
- task: DockerCompose@0
displayName: Run services
inputs:
dockerComposeFile: 'scripts/ci.docker-compose.yml'
action: 'Run services'
buildImages: false
- task: CmdLine@2
displayName: Prepare Last tagged release
inputs:
script: |
git checkout $(git describe --abbrev=0 --match 'version/*')
sudo pip install -U wheel pipenv
pipenv install --dev
- task: CmdLine@2
displayName: Migrate to last tagged release
inputs:
script: pipenv run ./manage.py migrate
- task: CmdLine@2
displayName: Install current branch
inputs:
script: |
set -x
git checkout ${{ variables.branchName }}
pipenv sync --dev
- task: CmdLine@2
displayName: Migrate to current branch
inputs:
script: pipenv run ./manage.py migrate
- job: coverage_unittest
pool:
vmImage: 'ubuntu-latest'
@ -265,7 +304,7 @@ stages:
repository: 'beryju/passbook'
command: 'buildAndPush'
Dockerfile: 'Dockerfile'
tags: 'gh-$(Build.SourceBranchName)'
tags: "gh-${{ variables.branchName }}"
- job: build_static
pool:
vmImage: 'ubuntu-latest'
@ -282,14 +321,14 @@ stages:
repository: 'beryju/passbook-static'
command: 'build'
Dockerfile: 'static.Dockerfile'
tags: 'gh-$(Build.SourceBranchName)'
tags: "gh-${{ variables.branchName }}"
arguments: "--network=beryjupassbook_default"
- task: Docker@2
inputs:
containerRegistry: 'dockerhub'
repository: 'beryju/passbook-static'
command: 'push'
tags: 'gh-$(Build.SourceBranchName)'
tags: "gh-${{ variables.branchName }}"
- stage: Deploy
jobs:
- job: deploy_dev

View File

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

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

View File

@ -55,16 +55,6 @@
"order": 1
}
},
{
"identifiers": {
"pk": "cd042fc6-cc92-4b98-b7e6-f4729df798d8"
},
"model": "passbook_policies_expression.expressionpolicy",
"attrs": {
"name": "default-password-change-password-equal",
"expression": "# Check that both passwords are equal.\nreturn request.context['password'] == request.context['password_repeat']"
}
},
{
"identifiers": {
"pk": "e54045a7-6ecb-4ad9-ad37-28e72d8e565e",
@ -118,9 +108,6 @@
"fields": [
"7db91ee8-4290-4e08-8d39-63f132402515",
"d30b5eb4-7787-4072-b1ba-65b46e928920"
],
"validation_policies": [
"cd042fc6-cc92-4b98-b7e6-f4729df798d8"
]
}
},

View File

@ -39,7 +39,6 @@ This designates a flow for unenrollment. This flow can contain any amount of ver
This designates a flow for recovery. This flow normally contains an [**identification**](stages/identification/index.md) stage to find the user. It can also contain any amount of verification stages, such as [**email**](stages/email/index.md) or [**captcha**](stages/captcha/index.md).
Afterwards, use the [**prompt**](stages/prompt/index.md) stage to ask the user for a new password and the [**user_write**](stages/user_write.md) stage to update the password.
### Change Password
### Setup
This designates a flow for password changes. This flow can contain any amount of verification stages, such as [**email**](stages/email/index.md) or [**captcha**](stages/captcha/index.md).
Afterwards, use the [**prompt**](stages/prompt/index.md) stage to ask the user for a new password and the [**user_write**](stages/user_write.md) stage to update the password.
This designates a flow for general setup. This designation doesn't have any constraints in what you can do. For example, by default this designation is used to configure Factors, like change a password and setup TOTP.

View File

@ -11,9 +11,9 @@ This installation method is for test-setups and small-scale productive setups.
Download the latest `docker-compose.yml` from [here](https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml). Place it in a directory of your choice.
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING=true >> .env`
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING__ENABLED=true >> .env`
To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.3-stable >> .env`
To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.6-stable >> .env`
If this is a fresh passbook install run the following commands to generate a password:
@ -39,4 +39,6 @@ Now you can pull the Docker images needed by running `docker-compose pull`. Afte
passbook will then be reachable via HTTP on port 80, and HTTPS on port 443. You can optionally configure the packaged traefik to use Let's Encrypt certificates for TLS Encryption.
If you plan to access passbook via a reverse proxy which does SSL Termination, make sure you use the HTTPS port, so passbook is aware of the SSL connection.
The initial setup process also creates a default admin user, the username and password for which is `pbadmin`. It is highly recommended to change this password as soon as you log in.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@ -15,27 +15,31 @@ From https://sentry.io
The following placeholders will be used:
- `sentry.company` is the FQDN of the Sentry install.
- `passbook.company` is the FQDN of the passbook install.
- `sentry.company` is the FQDN of the Sentry install.
- `passbook.company` is the FQDN of the passbook install.
Create an application in passbook. Create an OpenID provider with the following parameters:
Create an application in passbook. Create a SAML Provider with the following values
- Client Type: `Confidential`
- Response types: `code (Authorization Code Flow)`
- JWT Algorithm: `RS256`
- Redirect URIs: `https://sentry.company/auth/sso/`
- Scopes: `openid email`
- ACS URL: `https://sentry.company/saml/acs/<sentry organisation name>/`
- Audience: `https://sentry.company/saml/metadata/<sentry organisation name>/`
- Issuer: `passbook`
- Service Provider Binding: `Post`
- Property Mapping: Select all Autogenerated Mappings
## Sentry
**This guide assumes you've installed Sentry using [getsentry/onpremise](https://github.com/getsentry/onpremise)**
- Add `sentry-auth-oidc` to `onpremise/sentry/requirements.txt` (Create the file if it doesn't exist yet)
- Add the following block to your `onpremise/sentry/sentry.conf.py`:
```
OIDC_ISSUER = "passbook"
OIDC_CLIENT_ID = "<Client ID from passbook>"
OIDC_CLIENT_SECRET = "<Client Secret from passbook>"
OIDC_SCOPE = "openid email"
OIDC_DOMAIN = "https://passbook.company/application/oidc/"
```
Navigate to Settings -> Auth, and click on Configure next to SAML2
![](./auth.png)
In passbook, get the Metadata URL by right-clicking `Download Metadata` and selecting Copy Link Address, and paste that URL into Sentry.
On the next screen, input these Values
IdP User ID: `urn:oid:0.9.2342.19200300.100.1.1`
User Email: `urn:oid:0.9.2342.19200300.100.1.3`
First Name: `urn:oid:2.5.4.3`
After confirming, Sentry will authenticate with passbook, and you should be redirected back to a page confirming your settings.

View File

@ -0,0 +1,37 @@
# Sonarr Integration
!!! note
These instructions apply to all projects in the *arr Family. If you use multiple of these projects, you can assign them to the same Outpost.
## What is Sonarr
From https://github.com/Sonarr/Sonarr
!!! note ""
Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new episodes of your favorite shows and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available.
## Preparation
The following placeholders will be used:
- `sonarr.company` is the FQDN of the Sonarr install.
- `passbook.company` is the FQDN of the passbook install.
Create an application in passbook. Create a Proxy Provider with the following values
- Internal host
If Sonarr is running in docker, and you're deploying the passbook proxy on the same host, set the value to `http://sonarr:8989`, where sonarr is the name of your container.
If Sonarr is running on a different server than where you are deploying the passbook proxy, set the value to `http://sonarr.company:8989`.
- External host
Set this to the external URL you will be accessing Sonarr from.
## Deployment
Create an outpost deployment for the provider you've created above, as described [here](../../../outposts/outposts.md). Deploy this Outpost either on the same host or a different host that can access Sonarr.
The outpost will connect to passbook and configure itself.

View File

@ -16,14 +16,15 @@ From https://docs.ansible.com/ansible/2.5/reference_appendices/tower.html
The following placeholders will be used:
- `awx.company` is the FQDN of the AWX/Tower install.
- `passbook.company` is the FQDN of the passbook install.
- `awx.company` is the FQDN of the AWX/Tower install.
- `passbook.company` is the FQDN of the passbook install.
Create an application in passbook and note the slug, as this will be used later. Create a SAML provider with the following parameters:
- ACS URL: `https://awx.company/sso/complete/saml/`
- Audience: `awx`
- Issuer: `https://awx.company/sso/metadata/saml/`
- ACS URL: `https://awx.company/sso/complete/saml/`
- Audience: `awx`
- Service Provider Binding: Post
- Issuer: `https://awx.company/sso/metadata/saml/`
You can of course use a custom signing certificate, and adjust durations.

View File

@ -0,0 +1,58 @@
# Ubuntu Landscape Integration
## What is Ubuntu Landscape
From https://en.wikipedia.org/wiki/Landscape_(software)
!!! note ""
Landscape is a systems management tool developed by Canonical. It can be run on-premises or in the cloud depending on the needs of the user. It is primarily designed for use with Ubuntu derivatives such as Desktop, Server, and Core.
!!! warning
This requires passbook 0.10.3 or newer.
## Preparation
The following placeholders will be used:
- `landscape.company` is the FQDN of the Landscape server.
- `passbook.company` is the FQDN of the passbook install.
Landscape uses the OpenID-Connect Protocol for single-sign on.
## passbook Setup
Create an OAuth2/OpenID-Connect Provider with the default settings. Set the Redirect URIs to `https://landscape.company/login/handle-openid`. Select all Autogenerated Scopes.
Keep Note of the Client ID and the Client Secret.
Create an application and assign access policies to the application. Set the application's provider to the provider you've just created.
## Landscape Setup
On the Landscape Server, edit the file `/etc/landscape/service.conf` and add the following snippet under the `[landscape]` section:
```
oidc-issuer = https://passbook.company/application/o/<slug of the application you've created>/
oidc-client-id = <client ID of the provider you've created>
oidc-client-secret = <client Secret of the provider you've created>
```
Afterwards, run `sudo lsctl restart` to restart the Landscape services.
## Appendix
To make an OpenID-Connect User admin, you have to insert some rows into the database.
First login with your passbook user, and make sure the user is created successfully.
Run `sudo -u postgres psql landscape-standalone-main` on the Landscape server to open a PostgreSQL Prompt.
Then run `select * from person;` to get a list of all users. Take note of the ID given to your new user.
Run the following commands to make this user an administrator:
```sql
INSERT INTO person_account VALUES (<user id>, 1);
INSERT INTO person_access VALUES (<user id>, 1, 1);
```

View File

@ -47,7 +47,7 @@ Under *Sources*, click *Edit* and ensure that "Autogenerated Active Directory Ma
Under *Providers*, create an OAuth2/OpenID Provider with these settings:
- Client Type: Confidential
- Response Type: code
- Response Type: code (ADFS Compatibility Mode, sends id_token as access_token)
- JWT Algorithm: RS256
- Redirect URI: `https://vcenter.company/ui/login/oauth2/authcode`
- Post Logout Redirect URIs: `https://vcenter.company/ui/login`

View File

@ -15,6 +15,6 @@ services:
- 4443:4443
environment:
PASSBOOK_HOST: https://your-passbook.tld
PASSBOOK_INSECURE: 'true'
PASSBOOK_INSECURE: 'false'
PASSBOOK_TOKEN: token-generated-by-passbook
```

View File

@ -0,0 +1,9 @@
# Upgrading an Outpost
In the Outpost Overview list, you'll see if any deployed outposts are out of date.
![](./upgrading_outdated.png)
To upgrade the Outpost to the latest version, simple adjust the docker tag of the outpost the the new version.
Since the configuration is managed by passbook, that's all you have to do.

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -10,7 +10,6 @@ from selenium.webdriver.support import expected_conditions as ec
from e2e.utils import USER, SeleniumTestCase
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.policies.expression.models import ExpressionPolicy
from passbook.stages.email.models import EmailStage, EmailTemplates
from passbook.stages.identification.models import IdentificationStage
from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
@ -59,16 +58,9 @@ class TestFlowsEnroll(SeleniumTestCase):
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
)
# Password checking policy
password_policy = ExpressionPolicy.objects.create(
name="policy-enrollment-password-equals",
expression="return request.context['password'] == request.context['password_repeat']",
)
# Stages
first_stage = PromptStage.objects.create(name="prompt-stage-first")
first_stage.fields.set([username_prompt, password, password_repeat])
first_stage.validation_policies.set([password_policy])
first_stage.save()
second_stage = PromptStage.objects.create(name="prompt-stage-second")
second_stage.fields.set([name_field, email])
@ -152,16 +144,9 @@ class TestFlowsEnroll(SeleniumTestCase):
field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
)
# Password checking policy
password_policy = ExpressionPolicy.objects.create(
name="policy-enrollment-password-equals",
expression="return request.context['password'] == request.context['password_repeat']",
)
# Stages
first_stage = PromptStage.objects.create(name="prompt-stage-first")
first_stage.fields.set([username_prompt, password, password_repeat])
first_stage.validation_policies.set([password_policy])
first_stage.save()
second_stage = PromptStage.objects.create(name="prompt-stage-second")
second_stage.fields.set([name_field, email])

View File

@ -33,6 +33,7 @@ from passbook.providers.oauth2.models import (
)
LOGGER = get_logger()
APPLICATION_SLUG = "grafana"
@skipUnless(platform.startswith("linux"), "requires local docker")
@ -69,6 +70,12 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
"GF_AUTH_GENERIC_OAUTH_API_URL": (
self.url("passbook_providers_oauth2:userinfo")
),
"GF_AUTH_SIGNOUT_REDIRECT_URL": (
self.url(
"passbook_providers_oauth2:end-session",
application_slug=APPLICATION_SLUG,
)
),
"GF_LOG_LEVEL": "debug",
},
}
@ -97,7 +104,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
)
provider.save()
Application.objects.create(
name="Grafana", slug="grafana", provider=provider,
name="Grafana", slug=APPLICATION_SLUG, provider=provider,
)
self.driver.get("http://localhost:3000")
@ -137,7 +144,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
)
provider.save()
Application.objects.create(
name="Grafana", slug="grafana", provider=provider,
name="Grafana", slug=APPLICATION_SLUG, provider=provider,
)
self.driver.get("http://localhost:3000")
@ -171,6 +178,72 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
USER().email,
)
def test_authorization_logout(self):
"""test OpenID Provider flow with logout"""
sleep(1)
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider = OAuth2Provider.objects.create(
name="grafana",
client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id,
client_secret=self.client_secret,
rsa_key=CertificateKeyPair.objects.first(),
redirect_uris="http://localhost:3000/login/generic_oauth",
authorization_flow=authorization_flow,
response_type=ResponseTypes.CODE,
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE]
)
)
provider.save()
Application.objects.create(
name="Grafana", slug=APPLICATION_SLUG, provider=provider,
)
self.driver.get("http://localhost:3000")
self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click()
self.driver.find_element(By.ID, "id_uid_field").click()
self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username)
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click()
self.assertEqual(
self.driver.find_element(By.CLASS_NAME, "page-header__title").text,
USER().name,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute(
"value"
),
USER().name,
)
self.assertEqual(
self.driver.find_element(
By.CSS_SELECTOR, "input[name=email]"
).get_attribute("value"),
USER().email,
)
self.assertEqual(
self.driver.find_element(
By.CSS_SELECTOR, "input[name=login]"
).get_attribute("value"),
USER().email,
)
self.driver.find_element(By.CSS_SELECTOR, "[href='/logout']").click()
self.wait_for_url(
self.url(
"passbook_providers_oauth2:end-session",
application_slug=APPLICATION_SLUG,
)
)
self.driver.find_element(By.ID, "logout").click()
def test_authorization_consent_explicit(self):
"""test OpenID Provider flow (default authorization flow with explicit consent)"""
sleep(1)
@ -195,7 +268,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
)
provider.save()
app = Application.objects.create(
name="Grafana", slug="grafana", provider=provider,
name="Grafana", slug=APPLICATION_SLUG, provider=provider,
)
self.driver.get("http://localhost:3000")
@ -271,7 +344,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
)
provider.save()
app = Application.objects.create(
name="Grafana", slug="grafana", provider=provider,
name="Grafana", slug=APPLICATION_SLUG, provider=provider,
)
negative_policy = ExpressionPolicy.objects.create(

View File

@ -77,7 +77,7 @@ class TestProviderProxy(SeleniumTestCase):
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50:
if outpost.health:
if outpost.deployment_health:
break
healthcheck_retries += 1
sleep(0.5)

View File

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

View File

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

View File

@ -1,16 +1,28 @@
#!/usr/bin/env python
"""This file needs to be run from the root of the project to correctly
import passbook. This is done by the dockerfile."""
from json import dumps
from sys import stderr
from time import sleep
from psycopg2 import OperationalError, connect
from redis import Redis
from redis.exceptions import RedisError
from structlog import get_logger
from passbook.lib.config import CONFIG
LOGGER = get_logger()
def j_print(event: str, log_level: str = "info", **kwargs):
"""Print event in the same format as structlog with JSON.
Used before structlog is configured."""
data = {
"event": event,
"level": log_level,
"logger": __name__,
}
data.update(**kwargs)
print(dumps(data), file=stderr)
while True:
try:
@ -24,7 +36,7 @@ while True:
break
except OperationalError:
sleep(1)
LOGGER.warning("PostgreSQL Connection failed, retrying...")
j_print("PostgreSQL Connection failed, retrying...")
while True:
try:
@ -38,4 +50,4 @@ while True:
break
except RedisError:
sleep(1)
LOGGER.warning("Redis Connection failed, retrying...")
j_print("Redis Connection failed, retrying...")

View File

@ -32,6 +32,7 @@ nav:
- Proxy: providers/proxy.md
- Outposts:
- Overview: outposts/outposts.md
- Upgrading: outposts/upgrading.md
- Deploy on docker-compose: outposts/deploy-docker-compose.md
- Deploy on Kubernetes: outposts/deploy-kubernetes.md
- Expressions:
@ -53,6 +54,8 @@ nav:
- Sentry: integrations/services/sentry/index.md
- Ansible Tower/AWX: integrations/services/tower-awx/index.md
- VMware vCenter: integrations/services/vmware-vcenter/index.md
- Ubuntu Landscape: integrations/services/ubuntu-landscape/index.md
- Sonarr: integrations/services/sonarr/index.md
- Upgrading:
- to 0.9: upgrading/to-0.9.md
- to 0.10: upgrading/to-0.10.md

View File

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

View File

@ -53,6 +53,8 @@ class YAMLField(forms.JSONField):
)
if isinstance(converted, str):
return YAMLString(converted)
if converted is None:
return {}
return converted
def bound_data(self, data, initial):

View File

@ -1,26 +0,0 @@
"""passbook admin Middleware to impersonate users"""
from passbook.core.models import User
def impersonate(get_response):
"""Middleware to impersonate users"""
def middleware(request):
"""Middleware to impersonate users"""
# User is superuser and has __impersonate ID set
if request.user.is_superuser and "__impersonate" in request.GET:
request.session["impersonate_id"] = request.GET["__impersonate"]
# user wants to stop impersonation
elif "__unimpersonate" in request.GET and "impersonate_id" in request.session:
del request.session["impersonate_id"]
# Actually impersonate user
if request.user.is_superuser and "impersonate_id" in request.session:
request.user = User.objects.get(pk=request.session["impersonate_id"])
response = get_response(request)
return response
return middleware

View File

@ -1,5 +0,0 @@
"""passbook admin settings"""
MIDDLEWARE = [
"passbook.admin.middleware.impersonate",
]

View File

@ -3,18 +3,7 @@
{% load i18n %}
{% load humanize %}
{% load passbook_utils %}
{% block head %}
{{ block.super }}
<style>
.pf-m-success {
color: var(--pf-global--success-color--100);
}
.pf-m-danger {
color: var(--pf-global--danger-color--100);
}
</style>
{% endblock %}
{% load admin_reflection %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
@ -32,7 +21,7 @@
<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
<div class="pf-c-toolbar__bulk-select">
<a href="{% url 'passbook_admin:flow-create' %}?back={{ request.get_full_path }}" class="pf-c-button pf-m-primary" type="button">{% trans 'Create' %}</a>
<a href="{% url 'passbook_admin:outpost-create' %}?back={{ request.get_full_path }}" class="pf-c-button pf-m-primary" type="button">{% trans 'Create' %}</a>
</div>
{% include 'partials/pagination.html' %}
</div>
@ -43,6 +32,7 @@
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
<th role="columnheader" scope="col">{% trans 'Providers' %}</th>
<th role="columnheader" scope="col">{% trans 'Health' %}</th>
<th role="columnheader" scope="col">{% trans 'Version' %}</th>
<th role="cell"></th>
</tr>
</thead>
@ -50,7 +40,7 @@
{% for outpost in object_list %}
<tr role="row">
<th role="columnheader">
<a href="{% url 'passbook_outposts:setup' outpost_pk=outpost.pk %}">{{ outpost.name }}</a>
<span>{{ outpost.name }}</span>
</th>
<td role="cell">
<span>
@ -58,7 +48,7 @@
</span>
</td>
<td role="cell">
{% with health=outpost.health %}
{% with health=outpost.deployment_health %}
{% if health %}
<i class="fas fa-check pf-m-success"></i> {{ health|naturaltime }}
{% else %}
@ -66,10 +56,28 @@
{% endif %}
{% endwith %}
</td>
<td role="cell">
<span>
{% with ver=outpost.deployment_version %}
{% if ver.outdated %}
{% if ver.version == "" %}
<i class="fas fa-times pf-m-danger"></i> -
{% else %}
<i class="fas fa-times pf-m-danger"></i> {% blocktrans with is=ver.version should=ver.should %}{{ is }}, should be {{ should }}{% endblocktrans %}
{% endif %}
{% else %}
<i class="fas fa-check pf-m-success"></i> {{ ver.version }}
{% endif %}
{% endwith %}
</span>
</td>
<td>
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:outpost-update' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:outpost-delete' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
<a href="https://passbook.beryju.org/outposts/outposts/#deploy">{% trans 'Deploy' %}</a>
{% get_htmls outpost as htmls %}
{% for html in htmls %}
{{ html|safe }}
{% endfor %}
</td>
</tr>
{% endfor %}

View File

@ -55,7 +55,7 @@
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:user-update' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:user-delete' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
<a class="pf-c-button pf-m-tertiary" href="{% url 'passbook_admin:user-password-reset' pk=user.pk %}?back={{ request.get_full_path }}">{% trans 'Reset Password' %}</a>
<a class="pf-c-button pf-m-tertiary" href="{% url 'passbook_core:overview' %}?__impersonate={{ user.pk }}">{% trans 'Impersonate' %}</a>
<a class="pf-c-button pf-m-tertiary" href="{% url 'passbook_core:impersonate-init' user_id=user.pk %}">{% trans 'Impersonate' %}</a>
</td>
</tr>
{% endfor %}

View File

@ -1,4 +1,7 @@
"""passbook Outpost administration"""
from dataclasses import asdict
from typing import Any, Dict
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
@ -12,7 +15,7 @@ from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.admin.views.utils import DeleteMessageView
from passbook.lib.views import CreateAssignPermView
from passbook.outposts.forms import OutpostForm
from passbook.outposts.models import Outpost
from passbook.outposts.models import Outpost, OutpostConfig
class OutpostListView(LoginRequiredMixin, PermissionListMixin, ListView):
@ -41,6 +44,13 @@ class OutpostCreateView(
success_url = reverse_lazy("passbook_admin:outposts")
success_message = _("Successfully created Outpost")
def get_initial(self) -> Dict[str, Any]:
return {
"_config": asdict(
OutpostConfig(passbook_host=self.request.build_absolute_uri("/"))
)
}
class OutpostUpdateView(
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
@ -53,7 +63,7 @@ class OutpostUpdateView(
template_name = "generic/update.html"
success_url = reverse_lazy("passbook_admin:outposts")
success_message = _("Successfully updated Certificate-Key Pair")
success_message = _("Successfully updated Outpost")
class OutpostDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
@ -64,4 +74,4 @@ class OutpostDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessa
template_name = "generic/delete.html"
success_url = reverse_lazy("passbook_admin:outposts")
success_message = _("Successfully deleted Certificate-Key Pair")
success_message = _("Successfully deleted Outpost")

View File

@ -9,11 +9,12 @@ from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from guardian.shortcuts import get_objects_for_user
from passbook.admin.views.utils import DeleteMessageView
from passbook.lib.views import CreateAssignPermView
from passbook.policies.forms import PolicyBindingForm
from passbook.policies.models import PolicyBinding, PolicyBindingModel
from passbook.policies.models import PolicyBinding
class PolicyBindingListView(LoginRequiredMixin, PermissionListMixin, ListView):
@ -29,13 +30,18 @@ class PolicyBindingListView(LoginRequiredMixin, PermissionListMixin, ListView):
# Since `select_subclasses` does not work with a foreign key, we have to do two queries here
# First, get all pbm objects that have bindings attached
objects = (
PolicyBindingModel.objects.filter(policies__isnull=False)
get_objects_for_user(
self.request.user, "passbook_policies.view_policybindingmodel"
)
.filter(policies__isnull=False)
.select_subclasses()
.select_related()
.order_by("pk")
)
for pbm in objects:
pbm.bindings = PolicyBinding.objects.filter(target__pk=pbm.pbm_uuid)
pbm.bindings = get_objects_for_user(
self.request.user, self.permission_required
).filter(target__pk=pbm.pbm_uuid)
return objects

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,33 @@
# Generated by Django 3.1.1 on 2020-09-18 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_audit", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="event",
name="action",
field=models.TextField(
choices=[
("LOGIN", "login"),
("LOGIN_FAILED", "login_failed"),
("LOGOUT", "logout"),
("AUTHORIZE_APPLICATION", "authorize_application"),
("SUSPICIOUS_REQUEST", "suspicious_request"),
("SIGN_UP", "sign_up"),
("PASSWORD_RESET", "password_reset"),
("INVITE_CREATED", "invitation_created"),
("INVITE_USED", "invitation_used"),
("IMPERSONATION_STARTED", "impersonation_started"),
("IMPERSONATION_ENDED", "impersonation_ended"),
("CUSTOM", "custom"),
]
),
),
]

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

View File

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

View File

@ -40,12 +40,28 @@
</div>
</th>
<td role="cell">
<code>{{ entry.context }}</code>
<div>
<div>
<code>{{ entry.context }}</code>
</div>
{% if entry.user.on_behalf_of %}
<small>
{% blocktrans with username=entry.user.on_behalf_of.username %}
On behalf of {{ username }}
{% endblocktrans %}
</small>
{% endif %}
</div>
</td>
<td role="cell">
<span>
{{ entry.user }}
</span>
<div>
<div>{{ entry.user.username }}</div>
<small>
{% blocktrans with pk=entry.user.pk %}
ID: {{ pk }}
{% endblocktrans %}
</small>
</div>
</td>
<td role="cell">
<span>

View File

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

View File

@ -0,0 +1,56 @@
"""passbook admin Middleware to impersonate users"""
from logging import Logger
from threading import local
from typing import Callable
from uuid import uuid4
from django.http import HttpRequest, HttpResponse
SESSION_IMPERSONATE_USER = "passbook_impersonate_user"
SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user"
LOCAL = local()
class ImpersonateMiddleware:
"""Middleware to impersonate users"""
get_response: Callable[[HttpRequest], HttpResponse]
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
# No permission checks are done here, they need to be checked before
# SESSION_IMPERSONATE_USER is set.
if SESSION_IMPERSONATE_USER in request.session:
request.user = request.session[SESSION_IMPERSONATE_USER]
return self.get_response(request)
class RequestIDMiddleware:
"""Add a unique ID to every request"""
get_response: Callable[[HttpRequest], HttpResponse]
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
if not hasattr(request, "request_id"):
request_id = uuid4().hex
setattr(request, "request_id", request_id)
LOCAL.passbook = {"request_id": request_id}
response = self.get_response(request)
response["X-passbook-id"] = request.request_id
del LOCAL.passbook["request_id"]
return response
# pylint: disable=unused-argument
def structlog_add_request_id(logger: Logger, method_name: str, event_dict):
"""If threadlocal has passbook defined, add request_id to log"""
if hasattr(LOCAL, "passbook"):
event_dict["request_id"] = LOCAL.passbook.get("request_id", "")
return event_dict

View File

@ -14,7 +14,7 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
pbadmin, _ = User.objects.using(db_alias).get_or_create(
username="pbadmin", email="root@localhost", name="passbook Default Admin"
)
pbadmin.set_password("pbadmin") # noqa # nosec
pbadmin.set_password("pbadmin", signal=False) # noqa # nosec
pbadmin.save()

View File

@ -0,0 +1,24 @@
# Generated by Django 3.1.1 on 2020-09-17 10:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0009_group_is_superuser"),
]
operations = [
migrations.AlterModelOptions(
name="user",
options={
"permissions": (
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
),
"verbose_name": "User",
"verbose_name_plural": "Users",
},
),
]

View File

@ -90,15 +90,18 @@ class User(GuardianUserMixin, AbstractUser):
"""superuser == staff user"""
return self.is_superuser
def set_password(self, password):
if self.pk:
def set_password(self, password, signal=True):
if self.pk and signal:
password_changed.send(sender=self, user=self, password=password)
self.password_change_date = now()
return super().set_password(password)
class Meta:
permissions = (("reset_user_password", "Reset Password"),)
permissions = (
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
)
verbose_name = _("User")
verbose_name_plural = _("Users")
@ -157,7 +160,7 @@ class Application(PolicyBindingModel):
if self.meta_launch_url:
return self.meta_launch_url
if self.provider:
return self.provider.launch_url
return self.get_provider().launch_url
return None
def get_provider(self) -> Optional[Provider]:

View File

@ -21,13 +21,13 @@
{% endblock %}
</head>
<body>
{% if 'impersonate_id' in request.session %}
{% if 'passbook_impersonate_user' in request.session %}
<div class="pf-c-banner pf-m-warning pf-c-alert pf-m-sticky">
<div class="pf-l-flex pf-m-justify-content-center pf-m-justify-content-space-between-on-lg pf-m-nowrap" style="height: 100%;">
<div class=""></div>
<div class="pf-u-display-none pf-u-display-block-on-lg">
{% blocktrans with user=user %}You're currently impersonating {{ user }}.{% endblocktrans %}
<a href="?__unimpersonate=True" id="acceptMessage">{% trans 'Stop impersonation' %}</a>
<a href="{% url 'passbook_core:impersonate-end' %}?back={{ request.get_full_path }}" id="acceptMessage">{% trans 'Stop impersonation' %}</a>
</div>
<div class=""></div>
</div>

View File

@ -7,6 +7,7 @@
<style>
img.app-icon {
max-height: 72px;
width: auto !important;
}
</style>
{% endblock %}

View File

@ -0,0 +1,55 @@
"""impersonation tests"""
from django.shortcuts import reverse
from django.test.testcases import TestCase
from passbook.core.models import User
class TestImpersonation(TestCase):
"""impersonation tests"""
def setUp(self) -> None:
super().setUp()
self.other_user = User.objects.create(username="to-impersonate")
self.pbadmin = User.objects.get(username="pbadmin")
def test_impersonate_simple(self):
"""test simple impersonation and un-impersonation"""
self.client.force_login(self.pbadmin)
self.client.get(
reverse(
"passbook_core:impersonate-init", kwargs={"user_id": self.other_user.pk}
)
)
response = self.client.get(reverse("passbook_core:overview"))
self.assertIn(self.other_user.username, response.content.decode())
self.assertNotIn(self.pbadmin.username, response.content.decode())
self.client.get(reverse("passbook_core:impersonate-end"))
response = self.client.get(reverse("passbook_core:overview"))
self.assertNotIn(self.other_user.username, response.content.decode())
self.assertIn(self.pbadmin.username, response.content.decode())
def test_impersonate_denied(self):
"""test impersonation without permissions"""
self.client.force_login(self.other_user)
self.client.get(
reverse(
"passbook_core:impersonate-init", kwargs={"user_id": self.pbadmin.pk}
)
)
response = self.client.get(reverse("passbook_core:overview"))
self.assertIn(self.other_user.username, response.content.decode())
self.assertNotIn(self.pbadmin.username, response.content.decode())
def test_un_impersonate_empty(self):
"""test un-impersonation without impersonating first"""
self.client.force_login(self.other_user)
response = self.client.get(reverse("passbook_core:impersonate-end"))
self.assertRedirects(response, reverse("passbook_core:overview"))

View File

@ -1,11 +1,22 @@
"""passbook URL Configuration"""
from django.urls import path
from passbook.core.views import overview, user
from passbook.core.views import impersonate, overview, user
urlpatterns = [
# User views
path("-/user/", user.UserSettingsView.as_view(), name="user-settings"),
# Overview
path("", overview.OverviewView.as_view(), name="overview"),
# Impersonation
path(
"-/impersonation/<int:user_id>/",
impersonate.ImpersonateInitView.as_view(),
name="impersonate-init",
),
path(
"-/impersonation/end/",
impersonate.ImpersonateEndView.as_view(),
name="impersonate-end",
),
]

View File

@ -0,0 +1,58 @@
"""passbook impersonation views"""
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from structlog import get_logger
from passbook.audit.models import Event, EventAction
from passbook.core.middleware import (
SESSION_IMPERSONATE_ORIGINAL_USER,
SESSION_IMPERSONATE_USER,
)
from passbook.core.models import User
LOGGER = get_logger()
class ImpersonateInitView(View):
"""Initiate Impersonation"""
def get(self, request: HttpRequest, user_id: int) -> HttpResponse:
"""Impersonation handler, checks permissions"""
if not request.user.has_perm("impersonate"):
LOGGER.debug(
"User attempted to impersonate without permissions", user=request.user
)
return HttpResponse("Unauthorized", status=401)
user_to_be = get_object_or_404(User, pk=user_id)
request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user
request.session[SESSION_IMPERSONATE_USER] = user_to_be
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
return redirect("passbook_core:overview")
class ImpersonateEndView(View):
"""End User impersonation"""
def get(self, request: HttpRequest) -> HttpResponse:
"""End Impersonation handler"""
if (
SESSION_IMPERSONATE_USER not in request.session
or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session
):
LOGGER.debug("Can't end impersonation", user=request.user)
return redirect("passbook_core:overview")
original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
del request.session[SESSION_IMPERSONATE_USER]
del request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
return redirect("passbook_core:overview")

View File

@ -177,6 +177,6 @@ class FlowPlanner:
marker = ReevaluateMarker(binding=binding, user=user)
plan.markers.append(marker)
LOGGER.debug(
"f(plan): Finished building", flow=self.flow, duration_s=span.timestamp,
"f(plan): Finished building", flow=self.flow,
)
return plan

View File

@ -0,0 +1,57 @@
{% extends 'login/base.html' %}
{% load static %}
{% load i18n %}
{% load passbook_utils %}
{% block card_title %}
{% trans 'Permission denied' %}
{% endblock %}
{% block title %}
{% trans 'Permission denied' %}
{% endblock %}
{% block card %}
<form method="POST" class="pf-c-form">
{% csrf_token %}
{% include 'partials/form.html' %}
<div class="pf-c-form__group">
<p>
<i class="pf-icon pf-icon-error-circle-o"></i>
{% trans 'Request has been denied.' %}
</p>
{% if error %}
<hr>
<p>
{{ error }}
</p>
{% endif %}
{% if policy_result %}
<hr>
<em>
{% trans 'Explanation:' %}
</em>
<ul class="pf-c-list">
{% for source_result in policy_result.source_results %}
<li>
{% blocktrans with name=source_result.source_policy.name result=source_result.passing %}
Policy '{{ name }}' returned result '{{ result }}'
{% endblocktrans %}
{% if source_result.messages %}
<ul class="pf-c-list">
{% for message in source_result.messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% if 'back' in request.GET %}
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
{% endif %}
</form>
{% endblock %}

View File

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

View File

@ -187,9 +187,11 @@ class FlowExecutorView(View):
is a superuser."""
LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug)
self.cancel()
response = AccessDeniedResponse(self.request)
response = AccessDeniedResponse(
self.request, template="flows/denied_shell.html"
)
response.error_message = error_message
return response
return to_stage_response(self.request, response)
def cancel(self):
"""Cancel current execution and return a redirect"""

View File

@ -1,9 +1,23 @@
"""logging helpers"""
from logging import Logger
from os import getpid
from typing import Callable
# pylint: disable=unused-argument
def add_process_id(logger, method_name, event_dict):
def add_process_id(logger: Logger, method_name: str, event_dict):
"""Add the current process ID"""
event_dict["pid"] = getpid()
return event_dict
def add_common_fields(environment: str) -> Callable:
"""Add a common field to easily search for passbook logs"""
def add_common_field(logger: Logger, method_name: str, event_dict):
"""Add a common field to easily search for passbook logs"""
event_dict["app"] = "passbook"
event_dict["app_environment"] = environment
return event_dict
return add_common_field

View File

@ -5,6 +5,7 @@ from celery.exceptions import CeleryError
from django.core.exceptions import DisallowedHost, ValidationError
from django.db import InternalError, OperationalError, ProgrammingError
from django_redis.exceptions import ConnectionInterrupted
from ldap3.core.exceptions import LDAPException
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import RedisError
from rest_framework.exceptions import APIException
@ -39,6 +40,7 @@ def before_send(event, hint):
SentryIgnoredException,
WebSocketException,
CeleryError,
LDAPException,
)
if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"]

View File

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

View File

@ -83,7 +83,11 @@ class OutpostConsumer(JsonWebsocketConsumer):
def receive_json(self, content: Data):
msg = from_dict(WebsocketMessage, content)
if msg.instruction == WebsocketMessageInstruction.HELLO:
cache.set(self.outpost.health_cache_key, time(), timeout=60)
cache.set(self.outpost.state_cache_prefix("health"), time(), timeout=60)
if "version" in msg.args:
cache.set(
self.outpost.state_cache_prefix("version"), msg.args["version"]
)
elif msg.instruction == WebsocketMessageInstruction.ACK:
return

View File

@ -4,8 +4,8 @@ from django import forms
from django.utils.translation import gettext_lazy as _
from passbook.admin.fields import CodeMirrorWidget, YAMLField
from passbook.core.models import Provider
from passbook.outposts.models import Outpost
from passbook.providers.proxy.models import ProxyProvider
class OutpostForm(forms.ModelForm):
@ -13,7 +13,7 @@ class OutpostForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["providers"].queryset = Provider.objects.all().select_subclasses()
self.fields["providers"].queryset = ProxyProvider.objects.all()
class Meta:

View File

@ -1,7 +1,7 @@
"""Outpost models"""
from dataclasses import asdict, dataclass
from datetime import datetime
from typing import Iterable, Optional
from typing import Any, Dict, Iterable, Optional
from uuid import uuid4
from dacite import from_dict
@ -9,12 +9,19 @@ from django.contrib.postgres.fields import ArrayField
from django.core.cache import cache
from django.db import models, transaction
from django.db.models.base import Model
from django.http import HttpRequest
from django.utils import version
from django.utils.translation import gettext_lazy as _
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from packaging.version import InvalidVersion, parse
from passbook import __version__
from passbook.core.models import Provider, Token, TokenIntents, User
from passbook.lib.config import CONFIG
from passbook.lib.utils.template import render_to_string
OUR_VERSION = parse(__version__)
@dataclass
@ -91,20 +98,37 @@ class Outpost(models.Model):
"""Dump config into json"""
self._config = asdict(value)
@property
def health_cache_key(self) -> str:
"""Key by which the outposts health status is saved"""
return f"outpost_{self.uuid.hex}_health"
def state_cache_prefix(self, suffix: str) -> str:
"""Key by which the outposts status is saved"""
return f"outpost_{self.uuid.hex}_state_{suffix}"
@property
def health(self) -> Optional[datetime]:
def deployment_health(self) -> Optional[datetime]:
"""Get outpost's health status"""
key = self.health_cache_key
key = self.state_cache_prefix("health")
value = cache.get(key, None)
if value:
return datetime.fromtimestamp(value)
return None
@property
def deployment_version(self) -> Dict[str, Any]:
"""Get deployed outposts version, and if the version is behind ours.
Returns a dict with keys version and outdated."""
key = self.state_cache_prefix("version")
value = cache.get(key, None)
if not value:
return {"version": "", "outdated": False, "should": OUR_VERSION}
try:
outpost_version = parse(value)
return {
"version": value,
"outdated": outpost_version < OUR_VERSION,
"should": OUR_VERSION,
}
except InvalidVersion:
return {"version": version, "outdated": False, "should": OUR_VERSION}
@property
def user(self) -> User:
"""Get/create user with access to all required objects"""
@ -149,5 +173,12 @@ class Outpost(models.Model):
objects.append(provider)
return objects
def html_deployment_view(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal to view token and other config info"""
return render_to_string(
"outposts/deployment_modal.html",
{"outpost": self, "full_url": request.build_absolute_uri("/")},
)
def __str__(self) -> str:
return f"Outpost {self.name}"

View File

@ -0,0 +1,43 @@
{% load i18n %}
{% load static %}
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="saml-{{ provider.pk }}">{% trans 'View Deployment Info' %}</button>
<div class="pf-c-backdrop" id="saml-{{ provider.pk }}" hidden>
<div class="pf-l-bullseye">
<div class="pf-c-modal-box pf-m-lg" role="dialog">
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<div class="pf-c-modal-box__header">
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Outpost Deployment Info' %}</h1>
</div>
<div class="pf-c-modal-box__body" id="modal-description">
<p><a href="https://passbook.beryju.org/outposts/outposts/#deploy">{% trans 'View deployment documentation' %}</a></p>
<form class="pf-c-form">
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">PASSBOOK_HOST</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ full_url }}" />
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">PASSBOOK_TOKEN</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ outpost.token.token_uuid.hex }}" />
</div>
<h3>{% trans 'If your passbook Instance is using a self-signed certificate, set this value.' %}</h3>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">PASSBOOK_INSECURE</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="true" />
</div>
</form>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
</footer>
</div>
</div>
</div>

View File

@ -18,10 +18,9 @@ class AccessDeniedResponse(TemplateResponse):
error_message: Optional[str] = None
policy_result: Optional[PolicyResult] = None
def __init__(self, request: HttpRequest) -> None:
# For some reason pyright complains about keyword argument usage here
# pyright: reportGeneralTypeIssues=false
super().__init__(request=request, template="policies/denied.html")
# pyright: reportGeneralTypeIssues=false
def __init__(self, request: HttpRequest, template="policies/denied.html") -> None:
super().__init__(request, template)
self.title = _("Access denied")
def resolve_context(

View File

@ -19,7 +19,7 @@
<div class="pf-c-form__group">
<p>
<i class="pf-icon pf-icon-error-circle-o"></i>
{% trans 'Access denied' %}
{% trans 'Request has been denied.' %}
</p>
{% if error %}
<hr>

View File

@ -22,7 +22,6 @@ class OAuth2ProviderSerializer(ModelSerializer):
"jwt_alg",
"rsa_key",
"redirect_uris",
"post_logout_redirect_uris",
"sub_mode",
"property_mappings",
]

View File

@ -41,7 +41,6 @@ class OAuth2ProviderForm(forms.ModelForm):
"jwt_alg",
"rsa_key",
"redirect_uris",
"post_logout_redirect_uris",
"sub_mode",
"property_mappings",
]

View File

@ -0,0 +1,16 @@
# Generated by Django 3.1.1 on 2020-09-18 21:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_oauth2", "0003_auto_20200916_2129"),
]
operations = [
migrations.RemoveField(
model_name="oauth2provider", name="post_logout_redirect_uris",
),
]

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

@ -71,7 +71,7 @@ class ResponseTypes(models.TextChoices):
CODE = "code", _("code (Authorization Code Flow)")
CODE_ADFS = (
"code_adfs",
"code#adfs",
_("code (ADFS Compatibility Mode, sends id_token as access_token)"),
)
ID_TOKEN = "id_token", _("id_token (Implicit Flow)")
@ -157,12 +157,6 @@ class OAuth2Provider(Provider):
verbose_name=_("Redirect URIs"),
help_text=_("Enter each URI on a new line."),
)
post_logout_redirect_uris = models.TextField(
blank=True,
default="",
verbose_name=_("Post Logout Redirect URIs"),
help_text=_("Enter each URI on a new line."),
)
include_claims_in_id_token = models.BooleanField(
default=True,
@ -269,12 +263,11 @@ class OAuth2Provider(Provider):
@property
def launch_url(self) -> Optional[str]:
"""Guess launch_url based on first redirect_uri"""
if not self.redirect_uris:
if self.redirect_uris == "":
return None
main_url = self.redirect_uris[0]
main_url = self.redirect_uris.split("\n")[0]
launch_url = urlparse(main_url)
launch_url.path = ""
return launch_url.geturl()
return main_url.replace(launch_url.path, "")
def form(self) -> Type[ModelForm]:
from passbook.providers.oauth2.forms import OAuth2ProviderForm
@ -300,6 +293,7 @@ class OAuth2Provider(Provider):
"providers/oauth2/setup_url_modal.html",
{
"provider": self,
"issuer": self.get_issuer(request),
"authorize": request.build_absolute_uri(
reverse("passbook_providers_oauth2:authorize",)
),
@ -346,7 +340,6 @@ class BaseGrantModel(models.Model):
abstract = True
# pylint: disable=too-many-instance-attributes
class AuthorizationCode(ExpiringModel, BaseGrantModel):
"""OAuth2 Authorization Code"""
@ -373,7 +366,6 @@ class AuthorizationCode(ExpiringModel, BaseGrantModel):
@dataclass
# plyint: disable=too-many-instance-attributes
class IDToken:
"""The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be
Authenticated is the ID Token data structure. The ID Token is a security token that contains

View File

@ -0,0 +1,38 @@
{% extends 'login/base_full.html' %}
{% load static %}
{% load i18n %}
{% load passbook_utils %}
{% block title %}
{% trans 'End session' %}
{% endblock %}
{% block card_title %}
{% blocktrans with application=application.name %}
You've logged out of {{ application }}.
{% endblocktrans %}
{% endblock %}
{% block card %}
<form method="POST" class="pf-c-form">
<p>
{% blocktrans with application=application.name %}
You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your passbook account.
{% endblocktrans %}
</p>
<a id="pb-back-home" href="{% url 'passbook_core:overview' %}" class="pf-c-button pf-m-primary">{% trans 'Go back to overview' %}</a>
<a id="logout" href="{% url 'passbook_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">{% trans 'Log out of passbook' %}</a>
{% if application.get_launch_url %}
<a href="{{ application.get_launch_url }}" class="pf-c-button pf-m-secondary">
{% blocktrans with application=application.name %}
Log back into {{ application }}
{% endblocktrans %}
</a>
{% endif %}
</form>
{% endblock %}

View File

@ -13,6 +13,19 @@
</div>
<div class="pf-c-modal-box__body" id="modal-description">
<form class="pf-c-form">
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration URL' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ provider_info }}" />
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration Issuer' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ issuer }}" />
</div>
<hr>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'Authorize URL' %}</span>
@ -31,13 +44,6 @@
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ userinfo }}" />
</div>
<hr>
<div class="pf-c-form__group">
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">{% trans 'OpenID Configuration URL' %}</span>
</label>
<input class="pf-c-form-control" readonly type="text" value="{{ provider_info }}" />
</div>
</form>
</div>
<footer class="pf-c-modal-box__footer pf-m-align-left">

View File

@ -20,12 +20,16 @@ urlpatterns = [
csrf_exempt(protected_resource_view([SCOPE_OPENID])(UserInfoView.as_view())),
name="userinfo",
),
path("end-session/", EndSessionView.as_view(), name="end-session",),
path(
"introspect/",
csrf_exempt(TokenIntrospectionView.as_view()),
name="token-introspection",
),
path(
"<slug:application_slug>/end-session/",
EndSessionView.as_view(),
name="end-session",
),
path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"),
path(
"<slug:application_slug>/.well-known/openid-configuration",

View File

@ -57,7 +57,6 @@ ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET}
@dataclass
# pylint: disable=too-many-instance-attributes
class OAuthAuthorizationParams:
"""Parameteres required to authorize an OAuth Client"""
@ -164,8 +163,15 @@ class OAuthAuthorizationParams:
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
# Response type parameter validation.
if is_open_id and self.response_type != self.provider.response_type:
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
if is_open_id:
actual_response_type = self.provider.response_type
if "#" in self.provider.response_type:
hash_index = actual_response_type.index("#")
actual_response_type = actual_response_type[:hash_index]
if self.response_type != actual_response_type:
raise AuthorizeError(
self.redirect_uri, "invalid_request", self.grant_type
)
# PKCE validation of the transformation method.
if self.code_challenge:

View File

@ -32,7 +32,10 @@ class ProviderInfoView(View):
reverse("passbook_providers_oauth2:userinfo")
),
"end_session_endpoint": self.request.build_absolute_uri(
reverse("passbook_providers_oauth2:end-session")
reverse(
"passbook_providers_oauth2:end-session",
kwargs={"application_slug": provider.application.slug},
)
),
"introspection_endpoint": self.request.build_absolute_uri(
reverse("passbook_providers_oauth2:token-introspection")
@ -63,7 +66,9 @@ class ProviderInfoView(View):
provider: OAuth2Provider = get_object_or_404(
OAuth2Provider, pk=application.provider_id
)
response = JsonResponse(self.get_info(provider))
response = JsonResponse(
self.get_info(provider), json_dumps_params={"indent": 2}
)
response["Access-Control-Allow-Origin"] = "*"
return response

View File

@ -1,45 +1,22 @@
"""passbook OAuth2 Session Views"""
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
from typing import Any, Dict
from django.contrib.auth.views import LogoutView
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.views.generic.base import TemplateView
from passbook.core.models import Application
from passbook.providers.oauth2.models import OAuth2Provider
from passbook.providers.oauth2.utils import client_id_from_id_token
class EndSessionView(LogoutView):
class EndSessionView(TemplateView):
"""Allow the client to end the Session"""
def dispatch(
self, request: HttpRequest, application_slug: str, *args, **kwargs
) -> HttpResponse:
template_name = "providers/oauth2/end_session.html"
application = get_object_or_404(Application, slug=application_slug)
provider: OAuth2Provider = get_object_or_404(
OAuth2Provider, pk=application.provider_id
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["application"] = get_object_or_404(
Application, slug=self.kwargs["application_slug"]
)
id_token_hint = request.GET.get("id_token_hint", "")
post_logout_redirect_uri = request.GET.get("post_logout_redirect_uri", "")
state = request.GET.get("state", "")
if id_token_hint:
client_id = client_id_from_id_token(id_token_hint)
try:
provider = OAuth2Provider.objects.get(client_id=client_id)
if post_logout_redirect_uri in provider.post_logout_redirect_uris:
if state:
uri = urlsplit(post_logout_redirect_uri)
query_params = parse_qs(uri.query)
query_params["state"] = state
uri = uri._replace(query=urlencode(query_params, doseq=True))
self.next_page = urlunsplit(uri)
else:
self.next_page = post_logout_redirect_uri
except OAuth2Provider.DoesNotExist:
pass
return super().dispatch(request, *args, **kwargs)
return context

View File

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

View File

@ -55,6 +55,7 @@ class ProxyProviderSerializer(ModelSerializer):
"internal_host",
"external_host",
"certificate",
"skip_path_regex",
]
@ -93,6 +94,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
"oidc_configuration",
"cookie_secret",
"certificate",
"skip_path_regex",
]
@swagger_serializer_method(serializer_or_field=OpenIDConnectConfigurationSerializer)

View File

@ -35,6 +35,7 @@ class ProxyProviderForm(forms.ModelForm):
"internal_host",
"external_host",
"certificate",
"skip_path_regex",
]
widgets = {
"name": forms.TextInput(),

View File

@ -0,0 +1,22 @@
# Generated by Django 3.1.1 on 2020-09-19 09:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_proxy", "0005_auto_20200914_1536"),
]
operations = [
migrations.AddField(
model_name="proxyprovider",
name="skip_path_regex",
field=models.TextField(
blank=True,
default="",
help_text="Regular expression for which authentication is not required. Each new line is interpreted as a new Regular Expression.",
),
),
]

View File

@ -49,6 +49,17 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
cookie_secret = models.TextField(default=get_cookie_secret)
skip_path_regex = models.TextField(
default="",
blank=True,
help_text=_(
(
"Regular expression for which authentication is not required. "
"Each new line is interpreted as a new Regular Expression."
)
),
)
certificate = models.ForeignKey(
CertificateKeyPair, on_delete=models.SET_NULL, null=True, blank=True,
)

View File

@ -107,8 +107,7 @@ class SAMLProvider(Provider):
def launch_url(self) -> Optional[str]:
"""Guess launch_url based on acs URL"""
launch_url = urlparse(self.acs_url)
launch_url.path = ""
return launch_url.geturl()
return self.acs_url.replace(launch_url.path, "")
def form(self) -> Type[ModelForm]:
from passbook.providers.saml.forms import SAMLProviderForm
@ -130,7 +129,7 @@ class SAMLProvider(Provider):
return None
def html_metadata_view(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with to view Metadata without downloading it"""
"""return template and context modal to view Metadata without downloading it"""
from passbook.providers.saml.views import DescriptorDownloadView
try:
@ -161,7 +160,8 @@ class SAMLPropertyMapping(PropertyMapping):
return SAMLPropertyMappingForm
def __str__(self):
return f"SAML Property Mapping {self.saml_name}"
name = self.friendly_name if self.friendly_name != "" else self.saml_name
return f"SAML Property Mapping {self.name} ({name})"
class Meta:

View File

@ -102,11 +102,14 @@ class ASGILogger:
await self.app(scope, receive, send_hooked)
def _get_ip(self) -> str:
client_ip = None
for header in ASGI_IP_HEADERS:
if header in self.headers:
return self.headers[header].decode()
client_ip, _ = self.scope.get("client", ("", 0))
return client_ip
client_ip = self.headers[header].decode()
if not client_ip:
client_ip, _ = self.scope.get("client", ("", 0))
# Check if header has multiple values, and use the first one
return client_ip.split(", ")[0]
def log(self, runtime: float):
"""Outpot access logs in a structured format"""

View File

@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/2.1/ref/settings/
import importlib
import os
import sys
from json import dumps
import structlog
@ -21,8 +22,9 @@ from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
from passbook import __version__
from passbook.core.middleware import structlog_add_request_id
from passbook.lib.config import CONFIG
from passbook.lib.logging import add_process_id
from passbook.lib.logging import add_common_fields, add_process_id
from passbook.lib.sentry import before_send
@ -35,7 +37,7 @@ def j_print(event: str, log_level: str = "info", **kwargs):
"logger": __name__,
}
data.update(**kwargs)
print(dumps(data))
print(dumps(data), file=sys.stderr)
LOGGER = structlog.get_logger()
@ -174,11 +176,14 @@ MIDDLEWARE = [
"django_prometheus.middleware.PrometheusBeforeMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"passbook.core.middleware.RequestIDMiddleware",
"passbook.audit.middleware.AuditMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"passbook.core.middleware.ImpersonateMiddleware",
"django_prometheus.middleware.PrometheusAfterMiddleware",
]
@ -328,6 +333,8 @@ structlog.configure_once(
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
add_process_id,
add_common_fields(CONFIG.y("error_reporting.environment", "customer")),
structlog_add_request_id,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(),
structlog.processors.StackInfoRenderer(),

View File

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

View File

@ -1,9 +1,12 @@
"""passbook LDAP Authentication Backend"""
from typing import Optional
import ldap3
from django.contrib.auth.backends import ModelBackend
from django.http import HttpRequest
from structlog import get_logger
from passbook.sources.ldap.connector import Connector
from passbook.core.models import User
from passbook.sources.ldap.models import LDAPSource
LOGGER = get_logger()
@ -18,7 +21,56 @@ class LDAPBackend(ModelBackend):
return None
for source in LDAPSource.objects.filter(enabled=True):
LOGGER.debug("LDAP Auth attempt", source=source)
user = Connector(source).auth_user(**kwargs)
user = self.auth_user(source, **kwargs)
if user:
return user
return None
def auth_user(
self, source: LDAPSource, password: str, **filters: str
) -> Optional[User]:
"""Try to bind as either user_dn or mail with password.
Returns True on success, otherwise False"""
users = User.objects.filter(**filters)
if not users.exists():
return None
user: User = users.first()
if "distinguishedName" not in user.attributes:
LOGGER.debug(
"User doesn't have DN set, assuming not LDAP imported.", user=user
)
return None
# Either has unusable password,
# or has a password, but couldn't be authenticated by ModelBackend.
# This means we check with a bind to see if the LDAP password has changed
if self.auth_user_by_bind(source, user, password):
# Password given successfully binds to LDAP, so we save it in our Database
LOGGER.debug("Updating user's password in DB", user=user)
user.set_password(password, signal=False)
user.save()
return user
# Password doesn't match
LOGGER.debug("Failed to bind, password invalid")
return None
def auth_user_by_bind(
self, source: LDAPSource, user: User, password: str
) -> Optional[User]:
"""Attempt authentication by binding to the LDAP server as `user`. This
method should be avoided as its slow to do the bind."""
# Try to bind as new user
LOGGER.debug("Attempting Binding as user", user=user)
try:
temp_connection = ldap3.Connection(
source.connection.server,
user=user.attributes.get("distinguishedName"),
password=password,
raise_exceptions=True,
)
temp_connection.bind()
return user
except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
except ldap3.core.exceptions.LDAPException as exception:
LOGGER.warning(exception)
return None

View File

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

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

@ -1,13 +1,16 @@
"""passbook LDAP Models"""
from datetime import datetime
from typing import Optional, Type
from django.core.cache import cache
from django.db import models
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _
from ldap3 import Connection, Server
from ldap3 import ALL, Connection, Server
from passbook.core.models import Group, PropertyMapping, Source
from passbook.lib.models import DomainlessURLValidator
from passbook.lib.utils.template import render_to_string
class LDAPSource(Source):
@ -49,6 +52,16 @@ class LDAPSource(Source):
)
sync_users = models.BooleanField(default=True)
sync_users_password = models.BooleanField(
default=True,
help_text=_(
(
"When a user changes their password, sync it back to LDAP. "
"This can only be enabled on a single LDAP source."
)
),
unique=True,
)
sync_groups = models.BooleanField(default=True)
sync_parent_group = models.ForeignKey(
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
@ -59,13 +72,27 @@ class LDAPSource(Source):
return LDAPSourceForm
def state_cache_prefix(self, suffix: str) -> str:
"""Key by which the ldap source status is saved"""
return f"source_ldap_{self.pk}_state_{suffix}"
@property
def ui_additional_info(self) -> str:
last_sync = cache.get(self.state_cache_prefix("last_sync"), None)
if last_sync:
last_sync = datetime.fromtimestamp(last_sync)
return render_to_string(
"ldap/source_list_status.html", {"source": self, "last_sync": last_sync}
)
_connection: Optional[Connection] = None
@property
def connection(self) -> Connection:
"""Get a fully connected and bound LDAP Connection"""
if not self._connection:
server = Server(self.server_uri)
server = Server(self.server_uri, get_info=ALL)
self._connection = Connection(
server,
raise_exceptions=True,
@ -95,7 +122,7 @@ class LDAPPropertyMapping(PropertyMapping):
return LDAPPropertyMappingForm
def __str__(self):
return f"LDAP Property Mapping {self.expression} -> {self.object_field}"
return self.name
class Meta:

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"""
from typing import Any, Dict
from django.core.exceptions import ValidationError
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from ldap3.core.exceptions import LDAPException
from passbook.core.models import User
from passbook.core.signals import password_changed
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.sources.ldap.models import LDAPSource
from passbook.sources.ldap.password import LDAPPasswordChanger
from passbook.sources.ldap.tasks import sync_single
from passbook.stages.prompt.signals import password_validate
@receiver(post_save, sender=LDAPSource)
@ -12,3 +22,38 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
"""Ensure that source is synced on save (if enabled)"""
if instance.enabled:
sync_single.delay(instance.pk)
@receiver(password_validate)
# pylint: disable=unused-argument
def ldap_password_validate(sender, password: str, plan_context: Dict[str, Any], **__):
"""if there's an LDAP Source with enabled password sync, check the password"""
sources = LDAPSource.objects.filter(sync_users_password=True)
if not sources.exists():
return
source = sources.first()
changer = LDAPPasswordChanger(source)
if changer.check_ad_password_complexity_enabled():
passing = changer.ad_password_complexity(
password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
)
if not passing:
raise ValidationError(
_("Password does not match Active Direcory Complexity.")
)
@receiver(password_changed)
# pylint: disable=unused-argument
def ldap_sync_password(sender, user: User, password: str, **_):
"""Connect to ldap and update password. We do this in the background to get
automatic retries on error."""
sources = LDAPSource.objects.filter(sync_users_password=True)
if not sources.exists():
return
source = sources.first()
changer = LDAPPasswordChanger(source)
try:
changer.change_password(user, password)
except LDAPException as exc:
raise ValidationError("Failed to set password") from exc

View File

@ -1,5 +1,5 @@
"""Wrapper for ldap3 to easily manage user"""
from typing import Any, Dict, Optional
"""Sync LDAP Users and groups into passbook"""
from typing import Any, Dict
import ldap3
import ldap3.core.exceptions
@ -13,19 +13,14 @@ from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
LOGGER = get_logger()
class Connector:
"""Wrapper for ldap3 to easily manage user authentication and creation"""
class LDAPSynchronizer:
"""Sync LDAP Users and groups into passbook"""
_source: LDAPSource
def __init__(self, source: LDAPSource):
self._source = source
@staticmethod
def encode_pass(password: str) -> bytes:
"""Encodes a plain-text password so it can be used by AD"""
return '"{}"'.format(password).encode("utf-16-le")
@property
def base_dn_users(self) -> str:
"""Shortcut to get full base_dn for user lookups"""
@ -187,48 +182,3 @@ class Connector:
"distinguishedName"
)
return properties
def auth_user(self, password: str, **filters: str) -> Optional[User]:
"""Try to bind as either user_dn or mail with password.
Returns True on success, otherwise False"""
users = User.objects.filter(**filters)
if not users.exists():
return None
user: User = users.first()
if "distinguishedName" not in user.attributes:
LOGGER.debug(
"User doesn't have DN set, assuming not LDAP imported.", user=user
)
return None
# Either has unusable password,
# or has a password, but couldn't be authenticated by ModelBackend.
# This means we check with a bind to see if the LDAP password has changed
if self.auth_user_by_bind(user, password):
# Password given successfully binds to LDAP, so we save it in our Database
LOGGER.debug("Updating user's password in DB", user=user)
user.set_password(password)
user.save()
return user
# Password doesn't match
LOGGER.debug("Failed to bind, password invalid")
return None
def auth_user_by_bind(self, user: User, password: str) -> Optional[User]:
"""Attempt authentication by binding to the LDAP server as `user`. This
method should be avoided as its slow to do the bind."""
# Try to bind as new user
LOGGER.debug("Attempting Binding as user", user=user)
try:
temp_connection = ldap3.Connection(
self._source.connection.server,
user=user.attributes.get("distinguishedName"),
password=password,
raise_exceptions=True,
)
temp_connection.bind()
return user
except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
except ldap3.core.exceptions.LDAPException as exception:
LOGGER.warning(exception)
return None

View File

@ -1,7 +1,11 @@
"""LDAP Sync tasks"""
from time import time
from django.core.cache import cache
from passbook.root.celery import CELERY_APP
from passbook.sources.ldap.connector import Connector
from passbook.sources.ldap.models import LDAPSource
from passbook.sources.ldap.sync import LDAPSynchronizer
@CELERY_APP.task()
@ -14,8 +18,10 @@ def sync():
@CELERY_APP.task()
def sync_single(source_pk):
"""Sync a single source"""
source = LDAPSource.objects.get(pk=source_pk)
connector = Connector(source)
connector.sync_users()
connector.sync_groups()
connector.sync_membership()
source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
syncer = LDAPSynchronizer(source)
syncer.sync_users()
syncer.sync_groups()
syncer.sync_membership()
cache_key = source.state_cache_prefix("last_sync")
cache.set(cache_key, time(), timeout=60 * 60)

View File

@ -0,0 +1,8 @@
{% load humanize %}
{% load i18n %}
{% if last_sync %}
<i class="fas fa-check pf-m-success"></i> {% blocktrans with last_sync=last_sync|naturaltime %}Synced {{ last_sync }}.{% endblocktrans %}
{% else %}
<i class="fas fa-times pf-m-danger"></i> Not synced yet/Sync in Progress
{% endif %}

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

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