Compare commits

..

72 Commits

Author SHA1 Message Date
566ebae065 new release: 0.10.2-stable 2020-09-15 12:04:00 +02:00
9b62a6403b helm: fix affinity rules and resources 2020-09-15 11:41:11 +02:00
8c465b2026 outposts: remove unused import 2020-09-15 11:32:25 +02:00
6b7da71aa8 lib: improve error handling for sentry 2020-09-15 11:29:43 +02:00
e95bbfab9a outposts: disable WIP k8s controller 2020-09-15 11:25:59 +02:00
e401575894 lifecycle: fix worker not running scheduled tasks 2020-09-15 11:20:28 +02:00
6428801270 e2e: update e2e tests for new AccessDenied response 2020-09-15 10:30:04 +02:00
3e13c13619 flows: replace passbook_flows:denied with AccessDenied Reeponse 2020-09-15 09:54:19 +02:00
92f79eb30e policies: add AccessDeniedResponse as general response when access was denied 2020-09-15 09:53:59 +02:00
e7472de4bf sources/ldap: sync source on save 2020-09-14 23:35:01 +02:00
494950ac65 admin: fix anonymous user not being removed from user count 2020-09-14 23:19:16 +02:00
4d51295db2 new release: 0.10.1-stable 2020-09-14 23:08:57 +02:00
3bbded3555 docs: remove default password for docker-compose, improve instructions 2020-09-14 23:08:04 +02:00
b3262e2a82 docs: add docs for passbook_user_debug 2020-09-14 22:51:50 +02:00
40614a65fc flows: move complete denied view and template to flows 2020-09-14 21:52:43 +02:00
3cf558d594 providers/*: pass policy result objects when access denied 2020-09-14 21:52:25 +02:00
812cc0d2f1 policies: add references for source_policy and source_results 2020-09-14 21:51:59 +02:00
e21ed92848 providers/oauth2: ensure flow is cleaned up on error 2020-09-14 18:40:44 +02:00
5184c4b7ef flows: fix FlowNonApplicableException and EmptyFlowException leading to infinite spinners 2020-09-14 18:40:26 +02:00
2c07859b68 core: add automatic launch_url detection based on provider 2020-09-14 18:12:42 +02:00
ae6304c05e providers/proxy: fix provider requiring a certificate to be selected 2020-09-14 17:37:06 +02:00
501683e3cb outposts: add tests for permissions 2020-09-14 17:34:07 +02:00
cc8afa8706 admin: don't show policy as unbound when used as validation policy 2020-09-14 15:44:33 +02:00
17a9e02bc0 docs: update kubernetes deployment example 2020-09-14 15:41:24 +02:00
6a669992a8 outposts: fix permissions not being updated when providers are modified 2020-09-14 15:41:02 +02:00
7ea5c22b6c root: fix channels not loading redis connection details 2020-09-14 14:21:43 +02:00
b11d6a5891 build(deps): bump django-storages from 1.10 to 1.10.1 (#212)
Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
Co-authored-by: Jens L <jens@beryju.org>
2020-09-14 10:29:49 +02:00
49830367a7 build(deps-dev): bump coverage from 5.2.1 to 5.3 (#213)
Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
Co-authored-by: Jens L <jens@beryju.org>
2020-09-14 10:26:25 +02:00
e69ca5a229 ci: fix coverage combine for unittest and e2e 2020-09-14 09:52:43 +02:00
a57d21f5e8 build(deps): bump boto3 from 1.14.59 to 1.14.60 (#210)
Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
Co-authored-by: Jens L <jens@beryju.org>
2020-09-14 09:09:34 +02:00
c7026407c6 policies: fix type error 2020-09-14 00:28:23 +02:00
69eecd6b60 helm: add soft-affinity rules for worker and web 2020-09-14 00:12:40 +02:00
810f10edfe providers/oauth2: fix several small implicit flow errors 2020-09-14 00:11:11 +02:00
1c57128f11 providers/oauth2: fix token to code_token 2020-09-13 23:42:45 +02:00
82eade3eb1 new release: 0.10.0-stable 2020-09-13 23:03:38 +02:00
56a9dcc88d ci: fix CI trying to run e2e tests 2020-09-13 23:02:46 +02:00
fe70d80189 docs: fix kubernetes values version 2020-09-13 22:31:42 +02:00
e97e22c58a root: fix readme image link 2020-09-13 22:27:26 +02:00
bb4e39aab6 docs: add outpost deployment docs, link in outposts list 2020-09-13 22:20:17 +02:00
a8744f443c outposts: fix Kubernetes Controller not exporting dicts, secrets not being b64 encoded 2020-09-13 22:19:26 +02:00
7fe9b8f0b4 providers/proxy: add domainless URL Validator 2020-09-13 21:52:34 +02:00
696aa7e5f6 core: fix path to default icon 2020-09-13 20:47:17 +02:00
e1d82aee1d ci: run e2e tests on custom agent 2020-09-13 19:49:13 +02:00
151374f565 stages/email: fix loading of static files when path is a directory 2020-09-13 18:24:49 +02:00
bebeff9f7f root: allow for changing of logo and branding 2020-09-13 17:52:33 +02:00
8b99afa34d stages/email: fix binary files not being encoded correctly 2020-09-13 17:40:13 +02:00
b317852e8a static: replace brand.svg with text and font 2020-09-13 17:33:30 +02:00
24ae35c35a build(deps-dev): bump pytest from 6.0.1 to 6.0.2 (#211)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/6.0.1...6.0.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-13 16:01:23 +02:00
8e6bb48227 sources/saml: add mitigation for idp-initiated requests 2020-09-13 15:39:25 +02:00
7a4e8af1ae outpost: fix outpost update signal only being sent to outposts connected to the same passbook instance 2020-09-13 14:29:40 +02:00
0161205c82 sources/saml: fix previous request ID being wrongly compared
request ID was compared to request ID not InResponseTo field
2020-09-13 14:00:56 +02:00
ca0ba85023 providers/saml: disallow idp-initiated SSO by default and validate Request ID 2020-09-12 00:53:44 +02:00
c2ebaa7f64 e2e: add oauth source test case with SameSite strict 2020-09-11 23:54:20 +02:00
23cccebb96 pytest (#209) 2020-09-11 23:21:11 +02:00
3f5d30e6fe build(deps): bump boto3 from 1.14.58 to 1.14.59 (#208)
Bumps [boto3](https://github.com/boto/boto3) from 1.14.58 to 1.14.59.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.14.58...1.14.59)

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-11 08:53:42 +02:00
ca735349f9 proxy: fix listening on wrong ip 2020-09-10 21:13:26 +02:00
25ce8c6dc7 build(deps): bump boto3 from 1.14.56 to 1.14.58 (#206)
Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
Co-authored-by: Jens L <jens@beryju.org>
2020-09-10 18:28:22 +02:00
081ac0bcdb root/asgi: hide healthcheck logs from sentry 2020-09-10 17:29:13 +02:00
8a07b349ee root: fix IP detection in ASGI logger, attempt to fix out of order issues 2020-09-10 16:58:25 +02:00
b3468bc265 providers/oauth2: fix comparison to undefined ResponseTypes 2020-09-10 16:26:55 +02:00
4edfad869f helm: fix missing .Values prefix for replicas 2020-09-10 15:07:56 +02:00
404f5d7912 new release: 0.10.0-rc6 2020-09-10 14:35:17 +02:00
8bea99a953 ci: run on release publish and creation 2020-09-10 14:35:13 +02:00
0b0ba33dce new release: 0.10.0-rc5 2020-09-10 14:24:31 +02:00
e3627b2cd9 ci: generate proxy api client before building docker image 2020-09-10 14:24:02 +02:00
37fac3ae00 ci: fix release being run on release edit 2020-09-10 13:25:08 +02:00
17a90adf3e new release: 0.10.0-rc4 2020-09-10 13:17:38 +02:00
7c3590f8ef ci: fix tests not being run in bash 2020-09-10 13:17:34 +02:00
7471415e7f new release: 0.10.0-rc3 2020-09-10 13:13:32 +02:00
9339d496f9 root: use PASSBOOK_TAG for static container 2020-09-10 13:13:27 +02:00
e72000eb06 new release: 0.10.0-rc2 2020-09-10 13:11:34 +02:00
ec5ff7c14d ci: fix docker-compose failing during release tag 2020-09-10 13:10:51 +02:00
141 changed files with 1559 additions and 693 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.10.0-rc1
current_version = 0.10.2-stable
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
@ -19,6 +19,8 @@ values =
[bumpversion:file:docs/installation/docker-compose.md]
[bumpversion:file:docs/installation/kubernetes.md]
[bumpversion:file:docker-compose.yml]
[bumpversion:file:helm/values.yaml]

View File

@ -1,5 +1,6 @@
[run]
source = passbook
relative_files = true
omit =
*/asgi.py
manage.py

View File

@ -1,6 +1,8 @@
name: passbook-release
name: passbook-on-release
on:
release
release:
types: [published, created]
jobs:
# Build
@ -16,17 +18,26 @@ jobs:
- name: Building Docker Image
run: docker build
--no-cache
-t beryju/passbook:0.10.0-rc1
-t beryju/passbook:0.10.2-stable
-t beryju/passbook:latest
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook:0.10.0-rc1
run: docker push beryju/passbook:0.10.2-stable
- name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook:latest
build-proxy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-go@v2
with:
go-version: "^1.15"
- name: prepare go api client
run: |
cd proxy
go get -u github.com/go-swagger/go-swagger/cmd/swagger
swagger generate client -f ../swagger.yaml -A passbook -t pkg/
go build -v .
- name: Docker Login Registry
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
@ -37,11 +48,11 @@ jobs:
cd proxy
docker build \
--no-cache \
-t beryju/passbook-proxy:0.10.0-rc1 \
-t beryju/passbook-proxy:0.10.2-stable \
-t beryju/passbook-proxy:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-proxy:0.10.0-rc1
run: docker push beryju/passbook-proxy:0.10.2-stable
- name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-proxy:latest
build-static:
@ -66,11 +77,11 @@ jobs:
run: docker build
--no-cache
--network=$(docker network ls | grep github | awk '{print $1}')
-t beryju/passbook-static:0.10.0-rc1
-t beryju/passbook-static:0.10.2-stable
-t beryju/passbook-static:latest
-f static.Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-static:0.10.0-rc1
run: docker push beryju/passbook-static:0.10.2-stable
- name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-static:latest
test-release:
@ -82,10 +93,13 @@ jobs:
- uses: actions/checkout@v1
- name: Run test suite in final docker images
run: |
sudo apt-get install -y pwgen
echo "PG_PASS=$(pwgen 40 1)" >> .env
echo "PASSBOOK_SECRET_KEY=$(pwgen 50 1)" >> .env
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server bash -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test"
docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook"
sentry-release:
needs:
- test-release
@ -100,5 +114,5 @@ jobs:
SENTRY_PROJECT: passbook
SENTRY_URL: https://sentry.beryju.org
with:
tagName: 0.10.0-rc1
tagName: 0.10.2-stable
environment: beryjuorg-prod

View File

@ -1,10 +1,10 @@
name: passbook-on-tag
on:
push:
tags:
- 'version/*'
name: passbook-version-tag
jobs:
build:
name: Create Release from Tag
@ -13,6 +13,10 @@ jobs:
- uses: actions/checkout@master
- name: Pre-release test
run: |
sudo apt-get install -y pwgen
echo "PASSBOOK_TAG=latest" >> .env
echo "PG_PASS=$(pwgen 40 1)" >> .env
echo "PASSBOOK_SECRET_KEY=$(pwgen 50 1)" >> .env
docker-compose pull -q
docker build \
--no-cache \
@ -20,7 +24,7 @@ jobs:
-f Dockerfile .
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server bash -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test"
docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook"
- name: Install Helm
run: |
apt update && apt install -y curl
@ -30,7 +34,7 @@ jobs:
helm dependency update helm/
helm package helm/
mv passbook-*.tgz passbook-chart.tgz
- name: Extract verison number
- name: Extract version number
id: get_version
uses: actions/github-script@0.2.0
with:

View File

@ -1,7 +1,7 @@
all: lint-fix lint coverage gen
coverage:
coverage run --concurrency=multiprocessing manage.py test passbook --failfast
coverage run --concurrency=multiprocessing manage.py test --failfast -v 3
coverage combine
coverage html
coverage report
@ -18,3 +18,9 @@ lint:
gen: coverage
./manage.py generate_swagger -o swagger.yaml -f yaml
local-stack:
export PASSBOOK_TAG=testing
docker build -t beryju/passbook:testng .
docker-compose up -d
docker-compose run --rm server migrate

View File

@ -59,5 +59,6 @@ docker = "*"
pylint = "*"
pylint-django = "*"
selenium = "*"
unittest-xml-reporting = "*"
prospector = "*"
pytest = "*"
pytest-django = "*"

