Compare commits

...

84 Commits

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

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

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

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

Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
2020-09-17 10:01:23 +02:00
990f5f0a43 proxy: bump versions 2020-09-17 09:35:16 +02:00
97ce143efe lifecycle: adjust worker count 2020-09-17 09:35:08 +02:00
cbbe174fd8 build(deps): bump boto3 from 1.14.62 to 1.14.63 (#218)
Bumps [boto3](https://github.com/boto/boto3) from 1.14.62 to 1.14.63.
- [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.62...1.14.63)

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-17 08:57:57 +02:00
da3c640343 admin: fix type annotation for latest_version() 2020-09-16 23:54:55 +02:00
4b39c71de0 providers/oauth2: accept token as post param 2020-09-16 23:38:55 +02:00
818f417fd8 providers/oauth2: only send id_token as access_token if ADFS compat mode is enabled 2020-09-16 23:31:03 +02:00
f1ccef7f6a e2e: add tests for proxy provider and outposts 2020-09-16 23:22:17 +02:00
6187436518 build(deps): bump sentry-sdk from 0.17.4 to 0.17.6 (#217)
Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 0.17.4 to 0.17.6.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGES.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/0.17.4...0.17.6)

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-16 10:30:19 +02:00
9559ee7cb9 build(deps-dev): bump pytest-django from 3.9.0 to 3.10.0 (#215)
Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 3.9.0 to 3.10.0.
- [Release notes](https://github.com/pytest-dev/pytest-django/releases)
- [Changelog](https://github.com/pytest-dev/pytest-django/blob/master/docs/changelog.rst)
- [Commits](https://github.com/pytest-dev/pytest-django/compare/v3.9.0...v3.10.0)

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

Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>
2020-09-16 10:08:25 +02:00
72e9c4e6fa build(deps): bump boto3 from 1.14.60 to 1.14.62 (#216)
Bumps [boto3](https://github.com/boto/boto3) from 1.14.60 to 1.14.62.
- [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.60...1.14.62)

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-16 09:44:23 +02:00
97b8a025b3 ci: cleanup containers post e2e job 2020-09-16 09:08:10 +02:00
ea9687c30b core: don't fail migrations if no pbadmin exists 2020-09-15 23:37:39 +02:00
0a5e14a352 core: make is_superuser a group property, remove from user 2020-09-15 23:10:31 +02:00
0325847c22 docs: add vmware vsphere integration doc 2020-09-15 21:51:27 +02:00
491dcc1159 sources/ldap: improve default Property Mappings 2020-09-15 21:51:08 +02:00
6292049c74 sources/ldap: add limited support for attributes as object_fields on LDAPPropertyMappings 2020-09-15 21:08:14 +02:00
1e97af772f providers/oauth2: add workaround for vcenter 2020-09-15 20:54:54 +02:00
5c622cd4d2 providers/oauth2: make sub configurable based on hash, username, email and upn 2020-09-15 20:54:42 +02:00
c4de808c4e readme: link to install instructions from docs 2020-09-15 17:17:29 +02:00
8c604d225b static: update flow background 2020-09-15 16:14:13 +02:00
c7daadfb18 core: fix logic error in expired models cleanup 2020-09-15 12:53:02 +02:00
683968c96e sources/ldap: register ldap sources 2020-09-15 12:36:33 +02:00
c94added99 helm: bump dependency versions 2020-09-15 12:36:09 +02:00
61c00e5b39 ci: create release as draft 2020-09-15 12:36:02 +02:00
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
137 changed files with 2015 additions and 570 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.10.1-stable
current_version = 0.10.4-stable
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
@ -15,8 +15,6 @@ values =
beta
stable
[bumpversion:file:README.md]
[bumpversion:file:docs/installation/docker-compose.md]
[bumpversion:file:docs/installation/kubernetes.md]
@ -30,3 +28,5 @@ values =
[bumpversion:file:.github/workflows/release.yml]
[bumpversion:file:passbook/__init__.py]
[bumpversion:file:proxy/pkg/version.go]

View File

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

View File

@ -49,7 +49,7 @@ jobs:
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ steps.get_version.outputs.result }}
draft: false
draft: true
prerelease: false
- name: Upload packaged Helm Chart
id: upload-release-asset

View File

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

103
Pipfile.lock generated
View File

@ -74,18 +74,17 @@
},
"boto3": {
"hashes": [
"sha256:79e95f428c485ea817969a78e77a311d2ec4d82e0955639d6126189c990ddad3",
"sha256:d8ca27ee13deeb1a9e79f2fe5f923effa60947ed49bbdfbc2a9f5790aef64217"
"sha256:44073b1b1823ffc9edcf9027afbca908dad6bd5000f512ca73f929f6a604ae24"
],
"index": "pypi",
"version": "==1.14.60"
"version": "==1.15.1"
},
"botocore": {
"hashes": [
"sha256:193f193a66ac79106725e14dd73e28ed36bcec99b37156538a2202d061056a58",
"sha256:e55a4fc652537f5ccb2362133f3928ebeafb04ee9fe15ea11c2df80ba4ef8a12"
"sha256:6bdf60281c2e80360fe904851a1a07df3dcfe066fe88dc7fba2b5e626ac05c8c",
"sha256:d6bdf51c8880aa9974e6b61d2f7d9d1debe407287e2e9e60f36c789fe8ba6790"
],
"version": "==1.17.60"
"version": "==1.18.1"
},
"cachetools": {
"hashes": [
@ -111,36 +110,44 @@
},
"cffi": {
"hashes": [
"sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e",
"sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c",
"sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e",
"sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1",
"sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4",
"sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2",
"sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c",
"sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0",
"sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798",
"sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1",
"sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4",
"sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731",
"sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4",
"sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c",
"sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487",
"sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e",
"sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f",
"sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123",
"sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c",
"sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b",
"sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650",
"sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad",
"sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75",
"sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82",
"sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7",
"sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15",
"sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa",
"sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281"
"sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d",
"sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b",
"sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4",
"sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f",
"sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3",
"sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579",
"sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537",
"sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e",
"sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05",
"sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171",
"sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca",
"sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522",
"sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c",
"sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc",
"sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d",
"sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808",
"sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828",
"sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869",
"sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d",
"sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9",
"sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0",
"sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc",
"sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15",
"sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c",
"sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a",
"sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3",
"sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1",
"sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768",
"sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d",
"sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b",
"sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e",
"sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d",
"sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730",
"sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394",
"sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1",
"sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591"
],
"version": "==1.14.2"
"version": "==1.14.3"
},
"channels": {
"hashes": [
@ -348,14 +355,6 @@
"index": "pypi",
"version": "==0.3.0"
},
"docutils": {
"hashes": [
"sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0",
"sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827",
"sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"
],
"version": "==0.15.2"
},
"drf-yasg": {
"hashes": [
"sha256:5572e9d5baab9f6b49318169df9789f7399d0e3c7bdac8fdb8dfccf1d5d2b1ca",
@ -387,10 +386,10 @@
},
"google-auth": {
"hashes": [
"sha256:bcbd9f970e7144fe933908aa286d7a12c44b7deb6d78a76871f0377a29d09789",
"sha256:f4d5093f13b1b1c0a434ab1dc851cd26a983f86a4d75c95239974e33ed406a87"
"sha256:7084c50c03f7a8a5696ef4500e65df0c525a0f6909f3c70b9ee65900a230c755",
"sha256:dcf86c5adc3a8a7659be190b12bb8912ae019cfd9ee2a571ea881e289fafbe39"
],
"version": "==1.21.1"
"version": "==1.21.2"
},
"gunicorn": {
"hashes": [
@ -952,11 +951,11 @@
},
"sentry-sdk": {
"hashes": [
"sha256:97bff68e57402ad39674e6fe2545df0d5eea41c3d51e280c170761705c8c20ff",
"sha256:a16caf9ce892623081cbb9a95f6c1f892778bb123909b0ed7afdfb52ce7a58a1"
"sha256:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24",
"sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a"
],
"index": "pypi",
"version": "==0.17.4"
"version": "==0.17.6"
},
"service-identity": {
"hashes": [
@ -1550,11 +1549,11 @@
},
"pytest-django": {
"hashes": [
"sha256:64f99d565dd9497af412fcab2989fe40982c1282d4118ff422b407f3f7275ca5",
"sha256:664e5f42242e5e182519388f01b9f25d824a9feb7cd17d8f863c8d776f38baf9"
"sha256:4de6dbd077ed8606616958f77655fed0d5e3ee45159475671c7fa67596c6dba6",
"sha256:c33e3d3da14d8409b125d825d4e74da17bb252191bf6fc3da6856e27a8b73ea4"
],
"index": "pypi",
"version": "==3.9.0"
"version": "==3.10.0"
},
"pytz": {
"hashes": [

View File

@ -13,20 +13,7 @@ passbook is an open-source Identity Provider focused on flexibility and versatil
## Installation
For small/test setups it is recommended to use docker-compose.
```
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.1-stable
# 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
```
For small/test setups it is recommended to use docker-compose, see the [documentation](https://passbook.beryju.org/installation/docker-compose/)
For bigger setups, there is a Helm Chart in the `helm/` directory. This is documented [here](https://passbook.beryju.org//installation/kubernetes/)

View File

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

View File

@ -181,7 +181,14 @@ stages:
- task: CmdLine@2
displayName: Run full test suite
inputs:
script: pipenv run coverage run ./manage.py test e2e -v 3
script: |
pipenv run coverage run ./manage.py test e2e -v 3
- task: CmdLine@2
condition: always()
displayName: Cleanup
inputs:
script: |
docker stop $(docker ps -aq)
- task: CmdLine@2
displayName: Prepare unittests and coverage for upload
inputs:

View File

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

View File

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

View File

@ -11,9 +11,9 @@ This installation method is for test-setups and small-scale productive setups.
Download the latest `docker-compose.yml` from [here](https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml). Place it in a directory of your choice.
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING=true >> .env`
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING__ENABLED=true >> .env`
To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.1-stable >> .env`
To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.4-stable >> .env`
If this is a fresh passbook install run the following commands to generate a password:

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,77 @@
# VMware vCenter Integration
## What is vCenter
From https://en.wikipedia.org/wiki/VCenter
!!! note ""
vCenter Server is the centralized management utility for VMware, and is used to manage virtual machines, multiple ESXi hosts, and all dependent components from a single centralized location. VMware vMotion and svMotion require the use of vCenter and ESXi hosts.
!!! warning
This requires passbook 0.10.3 or newer.
!!! warning
This requires VMware vCenter 7.0.0 or newer.
## Preparation
The following placeholders will be used:
- `vcenter.company` is the FQDN of the vCenter server.
- `passbook.company` is the FQDN of the passbook install.
Since vCenter only allows OpenID-Connect in combination with Active Directory, it is recommended to have passbook sync with the same Active Directory.
### Step 1
Under *Property Mappings*, create a *Scope Mapping*. Give it a name like "OIDC-Scope-VMware-vCenter". Set the scope name to `openid` and the expression to the following
```python
return {
"domain": "<your active directory domain>",
}
```
### Step 2
!!! note
If your Active Directory Schema is the same as your Email address schema, skip to Step 3.
Under *Sources*, click *Edit* and ensure that "Autogenerated Active Directory Mapping: userPrincipalName -> attributes.upn" has been added to your source.
### Step 3
Under *Providers*, create an OAuth2/OpenID Provider with these settings:
- Client Type: Confidential
- Response Type: code (ADFS Compatibility Mode, sends id_token as access_token)
- JWT Algorithm: RS256
- Redirect URI: `https://vcenter.company/ui/login/oauth2/authcode`
- Post Logout Redirect URIs: `https://vcenter.company/ui/login`
- Sub Mode: If your Email address Schema matches your UPN, select "Based on the User's Email...", otherwise select "Based on the User's UPN...".
- Scopes: Select the Scope Mapping you've created in Step 1
![](./passbook_setup.png)
### Step 4
Create an application which uses this provider. Optionally apply access restrictions to the application.
## vCenter Setup
Login as local Administrator account (most likely ends with vsphere.local). Using the Menu in the Navigation bar, navigate to *Administration -> Single Sing-on -> Configuration*.
Click on *Change Identity Provider* in the top-right corner.
In the wizard, select "Microsoft ADFS" and click Next.
Fill in the Client Identifier and Shared Secret from the Provider in passbook. For the OpenID Address, click on *View Setup URLs* in passbook, and copy the OpenID Configuration URL.
On the next page, fill in your Active Directory Connection Details. These should be similar to what you have set in passbook.
![](./vcenter_post_setup.png)
If your vCenter was already setup with LDAP beforehand, your Role assignments will continue to work.

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -6,6 +6,7 @@ from unittest.case import skipUnless
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 e2e.utils import USER, SeleniumTestCase
from passbook.core.models import Application
@ -214,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

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

@ -0,0 +1,96 @@
"""Proxy and Outpost e2e tests"""
from sys import platform
from time import sleep
from typing import Any, Dict, Optional
from unittest.case import skipUnless
from docker.client import DockerClient, from_env
from docker.models.containers import Container
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from e2e.utils import USER, SeleniumTestCase
from passbook.core.models import Application
from passbook.flows.models import Flow
from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType
from passbook.providers.proxy.models import ProxyProvider
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestProviderProxy(SeleniumTestCase):
"""Proxy and Outpost e2e tests"""
proxy_container: Container
def tearDown(self) -> None:
super().tearDown()
self.proxy_container.kill()
def get_container_specs(self) -> Optional[Dict[str, Any]]:
return {
"image": "traefik/whoami:latest",
"detach": True,
"network_mode": "host",
"auto_remove": True,
}
def start_proxy(self, outpost: Outpost) -> Container:
"""Start proxy container based on outpost created"""
client: DockerClient = from_env()
container = client.containers.run(
image="beryju/passbook-proxy:latest",
detach=True,
network_mode="host",
auto_remove=True,
environment={
"PASSBOOK_HOST": self.live_server_url,
"PASSBOOK_TOKEN": outpost.token.token_uuid.hex,
},
)
return container
def test_proxy_simple(self):
"""Test simple outpost setup with single provider"""
proxy: ProxyProvider = ProxyProvider.objects.create(
name="proxy_provider",
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
internal_host="http://localhost:80",
external_host="http://localhost:4180",
)
# Ensure OAuth2 Params are set
proxy.set_oauth_defaults()
proxy.save()
# we need to create an application to actually access the proxy
Application.objects.create(name="proxy", slug="proxy", provider=proxy)
outpost: Outpost = Outpost.objects.create(
name="proxy_outpost",
type=OutpostType.PROXY,
deployment_type=OutpostDeploymentType.CUSTOM,
)
outpost.providers.add(proxy)
outpost.save()
self.proxy_container = self.start_proxy(outpost)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50:
if outpost.deployment_health:
break
healthcheck_retries += 1
sleep(0.5)
self.driver.get("http://localhost:4180")
self.driver.find_element(By.ID, "id_uid_field").click()
self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username)
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
sleep(1)
full_body_text = self.driver.find_element(By.CSS_SELECTOR, "pre").text
self.assertIn("X-Forwarded-Preferred-Username: pbadmin", full_body_text)

View File

@ -8,6 +8,7 @@ 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
@ -206,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

@ -50,6 +50,8 @@ class SeleniumTestCase(StaticLiveServerTestCase):
def _start_container(self, specs: Dict[str, Any]) -> Container:
client: DockerClient = from_env()
container = client.containers.run(**specs)
if "healthcheck" not in specs:
return container
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")

View File

@ -1,15 +1,15 @@
apiVersion: v2
appVersion: "0.10.1-stable"
appVersion: "0.10.4-stable"
description: A Helm chart for passbook.
name: passbook
version: "0.10.1-stable"
version: "0.10.4-stable"
icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
dependencies:
- name: postgresql
version: 9.3.2
version: 9.4.1
repository: https://charts.bitnami.com/bitnami
condition: install.postgresql
- name: redis
version: 10.7.16
version: 10.9.0
repository: https://charts.bitnami.com/bitnami
condition: install.redis

View File

@ -25,21 +25,23 @@ spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- 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"
- 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 }}"
@ -109,7 +111,7 @@ spec:
resources:
requests:
cpu: 100m
memory: 200M
memory: 300M
limits:
cpu: 300m
memory: 350M
memory: 500M

View File

@ -25,21 +25,23 @@ spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- 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"
- 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 }}"
@ -68,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.1-stable
tag: 0.10.4-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

@ -47,5 +47,5 @@ logconfig_dict = {
if Path("/var/run/secrets/kubernetes.io").exists():
workers = 2
else:
worker = cpu_count()
worker = cpu_count() * 2 + 1
threads = 4

View File

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

View File

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

View File

@ -15,11 +15,8 @@ class CodeMirrorWidget(forms.Textarea):
self.mode = mode
def render(self, *args, **kwargs):
if "attrs" not in kwargs:
kwargs["attrs"] = {}
attrs = kwargs["attrs"]
if "class" not in attrs:
attrs["class"] = ""
attrs = kwargs.setdefault("attrs", {})
attrs.setdefault("class", "")
attrs["class"] += " codemirror"
attrs["data-cm-mode"] = self.mode
return super().render(*args, **kwargs)
@ -56,6 +53,8 @@ class YAMLField(forms.JSONField):
)
if isinstance(converted, str):
return YAMLString(converted)
if converted is None:
return {}
return converted
def bound_data(self, data, initial):

View File

@ -12,7 +12,7 @@ class UserForm(forms.ModelForm):
class Meta:
model = User
fields = ["username", "name", "email", "is_staff", "is_active", "attributes"]
fields = ["username", "name", "email", "is_active", "attributes"]
widgets = {
"name": forms.TextInput,
"attributes": CodeMirrorWidget,

View File

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

View File

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

View File

@ -50,7 +50,7 @@
</td>
<td role="cell">
<span>
{{ group.user_set.all|length }}
{{ group.users.all|length }}
</span>
</td>
<td>

View File

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

View File

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

View File

@ -8,7 +8,7 @@ from django.test import Client, TestCase
from django.urls.exceptions import NoReverseMatch
from passbook.admin.urls import urlpatterns
from passbook.core.models import User
from passbook.core.models import Group, User
from passbook.lib.utils.reflection import get_apps
@ -16,7 +16,9 @@ class TestAdmin(TestCase):
"""Generic admin tests"""
def setUp(self):
self.user = User.objects.create_superuser(username="test")
self.user = User.objects.create_user(username="test")
self.user.pb_groups.add(Group.objects.filter(is_superuser=True).first())
self.user.save()
self.client = Client()
self.client.force_login(self.user)

View File

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

View File

@ -1,8 +1,10 @@
"""passbook administration overview"""
from typing import Union
from django.core.cache import cache
from django.shortcuts import redirect, reverse
from django.views.generic import TemplateView
from packaging.version import Version, parse
from packaging.version import LegacyVersion, Version, parse
from requests import RequestException, get
from passbook import __version__
@ -16,7 +18,7 @@ from passbook.stages.invitation.models import Invitation
VERSION_CACHE_KEY = "passbook_latest_version"
def latest_version() -> Version:
def latest_version() -> Union[LegacyVersion, Version]:
"""Get latest release from GitHub, cached"""
if not cache.get(VERSION_CACHE_KEY):
try:
@ -45,7 +47,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())

View File

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

View File

@ -6,15 +6,16 @@ from uuid import UUID, uuid4
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.base import Model
from django.http import HttpRequest
from django.utils.translation import gettext as _
from django.views.debug import SafeExceptionReporterFilter
from guardian.shortcuts import get_anonymous_user
from structlog import get_logger
from passbook.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER
from passbook.lib.utils.http import get_client_ip
LOGGER = get_logger()
@ -36,6 +37,19 @@ def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
return final_dict
def model_to_dict(model: Model) -> Dict[str, Any]:
"""Convert model to dict"""
name = str(model)
if hasattr(model, "name"):
name = model.name
return {
"app": model._meta.app_label,
"model_name": model._meta.model_name,
"pk": model.pk,
"name": name,
}
def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
"""clean source of all Models that would interfere with the JSONField.
Models are replaced with a dictionary of {
@ -48,18 +62,7 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
if isinstance(value, dict):
final_dict[key] = sanitize_dict(value)
elif isinstance(value, models.Model):
model_content_type = ContentType.objects.get_for_model(value)
name = str(value)
if hasattr(value, "name"):
name = value.name
final_dict[key] = sanitize_dict(
{
"app": model_content_type.app_label,
"model_name": model_content_type.model,
"pk": value.pk,
"name": name,
}
)
final_dict[key] = sanitize_dict(model_to_dict(value))
elif isinstance(value, UUID):
final_dict[key] = value.hex
else:
@ -79,6 +82,8 @@ class EventAction(Enum):
PASSWORD_RESET = "password_reset" # noqa # nosec
INVITE_CREATED = "invitation_created"
INVITE_USED = "invitation_used"
IMPERSONATION_STARTED = "impersonation_started"
IMPERSONATION_ENDED = "impersonation_ended"
CUSTOM = "custom"
@staticmethod
@ -140,6 +145,12 @@ class Event(models.Model):
self.user = request.user
if user:
self.user = user
# Check if we're currently impersonating, and add that user
if hasattr(request, "session"):
if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
self.context["on_behalf_of"] = model_to_dict(
request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
)
# User 255.255.255.255 as fallback if IP cannot be determined
self.client_ip = get_client_ip(request) or "255.255.255.255"
# If there's no app set, we get it from the requests too

View File

@ -11,7 +11,7 @@ class GroupSerializer(ModelSerializer):
class Meta:
model = Group
fields = ["pk", "name", "parent", "user_set", "attributes"]
fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"]
class GroupViewSet(ModelViewSet):

View File

@ -1,5 +1,5 @@
"""User API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.serializers import BooleanField, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.core.models import User
@ -8,10 +8,12 @@ from passbook.core.models import User
class UserSerializer(ModelSerializer):
"""User Serializer"""
is_superuser = BooleanField(read_only=True)
class Meta:
model = User
fields = ["pk", "username", "name", "email"]
fields = ["pk", "username", "name", "is_superuser", "email"]
class UserViewSet(ModelViewSet):

View File

@ -18,21 +18,19 @@ class GroupForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.initial["members"] = self.instance.user_set.values_list(
"pk", flat=True
)
self.initial["members"] = self.instance.users.values_list("pk", flat=True)
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
if instance.pk:
instance.user_set.clear()
instance.user_set.add(*self.cleaned_data["members"])
instance.users.clear()
instance.users.add(*self.cleaned_data["members"])
return instance
class Meta:
model = Group
fields = ["name", "parent", "members", "attributes"]
fields = ["name", "is_superuser", "parent", "members", "attributes"]
widgets = {
"name": forms.TextInput(),
"attributes": CodeMirrorWidget,

View File

@ -0,0 +1,26 @@
"""passbook admin Middleware to impersonate users"""
from typing import Callable
from django.http import HttpRequest, HttpResponse
SESSION_IMPERSONATE_USER = "passbook_impersonate_user"
SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user"
class ImpersonateMiddleware:
"""Middleware to impersonate users"""
get_response: Callable[[HttpRequest], HttpResponse]
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
# No permission checks are done here, they need to be checked before
# SESSION_IMPERSONATE_USER is set.
if SESSION_IMPERSONATE_USER in request.session:
request.user = request.session[SESSION_IMPERSONATE_USER]
return self.get_response(request)

View File

@ -1,7 +1,7 @@
# Generated by Django 3.0.6 on 2020-05-23 16:40
from django.apps.registry import Apps
from django.db import migrations
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
@ -15,8 +15,6 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
username="pbadmin", email="root@localhost", name="passbook Default Admin"
)
pbadmin.set_password("pbadmin") # noqa # nosec
pbadmin.is_superuser = True
pbadmin.is_staff = True
pbadmin.save()
@ -27,5 +25,15 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RemoveField(model_name="user", name="is_superuser",),
migrations.RemoveField(model_name="user", name="is_staff",),
migrations.RunPython(create_default_user),
migrations.AddField(
model_name="user",
name="is_superuser",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user", name="is_staff", field=models.BooleanField(default=False)
),
]

View File

@ -0,0 +1,49 @@
# Generated by Django 3.1.1 on 2020-09-15 19:53
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import passbook.core.models
def create_default_admin_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("passbook_core", "Group")
User = apps.get_model("passbook_core", "User")
# Creates a default admin group
group, _ = Group.objects.using(db_alias).get_or_create(
is_superuser=True, defaults={"name": "passbook Admins",}
)
group.users.set(User.objects.filter(username="pbadmin"))
group.save()
class Migration(migrations.Migration):
dependencies = [
("passbook_core", "0008_auto_20200824_1532"),
]
operations = [
migrations.RemoveField(model_name="user", name="is_superuser",),
migrations.RemoveField(model_name="user", name="is_staff",),
migrations.AlterField(
model_name="user",
name="pb_groups",
field=models.ManyToManyField(
related_name="users", to="passbook_core.Group"
),
),
migrations.AddField(
model_name="group",
name="is_superuser",
field=models.BooleanField(
default=False, help_text="Users added to this group will be superusers."
),
),
migrations.RunPython(create_default_admin_group),
migrations.AlterModelManagers(
name="user", managers=[("objects", passbook.core.models.UserManager()),],
),
]

View File

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

View File

@ -4,6 +4,7 @@ from typing import Any, Optional, Type
from uuid import uuid4
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.db import models
from django.db.models import Q, QuerySet
from django.forms import ModelForm
@ -34,7 +35,12 @@ class Group(models.Model):
"""Custom Group model which supports a basic hierarchy"""
group_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.CharField(_("name"), max_length=80)
is_superuser = models.BooleanField(
default=False, help_text=_("Users added to this group will be superusers.")
)
parent = models.ForeignKey(
"Group",
blank=True,
@ -52,6 +58,14 @@ class Group(models.Model):
unique_together = (("name", "parent",),)
class UserManager(DjangoUserManager):
"""Custom user manager that doesn't assign is_superuser and is_staff"""
def create_user(self, username, email=None, password=None, **extra_fields):
"""Custom user manager that doesn't assign is_superuser and is_staff"""
return self._create_user(username, email, password, **extra_fields)
class User(GuardianUserMixin, AbstractUser):
"""Custom User model to allow easier adding o f user-based settings"""
@ -59,11 +73,23 @@ class User(GuardianUserMixin, AbstractUser):
name = models.TextField(help_text=_("User's display name."))
sources = models.ManyToManyField("Source", through="UserSourceConnection")
pb_groups = models.ManyToManyField("Group")
pb_groups = models.ManyToManyField("Group", related_name="users")
password_change_date = models.DateTimeField(auto_now_add=True)
attributes = models.JSONField(default=dict, blank=True)
objects = UserManager()
@property
def is_superuser(self) -> bool:
"""Get supseruser status based on membership in a group with superuser status"""
return self.pb_groups.filter(is_superuser=True).exists()
@property
def is_staff(self) -> bool:
"""superuser == staff user"""
return self.is_superuser
def set_password(self, password):
if self.pk:
password_changed.send(sender=self, user=self, password=password)
@ -72,7 +98,10 @@ class User(GuardianUserMixin, AbstractUser):
class Meta:
permissions = (("reset_user_password", "Reset Password"),)
permissions = (
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
)
verbose_name = _("User")
verbose_name_plural = _("Users")
@ -131,7 +160,7 @@ class Application(PolicyBindingModel):
if self.meta_launch_url:
return self.meta_launch_url
if self.provider:
return self.provider.launch_url
return self.get_provider().launch_url
return None
def get_provider(self) -> Optional[Provider]:

View File

@ -1,4 +1,5 @@
"""passbook core tasks"""
from django.utils.timezone import now
from structlog import get_logger
from passbook.core.models import ExpiringModel
@ -12,5 +13,10 @@ def clean_expired_models():
"""Remove expired objects"""
for cls in ExpiringModel.__subclasses__():
cls: ExpiringModel
amount, _ = cls.filter_not_expired().delete()
amount, _ = (
cls.objects.all()
.exclude(expiring=False)
.exclude(expiring=True, expires__gt=now())
.delete()
)
LOGGER.debug("Deleted expired models", model=cls, amount=amount)

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ class TestOverviewViews(TestCase):
def setUp(self):
super().setUp()
self.user = User.objects.create_superuser(
self.user = User.objects.create_user(
username="unittest user",
email="unittest@example.com",
password="".join(

View File

@ -13,7 +13,7 @@ class TestUserViews(TestCase):
def setUp(self):
super().setUp()
self.user = User.objects.create_superuser(
self.user = User.objects.create_user(
username="unittest user",
email="unittest@example.com",
password="".join(

View File

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

View File

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

View File

@ -4,7 +4,7 @@ from django.core.management.base import BaseCommand, no_translations
from passbook.flows.transfer.importer import FlowImporter
class Command(BaseCommand):
class Command(BaseCommand): # pragma: no cover
"""Apply flow from commandline"""
@no_translations

View File

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

View File

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

View File

@ -1,16 +1,19 @@
"""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_str
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from passbook.flows.markers import ReevaluateMarker, StageMarker
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
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
@ -19,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"""
@ -50,6 +62,9 @@ class TestFlowExecutor(TestCase):
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,11 +81,12 @@ class TestFlowExecutor(TestCase):
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)
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(
@ -84,10 +100,8 @@ class TestFlowExecutor(TestCase):
reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{"type": "redirect", "to": reverse("passbook_flows:denied")},
)
self.assertIsInstance(response, AccessDeniedResponse)
self.assertInHTML(EmptyFlowException.__doc__, response.rendered_content)
def test_invalid_flow_redirect(self):
"""Tests that an invalid flow still redirects"""
@ -101,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"""

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,10 +9,9 @@ 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.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import TemplateView, View
from structlog import get_logger
@ -24,6 +23,7 @@ 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.policies.http import AccessDeniedResponse
LOGGER = get_logger()
# Argument used to redirect user after login
@ -31,8 +31,6 @@ NEXT_ARG_NAME = "next"
SESSION_KEY_PLAN = "passbook_flows_plan"
SESSION_KEY_APPLICATION_PRE = "passbook_flows_application_pre"
SESSION_KEY_GET = "passbook_flows_get"
SESSION_KEY_DENIED_ERROR = "passbook_flows_denied_error"
SESSION_KEY_DENIED_POLICY_RESULT = "passbook_flows_denied_policy_result"
@method_decorator(xframe_options_sameorigin, name="dispatch")
@ -56,9 +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 to_stage_response(
self.request, self.stage_invalid(error_message=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
@ -83,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()
@ -119,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"""
@ -141,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)
@ -205,12 +187,11 @@ class FlowExecutorView(View):
is a superuser."""
LOGGER.debug("f(exec): Stage invalid", flow_slug=self.flow.slug)
self.cancel()
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
):
self.request.session[SESSION_KEY_DENIED_ERROR] = error_message
return redirect_with_qs("passbook_flows:denied", self.request.GET)
response = AccessDeniedResponse(
self.request, template="flows/denied_shell.html"
)
response.error_message = error_message
return to_stage_response(self.request, response)
def cancel(self):
"""Cancel current execution and return a redirect"""
@ -224,21 +205,30 @@ class FlowExecutorView(View):
del self.request.session[key]
class FlowPermissionDeniedView(TemplateView):
"""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."""
template_name = "flows/denied.html"
title = _("Permission denied.")
error: Exception
def get_context_data(self, **kwargs):
kwargs["title"] = self.title
if SESSION_KEY_DENIED_ERROR in self.request.session:
kwargs["error"] = self.request.session[SESSION_KEY_DENIED_ERROR]
if SESSION_KEY_DENIED_POLICY_RESULT in self.request.session:
kwargs["policy_result"] = self.request.session[
SESSION_KEY_DENIED_POLICY_RESULT
]
return super().get_context_data(**kwargs)
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

@ -1,13 +1,16 @@
"""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
from ldap3.core.exceptions import LDAPException
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 +38,9 @@ def before_send(event, hint):
OSError,
RedisError,
SentryIgnoredException,
WebSocketException,
CeleryError,
LDAPException,
)
if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"]

View File

@ -1,11 +1,5 @@
"""passbook lib template utilities"""
from django.template import Context, Template, loader
def render_from_string(tmpl: str, ctx: Context) -> str:
"""Render template from string to string"""
template = Template(tmpl)
return template.render(ctx)
from django.template import Context, loader
def render_to_string(template_path: str, ctx: Context) -> str:

View File

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

View File

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

View File

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

View File

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

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

View File

@ -30,7 +30,7 @@ class GroupMembershipPolicy(Policy):
return GroupMembershipPolicyForm
def passes(self, request: PolicyRequest) -> PolicyResult:
return PolicyResult(self.group.user_set.filter(pk=request.user.pk).exists())
return PolicyResult(self.group.users.filter(pk=request.user.pk).exists())
class Meta:

View File

@ -24,7 +24,7 @@ class TestGroupMembershipPolicy(TestCase):
def test_valid(self):
"""user in group"""
group = Group.objects.create(name="test")
group.user_set.add(get_anonymous_user())
group.users.add(get_anonymous_user())
group.save()
policy: GroupMembershipPolicy = GroupMembershipPolicy.objects.create(
group=group

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

@ -0,0 +1,43 @@
"""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
# pyright: reportGeneralTypeIssues=false
def __init__(self, request: HttpRequest, template="policies/denied.html") -> None:
super().__init__(request, template)
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,16 +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,
SESSION_KEY_DENIED_POLICY_RESULT,
)
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()
@ -31,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(
@ -43,10 +43,10 @@ class PolicyAccessMixin(BaseMixin, AccessMixin):
self, result: Optional[PolicyResult] = None
) -> HttpResponse:
"""Function called when user has no permissions but is authenticated"""
response = AccessDeniedResponse(self.request)
if result:
self.request.session[SESSION_KEY_DENIED_POLICY_RESULT] = result
# TODO: Remove this URL and render the view instead
return redirect("passbook_flows:denied")
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

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

View File

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

View File

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

View File

@ -0,0 +1,33 @@
# Generated by Django 3.1.1 on 2020-09-15 18:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_oauth2", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="oauth2provider",
name="sub_mode",
field=models.TextField(
choices=[
("hashed_user_id", "Based on the Hashed User ID"),
("user_username", "Based on the username"),
(
"user_email",
"Based on the User's Email. This is recommended over the UPN method.",
),
(
"user_upn",
"Based on the User's UPN, only works if user has a 'upn' attribute set. Use this method only if you have different UPN and Mail domains.",
),
],
default="hashed_user_id",
help_text="Configure what data should be used as unique User Identifier. For most cases, the default should be fine.",
),
),
]

View File

@ -0,0 +1,44 @@
# Generated by Django 3.1.1 on 2020-09-16 21:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_oauth2", "0002_oauth2provider_sub_mode"),
]
operations = [
migrations.AlterField(
model_name="oauth2provider",
name="client_type",
field=models.CharField(
choices=[("confidential", "Confidential"), ("public", "Public")],
default="confidential",
help_text="Confidential clients are capable of maintaining the confidentiality\n of their credentials. Public clients are incapable.",
max_length=30,
verbose_name="Client Type",
),
),
migrations.AlterField(
model_name="oauth2provider",
name="response_type",
field=models.TextField(
choices=[
("code", "code (Authorization Code Flow)"),
(
"code_adfs",
"code (ADFS Compatibility Mode, sends id_token as access_token)",
),
("id_token", "id_token (Implicit Flow)"),
("id_token token", "id_token token (Implicit Flow)"),
("code token", "code token (Hybrid Flow)"),
("code id_token", "code id_token (Hybrid Flow)"),
("code id_token token", "code id_token token (Hybrid Flow)"),
],
default="code",
help_text="Response Type required by the client.",
),
),
]

View File

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

View File

@ -31,8 +31,8 @@ from passbook.providers.oauth2.generators import (
class ClientTypes(models.TextChoices):
"""<b>Confidential</b> clients are capable of maintaining the confidentiality
of their credentials. <b>Public</b> clients are incapable."""
"""Confidential clients are capable of maintaining the confidentiality
of their credentials. Public clients are incapable."""
CONFIDENTIAL = "confidential", _("Confidential")
PUBLIC = "public", _("Public")
@ -46,10 +46,34 @@ class GrantTypes(models.TextChoices):
HYBRID = "hybrid"
class SubModes(models.TextChoices):
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
USER_USERNAME = "user_username", _("Based on the username")
USER_EMAIL = (
"user_email",
_("Based on the User's Email. This is recommended over the UPN method."),
)
USER_UPN = (
"user_upn",
_(
(
"Based on the User's UPN, only works if user has a 'upn' attribute set. "
"Use this method only if you have different UPN and Mail domains."
)
),
)
class ResponseTypes(models.TextChoices):
"""Response Type required by the client."""
CODE = "code", _("code (Authorization Code Flow)")
CODE_ADFS = (
"code#adfs",
_("code (ADFS Compatibility Mode, sends id_token as access_token)"),
)
ID_TOKEN = "id_token", _("id_token (Implicit Flow)")
ID_TOKEN_TOKEN = "id_token token", _("id_token token (Implicit Flow)")
CODE_TOKEN = "code token", _("code token (Hybrid Flow)")
@ -84,7 +108,7 @@ class ScopeMapping(PropertyMapping):
return ScopeMappingForm
def __str__(self):
return f"Scope Mapping '{self.scope_name}'"
return f"Scope Mapping {self.name} ({self.scope_name})"
class Meta:
@ -133,12 +157,6 @@ class OAuth2Provider(Provider):
verbose_name=_("Redirect URIs"),
help_text=_("Enter each URI on a new line."),
)
post_logout_redirect_uris = models.TextField(
blank=True,
default="",
verbose_name=_("Post Logout Redirect URIs"),
help_text=_("Enter each URI on a new line."),
)
include_claims_in_id_token = models.BooleanField(
default=True,
@ -162,6 +180,17 @@ class OAuth2Provider(Provider):
),
)
sub_mode = models.TextField(
choices=SubModes.choices,
default=SubModes.HASHED_USER_ID,
help_text=_(
(
"Configure what data should be used as unique User Identifier. For most cases, "
"the default should be fine."
)
),
)
rsa_key = models.ForeignKey(
CertificateKeyPair,
verbose_name=_("RSA Key"),
@ -234,12 +263,11 @@ class OAuth2Provider(Provider):
@property
def launch_url(self) -> Optional[str]:
"""Guess launch_url based on first redirect_uri"""
if not self.redirect_uris:
if self.redirect_uris == "":
return None
main_url = self.redirect_uris[0]
main_url = self.redirect_uris.split("\n")[0]
launch_url = urlparse(main_url)
launch_url.path = ""
return launch_url.geturl()
return main_url.replace(launch_url.path, "")
def form(self) -> Type[ModelForm]:
from passbook.providers.oauth2.forms import OAuth2ProviderForm
@ -249,6 +277,14 @@ class OAuth2Provider(Provider):
def __str__(self):
return f"OAuth2 Provider {self.name}"
def encode(self, payload: Dict[str, Any]) -> str:
"""Represent the ID Token as a JSON Web Token (JWT)."""
keys = self.get_jwt_keys()
# If the provider does not have an RSA Key assigned, it was switched to Symmetric
self.refresh_from_db()
jws = JWS(payload, alg=self.jwt_alg)
return jws.sign_compact(keys)
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
try:
@ -257,6 +293,7 @@ class OAuth2Provider(Provider):
"providers/oauth2/setup_url_modal.html",
{
"provider": self,
"issuer": self.get_issuer(request),
"authorize": request.build_absolute_uri(
reverse("passbook_providers_oauth2:authorize",)
),
@ -303,7 +340,6 @@ class BaseGrantModel(models.Model):
abstract = True
# pylint: disable=too-many-instance-attributes
class AuthorizationCode(ExpiringModel, BaseGrantModel):
"""OAuth2 Authorization Code"""
@ -330,7 +366,6 @@ class AuthorizationCode(ExpiringModel, BaseGrantModel):
@dataclass
# plyint: disable=too-many-instance-attributes
class IDToken:
"""The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be
Authenticated is the ID Token data structure. The ID Token is a security token that contains
@ -368,14 +403,6 @@ class IDToken:
dic.update(self.claims)
return dic
def encode(self, provider: OAuth2Provider) -> str:
"""Represent the ID Token as a JSON Web Token (JWT)."""
keys = provider.get_jwt_keys()
# If the provider does not have an RSA Key assigned, it was switched to Symmetric
provider.refresh_from_db()
jws = JWS(self.to_dict(), alg=provider.jwt_alg)
return jws.sign_compact(keys)
class RefreshToken(ExpiringModel, BaseGrantModel):
"""OAuth2 Refresh Token"""
@ -424,7 +451,22 @@ class RefreshToken(ExpiringModel, BaseGrantModel):
def create_id_token(self, user: User, request: HttpRequest) -> IDToken:
"""Creates the id_token.
See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
sub = sha256(f"{user.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
sub = ""
if self.provider.sub_mode == SubModes.HASHED_USER_ID:
sub = sha256(f"{user.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
elif self.provider.sub_mode == SubModes.USER_EMAIL:
sub = user.email
elif self.provider.sub_mode == SubModes.USER_USERNAME:
sub = user.username
elif self.provider.sub_mode == SubModes.USER_UPN:
sub = user.attributes["upn"]
else:
raise ValueError(
(
f"Provider {self.provider} has invalid sub_mode "
f"selected: {self.provider.sub_mode}"
)
)
# Convert datetimes into timestamps.
now = int(time.time())

View File

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

View File

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

View File

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

View File

@ -61,11 +61,12 @@ def extract_access_token(request: HttpRequest) -> str:
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if re.compile(r"^[Bb]earer\s{1}.+$").match(auth_header):
access_token = auth_header.split()[1]
else:
access_token = request.GET.get("access_token", "")
return access_token
return auth_header.split()[1]
if "access_token" in request.POST:
return request.POST.get("access_token")
if "access_token" in request.GET:
return request.GET.get("access_token")
return ""
def extract_client_auth(request: HttpRequest) -> Tuple[str, str]:

View File

@ -57,7 +57,6 @@ ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET}
@dataclass
# pylint: disable=too-many-instance-attributes
class OAuthAuthorizationParams:
"""Parameteres required to authorize an OAuth Client"""
@ -90,7 +89,7 @@ class OAuthAuthorizationParams:
response_type = query_dict.get("response_type", "")
grant_type = None
# Determine which flow to use.
if response_type in [ResponseTypes.CODE]:
if response_type in [ResponseTypes.CODE, ResponseTypes.CODE_ADFS]:
grant_type = GrantTypes.AUTHORIZATION_CODE
elif response_type in [
ResponseTypes.ID_TOKEN,
@ -164,8 +163,15 @@ class OAuthAuthorizationParams:
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
# Response type parameter validation.
if is_open_id and self.response_type != self.provider.response_type:
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
if is_open_id:
actual_response_type = self.provider.response_type
if "#" in self.provider.response_type:
hash_index = actual_response_type.index("#")
actual_response_type = actual_response_type[:hash_index]
if self.response_type != actual_response_type:
raise AuthorizeError(
self.redirect_uri, "invalid_request", self.grant_type
)
# PKCE validation of the transformation method.
if self.code_challenge:
@ -281,7 +287,9 @@ class OAuthFulfillmentStage(StageView):
ResponseTypes.CODE_ID_TOKEN,
ResponseTypes.CODE_ID_TOKEN_TOKEN,
]:
query_fragment["id_token"] = id_token.encode(self.provider)
query_fragment["id_token"] = self.provider.encode(
id_token.to_dict()
)
token.id_token = id_token
# Store the token.

View File

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

View File

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

View File

@ -18,6 +18,7 @@ from passbook.providers.oauth2.models import (
AuthorizationCode,
OAuth2Provider,
RefreshToken,
ResponseTypes,
)
from passbook.providers.oauth2.utils import TokenResponse, extract_client_auth
@ -25,7 +26,6 @@ LOGGER = get_logger()
@dataclass
# pylint: disable=too-many-instance-attributes
class TokenParams:
"""Token params"""
@ -190,17 +190,24 @@ class TokenView(View):
# We don't need to store the code anymore.
self.params.authorization_code.delete()
dic = {
response_dict = {
"access_token": refresh_token.access_token,
"refresh_token": refresh_token.refresh_token,
"token_type": "bearer",
"token_type": "Bearer",
"expires_in": timedelta_from_string(
self.params.provider.token_validity
).seconds,
"id_token": refresh_token.id_token.encode(refresh_token.provider),
"id_token": refresh_token.provider.encode(refresh_token.id_token.to_dict()),
}
return dic
if self.params.provider.response_type == ResponseTypes.CODE_ADFS:
# This seems to be expected by some OIDC Clients
# namely VMware vCenter. This is not documented in any OpenID or OAuth2 Standard.
# Maybe this should be a setting
# in the future?
response_dict["access_token"] = response_dict["id_token"]
return response_dict
def create_refresh_response_dic(self) -> Dict[str, Any]:
"""See https://tools.ietf.org/html/rfc6749#section-6"""
@ -237,8 +244,8 @@ class TokenView(View):
"expires_in": timedelta_from_string(
refresh_token.provider.token_validity
).seconds,
"id_token": refresh_token.id_token.encode(
self.params.refresh_token.provider
"id_token": self.params.provider.encode(
self.params.refresh_token.id_token.to_dict()
),
}

View File

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

View File

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

View File

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

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