167
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "a798bbd0b97857cac136c1743b8d6ad8bf8c3d95e2760c71d324bb2a7f47f678"
"sha256": "80570636236962f4b934a884817292de9f7bb48520aa964afc2959b0f795fb57"
},
"pipfile-spec": 6,
"requires": {
@ -74,18 +74,18 @@
},
"boto3": {
"hashes": [
"sha256:2ab73b0c400ab8c7df84bee7564ef8a0813021da28dd7a05fcbffb77a8ae9de9",
"sha256:bb2222fa02fcd09b39e581e532d4f013ea850742d8cd46e9c10a21028b6d2ef5"
"sha256:79e95f428c485ea817969a78e77a311d2ec4d82e0955639d6126189c990ddad3",
"sha256:d8ca27ee13deeb1a9e79f2fe5f923effa60947ed49bbdfbc2a9f5790aef64217"
],
"index": "pypi",
"version": "==1.14.56"
"version": "==1.14.60"
},
"botocore": {
"hashes": [
"sha256:37cc3f1013c00dc0f061582198d6b785dadf147bd99307d41c5c0e47debca65c",
"sha256:acd2df778a5e12b2a16ac040ce6e91a6c6f2d7ac67bd4f966472ce5c68b5b62d"
"sha256:193f193a66ac79106725e14dd73e28ed36bcec99b37156538a2202d061056a58",
"sha256:e55a4fc652537f5ccb2362133f3928ebeafb04ee9fe15ea11c2df80ba4ef8a12"
],
"version": "==1.17.58"
"version": "==1.17.60"
},
"cachetools": {
"hashes": [
@ -327,11 +327,11 @@
},
"django-storages": {
"hashes": [
"sha256:1e37da57678e6cf1e9914f84099a305323e4e1f261afe54fdb703cae7aa6fbc3",
"sha256:36ed8dab33d761954498189592ce005920095fcbc02dab4184eb51393c370991"
"sha256:12de8fb2605b9b57bfaf54b075280d7cbb3b3ee1ca4bc9b9add147af87fe3a2c",
"sha256:652275ab7844538c462b62810276c0244866f345878256a9e0e86f5b1283ae18"
],
"index": "pypi",
"version": "==1.10"
"version": "==1.10.1"
},
"djangorestframework": {
"hashes": [
@ -835,9 +835,9 @@
},
"pyrsistent": {
"hashes": [
"sha256:27515d2d5db0629c7dadf6fbe76973eb56f098c1b01d36de42eb69220d2c19e4"
"sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e"
],
"version": "==0.17.2"
"version": "==0.17.3"
},
"python-dateutil": {
"hashes": [
@ -1269,43 +1269,43 @@
},
"coverage": {
"hashes": [
"sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb",
"sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3",
"sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716",
"sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034",
"sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3",
"sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8",
"sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0",
"sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f",
"sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4",
"sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962",
"sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d",
"sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b",
"sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4",
"sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3",
"sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258",
"sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59",
"sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01",
"sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd",
"sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b",
"sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d",
"sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89",
"sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd",
"sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b",
"sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d",
"sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46",
"sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546",
"sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082",
"sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b",
"sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4",
"sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8",
"sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811",
"sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd",
"sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651",
"sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0"
"sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
"sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
"sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
"sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
"sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
"sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
"sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
"sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
"sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
"sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
"sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
"sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
"sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
"sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
"sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
"sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
"sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
"sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
"sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
"sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
"sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
"sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
"sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
"sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
"sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
"sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
"sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
"sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
"sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
"sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
"sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
"sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
"sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
"sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
],
"index": "pypi",
"version": "==5.2.1"
"version": "==5.3"
},
"django": {
"hashes": [
@ -1373,6 +1373,13 @@
],
"version": "==2.10"
},
"iniconfig": {
"hashes": [
"sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
"sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"
],
"version": "==1.0.1"
},
"isort": {
"hashes": [
"sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
@ -1413,6 +1420,21 @@
],
"version": "==0.6.1"
},
"more-itertools": {
"hashes": [
"sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
"sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
],
"version": "==8.5.0"
},
"packaging": {
"hashes": [
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
"sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
],
"index": "pypi",
"version": "==20.4"
},
"pathspec": {
"hashes": [
"sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
@ -1434,6 +1456,13 @@
],
"version": "==0.10.0"
},
"pluggy": {
"hashes": [
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
],
"version": "==0.13.1"
},
"prospector": {
"hashes": [
"sha256:43e5e187c027336b0e4c4aa6a82d66d3b923b5ec5b51968126132e32f9d14a2f"
@ -1441,6 +1470,13 @@
"index": "pypi",
"version": "==1.3.0"
},
"py": {
"hashes": [
"sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
"sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
],
"version": "==1.9.0"
},
"pycodestyle": {
"hashes": [
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
@ -1497,6 +1533,29 @@
],
"version": "==0.6"
},
"pyparsing": {
"hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
"version": "==2.4.7"
},
"pytest": {
"hashes": [
"sha256:0e37f61339c4578776e090c3b8f6b16ce4db333889d65d0efb305243ec544b40",
"sha256:c8f57c2a30983f469bf03e68cdfa74dc474ce56b8f280ddcb080dfd91df01043"
],
"index": "pypi",
"version": "==6.0.2"
},
"pytest-django": {
"hashes": [
"sha256:64f99d565dd9497af412fcab2989fe40982c1282d4118ff422b407f3f7275ca5",
"sha256:664e5f42242e5e182519388f01b9f25d824a9feb7cd17d8f863c8d776f38baf9"
],
"index": "pypi",
"version": "==3.9.0"
},
"pytz": {
"hashes": [
"sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
@ -1604,10 +1663,10 @@
},
"stevedore": {
"hashes": [
"sha256:a34086819e2c7a7f86d5635363632829dab8014e5fd7be2454c7cba84ac7514e",
"sha256:ddc09a744dc224c84ec8e8efcb70595042d21c97c76df60daee64c9ad53bc7ee"
"sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62",
"sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0"
],
"version": "==3.2.1"
"version": "==3.2.2"
},
"toml": {
"hashes": [
@ -1642,14 +1701,6 @@
],
"version": "==1.4.1"
},
"unittest-xml-reporting": {
"hashes": [
"sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca",
"sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695"
],
"index": "pypi",
"version": "==3.0.4"
},
"urllib3": {
"extras": [
"secure"

View File

@ -1,4 +1,4 @@
<img src="passbook/static/static/passbook/logo.svg" height="50" alt="passbook logo"><img src="passbook/static/static/passbook/brand_inverted.svg" height="50" alt="passbook">
<img src="docs/images/logo.svg" height="50" alt="passbook logo"><img src="docs/images/brand_inverted.svg" height="50" alt="passbook">
[![CI Build status](https://img.shields.io/azure-devops/build/beryjuorg/passbook/1?style=flat-square)](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=1)
![Tests](https://img.shields.io/azure-devops/tests/beryjuorg/passbook/1?compact_message&style=flat-square)
@ -20,7 +20,7 @@ wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml
# Optionally enable Error-reporting
# export PASSBOOK_ERROR_REPORTING=true
# Optionally deploy a different version
# export PASSBOOK_TAG=0.10.0-rc1
# export PASSBOOK_TAG=0.10.2-stable
# If this is a productive installation, set a different PostgreSQL Password
# export PG_PASS=$(pwgen 40 1)
docker-compose pull

View File

@ -139,7 +139,7 @@ stages:
displayName: Run full test suite
inputs:
script: |
pipenv run coverage run ./manage.py test passbook
pipenv run coverage run ./manage.py test passbook -v 3
mkdir output-unittest
mv unittest.xml output-unittest/unittest.xml
mv .coverage output-unittest/coverage
@ -150,7 +150,7 @@ stages:
publishLocation: 'pipeline'
- job: coverage_e2e
pool:
vmImage: 'ubuntu-latest'
name: coventry
steps:
- task: UsePythonVersion@0
inputs:
@ -181,7 +181,7 @@ stages:
- task: CmdLine@2
displayName: Run full test suite
inputs:
script: pipenv run coverage run ./manage.py test e2e
script: pipenv run coverage run ./manage.py test e2e -v 3
- task: CmdLine@2
displayName: Prepare unittests and coverage for upload
inputs:
@ -225,11 +225,9 @@ stages:
script: |
sudo pip install -U wheel pipenv
pipenv install --dev
find .
pipenv run coverage combine coverage-e2e/coverage coverage-unittest/coverage
pipenv run coverage xml
pipenv run coverage html
find .
- task: PublishCodeCoverageResults@1
inputs:
codeCoverageTool: 'Cobertura'
@ -300,4 +298,4 @@ stages:
chartType: 'FilePath'
chartPath: 'helm/'
releaseName: 'passbook-dev'
recreate: true
recreate: true

View File

@ -14,6 +14,8 @@ services:
- POSTGRES_DB=passbook
labels:
- traefik.enable=false
env_file:
- .env
redis:
image: redis
networks:
@ -21,13 +23,12 @@ services:
labels:
- traefik.enable=false
server:
image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-rc1}
image: beryju/passbook:${PASSBOOK_TAG:-0.10.2-stable}
command: server
environment:
PASSBOOK_REDIS__HOST: redis
PASSBOOK_ERROR_REPORTING: ${PASSBOOK_ERROR_REPORTING:-false}
PASSBOOK_POSTGRESQL__HOST: postgresql
PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS:-thisisnotagoodpassword}
PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS}
PASSBOOK_LOG_LEVEL: debug
ports:
- 8000
@ -37,8 +38,10 @@ services:
- traefik.port=8000
- traefik.docker.network=internal
- traefik.frontend.rule=PathPrefix:/
env_file:
- .env
worker:
image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-rc1}
image: beryju/passbook:${PASSBOOK_TAG:-0.10.2-stable}
command: worker
networks:
- internal
@ -46,12 +49,13 @@ services:
- traefik.enable=false
environment:
PASSBOOK_REDIS__HOST: redis
PASSBOOK_ERROR_REPORTING: ${PASSBOOK_ERROR_REPORTING:-false}
PASSBOOK_POSTGRESQL__HOST: postgresql
PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS:-thisisnotagoodpassword}
PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS}
PASSBOOK_LOG_LEVEL: debug
env_file:
- .env
static:
image: beryju/passbook-static:0.10.0-rc1
image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.2-stable}
networks:
- internal
labels:

View File

@ -11,14 +11,21 @@ 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 deploy a different version run `echo PASSBOOK_TAG=0.10.2-stable >> .env`
If this is a fresh passbook install run the following commands to generate a password:
```
sudo apt-get install -y pwgen
echo "PG_PASS=$(pwgen 40 1)" >> .env
echo "PASSBOOK_SECRET_KEY=$(pwgen 50 1)" >> .env
```
Afterwards, run these commands to finish
```
wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml
# Optionally enable Error-reporting
# export PASSBOOK_ERROR_REPORTING=true
# Optionally deploy a different version
# export PASSBOOK_TAG=0.10.0-rc1
# If this is a productive installation, set a different PostgreSQL Password
# export PG_PASS=$(pwgen 40 1)
docker-compose pull
docker-compose up -d
docker-compose run --rm server migrate

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.9.0-stable
tag: 0.10.2-stable
nameOverride: ""

View File

@ -0,0 +1,20 @@
# Outpost deployment in docker-compose
To deploy an outpost with docker-compose, use this snippet in your docker-compose file.
You can also run the outpost in a separate docker-compose project, you just have to ensure that the outpost container can reach your application container.
```yaml
version: 3.5
services:
passbook_proxy:
image: beryju/passbook-proxy:0.10.0-stable
ports:
- 4180:4180
- 4443:4443
environment:
PASSBOOK_HOST: https://your-passbook.tld
PASSBOOK_INSECURE: 'true'
PASSBOOK_TOKEN: token-generated-by-passbook
```

View File

@ -0,0 +1,99 @@
# Outpost deployment on Kubernetes
Use the following manifest, replacing all values surrounded with `__`.
Afterwards, configure the proxy provider to connect to `<service name>.<namespace>.svc.cluster.local`, and update your Ingress to connect to the `passbook-outpost` service.
```yaml
apiVersion: v1
kind: Secret
metadata:
labels:
app.kubernetes.io/instance: test
app.kubernetes.io/managed-by: passbook.beryju.org
app.kubernetes.io/name: passbook-proxy
app.kubernetes.io/version: 0.10.0
name: passbook-outpost-api
stringData:
passbook_host: '__PASSBOOK_URL__'
passbook_host_insecure: 'true'
token: '__PASSBOOK_TOKEN__'
type: Opaque
---
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/instance: test
app.kubernetes.io/managed-by: passbook.beryju.org
app.kubernetes.io/name: passbook-proxy
app.kubernetes.io/version: 0.10.0
name: passbook-outpost
spec:
ports:
- name: http
port: 4180
protocol: TCP
targetPort: http
- name: https
port: 4443
protocol: TCP
targetPort: https
selector:
app.kubernetes.io/instance: test
app.kubernetes.io/managed-by: passbook.beryju.org
app.kubernetes.io/name: passbook-proxy
app.kubernetes.io/version: 0.10.0
type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app.kubernetes.io/instance: test
app.kubernetes.io/managed-by: passbook.beryju.org
app.kubernetes.io/name: passbook-proxy
app.kubernetes.io/version: 0.10.0
name: passbook-outpost
spec:
selector:
matchLabels:
app.kubernetes.io/instance: test
app.kubernetes.io/managed-by: passbook.beryju.org
app.kubernetes.io/name: passbook-proxy
app.kubernetes.io/version: 0.10.0
template:
metadata:
labels:
app.kubernetes.io/instance: test
app.kubernetes.io/managed-by: passbook.beryju.org
app.kubernetes.io/name: passbook-proxy
app.kubernetes.io/version: 0.10.0
spec:
containers:
- env:
- name: PASSBOOK_HOST
valueFrom:
secretKeyRef:
key: passbook_host
name: passbook-outpost-api
- name: PASSBOOK_TOKEN
valueFrom:
secretKeyRef:
key: token
name: passbook-outpost-api
- name: PASSBOOK_INSECURE
valueFrom:
secretKeyRef:
key: passbook_host_insecure
name: passbook-outpost-api
image: beryju/passbook-proxy:0.10.0-stable
name: proxy
ports:
- containerPort: 4180
name: http
protocol: TCP
- containerPort: 4443
name: https
protocol: TCP
```

View File

@ -6,21 +6,9 @@ An outpost is a single deployment of a passbook component, which can be deployed
Upon creation, a service account and a token is generated. The service account only has permissions to read the outpost and provider configuration. This token is used by the Outpost to connect to passbook.
To deploy an outpost, you can for example use this docker-compose snippet:
To deploy an outpost, see: <a name="deploy">
```yaml
version: 3.5
- [Kubernetes](deploy-kubernetes.md)
- [docker-compose](deploy-docker-compose.md)
services:
passbook_proxy:
image: beryju/passbook-proxy:0.10.0-stable
ports:
- 4180:4180
- 4443:4443
environment:
PASSBOOK_HOST: https://your-passbook.tld
PASSBOOK_INSECURE: 'true'
PASSBOOK_TOKEN: token-generated-by-passbook
```
In future versions, this snippet will be automatically generated. You will also be able to deploy an outpost directly into a kubernetes cluster.w
In future versions, this snippet will be automatically generated. You will also be able to deploy an outpost directly into a kubernetes cluster.

View File

@ -0,0 +1,11 @@
# Troubleshooting access problems
## I get an access denied error when trying to access an application.
If your user is a superuser, or has the attribute `passbook_user_debug` set to true:
![](./passbook_user_debug.png)
Afterwards, try to access the application again. You will now see a message explaining which policy denied you access:
![](./access_denied_message.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -2,7 +2,7 @@ version: '3.7'
services:
chrome:
image: selenium/standalone-chrome-debug:3.141.59-20200525
image: selenium/standalone-chrome-debug:3.141.59-20200719
volumes:
- /dev/shm:/dev/shm
network_mode: host

View File

@ -1,13 +1,12 @@
"""Test Enroll flow"""
from time import sleep
from sys import platform
from typing import Any, Dict, Optional
from unittest.case import skipUnless
from django.test import override_settings
from docker import DockerClient, from_env
from docker.models.containers import Container
from docker.types import Healthcheck
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
from structlog import get_logger
from e2e.utils import USER, SeleniumTestCase
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
@ -18,41 +17,23 @@ from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
from passbook.stages.user_login.models import UserLoginStage
from passbook.stages.user_write.models import UserWriteStage
LOGGER = get_logger()
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestFlowsEnroll(SeleniumTestCase):
"""Test Enroll flow"""
def setUp(self):
self.container = self.setup_client()
super().setUp()
def setup_client(self) -> Container:
"""Setup test IdP container"""
client: DockerClient = from_env()
container = client.containers.run(
image="mailhog/mailhog:v1.0.1",
detach=True,
network_mode="host",
auto_remove=True,
healthcheck=Healthcheck(
def get_container_specs(self) -> Optional[Dict[str, Any]]:
return {
"image": "mailhog/mailhog:v1.0.1",
"detach": True,
"network_mode": "host",
"auto_remove": True,
"healthcheck": Healthcheck(
test=["CMD", "wget", "--spider", "http://localhost:8025"],
interval=5 * 100 * 1000000,
start_period=1 * 100 * 1000000,
),
)
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
LOGGER.info("Container failed healthcheck")
sleep(1)
def tearDown(self):
self.container.kill()
super().tearDown()
}
def test_enroll_2_step(self):
"""Test 2-step enroll flow"""
@ -220,21 +201,25 @@ class TestFlowsEnroll(SeleniumTestCase):
self.driver.find_element(By.ID, "id_name").send_keys("some name")
self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz")
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
sleep(3)
# Wait for the success message so we know the email is sent
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, ".pf-c-form > p"))
)
# Open Mailhog
self.driver.get("http://localhost:8025")
# Click on first message
self.wait.until(
ec.presence_of_element_located((By.CLASS_NAME, "msglist-message"))
)
self.driver.find_element(By.CLASS_NAME, "msglist-message").click()
sleep(3)
self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane"))
self.driver.find_element(By.ID, "confirm").click()
self.driver.close()
self.driver.switch_to.window(self.driver.window_handles[0])
# We're now logged in
sleep(3)
self.wait.until(
ec.presence_of_element_located(
(By.XPATH, "//a[contains(@href, '/-/user/')]")

View File

@ -1,10 +1,14 @@
"""test default login flow"""
from sys import platform
from unittest.case import skipUnless
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from e2e.utils import USER, SeleniumTestCase
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestFlowsLogin(SeleniumTestCase):
"""test default login flow"""

View File

@ -1,7 +1,6 @@
"""test stage setup flows (password change)"""
import string
from random import SystemRandom
from time import sleep
from sys import platform
from unittest.case import skipUnless
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
@ -9,9 +8,11 @@ from selenium.webdriver.common.keys import Keys
from e2e.utils import USER, SeleniumTestCase
from passbook.core.models import User
from passbook.flows.models import Flow, FlowDesignation
from passbook.providers.oauth2.generators import generate_client_secret
from passbook.stages.password.models import PasswordStage
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestFlowsStageSetup(SeleniumTestCase):
"""test stage setup flows"""
@ -27,10 +28,7 @@ class TestFlowsStageSetup(SeleniumTestCase):
stage.change_flow = flow
stage.save()
new_password = "".join(
SystemRandom().choice(string.ascii_uppercase + string.digits)
for _ in range(8)
)
new_password = generate_client_secret()
self.driver.get(
f"{self.live_server_url}/flows/default-authentication-flow/?next=%2F"
@ -48,7 +46,7 @@ class TestFlowsStageSetup(SeleniumTestCase):
self.driver.find_element(By.ID, "id_password_repeat").send_keys(new_password)
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
sleep(2)
self.wait_for_url(self.url("passbook_core:user-settings"))
# Because USER() is cached, we need to get the user manually here
user = User.objects.get(username=USER().username)
self.assertTrue(user.check_password(new_password))

View File

@ -1,12 +1,12 @@
"""test OAuth Provider flow"""
from time import sleep
from sys import platform
from typing import Any, Dict, Optional
from unittest.case import skipUnless
from docker import DockerClient, from_env
from docker.models.containers import Container
from docker.types import Healthcheck
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from structlog import get_logger
from selenium.webdriver.support import expected_conditions as ec
from e2e.utils import USER, SeleniumTestCase
from passbook.core.models import Application
@ -19,32 +19,29 @@ from passbook.providers.oauth2.generators import (
)
from passbook.providers.oauth2.models import ClientTypes, OAuth2Provider, ResponseTypes
LOGGER = get_logger()
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestProviderOAuth2Github(SeleniumTestCase):
"""test OAuth Provider flow"""
def setUp(self):
self.client_id = generate_client_id()
self.client_secret = generate_client_secret()
self.container = self.setup_client()
super().setUp()
def setup_client(self) -> Container:
def get_container_specs(self) -> Optional[Dict[str, Any]]:
"""Setup client grafana container which we test OAuth against"""
client: DockerClient = from_env()
container = client.containers.run(
image="grafana/grafana:7.1.0",
detach=True,
network_mode="host",
auto_remove=True,
healthcheck=Healthcheck(
return {
"image": "grafana/grafana:7.1.0",
"detach": True,
"network_mode": "host",
"auto_remove": True,
"healthcheck": Healthcheck(
test=["CMD", "wget", "--spider", "http://localhost:3000"],
interval=5 * 100 * 1000000,
start_period=1 * 100 * 1000000,
),
environment={
"environment": {
"GF_AUTH_GITHUB_ENABLED": "true",
"GF_AUTH_GITHUB_ALLOW_SIGN_UP": "true",
"GF_AUTH_GITHUB_CLIENT_ID": self.client_id,
@ -61,22 +58,10 @@ class TestProviderOAuth2Github(SeleniumTestCase):
),
"GF_LOG_LEVEL": "debug",
},
)
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
LOGGER.info("Container failed healthcheck")
sleep(1)
def tearDown(self):
self.container.kill()
super().tearDown()
}
def test_authorization_consent_implied(self):
"""test OAuth Provider flow (default authorization flow with implied consent)"""
sleep(1)
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
@ -129,7 +114,6 @@ class TestProviderOAuth2Github(SeleniumTestCase):
def test_authorization_consent_explicit(self):
"""test OAuth Provider flow (default authorization flow with explicit consent)"""
sleep(1)
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-explicit-consent"
@ -167,8 +151,13 @@ class TestProviderOAuth2Github(SeleniumTestCase):
By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]"
).text,
)
sleep(1)
self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click()
self.driver.find_element(
By.CSS_SELECTOR,
(
"form[action='/flows/b/default-provider-authorization-explicit-consent/'] "
"[type=submit]"
),
).click()
self.wait_for_url("http://localhost:3000/?orgId=1")
self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click()
@ -197,7 +186,6 @@ class TestProviderOAuth2Github(SeleniumTestCase):
def test_denied(self):
"""test OAuth Provider flow (default authorization flow, denied)"""
sleep(1)
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-explicit-consent"
@ -227,7 +215,10 @@ class TestProviderOAuth2Github(SeleniumTestCase):
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.wait_for_url(self.url("passbook_flows:denied"))
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1"))
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"Permission denied",

View File

@ -1,8 +1,9 @@
"""test OAuth2 OpenID Provider flow"""
from sys import platform
from time import sleep
from typing import Any, Dict, Optional
from unittest.case import skipUnless
from docker import DockerClient, from_env
from docker.models.containers import Container
from docker.types import Healthcheck
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
@ -34,29 +35,27 @@ from passbook.providers.oauth2.models import (
LOGGER = get_logger()
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestProviderOAuth2OIDC(SeleniumTestCase):
"""test OAuth with OpenID Provider flow"""
def setUp(self):
self.client_id = generate_client_id()
self.client_secret = generate_client_secret()
self.container = self.setup_client()
super().setUp()
def setup_client(self) -> Container:
"""Setup client grafana container which we test OIDC against"""
client: DockerClient = from_env()
container = client.containers.run(
image="grafana/grafana:7.1.0",
detach=True,
network_mode="host",
auto_remove=True,
healthcheck=Healthcheck(
def get_container_specs(self) -> Optional[Dict[str, Any]]:
return {
"image": "grafana/grafana:7.1.0",
"detach": True,
"network_mode": "host",
"auto_remove": True,
"healthcheck": Healthcheck(
test=["CMD", "wget", "--spider", "http://localhost:3000"],
interval=5 * 100 * 1000000,
start_period=1 * 100 * 1000000,
),
environment={
"environment": {
"GF_AUTH_GENERIC_OAUTH_ENABLED": "true",
"GF_AUTH_GENERIC_OAUTH_CLIENT_ID": self.client_id,
"GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret,
@ -72,18 +71,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
),
"GF_LOG_LEVEL": "debug",
},
)
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
LOGGER.info("Container failed healthcheck")
sleep(1)
def tearDown(self):
self.container.kill()
super().tearDown()
}
def test_redirect_uri_error(self):
"""test OpenID Provider flow (invalid redirect URI, check error message)"""
@ -297,7 +285,10 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
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.wait_for_url(self.url("passbook_flows:denied"))
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1"))
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"Permission denied",

View File

@ -1,11 +1,14 @@
"""test SAML Provider flow"""
from sys import platform
from time import sleep
from unittest.case import skipUnless
from docker import DockerClient, from_env
from docker.models.containers import Container
from docker.types import Healthcheck
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as ec
from structlog import get_logger
from e2e.utils import USER, SeleniumTestCase
@ -23,6 +26,7 @@ from passbook.providers.saml.models import (
LOGGER = get_logger()
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestProviderSAML(SeleniumTestCase):
"""test SAML Provider flow"""
@ -60,10 +64,6 @@ class TestProviderSAML(SeleniumTestCase):
LOGGER.info("Container failed healthcheck")
sleep(1)
def tearDown(self):
self.container.kill()
super().tearDown()
def test_sp_initiated_implicit(self):
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
@ -207,7 +207,10 @@ class TestProviderSAML(SeleniumTestCase):
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.wait_for_url(self.url("passbook_flows:denied"))
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "header > h1"))
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"Permission denied",

View File

@ -1,8 +1,11 @@
"""test OAuth Source"""
from os.path import abspath
from sys import platform
from time import sleep
from typing import Any, Dict, Optional
from unittest.case import skipUnless
from docker import DockerClient, from_env
from django.test import override_settings
from docker.models.containers import Container
from docker.types import Healthcheck
from selenium.webdriver.common.by import By
@ -21,6 +24,7 @@ CONFIG_PATH = "/tmp/dex.yml"
LOGGER = get_logger()
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestSourceOAuth(SeleniumTestCase):
"""test OAuth Source flow"""
@ -28,7 +32,7 @@ class TestSourceOAuth(SeleniumTestCase):
def setUp(self):
self.client_secret = generate_client_secret()
self.container = self.setup_client()
self.prepare_dex_config()
super().setUp()
def prepare_dex_config(self):
@ -66,34 +70,23 @@ class TestSourceOAuth(SeleniumTestCase):
with open(CONFIG_PATH, "w+") as _file:
safe_dump(config, _file)
def setup_client(self) -> Container:
"""Setup test Dex container"""
self.prepare_dex_config()
client: DockerClient = from_env()
container = client.containers.run(
image="quay.io/dexidp/dex:v2.24.0",
detach=True,
network_mode="host",
auto_remove=True,
command="serve /config.yml",
healthcheck=Healthcheck(
def get_container_specs(self) -> Optional[Dict[str, Any]]:
return {
"image": "quay.io/dexidp/dex:v2.24.0",
"detach": True,
"network_mode": "host",
"auto_remove": True,
"command": "serve /config.yml",
"healthcheck": Healthcheck(
test=["CMD", "wget", "--spider", "http://localhost:5556/dex/healthz"],
interval=5 * 100 * 1000000,
start_period=1 * 100 * 1000000,
),
volumes={abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro"}},
)
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
LOGGER.info("Container failed healthcheck")
sleep(1)
"volumes": {abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro"}},
}
def create_objects(self):
"""Create required objects"""
sleep(1)
# Bootstrap all needed objects
authentication_flow = Flow.objects.get(slug="default-source-authentication")
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
@ -111,10 +104,6 @@ class TestSourceOAuth(SeleniumTestCase):
consumer_secret=self.client_secret,
)
def tearDown(self):
self.container.kill()
super().tearDown()
def test_oauth_enroll(self):
"""test OAuth Source With With OIDC"""
self.create_objects()
@ -141,6 +130,7 @@ class TestSourceOAuth(SeleniumTestCase):
)
self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
self.wait.until(ec.presence_of_element_located((By.NAME, "username")))
# At this point we've been redirected back
# and we're asked for the username
self.driver.find_element(By.NAME, "username").click()
@ -167,6 +157,42 @@ class TestSourceOAuth(SeleniumTestCase):
"admin@example.com",
)
@override_settings(SESSION_COOKIE_SAMESITE="strict")
def test_oauth_samesite_strict(self):
"""test OAuth Source With SameSite set to strict
(=will fail because session is not carried over)"""
self.create_objects()
self.driver.get(self.live_server_url)
self.wait.until(
ec.presence_of_element_located(
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
)
)
self.driver.find_element(
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
).click()
# Now we should be at the IDP, wait for the login field
self.wait.until(ec.presence_of_element_located((By.ID, "login")))
self.driver.find_element(By.ID, "login").send_keys("admin@example.com")
self.driver.find_element(By.ID, "password").send_keys("password")
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)
# Wait until we're logged in
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, "button[type=submit]"))
)
self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
self.wait.until(
ec.presence_of_element_located((By.CSS_SELECTOR, ".pf-c-alert__title"))
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-alert__title").text,
"Authentication Failed.",
)
def test_oauth_enroll_auth(self):
"""test OAuth Source With With OIDC (enroll and authenticate again)"""
self.test_oauth_enroll()
@ -178,10 +204,11 @@ class TestSourceOAuth(SeleniumTestCase):
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
)
)
sleep(1)
self.driver.find_element(
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
).click()
sleep(1)
# Now we should be at the IDP, wait for the login field
self.wait.until(ec.presence_of_element_located((By.ID, "login")))
self.driver.find_element(By.ID, "login").send_keys("admin@example.com")

View File

@ -1,8 +1,9 @@
"""test SAML Source"""
from sys import platform
from time import sleep
from typing import Any, Dict, Optional
from unittest.case import skipUnless
from docker import DockerClient, from_env
from docker.models.containers import Container
from docker.types import Healthcheck
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
@ -68,48 +69,31 @@ Sm75WXsflOxuTn08LbgGc4s=
-----END PRIVATE KEY-----"""
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestSourceSAML(SeleniumTestCase):
"""test SAML Source flow"""
def setUp(self):
self.container = self.setup_client()
super().setUp()
def setup_client(self) -> Container:
"""Setup test IdP container"""
client: DockerClient = from_env()
container = client.containers.run(
image="kristophjunge/test-saml-idp:1.15",
detach=True,
network_mode="host",
auto_remove=True,
healthcheck=Healthcheck(
def get_container_specs(self) -> Optional[Dict[str, Any]]:
return {
"image": "kristophjunge/test-saml-idp:1.15",
"detach": True,
"network_mode": "host",
"auto_remove": True,
"healthcheck": Healthcheck(
test=["CMD", "curl", "http://localhost:8080"],
interval=5 * 100 * 1000000,
start_period=1 * 100 * 1000000,
),
environment={
"environment": {
"SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id",
"SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": (
f"{self.live_server_url}/source/saml/saml-idp-test/acs/"
),
},
)
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
LOGGER.info("Container failed healthcheck")
sleep(1)
def tearDown(self):
self.container.kill()
super().tearDown()
}
def test_idp_redirect(self):
"""test SAML Source With redirect binding"""
sleep(1)
# Bootstrap all needed objects
authentication_flow = Flow.objects.get(slug="default-source-authentication")
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
@ -161,7 +145,6 @@ class TestSourceSAML(SeleniumTestCase):
def test_idp_post(self):
"""test SAML Source With post binding"""
sleep(1)
# Bootstrap all needed objects
authentication_flow = Flow.objects.get(slug="default-source-authentication")
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
@ -215,7 +198,6 @@ class TestSourceSAML(SeleniumTestCase):
def test_idp_post_auto(self):
"""test SAML Source With post binding (auto redirect)"""
sleep(1)
# Bootstrap all needed objects
authentication_flow = Flow.objects.get(slug="default-source-authentication")
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")

View File

@ -4,13 +4,16 @@ from glob import glob
from importlib.util import module_from_spec, spec_from_file_location
from inspect import getmembers, isfunction
from os import environ, makedirs
from time import time
from time import sleep, time
from typing import Any, Dict, Optional
from django.apps import apps
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.db import connection, transaction
from django.db.utils import IntegrityError
from django.shortcuts import reverse
from docker import DockerClient, from_env
from docker.models.containers import Container
from selenium import webdriver
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from selenium.webdriver.remote.webdriver import WebDriver
@ -30,15 +33,35 @@ def USER() -> User: # noqa
class SeleniumTestCase(StaticLiveServerTestCase):
"""StaticLiveServerTestCase which automatically creates a Webdriver instance"""
container: Optional[Container] = None
def setUp(self):
super().setUp()
makedirs("selenium_screenshots/", exist_ok=True)
self.driver = self._get_driver()
self.driver.maximize_window()
self.driver.implicitly_wait(30)
self.wait = WebDriverWait(self.driver, 50)
self.driver.implicitly_wait(10)
self.wait = WebDriverWait(self.driver, 30)
self.apply_default_data()
self.logger = get_logger()
if specs := self.get_container_specs():
self.container = self._start_container(specs)
def _start_container(self, specs: Dict[str, Any]) -> Container:
client: DockerClient = from_env()
container = client.containers.run(**specs)
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
self.logger.info("Container failed healthcheck")
sleep(1)
def get_container_specs(self) -> Optional[Dict[str, Any]]:
"""Optionally get container specs which will launched on setup, wait for the container to
be healthy, and deleted again on tearDown"""
return None
def _get_driver(self) -> WebDriver:
return webdriver.Remote(
@ -57,6 +80,8 @@ class SeleniumTestCase(StaticLiveServerTestCase):
self.logger.warning(
line["message"], source=line["source"], level=line["level"]
)
if self.container:
self.container.kill()
self.driver.quit()
super().tearDown()

View File

@ -1,9 +1,9 @@
apiVersion: v2
appVersion: "0.10.0-rc1"
appVersion: "0.10.2-stable"
description: A Helm chart for passbook.
name: passbook
version: "0.10.0-rc1"
icon: https://github.com/BeryJu/passbook/blob/master/passbook/static/static/passbook/logo.svg
version: "0.10.2-stable"
icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
dependencies:
- name: postgresql
version: 9.3.2

View File

@ -9,7 +9,7 @@ metadata:
app.kubernetes.io/managed-by: {{ .Release.Service }}
k8s.passbook.beryju.org/component: web
spec:
replicas: {{ serverReplicas }}
replicas: {{ .Values.serverReplicas }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "passbook.name" . }}
@ -22,10 +22,29 @@ spec:
app.kubernetes.io/instance: {{ .Release.Name }}
k8s.passbook.beryju.org/component: web
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 1
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- {{ include "passbook.name" . }}
- key: app.kubernetes.io/instance
operator: In
values:
- {{ .Release.Name }}
- key: k8s.passbook.beryju.org/component
operator: In
values:
- web
topologyKey: "kubernetes.io/hostname"
initContainers:
- name: passbook-database-migrations
image: "{{ .Values.image.name }}:{{ .Values.image.tag }}"
imagePullPolicy: Always
args: [migrate]
envFrom:
- configMapRef:
@ -50,7 +69,6 @@ spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.name }}:{{ .Values.image.tag }}"
imagePullPolicy: Always
args: [server]
envFrom:
- configMapRef:
@ -93,7 +111,7 @@ spec:
resources:
requests:
cpu: 100m
memory: 200M
memory: 300M
limits:
cpu: 300m
memory: 350M
memory: 500M

View File

@ -9,7 +9,7 @@ metadata:
app.kubernetes.io/managed-by: {{ .Release.Service }}
k8s.passbook.beryju.org/component: worker
spec:
replicas: {{ workerReplicas }}
replicas: {{ .Values.workerReplicas }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "passbook.name" . }}
@ -22,6 +22,26 @@ spec:
app.kubernetes.io/instance: {{ .Release.Name }}
k8s.passbook.beryju.org/component: worker
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 1
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- {{ include "passbook.name" . }}
- key: app.kubernetes.io/instance
operator: In
values:
- {{ .Release.Name }}
- key: k8s.passbook.beryju.org/component
operator: In
values:
- worker
topologyKey: "kubernetes.io/hostname"
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.name }}:{{ .Values.image.tag }}"
@ -50,7 +70,7 @@ spec:
resources:
requests:
cpu: 150m
memory: 300M
memory: 400M
limits:
cpu: 300m
memory: 500M
memory: 600M

View File

@ -4,7 +4,7 @@
image:
name: beryju/passbook
name_static: beryju/passbook-static
tag: 0.10.0-rc1
tag: 0.10.2-stable
nameOverride: ""

View File

@ -4,7 +4,7 @@ printf '{"event": "Bootstrap completed", "level": "info", "logger": "bootstrap",
if [[ "$1" == "server" ]]; then
gunicorn -c /lifecycle/gunicorn.conf.py passbook.root.asgi:application
elif [[ "$1" == "worker" ]]; then
celery worker --autoscale=10,3 -E -B -A=passbook.root.celery -s=/tmp/celerybeat-schedule
celery worker --autoscale=10,3 -E -B -A=passbook.root.celery -s=/tmp/celerybeat-schedule -Q passbook,passbook_scheduled
elif [[ "$1" == "migrate" ]]; then
# Run system migrations first, run normal migrations after
python -m lifecycle.migrate

View File

@ -1,9 +1,10 @@
"""Gunicorn config"""
from multiprocessing import cpu_count
from pathlib import Path
import structlog
bind = "0.0.0.0:8000"
workers = 2
threads = 4
user = "passbook"
group = "passbook"
@ -40,3 +41,11 @@ logconfig_dict = {
"gunicorn": {"handlers": ["console"], "level": "INFO", "propagate": False},
},
}
# if we're running in kubernetes, use fixed workers because we can scale with more pods
# otherwise (assume docker-compose), use as much as we can
if Path("/var/run/secrets/kubernetes.io").exists():
workers = 2
else:
worker = cpu_count()
threads = 4

View File

@ -30,7 +30,10 @@ nav:
- OAuth2: providers/oauth2.md
- SAML: providers/saml.md
- Proxy: providers/proxy.md
- Outposts: outposts/outposts.md
- Outposts:
- Overview: outposts/outposts.md
- Deploy on docker-compose: outposts/deploy-docker-compose.md
- Deploy on Kubernetes: outposts/deploy-kubernetes.md
- Expressions:
- Overview: expressions/index.md
- Reference:
@ -52,6 +55,8 @@ nav:
- Upgrading:
- to 0.9: upgrading/to-0.9.md
- to 0.10: upgrading/to-0.10.md
- Troubleshooting:
- Access problems: troubleshooting/access.md
repo_name: "BeryJu/passbook"
repo_url: https://github.com/BeryJu/passbook

View File

@ -1,2 +1,2 @@
"""passbook"""
__version__ = "0.10.0-rc1"
__version__ = "0.10.2-stable"

View File

@ -69,6 +69,7 @@
<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>
</td>
</tr>
{% endfor %}

View File

@ -55,7 +55,7 @@
<th role="columnheader">
<div>
<div>{{ policy.name }}</div>
{% if not policy.bindings.exists %}
{% if not policy.bindings.exists and not policy.promptstage_set.exists %}
<i class="pf-icon pf-icon-warning-triangle"></i>
<small>{% trans 'Warning: Policy is not assigned.' %}</small>
{% else %}

View File

@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -7,7 +7,7 @@ from django.contrib.auth.mixins import (
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import DetailView, FormView, ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -45,7 +45,7 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
kwargs["application_count"] = len(Application.objects.all())
kwargs["policy_count"] = len(Policy.objects.all())
kwargs["user_count"] = len(User.objects.all())
kwargs["user_count"] = len(User.objects.all()) - 1 # Remove anonymous user
kwargs["provider_count"] = len(Provider.objects.all())
kwargs["source_count"] = len(Source.objects.all())
kwargs["stage_count"] = len(Stage.objects.all())
@ -58,7 +58,7 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
application=None
)
kwargs["policies_without_binding"] = len(
Policy.objects.filter(bindings__isnull=True)
Policy.objects.filter(bindings__isnull=True, promptstage__isnull=True)
)
kwargs["cached_policies"] = len(cache.keys("policy_*"))
kwargs["cached_flows"] = len(cache.keys("flow_*"))

View File

@ -10,7 +10,7 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.db.models import QuerySet
from django.http import HttpResponse
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import FormView
from django.views.generic.detail import DetailView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -6,7 +6,7 @@ from django.contrib.auth.mixins import (
from django.contrib.messages.views import SuccessMessageMixin
from django.db.models import QuerySet
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.admin.views.utils import (

View File

@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.admin.views.utils import (

View File

@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.admin.views.utils import (

View File

@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.admin.views.utils import (

View File

@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -6,7 +6,7 @@ from django.contrib.auth.mixins import (
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpResponseRedirect
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import ListView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -1,7 +1,7 @@
"""passbook Token administration"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import ListView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin

View File

@ -9,7 +9,7 @@ from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse, reverse_lazy
from django.utils.http import urlencode
from django.utils.translation import ugettext as _
from django.utils.translation import gettext as _
from django.views.generic import DetailView, ListView, UpdateView
from guardian.mixins import (
PermissionListMixin,

View File

@ -1,6 +1,5 @@
"""api v2 urls"""
from django.conf.urls import url
from django.urls import path
from django.urls import path, re_path
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import routers
@ -119,7 +118,7 @@ SchemaView = get_schema_view(
)
urlpatterns = [
url(
re_path(
r"^swagger(?P<format>\.json|\.yaml)$",
SchemaView.without_ui(cache_timeout=0),
name="schema-json",

View File

@ -29,7 +29,16 @@ class ApplicationForm(forms.ModelForm):
]
widgets = {
"name": forms.TextInput(),
"meta_launch_url": forms.TextInput(),
"meta_launch_url": forms.TextInput(
attrs={
"placeholder": _(
(
"If left empty, passbook will try to extract the launch URL "
"based on the selected provider."
)
)
}
),
"meta_icon_url": forms.TextInput(),
"meta_publisher": forms.TextInput(),
}

View File

@ -22,6 +22,7 @@ from passbook.lib.models import CreatedUpdatedModel
from passbook.policies.models import PolicyBindingModel
LOGGER = get_logger()
PASSBOOK_USER_DEBUG = "passbook_user_debug"
def default_token_duration():
@ -92,6 +93,12 @@ class Provider(models.Model):
objects = InheritanceManager()
@property
def launch_url(self) -> Optional[str]:
"""URL to this provider and initiate authorization for the user.
Can return None for providers that are not URL-based"""
return None
def form(self) -> Type[ModelForm]:
"""Return Form class used to edit this object"""
raise NotImplementedError
@ -119,6 +126,14 @@ class Application(PolicyBindingModel):
meta_description = models.TextField(default="", blank=True)
meta_publisher = models.TextField(default="", blank=True)
def get_launch_url(self) -> Optional[str]:
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
if self.meta_launch_url:
return self.meta_launch_url
if self.provider:
return self.provider.launch_url
return None
def get_provider(self) -> Optional[Provider]:
"""Get casted provider instance"""
if not self.provider:

View File

@ -20,8 +20,12 @@
</button>
</div>
<a class="pf-c-page__header-brand-link">
<img class="pf-c-brand" src="{% static 'passbook/logo.png' %}" alt="" />
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" alt="passbook" />
<div class="pf-c-brand pb-brand">
<img src="{{ config.passbook.branding.logo }}" alt="passbook icon">
{% if config.passbook.branding.title_show %}
<small><small>{{ config.passbook.branding.title }}</small></small>
{% endif %}
</div>
</a>
</div>
<div class="pf-c-page__header-nav">

View File

@ -6,15 +6,17 @@
<html lang="en">
<head>
<link rel="preload" href="{% static 'passbook/fonts/DINEngschriftStd.woff2' %}" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{% static 'passbook/fonts/DINEngschriftStd.woff' %}" as="font" type="font/woff" crossorigin>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}{% trans title|default:"passbook" %}{% endblock %}</title>
<title>{% block title %}{% trans title|default:config.passbook.branding.title %}{% endblock %}</title>
<link rel="icon" type="image/png" href="{% static 'passbook/logo.png' %}">
<link rel="shortcut icon" type="image/png" href="{% static 'passbook/logo.png' %}">
<link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly-addons.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'passbook/pf.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'passbook/passbook.css' %}">
{% block head %}
{% endblock %}
</head>
@ -35,6 +37,6 @@
{% endblock %}
{% block scripts %}
{% endblock %}
<script src="{% static 'passbook/pf.js' %}"></script>
<script src="{% static 'passbook/passbook.js' %}"></script>
</body>
</html>

View File

@ -22,8 +22,12 @@
<div class="pf-c-login">
<div class="pf-c-login__container">
<header class="pf-c-login__header">
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;" alt="passbook icon" />
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;" alt="passbook branding" />
<div class="pf-c-brand pb-brand">
<img src="{{ config.passbook.branding.logo }}" alt="passbook icon" />
{% if config.passbook.branding.title_show %}
<p>{{ config.passbook.branding.title }}</p>
{% endif %}
</div>
</header>
{% block main_container %}
<main class="pf-c-login__main">
@ -47,6 +51,13 @@
<a href="{{ link.href }}">{{ link.name }}</a>
</li>
{% endfor %}
{% if config.passbook.branding.title != "passbook" %}
<li>
<a href="https://github.com/beryju/passbook">
{% trans 'Powered by passbook' %}
</a>
</li>
{% endif %}
</ul>
</footer>
</div>

View File

@ -1,29 +0,0 @@
{% extends 'login/base_full.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 'Access denied' %}
</p>
</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

@ -24,7 +24,7 @@
{% if applications %}
<div class="pf-l-gallery pf-m-gutter">
{% for app in applications %}
<a href="{{ app.meta_launch_url }}" class="pf-c-card pf-m-hoverable pf-m-compact">
<a href="{{ app.get_launch_url }}" class="pf-c-card pf-m-hoverable pf-m-compact">
<div class="pf-c-card__header">
{% if not app.meta_icon_url %}
<i class="pf-icon pf-icon-arrow"></i>

View File

@ -1,30 +0,0 @@
"""passbook util view tests"""
import string
from random import SystemRandom
from django.test import RequestFactory, TestCase
from passbook.core.models import User
from passbook.core.views.utils import PermissionDeniedView
class TestUtilViews(TestCase):
"""Test Utility Views"""
def setUp(self):
self.user = User.objects.create_superuser(
username="unittest user",
email="unittest@example.com",
password="".join(
SystemRandom().choice(string.ascii_uppercase + string.digits)
for _ in range(8)
),
)
self.factory = RequestFactory()
def test_permission_denied_view(self):
"""Test PermissionDeniedView"""
request = self.factory.get("something")
request.user = self.user
response = PermissionDeniedView.as_view()(request)
self.assertEqual(response.status_code, 200)

View File

@ -1,14 +0,0 @@
"""passbook core utils view"""
from django.utils.translation import ugettext as _
from django.views.generic import TemplateView
class PermissionDeniedView(TemplateView):
"""Generic Permission denied view"""
template_name = "login/denied.html"
title = _("Permission denied.")
def get_context_data(self, **kwargs):
kwargs["title"] = self.title
return super().get_context_data(**kwargs)

View File

@ -2,8 +2,8 @@
class FlowNonApplicableException(BaseException):
"""Exception raised when a Flow does not apply to a user."""
"""Flow does not apply to current user (denied by policy)."""
class EmptyFlowException(BaseException):
"""Exception raised when a Flow Plan is empty"""
"""Flow has no stages."""

View File

@ -52,7 +52,7 @@ def create_default_source_enrollment_flow(
# PromptStage to ask user for their username
prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create(
name="default-source-enrollment-username-prompt",
name="Welcome to passbook! Please select a username.",
)
prompt, _ = Prompt.objects.using(db_alias).update_or_create(
field_key="username",

View File

@ -115,11 +115,12 @@ const updateFormAction = (form) => {
for (let index = 0; index < form.elements.length; index++) {
const element = form.elements[index];
if (element.value === form.action) {
console.log("Found Form action URL in form elements, not changing form action.");
console.log("pb-flow: Found Form action URL in form elements, not changing form action.");
return false;
}
}
form.action = flowBodyUrl;
console.log(`pb-flow: updated form.action ${flowBodyUrl}`);
return true;
};
const checkAutosubmit = (form) => {
@ -129,11 +130,11 @@ const checkAutosubmit = (form) => {
};
const setFormSubmitHandlers = () => {
document.querySelectorAll("#flow-body form").forEach(form => {
console.log(`Checking for autosubmit attribute ${form}`);
console.log(`pb-flow: Checking for autosubmit attribute ${form}`);
checkAutosubmit(form);
console.log(`Setting action for form ${form}`);
console.log(`pb-flow: Setting action for form ${form}`);
updateFormAction(form);
console.log(`Adding handler for form ${form}`);
console.log(`pb-flow: Adding handler for form ${form}`);
form.addEventListener('submit', (e) => {
e.preventDefault();
let formData = new FormData(form);
@ -145,6 +146,7 @@ const setFormSubmitHandlers = () => {
updateCard(data);
});
});
form.classList.add("pb-flow-wrapped");
});
};

View File

@ -1,9 +1,10 @@
"""flow views tests"""
from unittest.mock import MagicMock, PropertyMock, patch
from django.http import HttpRequest, HttpResponse
from django.shortcuts import reverse
from django.test import Client, TestCase
from django.utils.encoding import force_text
from django.utils.encoding import force_str
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from passbook.flows.markers import ReevaluateMarker, StageMarker
@ -12,6 +13,7 @@ from passbook.flows.planner import FlowPlan
from passbook.flows.views import NEXT_ARG_NAME, SESSION_KEY_PLAN
from passbook.lib.config import CONFIG
from passbook.policies.dummy.models import DummyPolicy
from passbook.policies.http import AccessDeniedResponse
from passbook.policies.models import PolicyBinding
from passbook.policies.types import PolicyResult
from passbook.stages.dummy.models import DummyStage
@ -20,6 +22,15 @@ POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False))
POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True))
def to_stage_response(request: HttpRequest, source: HttpResponse):
"""Mock for to_stage_response that returns the original response, so we can check
inheritance and member attributes"""
return source
TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response)
class TestFlowExecutor(TestCase):
"""Test views logic"""
@ -48,9 +59,12 @@ class TestFlowExecutor(TestCase):
"passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}
),
)
self.assertEqual(response.status_code, 400)
self.assertEqual(cancel_mock.call_count, 1)
self.assertEqual(response.status_code, 200)
self.assertEqual(cancel_mock.call_count, 2)
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
@patch(
"passbook.policies.engine.PolicyEngine.result", POLICY_RETURN_FALSE,
)
@ -66,9 +80,13 @@ class TestFlowExecutor(TestCase):
response = self.client.get(
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response, AccessDeniedResponse)
self.assertInHTML(FlowNonApplicableException.__doc__, response.rendered_content)
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)
def test_invalid_empty_flow(self):
"""Tests that an empty flow returns the correct error message"""
flow = Flow.objects.create(
@ -81,7 +99,8 @@ class TestFlowExecutor(TestCase):
response = self.client.get(
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response, AccessDeniedResponse)
self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content)
def test_invalid_flow_redirect(self):
@ -96,8 +115,10 @@ class TestFlowExecutor(TestCase):
dest = "/unique-string"
url = reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(url + f"?{NEXT_ARG_NAME}={dest}")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, dest)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content), {"type": "redirect", "to": dest},
)
def test_multi_stage_flow(self):
"""Test a full flow with multiple stages"""
@ -247,7 +268,7 @@ class TestFlowExecutor(TestCase):
response = self.client.post(exec_url)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_text(response.content),
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_core:overview")},
)
@ -293,7 +314,7 @@ class TestFlowExecutor(TestCase):
# First request, run the planner
response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200)
self.assertIn("dummy1", force_text(response.content))
self.assertIn("dummy1", force_str(response.content))
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
@ -316,13 +337,13 @@ class TestFlowExecutor(TestCase):
# but it won't save it, hence we cant' check the plan
response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200)
self.assertIn("dummy4", force_text(response.content))
self.assertIn("dummy4", force_str(response.content))
# fourth request, this confirms the last stage (dummy4)
# We do this request without the patch, so the policy results in false
response = self.client.post(exec_url)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_text(response.content),
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_core:overview")},
)

View File

@ -6,12 +6,10 @@ from passbook.flows.views import (
CancelView,
FlowExecutorShellView,
FlowExecutorView,
FlowPermissionDeniedView,
ToDefaultFlow,
)
urlpatterns = [
path("-/denied/", FlowPermissionDeniedView.as_view(), name="denied"),
path(
"-/default/authentication/",
ToDefaultFlow.as_view(designation=FlowDesignation.AUTHENTICATION),

View File

@ -9,7 +9,7 @@ from django.http import (
HttpResponseRedirect,
JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect, render, reverse
from django.shortcuts import get_object_or_404, redirect, reverse
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
@ -17,13 +17,13 @@ from django.views.generic import TemplateView, View
from structlog import get_logger
from passbook.audit.models import cleanse_dict
from passbook.core.views.utils import PermissionDeniedView
from passbook.core.models import PASSBOOK_USER_DEBUG
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from passbook.flows.models import Flow, FlowDesignation, Stage
from passbook.flows.planner import FlowPlan, FlowPlanner
from passbook.lib.utils.reflection import class_to_path
from passbook.lib.utils.urls import is_url_absolute, redirect_with_qs
from passbook.lib.views import bad_request_message
from passbook.policies.http import AccessDeniedResponse
LOGGER = get_logger()
# Argument used to redirect user after login
@ -54,7 +54,7 @@ class FlowExecutorView(View):
LOGGER.debug("f(exec): Redirecting to next on fail")
return redirect(self.request.GET.get(NEXT_ARG_NAME))
message = exc.__doc__ if exc.__doc__ else str(exc)
return bad_request_message(self.request, message)
return self.stage_invalid(error_message=message)
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
# Early check if theres an active Plan for the current session
@ -79,10 +79,10 @@ class FlowExecutorView(View):
self.plan = self._initiate_plan()
except FlowNonApplicableException as exc:
LOGGER.warning("f(exec): Flow not applicable to current user", exc=exc)
return self.handle_invalid_flow(exc)
return to_stage_response(self.request, self.handle_invalid_flow(exc))
except EmptyFlowException as exc:
LOGGER.warning("f(exec): Flow is empty", exc=exc)
return self.handle_invalid_flow(exc)
return to_stage_response(self.request, self.handle_invalid_flow(exc))
# We don't save the Plan after getting the next stage
# as it hasn't been successfully passed yet
next_stage = self.plan.next()
@ -115,14 +115,7 @@ class FlowExecutorView(View):
return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except
LOGGER.exception(exc)
return to_stage_response(
request,
render(
request,
"flows/error.html",
{"error": exc, "tb": "".join(format_tb(exc.__traceback__))},
),
)
return to_stage_response(request, FlowErrorResponse(request, exc))
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""pass post request to current stage"""
@ -137,14 +130,7 @@ class FlowExecutorView(View):
return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except
LOGGER.exception(exc)
return to_stage_response(
request,
render(
request,
"flows/error.html",
{"error": exc, "tb": "".join(format_tb(exc.__traceback__))},
),
)
return to_stage_response(request, FlowErrorResponse(request, exc))
def _initiate_plan(self) -> FlowPlan:
planner = FlowPlanner(self.flow)
@ -193,12 +179,17 @@ class FlowExecutorView(View):
)
return self._flow_done()
def stage_invalid(self) -> HttpResponse:
def stage_invalid(self, error_message: Optional[str] = None) -> HttpResponse:
"""Callback used stage when data is correct but a policy denies access
or the user account is disabled."""
or the user account is disabled.
Optionally, an exception can be passed, which will be shown if the current user
is a superuser."""
LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug)
self.cancel()
return redirect_with_qs("passbook_flows:denied", self.request.GET)
response = AccessDeniedResponse(self.request)
response.error_message = error_message
return response
def cancel(self):
"""Cancel current execution and return a redirect"""
@ -212,8 +203,30 @@ class FlowExecutorView(View):
del self.request.session[key]
class FlowPermissionDeniedView(PermissionDeniedView):
"""User could not be authenticated"""
class FlowErrorResponse(TemplateResponse):
"""Response class when an unhandled error occurs during a stage. Normal users
are shown an error message, superusers are shown a full stacktrace."""
error: Exception
def __init__(self, request: HttpRequest, error: Exception) -> None:
# For some reason pyright complains about keyword argument usage here
# pyright: reportGeneralTypeIssues=false
super().__init__(request=request, template="flows/error.html")
self.error = error
def resolve_context(
self, context: Optional[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
if not context:
context = {}
context["error"] = self.error
if self._request.user and self._request.user.is_authenticated:
if self._request.user.is_superuser or self._request.user.attributes.get(
PASSBOOK_USER_DEBUG, False
):
context["tb"] = "".join(format_tb(self.error.__traceback__))
return context
class FlowExecutorShellView(TemplateView):

View File

@ -10,6 +10,7 @@ redis:
password: ''
cache_db: 0
message_queue_db: 1
ws_db: 2
debug: false
log_level: info
@ -21,6 +22,10 @@ error_reporting:
send_pii: false
passbook:
branding:
title: passbook
title_show: true
logo: /static/passbook/logo.svg
# Optionally add links to the footer on the login page
footer_links:
- name: Documentation

View File

@ -1,5 +1,9 @@
"""Generic models"""
import re
from django.core.validators import URLValidator
from django.db import models
from django.utils.regex_helper import _lazy_re_compile
from model_utils.managers import InheritanceManager
from rest_framework.serializers import BaseSerializer
@ -48,3 +52,21 @@ class InheritanceForeignKey(models.ForeignKey):
"""Custom ForeignKey that uses InheritanceForwardManyToOneDescriptor"""
forward_related_accessor_class = InheritanceForwardManyToOneDescriptor
class DomainlessURLValidator(URLValidator):
"""Subclass of URLValidator which doesn't check the domain
(to allow hostnames without domain)"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.host_re = "(" + self.hostname_re + self.domain_re + "|localhost)"
self.regex = _lazy_re_compile(
r"^(?:[a-z0-9.+-]*)://" # scheme is validated separately
r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?" # user:pass authentication
r"(?:" + self.ipv4_re + "|" + self.ipv6_re + "|" + self.host_re + ")"
r"(?::\d{2,5})?" # port
r"(?:[/?#][^\s]*)?" # resource path
r"\Z",
re.IGNORECASE,
)

View File

@ -1,6 +1,7 @@
"""passbook sentry integration"""
from billiard.exceptions import WorkerLostError
from botocore.client import ClientError
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
@ -8,6 +9,7 @@ from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import RedisError
from rest_framework.exceptions import APIException
from structlog import get_logger
from websockets.exceptions import WebSocketException
LOGGER = get_logger()
@ -35,6 +37,8 @@ def before_send(event, hint):
OSError,
RedisError,
SentryIgnoredException,
WebSocketException,
CeleryError,
)
if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"]

View File

@ -27,12 +27,12 @@ class CreateAssignPermView(CreateView):
def bad_request_message(
request: HttpRequest, message: str, title="Bad Request"
request: HttpRequest,
message: str,
title="Bad Request",
template="error/generic.html",
) -> TemplateResponse:
"""Return generic error page with message, with status code set to 400"""
return TemplateResponse(
request,
"error/generic.html",
{"message": message, "card_title": _(title)},
status=400,
request, template, {"message": message, "card_title": _(title)}, status=400,
)

View File

@ -1,4 +1,5 @@
"""Kubernetes deployment controller"""
from base64 import b64encode
from io import StringIO
from kubernetes.client import (
@ -24,6 +25,11 @@ from passbook import __version__
from passbook.outposts.controllers.base import BaseController
def b64encode_str(input_string: str) -> str:
"""base64 encode string"""
return b64encode(input_string.encode()).decode()
class KubernetesController(BaseController):
"""Manage deployment of outpost in kubernetes"""
@ -37,9 +43,9 @@ class KubernetesController(BaseController):
with StringIO() as _str:
dump_all(
[
self.get_deployment_secret(),
self.get_deployment(),
self.get_service(),
self.get_deployment_secret().to_dict(),
self.get_deployment().to_dict(),
self.get_service().to_dict(),
],
stream=_str,
default_flow_style=False,
@ -63,15 +69,18 @@ class KubernetesController(BaseController):
def get_deployment_secret(self) -> V1Secret:
"""Get secret with token and passbook host"""
return V1Secret(
api_version="v1",
kind="secret",
type="Opaque",
metadata=self.get_object_meta(
name=f"passbook-outpost-{self.outpost.name}-api"
),
data={
"passbook_host": self.outpost.config.passbook_host,
"passbook_host_insecure": str(
self.outpost.config.passbook_host_insecure
"passbook_host": b64encode_str(self.outpost.config.passbook_host),
"passbook_host_insecure": b64encode_str(
str(self.outpost.config.passbook_host_insecure)
),
"token": self.outpost.token.token_uuid.hex,
"token": b64encode_str(self.outpost.token.token_uuid.hex),
},
)
@ -82,6 +91,8 @@ class KubernetesController(BaseController):
for port_name, port in self.deployment_ports.items():
ports.append(V1ServicePort(name=port_name, port=port))
return V1Service(
api_version="v1",
kind="service",
metadata=meta,
spec=V1ServiceSpec(ports=ports, selector=meta.labels, type="ClusterIP"),
)
@ -94,6 +105,8 @@ class KubernetesController(BaseController):
container_ports.append(V1ContainerPort(container_port=port, name=port_name))
meta = self.get_object_meta(name=f"passbook-outpost-{self.outpost.name}")
return V1Deployment(
api_version="apps/v1",
kind="deployment",
metadata=meta,
spec=V1DeploymentSpec(
replicas=1,

View File

@ -1,15 +1,16 @@
"""Outpost models"""
from dataclasses import asdict, dataclass
from datetime import datetime
from json import dumps, loads
from typing import Iterable, Optional
from uuid import uuid4
from dacite import from_dict
from django.contrib.postgres.fields import ArrayField
from django.core.cache import cache
from django.db import models
from django.db import models, transaction
from django.db.models.base import Model
from django.utils.translation import gettext_lazy as _
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from passbook.core.models import Provider, Token, TokenIntents, User
@ -30,13 +31,17 @@ class OutpostConfig:
)
class OutpostModel:
class OutpostModel(Model):
"""Base model for providers that need more objects than just themselves"""
def get_required_objects(self) -> Iterable[models.Model]:
"""Return a list of all required objects"""
return [self]
class Meta:
abstract = True
class OutpostType(models.TextChoices):
"""Outpost types, currently only the reverse proxy is available"""
@ -79,12 +84,12 @@ class Outpost(models.Model):
@property
def config(self) -> OutpostConfig:
"""Load config as OutpostConfig object"""
return from_dict(OutpostConfig, loads(self._config))
return from_dict(OutpostConfig, self._config)
@config.setter
def config(self, value):
"""Dump config into json"""
self._config = dumps(asdict(value))
self._config = asdict(value)
@property
def health_cache_key(self) -> str:
@ -100,24 +105,24 @@ class Outpost(models.Model):
return datetime.fromtimestamp(value)
return None
def _create_user(self) -> User:
"""Create user and assign permissions for all required objects"""
user: User = User.objects.create(username=f"pb-outpost-{self.uuid.hex}")
user.set_unusable_password()
user.save()
for model in self.get_required_objects():
assign_perm(
f"{model._meta.app_label}.view_{model._meta.model_name}", user, model
)
return user
@property
def user(self) -> User:
"""Get/create user with access to all required objects"""
user = User.objects.filter(username=f"pb-outpost-{self.uuid.hex}")
if user.exists():
return user.first()
return self._create_user()
users = User.objects.filter(username=f"pb-outpost-{self.uuid.hex}")
if not users.exists():
user: User = User.objects.create(username=f"pb-outpost-{self.uuid.hex}")
user.set_unusable_password()
user.save()
else:
user = users.first()
# To ensure the user only has the correct permissions, we delete all of them and re-add
# the ones the user needs
with transaction.atomic():
UserObjectPermission.objects.filter(user=user).delete()
for model in self.get_required_objects():
code_name = f"{model._meta.app_label}.view_{model._meta.model_name}"
assign_perm(code_name, user, model)
return user
@property
def token(self) -> Token:

View File

@ -1,10 +1,10 @@
"""Outposts Settings"""
from celery.schedules import crontab
# from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
"outposts_k8s": {
"task": "passbook.outposts.tasks.outpost_k8s_controller",
"schedule": crontab(minute="*/5"), # Run every 5 minutes
"options": {"queue": "passbook_scheduled"},
}
}
# CELERY_BEAT_SCHEDULE = {
# "outposts_k8s": {
# "task": "passbook.outposts.tasks.outpost_k8s_controller",
# "schedule": crontab(minute="*/5"), # Run every 5 minutes
# "options": {"queue": "passbook_scheduled"},
# }
# }

View File

@ -1,31 +1,31 @@
"""passbook outpost signals"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db.models import Model
from django.db.models.signals import post_save
from django.dispatch import receiver
from structlog import get_logger
from passbook.lib.utils.reflection import class_to_path
from passbook.outposts.models import Outpost, OutpostModel
from passbook.outposts.tasks import outpost_send_update
LOGGER = get_logger()
@receiver(post_save, sender=Outpost)
# pylint: disable=unused-argument
def ensure_user_and_token(sender, instance, **_):
def ensure_user_and_token(sender, instance: Model, **_):
"""Ensure that token is created/updated on save"""
_ = instance.token
@receiver(post_save)
# pylint: disable=unused-argument
def post_save_update(sender, instance, **_):
def post_save_update(sender, instance: Model, **_):
"""If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved,
we send a message down the relevant OutpostModels WS connection to trigger an update"""
if isinstance(instance, OutpostModel):
LOGGER.debug("triggering outpost update from outpostmodel", instance=instance)
_send_update(instance)
outpost_send_update.delay(class_to_path(instance.__class__), instance.pk)
return
for field in instance._meta.get_fields():
@ -46,13 +46,4 @@ def post_save_update(sender, instance, **_):
# Because the Outpost Model has an M2M to Provider,
# we have to iterate over the entire QS
for reverse in getattr(instance, field_name).all():
_send_update(reverse)
def _send_update(outpost_model: Model):
"""Send update trigger for each channel of an outpost model"""
for outpost in outpost_model.outpost_set.all():
channel_layer = get_channel_layer()
for channel in outpost.channels:
LOGGER.debug("sending update", channel=channel)
async_to_sync(channel_layer.send)(channel, {"type": "event.update"})
outpost_send_update(class_to_path(reverse.__class__), reverse.pk)

View File

@ -1,8 +1,22 @@
"""outpost tasks"""
from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType
from typing import Any
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from structlog import get_logger
from passbook.lib.utils.reflection import path_to_class
from passbook.outposts.models import (
Outpost,
OutpostDeploymentType,
OutpostModel,
OutpostType,
)
from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController
from passbook.root.celery import CELERY_APP
LOGGER = get_logger()
@CELERY_APP.task(bind=True)
# pylint: disable=unused-argument
@ -20,3 +34,16 @@ def outpost_k8s_controller_single(self, outpost: str, outpost_type: str):
"""Launch Kubernetes manager and reconcile deployment/service/etc"""
if outpost_type == OutpostType.PROXY:
ProxyKubernetesController(outpost).run()
@CELERY_APP.task()
def outpost_send_update(model_class: str, model_pk: Any):
"""Send outpost update to all registered outposts, irregardless to which passbook
instance they are connected"""
model = path_to_class(model_class)
outpost_model: OutpostModel = model.objects.get(pk=model_pk)
for outpost in outpost_model.outpost_set.all():
channel_layer = get_channel_layer()
for channel in outpost.channels:
LOGGER.debug("sending update", channel=channel)
async_to_sync(channel_layer.send)(channel, {"type": "event.update"})

View File

@ -0,0 +1,60 @@
"""outpost tests"""
from django.test import TestCase
from guardian.models import UserObjectPermission
from passbook.crypto.models import CertificateKeyPair
from passbook.flows.models import Flow
from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType
from passbook.providers.proxy.models import ProxyProvider
class OutpostTests(TestCase):
"""Outpost Tests"""
def test_service_account_permissions(self):
"""Test that the service account has correct permissions"""
provider: ProxyProvider = ProxyProvider.objects.create(
name="test",
internal_host="http://localhost",
external_host="http://localhost",
authorization_flow=Flow.objects.first(),
)
outpost: Outpost = Outpost.objects.create(
name="test",
type=OutpostType.PROXY,
deployment_type=OutpostDeploymentType.CUSTOM,
)
# Before we add a provider, the user should only have access to the outpost
permissions = UserObjectPermission.objects.filter(user=outpost.user)
self.assertEqual(len(permissions), 1)
self.assertEqual(permissions[0].object_pk, str(outpost.pk))
# We add a provider, user should only have access to outpost and provider
outpost.providers.add(provider)
outpost.save()
permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by(
"content_type__model"
)
self.assertEqual(len(permissions), 2)
self.assertEqual(permissions[0].object_pk, str(outpost.pk))
self.assertEqual(permissions[1].object_pk, str(provider.pk))
# Provider requires a certificate-key-pair, user should have permissions for it
keypair = CertificateKeyPair.objects.first()
provider.certificate = keypair
provider.save()
permissions = UserObjectPermission.objects.filter(user=outpost.user).order_by(
"content_type__model"
)
self.assertEqual(len(permissions), 3)
self.assertEqual(permissions[0].object_pk, str(keypair.pk))
self.assertEqual(permissions[1].object_pk, str(outpost.pk))
self.assertEqual(permissions[2].object_pk, str(provider.pk))
# Remove provider from outpost, user should only have access to outpost
outpost.providers.remove(provider)
outpost.save()
permissions = UserObjectPermission.objects.filter(user=outpost.user)
self.assertEqual(len(permissions), 1)
self.assertEqual(permissions[0].object_pk, str(outpost.pk))

View File

@ -68,7 +68,7 @@ class PolicyEngine:
def _check_policy_type(self, policy: Policy):
"""Check policy type, make sure it's not the root class as that has no logic implemented"""
# policy_type = type(policy)
# pyright: reportGeneralTypeIssues=false
if policy.__class__ == Policy:
raise TypeError(f"Policy '{policy}' is root type")
@ -109,19 +109,25 @@ class PolicyEngine:
@property
def result(self) -> PolicyResult:
"""Get policy-checking result"""
messages: List[str] = []
process_results: List[PolicyResult] = [
x.result for x in self.__processes if x.result
]
final_result = PolicyResult(False)
final_result.messages = []
final_result.source_results = list(process_results + self.__cached_policies)
for result in process_results + self.__cached_policies:
LOGGER.debug(
"P_ENG: result", passing=result.passing, messages=result.messages
)
if result.messages:
messages += result.messages
final_result.messages.extend(result.messages)
if not result.passing:
return PolicyResult(False, *messages)
return PolicyResult(True, *messages)
final_result.messages = tuple(final_result.messages)
final_result.passing = False
return final_result
final_result.messages = tuple(final_result.messages)
final_result.passing = True
return final_result
@property
def passing(self) -> bool:

View File

@ -7,7 +7,7 @@
<label for="" class="pf-c-form__label"></label>
<div class="c-form__horizontal-group">
<p>
Expression using Python. See <a href="https://passbook.beryju.org/policies/expression/">here</a> for a list of all variables.
Expression using Python. See <a target="_blank" href="https://passbook.beryju.org/policies/expression/">here</a> for a list of all variables.
</p>
</div>
</div>

44
passbook/policies/http.py Normal file
View File

@ -0,0 +1,44 @@
"""policy http response"""
from typing import Any, Dict, Optional
from django.http.request import HttpRequest
from django.template.response import TemplateResponse
from django.utils.translation import gettext as _
from passbook.core.models import PASSBOOK_USER_DEBUG
from passbook.policies.types import PolicyResult
class AccessDeniedResponse(TemplateResponse):
"""Response used for access denied messages. Can optionally show an error message,
and if the user is a superuser or has user_debug enabled, shows a policy result."""
title: str
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")
self.title = _("Access denied")
def resolve_context(
self, context: Optional[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
if not context:
context = {}
context["title"] = self.title
if self.error_message:
context["error"] = self.error_message
# Only show policy result if user is authenticated and
# either superuser or has PASSBOOK_USER_DEBUG set
if self.policy_result:
if self._request.user and self._request.user.is_authenticated:
if (
self._request.user.is_superuser
or self._request.user.attributes.get(PASSBOOK_USER_DEBUG, False)
):
context["policy_result"] = self.policy_result
return context

View File

@ -5,13 +5,13 @@ from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin
from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.utils.translation import gettext as _
from structlog import get_logger
from passbook.core.models import Application, Provider, User
from passbook.flows.views import SESSION_KEY_APPLICATION_PRE
from passbook.policies.engine import PolicyEngine
from passbook.policies.http import AccessDeniedResponse
from passbook.policies.types import PolicyResult
LOGGER = get_logger()
@ -28,6 +28,9 @@ class PolicyAccessMixin(BaseMixin, AccessMixin):
Provider functions to check application access, etc"""
def handle_no_permission(self, application: Optional[Application] = None):
"""User has no access and is not authenticated, so we remember the application
they try to access and redirect to the login URL. The application is saved to show
a hint on the Identification Stage what the user should login for."""
if application:
self.request.session[SESSION_KEY_APPLICATION_PRE] = application
return redirect_to_login(
@ -36,10 +39,14 @@ class PolicyAccessMixin(BaseMixin, AccessMixin):
self.get_redirect_field_name(),
)
def handle_no_permission_authorized(self) -> HttpResponse:
"""Function called when user has no permissions but is authorized"""
# TODO: Remove this URL and render the view instead
return redirect("passbook_flows:denied")
def handle_no_permission_authenticated(
self, result: Optional[PolicyResult] = None
) -> HttpResponse:
"""Function called when user has no permissions but is authenticated"""
response = AccessDeniedResponse(self.request)
if result:
response.policy_result = result
return response
def provider_to_application(self, provider: Provider) -> Application:
"""Lookup application assigned to provider, throw error if no application assigned"""

View File

@ -63,6 +63,7 @@ class PolicyProcess(Process):
except PolicyException as exc:
LOGGER.debug("P_ENG(proc): error", exc=exc)
policy_result = PolicyResult(False, str(exc))
policy_result.source_policy = self.binding.policy
# Invert result if policy.negate is set
if self.binding.negate:
policy_result.passing = not policy_result.passing

View File

@ -0,0 +1,57 @@
{% extends 'login/base_full.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 'Access 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

@ -1,13 +1,14 @@
"""policy structures"""
from __future__ import annotations
from typing import TYPE_CHECKING, Dict, Optional, Tuple
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from django.db.models import Model
from django.http import HttpRequest
if TYPE_CHECKING:
from passbook.core.models import User
from passbook.policies.models import Policy
class PolicyRequest:
@ -34,9 +35,14 @@ class PolicyResult:
passing: bool
messages: Tuple[str, ...]
source_policy: Optional[Policy]
source_results: Optional[List["PolicyResult"]]
def __init__(self, passing: bool, *messages: str):
self.passing = passing
self.messages = messages
self.source_policy = None
self.source_results = []
def __repr__(self):
return self.__str__()

View File

@ -6,6 +6,7 @@ import time
from dataclasses import asdict, dataclass, field
from hashlib import sha256
from typing import Any, Dict, List, Optional, Type
from urllib.parse import urlparse
from uuid import uuid4
from django.conf import settings
@ -14,7 +15,7 @@ from django.forms import ModelForm
from django.http import HttpRequest
from django.shortcuts import reverse
from django.utils import dateformat, timezone
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key
from jwkest.jws import JWS
@ -230,6 +231,16 @@ class OAuth2Provider(Provider):
except Provider.application.RelatedObjectDoesNotExist:
return None
@property
def launch_url(self) -> Optional[str]:
"""Guess launch_url based on first redirect_uri"""
if not self.redirect_uris:
return None
main_url = self.redirect_uris[0]
launch_url = urlparse(main_url)
launch_url.path = ""
return launch_url.geturl()
def form(self) -> Type[ModelForm]:
from passbook.providers.oauth2.forms import OAuth2ProviderForm

View File

@ -82,7 +82,7 @@ def extract_client_auth(request: HttpRequest) -> Tuple[str, str]:
b64_user_pass = auth_header.split()[1]
try:
user_pass = b64decode(b64_user_pass).decode("utf-8").split(":")
client_id, client_secret = tuple(user_pass)
client_id, client_secret = user_pass
except (ValueError, Error):
client_id = client_secret = ""
else:

View File

@ -10,7 +10,7 @@ from django.utils import timezone
from django.views import View
from structlog import get_logger
from passbook.core.models import Application, Token
from passbook.core.models import Application
from passbook.flows.models import in_memory_stage
from passbook.flows.planner import (
PLAN_CONTEXT_APPLICATION,
@ -93,9 +93,9 @@ class OAuthAuthorizationParams:
if response_type in [ResponseTypes.CODE]:
grant_type = GrantTypes.AUTHORIZATION_CODE
elif response_type in [
ResponseTypes.id_token,
ResponseTypes.id_token_token,
ResponseTypes.token,
ResponseTypes.ID_TOKEN,
ResponseTypes.ID_TOKEN_TOKEN,
ResponseTypes.CODE_TOKEN,
]:
grant_type = GrantTypes.IMPLICIT
elif response_type in [
@ -220,9 +220,11 @@ class OAuthFulfillmentStage(StageView):
)
return redirect(self.create_response_uri())
except (ClientIdError, RedirectUriError) as error:
self.executor.stage_invalid()
# pylint: disable=no-member
return bad_request_message(request, error.description, title=error.error)
except AuthorizeError as error:
self.executor.stage_invalid()
uri = error.create_uri(self.params.redirect_uri, self.params.state)
return redirect(uri)
@ -248,28 +250,26 @@ class OAuthFulfillmentStage(StageView):
str(self.params.state) if self.params.state else ""
]
elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
token: Token = self.provider.create_token(
token = self.provider.create_refresh_token(
user=self.request.user, scope=self.params.scope,
)
# Check if response_type must include access_token in the response.
if self.params.response_type in [
ResponseTypes.id_token_token,
ResponseTypes.code_id_token_token,
ResponseTypes.token,
ResponseTypes.code_token,
ResponseTypes.ID_TOKEN_TOKEN,
ResponseTypes.CODE_ID_TOKEN_TOKEN,
ResponseTypes.ID_TOKEN,
ResponseTypes.CODE_TOKEN,
]:
query_fragment["access_token"] = token.access_token
# We don't need id_token if it's an OAuth2 request.
if SCOPE_OPENID in self.params.scope:
id_token = token.create_id_token(
user=self.request.user,
request=self.request,
scope=self.params.scope,
user=self.request.user, request=self.request,
)
id_token.nonce = self.params.nonce
id_token.scope = self.params.scope
# Include at_hash when access_token is being returned.
if "access_token" in query_fragment:
id_token.at_hash = token.at_hash
@ -283,8 +283,6 @@ class OAuthFulfillmentStage(StageView):
]:
query_fragment["id_token"] = id_token.encode(self.provider)
token.id_token = id_token
else:
token.id_token = {}
# Store the token.
token.save()
@ -325,7 +323,7 @@ class AuthorizationFlowInitView(PolicyAccessMixin, View):
try:
application = self.provider_to_application(provider)
except Application.DoesNotExist:
return self.handle_no_permission_authorized()
return self.handle_no_permission_authenticated()
# Check if user is unauthenticated, so we pass the application
# for the identification stage
if not request.user.is_authenticated:
@ -333,7 +331,7 @@ class AuthorizationFlowInitView(PolicyAccessMixin, View):
# Check permissions
result = self.user_has_access(application)
if not result.passing:
return self.handle_no_permission_authorized()
return self.handle_no_permission_authenticated(result)
# TODO: End block
# Extract params so we can save them in the plan context
try:

View File

@ -2,7 +2,7 @@
from typing import Any, Dict, List
from django.http import HttpRequest, HttpResponse
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from django.views import View
from structlog import get_logger

View File

@ -0,0 +1,37 @@
# Generated by Django 3.1.1 on 2020-09-13 19:47
from django.db import migrations, models
import passbook.lib.models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_proxy", "0003_proxyprovider_certificate"),
]
operations = [
migrations.AlterField(
model_name="proxyprovider",
name="external_host",
field=models.TextField(
validators=[
passbook.lib.models.DomainlessURLValidator(
schemes=("http", "https")
)
]
),
),
migrations.AlterField(
model_name="proxyprovider",
name="internal_host",
field=models.TextField(
validators=[
passbook.lib.models.DomainlessURLValidator(
schemes=("http", "https")
)
]
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 3.1.1 on 2020-09-14 15:36
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_crypto", "0002_create_self_signed_kp"),
("passbook_providers_proxy", "0004_auto_20200913_1947"),
]
operations = [
migrations.AlterField(
model_name="proxyprovider",
name="certificate",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="passbook_crypto.certificatekeypair",
),
),
]

View File

@ -4,12 +4,12 @@ from random import SystemRandom
from typing import Iterable, Type
from urllib.parse import urljoin
from django.core.validators import URLValidator
from django.db import models
from django.forms import ModelForm
from django.utils.translation import gettext as _
from passbook.crypto.models import CertificateKeyPair
from passbook.lib.models import DomainlessURLValidator
from passbook.outposts.models import OutpostModel
from passbook.providers.oauth2.constants import (
SCOPE_OPENID,
@ -41,16 +41,16 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
Protocols by using a Reverse-Proxy."""
internal_host = models.TextField(
validators=[URLValidator(schemes=("http", "https"))]
validators=[DomainlessURLValidator(schemes=("http", "https"))]
)
external_host = models.TextField(
validators=[URLValidator(schemes=("http", "https"))]
validators=[DomainlessURLValidator(schemes=("http", "https"))]
)
cookie_secret = models.TextField(default=get_cookie_secret)
certificate = models.ForeignKey(
CertificateKeyPair, on_delete=models.SET_NULL, null=True
CertificateKeyPair, on_delete=models.SET_NULL, null=True, blank=True,
)
def form(self) -> Type[ModelForm]:

View File

@ -1,11 +1,12 @@
"""passbook saml_idp Models"""
from typing import Optional, Type
from urllib.parse import urlparse
from django.db import models
from django.forms import ModelForm
from django.http import HttpRequest
from django.shortcuts import reverse
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from structlog import get_logger
from passbook.core.models import PropertyMapping, Provider
@ -102,6 +103,13 @@ class SAMLProvider(Provider):
),
)
@property
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()
def form(self) -> Type[ModelForm]:
from passbook.providers.saml.forms import SAMLProviderForm

View File

@ -1,13 +1,22 @@
"""Test AuthN Request generator and parser"""
from django.contrib.sessions.middleware import SessionMiddleware
from django.http.request import QueryDict
from django.test import RequestFactory, TestCase
from guardian.utils import get_anonymous_user
from passbook.crypto.models import CertificateKeyPair
from passbook.flows.models import Flow
from passbook.providers.saml.models import SAMLProvider
from passbook.providers.saml.processors.assertion import AssertionProcessor
from passbook.providers.saml.processors.request_parser import AuthNRequestParser
from passbook.providers.saml.utils.encoding import deflate_and_base64_encode
from passbook.sources.saml.exceptions import MismatchedRequestID
from passbook.sources.saml.models import SAMLSource
from passbook.sources.saml.processors.request import RequestProcessor
from passbook.sources.saml.processors.request import (
SESSION_REQUEST_ID,
RequestProcessor,
)
from passbook.sources.saml.processors.response import ResponseProcessor
class TestAuthNRequest(TestCase):
@ -31,6 +40,11 @@ class TestAuthNRequest(TestCase):
def test_signed_valid(self):
"""Test generated AuthNRequest with valid signature"""
http_request = self.factory.get("/")
middleware = SessionMiddleware()
middleware.process_request(http_request)
http_request.session.save()
# First create an AuthNRequest
request_proc = RequestProcessor(self.source, http_request, "test_state")
request = request_proc.build_auth_n()
@ -44,6 +58,11 @@ class TestAuthNRequest(TestCase):
def test_signed_valid_detached(self):
"""Test generated AuthNRequest with valid signature (detached)"""
http_request = self.factory.get("/")
middleware = SessionMiddleware()
middleware.process_request(http_request)
http_request.session.save()
# First create an AuthNRequest
request_proc = RequestProcessor(self.source, http_request, "test_state")
params = request_proc.build_auth_n_detached()
@ -53,3 +72,37 @@ class TestAuthNRequest(TestCase):
)
self.assertEqual(parsed_request.id, request_proc.request_id)
self.assertEqual(parsed_request.relay_state, "test_state")
def test_request_id_invalid(self):
"""Test generated AuthNRequest with invalid request ID"""
http_request = self.factory.get("/")
http_request.user = get_anonymous_user()
middleware = SessionMiddleware()
middleware.process_request(http_request)
http_request.session.save()
# First create an AuthNRequest
request_proc = RequestProcessor(self.source, http_request, "test_state")
request = request_proc.build_auth_n()
# change the request ID
http_request.session[SESSION_REQUEST_ID] = "test"
http_request.session.save()
# To get an assertion we need a parsed request (parsed by provider)
parsed_request = AuthNRequestParser(self.provider).parse(
deflate_and_base64_encode(request), "test_state"
)
# Now create a response and convert it to string (provider)
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
response = response_proc.build_response()
# Now parse the response (source)
http_request.POST = QueryDict(mutable=True)
http_request.POST["SAMLResponse"] = deflate_and_base64_encode(response)
response_parser = ResponseProcessor(self.source)
with self.assertRaises(MismatchedRequestID):
response_parser.parse(http_request)

View File

@ -62,8 +62,9 @@ class SAMLSSOView(PolicyAccessMixin, View):
)
if not request.user.is_authenticated:
return self.handle_no_permission(self.application)
if not self.user_has_access(self.application).passing:
return self.handle_no_permission_authorized()
has_access = self.user_has_access(self.application)
if not has_access.passing:
return self.handle_no_permission_authenticated(has_access)
# Call the method handler, which checks the SAML Request
method_response = super().dispatch(request, *args, application_slug, **kwargs)
if method_response:

View File

@ -32,6 +32,11 @@ Send = typing.Callable[[Message], typing.Awaitable[None]]
ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]]
ASGI_IP_HEADERS = (
b"x-forwarded-for",
b"x-real-ip",
)
LOGGER = get_logger("passbook.asgi")
@ -51,7 +56,6 @@ class ASGILogger:
"""ASGI Logger, instantiated for each request"""
app: ASGIApp
send: Send
scope: Scope
headers: Dict[ByteString, Any]
@ -64,11 +68,26 @@ class ASGILogger:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
self.send = send
self.scope = scope
self.content_length = 0
self.headers = dict(scope.get("headers", []))
async def send_hooked(message: Message) -> None:
"""Hooked send method, which records status code and content-length, and for the final
requests logs it"""
headers = dict(message.get("headers", []))
if "status" in message:
self.status_code = message["status"]
if b"Content-Length" in headers:
self.content_length += int(headers.get(b"Content-Length", b"0"))
if message["type"] == "http.response.body" and not message["more_body"]:
runtime = int((time() - self.start) * 10 ** 6)
self.log(runtime)
await send(message)
if self.headers.get(b"host", b"") == b"kubernetes-healthcheck-host":
# Don't log kubernetes health/readiness requests
await send({"type": "http.response.start", "status": 204, "headers": []})
@ -80,25 +99,12 @@ class ASGILogger:
# https://code.djangoproject.com/ticket/31508
# https://github.com/encode/uvicorn/issues/266
return
await self.app(scope, receive, self.send_hooked)
async def send_hooked(self, message: Message) -> None:
"""Hooked send method, which records status code and content-length, and for the final
requests logs it"""
headers = dict(message.get("headers", []))
if "status" in message:
self.status_code = message["status"]
if b"Content-Length" in headers:
self.content_length += int(headers.get(b"Content-Length", b"0"))
if message["type"] == "http.response.body" and not message["more_body"]:
runtime = int((time() - self.start) * 10 ** 6)
self.log(runtime)
return await self.send(message)
await self.app(scope, receive, send_hooked)
def _get_ip(self) -> str:
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
@ -119,6 +125,6 @@ class ASGILogger:
)
application = SentryAsgiMiddleware(
ASGILogger(guarantee_single_callable(get_default_application()))
application = ASGILogger(
guarantee_single_callable(SentryAsgiMiddleware(get_default_application()))
)

View File

@ -12,7 +12,6 @@ https://docs.djangoproject.com/en/2.1/ref/settings/
import importlib
import os
import sys
from json import dumps
import structlog
@ -156,6 +155,7 @@ DJANGO_REDIS_IGNORE_EXCEPTIONS = True
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_SAMESITE = "lax"
MIDDLEWARE = [
"django_prometheus.middleware.PrometheusBeforeMiddleware",
@ -193,7 +193,12 @@ ASGI_APPLICATION = "passbook.root.routing.application"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {"hosts": [(CONFIG.y("redis.host"), 6379)]},
"CONFIG": {
"hosts": [
f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}:6379"
f"/{CONFIG.y('redis.ws_db')}"
],
},
},
}
@ -372,15 +377,9 @@ LOGGING = {
}
TEST = False
TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner"
TEST_RUNNER = "passbook.root.test_runner.PytestTestRunner"
LOG_LEVEL = CONFIG.y("log_level").upper()
TEST_OUTPUT_FILE_NAME = "unittest.xml"
if len(sys.argv) >= 2 and sys.argv[1] == "test":
LOG_LEVEL = "DEBUG"
TEST = True
CELERY_TASK_ALWAYS_EAGER = True
_LOGGING_HANDLER_MAP = {
"": LOG_LEVEL,
@ -431,7 +430,6 @@ for _app in INSTALLED_APPS:
pass
if DEBUG:
SESSION_COOKIE_SAMESITE = None
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")

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