Compare commits

...

74 Commits

Author SHA1 Message Date
Jens Langhammer
251672a67d new release: 0.12.8-stable 2020-10-29 22:37:53 +01:00
Jens Langhammer
4ffc0e2a08 docs: update proxy docs 2020-10-29 22:12:13 +01:00
Jens Langhammer
4e1808632d proxy: add support for additionalHeaders 2020-10-29 22:09:53 +01:00
Jens Langhammer
791627d3ce proxy: remove unused code 2020-10-29 21:46:26 +01:00
Jens Langhammer
f3df3a0157 providers/proxy: add sticky sessions to ingress 2020-10-29 17:25:51 +01:00
Jens Langhammer
6aaae53a19 proxy: use host not hostname to match header 2020-10-29 17:25:39 +01:00
Jens Langhammer
4d84f6d598 outposts: ensure permissions are updated when a related object is saved 2020-10-29 17:25:29 +01:00
dependabot[bot]
4e2349b6d9 build(deps): bump boto3 from 1.16.5 to 1.16.7 (#303)
Bumps [boto3](https://github.com/boto/boto3) from 1.16.5 to 1.16.7.
- [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.16.5...1.16.7)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-29 15:35:56 +01:00
dependabot[bot]
cd57b8f7f3 build(deps): bump drf-yasg2 from 1.19.3 to 1.19.4 (#302)
Bumps [drf-yasg2](https://github.com/JoelLefkowitz/drf-yasg) from 1.19.3 to 1.19.4.
- [Release notes](https://github.com/JoelLefkowitz/drf-yasg/releases)
- [Changelog](https://github.com/JoelLefkowitz/drf-yasg/blob/master/docs/changelog.rst)
- [Commits](https://github.com/JoelLefkowitz/drf-yasg/compare/1.19.3...1.19.4)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-29 12:14:11 +01:00
dependabot[bot]
40b1fc06b0 build(deps): bump @patternfly/patternfly in /passbook/static/static (#301)
Bumps [@patternfly/patternfly](https://github.com/patternfly/patternfly) from 4.50.4 to 4.59.1.
- [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.50.4...prerelease-v4.59.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jens L <jens@beryju.org>
2020-10-29 09:21:39 +01:00
dependabot[bot]
02fa217e28 build(deps-dev): bump pytest from 6.1.1 to 6.1.2 (#300)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.1.1 to 6.1.2.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/6.1.1...6.1.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-29 09:21:09 +01:00
Jens Langhammer
6652514358 lib: improve error ignore list 2020-10-28 19:00:11 +01:00
Jens Langhammer
dcd3dc9744 lib: ensure tasks don't expire 2020-10-28 18:53:39 +01:00
Jens Langhammer
d6afdc575e new release: 0.12.7-stable 2020-10-27 11:36:46 +01:00
Jens Langhammer
287b38efee e2e: don't use proxy for quay 2020-10-27 10:30:08 +01:00
Jens Langhammer
e805fb62fb e2e: use docker proxy for test images 2020-10-27 09:50:06 +01:00
dependabot[bot]
c92dda77f1 build(deps): bump boto3 from 1.16.4 to 1.16.5 (#299)
Bumps [boto3](https://github.com/boto/boto3) from 1.16.4 to 1.16.5.
- [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.16.4...1.16.5)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-27 09:45:58 +01:00
Jens Langhammer
f12fd78822 stages/user_login: replace usage of -1 with 0 2020-10-27 00:31:22 +01:00
Jens Langhammer
caba183c9b static: fix class name of FlowShellCard 2020-10-27 00:30:55 +01:00
Jens Langhammer
3aeaa121a3 root: add dockerfile to dependabot 2020-10-27 00:30:42 +01:00
Jens Langhammer
a9f3118a7d docs: add home-assistant integration docs 2020-10-26 22:14:51 +01:00
Jens Langhammer
054b819262 stages/user_login: use timedelta_string_validator instead of seconds 2020-10-26 22:03:27 +01:00
Jens Langhammer
6b3411f63b root: fix permission denied error for backups 2020-10-26 21:12:20 +01:00
Jens Langhammer
6a8000ea0d root: ensure traefik logs are json 2020-10-26 18:39:49 +01:00
Jens Langhammer
352d4db0d7 e2e: add NoSuchElementException to @retry 2020-10-26 17:57:01 +01:00
Jens Langhammer
4b665cfb8f static: fix FlowShellCard not returning the response 2020-10-26 11:00:37 +01:00
Jens Langhammer
4e12003944 api: remove authentication fallback for pre-0.12 proxies 2020-10-26 11:00:19 +01:00
Jens Langhammer
6bfd465855 static: improve error handling for FlowShellCard to prevent infinite spinners 2020-10-26 10:52:13 +01:00
dependabot[bot]
e8670aa693 build(deps): bump codemirror in /passbook/static/static (#295)
Bumps [codemirror](https://github.com/codemirror/CodeMirror) from 5.58.1 to 5.58.2.
- [Release notes](https://github.com/codemirror/CodeMirror/releases)
- [Changelog](https://github.com/codemirror/CodeMirror/blob/master/CHANGELOG.md)
- [Commits](https://github.com/codemirror/CodeMirror/compare/5.58.1...5.58.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-26 10:35:47 +01:00
dependabot[bot]
5263e750b1 build(deps): bump boto3 from 1.16.3 to 1.16.4 (#296)
Bumps [boto3](https://github.com/boto/boto3) from 1.16.3 to 1.16.4.
- [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.16.3...1.16.4)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-26 10:35:24 +01:00
dependabot[bot]
a2a9d73296 build(deps): bump django-otp from 1.0.1 to 1.0.2 (#297)
Bumps [django-otp](https://github.com/django-otp/django-otp) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/django-otp/django-otp/releases)
- [Changelog](https://github.com/django-otp/django-otp/blob/master/CHANGES.rst)
- [Commits](https://github.com/django-otp/django-otp/compare/v1.0.1...v1.0.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-26 08:31:24 +01:00
Jens Langhammer
6befc9d627 helm: re-disable redis clustering 2020-10-23 20:45:21 +02:00
Jens Langhammer
73497a27cc new release: 0.12.6-stable 2020-10-23 18:42:29 +02:00
Jens Langhammer
f3098418f2 core: fix backup task not being registered, add fallback for api to remove info on ImportError
celery only discovers tasks from installed apps, which `lib` is not, hence the schedule didn't trigger it
2020-10-23 18:32:28 +02:00
dependabot[bot]
a5197963b2 build(deps-dev): bump pytest-django from 4.0.0 to 4.1.0 (#293)
Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.0.0 to 4.1.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/v4.0.0...v4.1.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-23 09:38:49 +02:00
dependabot[bot]
e4634bcc78 build(deps): bump boto3 from 1.16.2 to 1.16.3 (#294)
Bumps [boto3](https://github.com/boto/boto3) from 1.16.2 to 1.16.3.
- [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.16.2...1.16.3)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-23 08:01:43 +02:00
Jens Langhammer
74da44a6a9 helm: add readme, general cleanup 2020-10-22 17:25:30 +02:00
Jens Langhammer
3324473cd0 new release: 0.12.5-stable 2020-10-22 14:22:32 +02:00
Jens Langhammer
39d8038533 e2e: Fix @retry decorator not truncating database 2020-10-22 14:05:29 +02:00
Jens Langhammer
bbcf58705f lib: add configurable avatars, set to none mode for tests 2020-10-22 14:03:31 +02:00
Jens Langhammer
7b5a0964b2 outposts: handle docker connection error on init 2020-10-22 12:50:06 +02:00
Jens Langhammer
8eca76e464 root: fix docker permission error 2020-10-22 11:54:23 +02:00
Jens L
fb9ab368f8 root: fix typo in docker-compose 2020-10-22 11:30:53 +02:00
dependabot[bot]
877279b2ee build(deps): bump rollup in /passbook/static/static (#292)
Bumps [rollup](https://github.com/rollup/rollup) from 2.32.0 to 2.32.1.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.32.0...v2.32.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-22 11:30:03 +02:00
dependabot[bot]
301be4b411 build(deps): bump boto3 from 1.16.1 to 1.16.2 (#291)
Bumps [boto3](https://github.com/boto/boto3) from 1.16.1 to 1.16.2.
- [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.16.1...1.16.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-22 08:05:29 +02:00
dependabot[bot]
728f527ccb build(deps): bump drf-yasg2 from 1.19.2 to 1.19.3 (#290)
Bumps [drf-yasg2](https://github.com/JoelLefkowitz/drf-yasg) from 1.19.2 to 1.19.3.
- [Release notes](https://github.com/JoelLefkowitz/drf-yasg/releases)
- [Changelog](https://github.com/JoelLefkowitz/drf-yasg/blob/master/docs/changelog.rst)
- [Commits](https://github.com/JoelLefkowitz/drf-yasg/compare/1.19.2...1.19.3)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-21 09:51:39 +02:00
dependabot[bot]
3f1c790b1d build(deps): bump boto3 from 1.16.0 to 1.16.1 (#289)
Bumps [boto3](https://github.com/boto/boto3) from 1.16.0 to 1.16.1.
- [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.16.0...1.16.1)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-21 09:12:13 +02:00
Jens Langhammer
b00573bde2 new release: 0.12.4-stable 2020-10-20 22:31:31 +02:00
Jens Langhammer
aeee3ad7f9 e2e: add @retry decorator to make e2e tests more reliable 2020-10-20 18:51:17 +02:00
Jens Langhammer
ef021495ef flows: revert evaluate_on_call rename for backwards compatibility 2020-10-20 15:41:50 +02:00
Jens Langhammer
061eab4b36 docs: fix keys for example flows 2020-10-20 15:14:41 +02:00
Jens Langhammer
870e01f836 flows: rename re_evaluate_policies to evaluate_on_call, add evaluate_on_plan 2020-10-20 15:06:36 +02:00
Jens Langhammer
e2ca72adf0 stages/user_login: only show successful login message at login stage 2020-10-20 12:11:59 +02:00
Jens Langhammer
395ef43eae policies/expression: fix ip_network not being imported by default 2020-10-20 12:05:56 +02:00
Jens Langhammer
a4cc653757 new release: 0.12.3-stable 2020-10-20 10:24:45 +02:00
Jens Langhammer
db4ff20906 outposts: fix service using incorrect pod selector 2020-10-20 10:18:05 +02:00
dependabot[bot]
1f0fbd33b6 build(deps): bump urllib3 from 1.25.10 to 1.25.11 (#287)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.25.10 to 1.25.11.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/master/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.25.10...1.25.11)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-20 10:17:46 +02:00
dependabot[bot]
5de8d2721e build(deps): bump uvicorn from 0.12.1 to 0.12.2 (#286)
Bumps [uvicorn](https://github.com/encode/uvicorn) from 0.12.1 to 0.12.2.
- [Release notes](https://github.com/encode/uvicorn/releases)
- [Changelog](https://github.com/encode/uvicorn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/encode/uvicorn/compare/0.12.1...0.12.2)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-20 10:09:37 +02:00
dependabot[bot]
0d65da9a9e build(deps): bump boto3 from 1.15.18 to 1.16.0 (#288)
Bumps [boto3](https://github.com/boto/boto3) from 1.15.18 to 1.16.0.
- [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.15.18...1.16.0)

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-20 09:34:55 +02:00
Jens Langhammer
4316ee4330 root: implement db backups with monitored task, update docs 2020-10-19 22:17:47 +02:00
Jens Langhammer
2ed9a1dbe3 */tasks: update phrasing 2020-10-19 21:35:31 +02:00
Jens Langhammer
8e03824d20 lib: always set task's UID, even for unexpected errors 2020-10-19 21:30:21 +02:00
Jens Langhammer
754dbdd0e5 outpost: fix logs for kubernetes controller 2020-10-19 21:29:58 +02:00
Jens Langhammer
e13d348315 new release: 0.12.2-stable 2020-10-19 19:36:36 +02:00
Jens Langhammer
169f3ebe5b outposts: fix logger again 2020-10-19 18:52:17 +02:00
Jens Langhammer
f8ad604e85 outposts: add more tests 2020-10-19 17:47:51 +02:00
Jens Langhammer
774b9c8a61 outposts: update kubernetes controller to use pk as identifier instead of name 2020-10-19 17:39:12 +02:00
Jens Langhammer
d8c522233e outposts: fix outpost mangling log output 2020-10-19 16:54:11 +02:00
Jens Langhammer
82d50f7eaa outposts: fix list showing questionmark when only one outpost is registered 2020-10-19 16:34:16 +02:00
Jens Langhammer
1c426c5136 outposts: trigger deployment re-create when selector changes 2020-10-19 16:21:39 +02:00
Jens Langhammer
d6e14cc551 proxy: show version on startup 2020-10-19 16:21:13 +02:00
Jens Langhammer
c3917ebc2e lifecycle: fix formatting 2020-10-19 16:13:45 +02:00
Jens Langhammer
7203bd37a3 outposts: replace migration with string backup handler 2020-10-19 16:04:38 +02:00
Jens Langhammer
597188c7ee lifecycle: fix migration trying to load all classes 2020-10-19 15:55:16 +02:00
101 changed files with 1631 additions and 1571 deletions

View File

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

View File

@@ -24,3 +24,19 @@ updates:
open-pull-requests-limit: 10
assignees:
- BeryJu
- package-ecosystem: docker
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
assignees:
- BeryJu
- package-ecosystem: docker
directory: "/proxy"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
assignees:
- BeryJu

View File

@@ -18,11 +18,11 @@ jobs:
- name: Building Docker Image
run: docker build
--no-cache
-t beryju/passbook:0.12.1-stable
-t beryju/passbook:0.12.8-stable
-t beryju/passbook:latest
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook:0.12.1-stable
run: docker push beryju/passbook:0.12.8-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.12.1-stable \
-t beryju/passbook-proxy:0.12.8-stable \
-t beryju/passbook-proxy:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-proxy:0.12.1-stable
run: docker push beryju/passbook-proxy:0.12.8-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.12.1-stable
-t beryju/passbook-static:0.12.8-stable
-t beryju/passbook-static:latest
-f static.Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-static:0.12.1-stable
run: docker push beryju/passbook-static:0.12.8-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.12.1-stable
tagName: 0.12.8-stable
environment: beryjuorg-prod

View File

@@ -25,7 +25,16 @@ RUN apt-get update && \
pip install -r /requirements.txt --no-cache-dir && \
apt-get remove --purge -y build-essential && \
apt-get autoremove --purge -y && \
adduser --system --no-create-home --uid 1000 --group --home /passbook passbook
# This is quite hacky, but docker has no guaranteed Group ID
# we could instead check for the GID of the socket and add the user dynamically,
# but then we have to drop permmissions later
groupadd -g 998 docker_998 && \
groupadd -g 999 docker_999 && \
adduser --system --no-create-home --uid 1000 --group --home /passbook passbook && \
usermod -a -G docker_998 passbook && \
usermod -a -G docker_999 passbook && \
mkdir /backups && \
chown passbook:passbook /backups
COPY ./passbook/ /passbook
COPY ./manage.py /

View File

@@ -12,7 +12,7 @@ lint-fix:
lint:
pyright passbook e2e lifecycle
bandit -r passbook e2e lifecycle
bandit -r passbook e2e lifecycle -x node_modules
pylint passbook e2e lifecycle
prospector

136
Pipfile.lock generated
View File

@@ -74,18 +74,18 @@
},
"boto3": {
"hashes": [
"sha256:9ab957090f7893172768bb8b8d2c5cce0afd36a9d36d73a9fb14168f72d75a8b",
"sha256:f56148e2c6b9a2d704218da42f07d72f00270bfddb13bc1bdea20d3327daa51e"
"sha256:2cabcdc217a128832d6c948cae22cbd3af03ae0736efcb59749f1f11f528be54",
"sha256:b378c28c2db3be96abc2ca460c2f08424da8960b87d5d430cb7d6b712ec255b2"
],
"index": "pypi",
"version": "==1.15.18"
"version": "==1.16.7"
},
"botocore": {
"hashes": [
"sha256:de5f9fc0c7e88ee7ba831fa27475be258ae09ece99143ed623d3618a3c84ee2c",
"sha256:e224754230e7e015836ba20037cac6321e8e2ce9b8627c14d579fcb37249decd"
"sha256:1481d6d3ccb77cb7cd97395110408238f3ab93b0d823156c7a2fb697604eb50d",
"sha256:ab59f842797cbd09ee7d9e3f353bb9546f428853d94db448977dd554320620b3"
],
"version": "==1.18.18"
"version": "==1.19.7"
},
"cachetools": {
"hashes": [
@@ -310,11 +310,11 @@
},
"django-otp": {
"hashes": [
"sha256:2fb1c8dbd7e7ae76a65b63d89d3d8c3e1105a48bc29830b81c6e417a89380658",
"sha256:fef1f2de9a52bc37e16211b98b4323e5b34fa24739116fbe3d1ff018c17ebea8"
"sha256:8ba5ab9bd2738c7321376c349d7cce49cf4404e79f6804e0a3cc462a91728e18",
"sha256:f523fb9dec420f28a29d3e2ad72ac06f64588956ed4f2b5b430d8e957ebb8287"
],
"index": "pypi",
"version": "==1.0.1"
"version": "==1.0.2"
},
"django-prometheus": {
"hashes": [
@@ -373,11 +373,11 @@
},
"drf-yasg2": {
"hashes": [
"sha256:c4aa21d52f3964f99748eed68eb24be0fdad65e55bb56b99ae85c950718bac64",
"sha256:e880b3fa298a614360f4d882e8bc1712b51e1b28696acbd2684ac0ab18275a62"
"sha256:7037a8041eb5d1073fa504a284fc889685f93d0bfd008a963db1b366db786734",
"sha256:75e661ca5cf15eb44fcfab408c7b864f87c20794f564aa08b3a31817a857f19d"
],
"index": "pypi",
"version": "==1.19.2"
"version": "==1.19.4"
},
"eight": {
"hashes": [
@@ -885,10 +885,10 @@
},
"python-dotenv": {
"hashes": [
"sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d",
"sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"
"sha256:0c8d1b80d1a1e91717ea7d526178e3882732420b03f08afea0406db6402e220e",
"sha256:587825ed60b1711daea4832cf37524dfd404325b7db5e25ebe88c495c9f807a0"
],
"version": "==0.14.0"
"version": "==0.15.0"
},
"pytz": {
"hashes": [
@@ -1100,23 +1100,23 @@
"secure"
],
"hashes": [
"sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
"sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2",
"sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"
],
"index": "pypi",
"markers": null,
"version": "==1.25.10"
"version": "==1.25.11"
},
"uvicorn": {
"extras": [
"standard"
],
"hashes": [
"sha256:a461e76406088f448f36323f5ac774d50e5a552b6ccb54e4fca8d83ef614a7c2",
"sha256:d06a25caa8dc680ad92eb3ec67363f5281c092059613a1cc0100acba37fc0f45"
"sha256:8ff7495c74b8286a341526ff9efa3988ebab9a4b2f561c7438c3cb420992d7dd",
"sha256:e5dbed4a8a44c7b04376021021d63798d6a7bcfae9c654a0b153577b93854fba"
],
"index": "pypi",
"version": "==0.12.1"
"version": "==0.12.2"
},
"uvloop": {
"hashes": [
@@ -1400,10 +1400,10 @@
},
"gitpython": {
"hashes": [
"sha256:138016d519bf4dd55b22c682c904ed2fd0235c3612b2f8f65ce218ff358deed8",
"sha256:a03f728b49ce9597a6655793207c6ab0da55519368ff5961e4a74ae475b9fa8e"
"sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b",
"sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8"
],
"version": "==3.1.9"
"version": "==3.1.11"
},
"iniconfig": {
"hashes": [
@@ -1476,10 +1476,10 @@
},
"pbr": {
"hashes": [
"sha256:14bfd98f51c78a3dd22a1ef45cf194ad79eee4a19e8e1a0d5c7f8e81ffe182ea",
"sha256:5adc0f9fc64319d8df5ca1e4e06eea674c26b80e6f00c530b18ce6a6592ead15"
"sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9",
"sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00"
],
"version": "==5.5.0"
"version": "==5.5.1"
},
"pep8-naming": {
"hashes": [
@@ -1574,19 +1574,19 @@
},
"pytest": {
"hashes": [
"sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9",
"sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92"
"sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe",
"sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"
],
"index": "pypi",
"version": "==6.1.1"
"version": "==6.1.2"
},
"pytest-django": {
"hashes": [
"sha256:0e91003fdd41ac0322c1978682be2ca180bc564203dd53c698f99242bf513614",
"sha256:5f964ccda1f551e00589ab0679a7c45c36c509a44b5bfb5ad07954e0ae3f4bed"
"sha256:10e384e6b8912ded92db64c58be8139d9ae23fb8361e5fc139d8e4f8fc601bc2",
"sha256:26f02c16d36fd4c8672390deebe3413678d89f30720c16efb8b2a6bf63b9041f"
],
"index": "pypi",
"version": "==4.0.0"
"version": "==4.1.0"
},
"pytz": {
"hashes": [
@@ -1614,35 +1614,34 @@
},
"regex": {
"hashes": [
"sha256:02686a2f0b1a4be0facdd0d3ad4dc6c23acaa0f38fb5470d892ae88584ba705c",
"sha256:137da580d1e6302484be3ef41d72cf5c3ad22a076070051b7449c0e13ab2c482",
"sha256:20cdd7e1736f4f61a5161aa30d05ac108ab8efc3133df5eb70fe1e6a23ea1ca6",
"sha256:25991861c6fef1e5fd0a01283cf5658c5e7f7aa644128e85243bc75304e91530",
"sha256:26b85672275d8c7a9d4ff93dbc4954f5146efdb2ecec89ad1de49439984dea14",
"sha256:2f60ba5c33f00ce9be29a140e6f812e39880df8ba9cb92ad333f0016dbc30306",
"sha256:3dd952f3f8dc01b72c0cf05b3631e05c50ac65ddd2afdf26551638e97502107b",
"sha256:578ac6379e65eb8e6a85299b306c966c852712c834dc7eef0ba78d07a828f67b",
"sha256:5d4a3221f37520bb337b64a0632716e61b26c8ae6aaffceeeb7ad69c009c404b",
"sha256:608d6c05452c0e6cc49d4d7407b4767963f19c4d2230fa70b7201732eedc84f2",
"sha256:65b6b018b07e9b3b6a05c2c3bb7710ed66132b4df41926c243887c4f1ff303d5",
"sha256:698f8a5a2815e1663d9895830a063098ae2f8f2655ae4fdc5dfa2b1f52b90087",
"sha256:6c72adb85adecd4522a488a751e465842cdd2a5606b65464b9168bf029a54272",
"sha256:6d4cdb6c20e752426b2e569128488c5046fb1b16b1beadaceea9815c36da0847",
"sha256:6e9f72e0ee49f7d7be395bfa29e9533f0507a882e1e6bf302c0a204c65b742bf",
"sha256:828618f3c3439c5e6ef8621e7c885ca561bbaaba90ddbb6a7dfd9e1ec8341103",
"sha256:85b733a1ef2b2e7001aff0e204a842f50ad699c061856a214e48cfb16ace7d0c",
"sha256:8958befc139ac4e3f16d44ec386c490ea2121ed8322f4956f83dd9cad8e9b922",
"sha256:a51e51eecdac39a50ede4aeed86dbef4776e3b73347d31d6ad0bc9648ba36049",
"sha256:aeac7c9397480450016bc4a840eefbfa8ca68afc1e90648aa6efbfe699e5d3bb",
"sha256:aef23aed9d4017cc74d37f703d57ce254efb4c8a6a01905f40f539220348abf9",
"sha256:af1f5e997dd1ee71fb6eb4a0fb6921bf7a778f4b62f1f7ef0d7445ecce9155d6",
"sha256:b5eeaf4b5ef38fab225429478caf71f44d4a0b44d39a1aa4d4422cda23a9821b",
"sha256:d25f5cca0f3af6d425c9496953445bf5b288bb5b71afc2b8308ad194b714c159",
"sha256:d81be22d5d462b96a2aa5c512f741255ba182995efb0114e5a946fe254148df1",
"sha256:e935a166a5f4c02afe3f7e4ce92ce5a786f75c6caa0c4ce09c922541d74b77e8",
"sha256:ef3a55b16c6450574734db92e0a3aca283290889934a23f7498eaf417e3af9f0"
"sha256:03855ee22980c3e4863dc84c42d6d2901133362db5daf4c36b710dd895d78f0a",
"sha256:06b52815d4ad38d6524666e0d50fe9173533c9cc145a5779b89733284e6f688f",
"sha256:11116d424734fe356d8777f89d625f0df783251ada95d6261b4c36ad27a394bb",
"sha256:119e0355dbdd4cf593b17f2fc5dbd4aec2b8899d0057e4957ba92f941f704bf5",
"sha256:1ec66700a10e3c75f1f92cbde36cca0d3aaee4c73dfa26699495a3a30b09093c",
"sha256:2dc522e25e57e88b4980d2bdd334825dbf6fa55f28a922fc3bfa60cc09e5ef53",
"sha256:3a5f08039eee9ea195a89e180c5762bfb55258bfb9abb61a20d3abee3b37fd12",
"sha256:49461446b783945597c4076aea3f49aee4b4ce922bd241e4fcf62a3e7c61794c",
"sha256:4afa350f162551cf402bfa3cd8302165c8e03e689c897d185f16a167328cc6dd",
"sha256:4b5a9bcb56cc146c3932c648603b24514447eafa6ce9295234767bf92f69b504",
"sha256:625116aca6c4b57c56ea3d70369cacc4d62fead4930f8329d242e4fe7a58ce4b",
"sha256:654c1635f2313d0843028487db2191530bca45af61ca85d0b16555c399625b0e",
"sha256:8092a5a06ad9a7a247f2a76ace121183dc4e1a84c259cf9c2ce3bbb69fac3582",
"sha256:832339223b9ce56b7b15168e691ae654d345ac1635eeb367ade9ecfe0e66bee0",
"sha256:8ca9dca965bd86ea3631b975d63b0693566d3cc347e55786d5514988b6f5b84c",
"sha256:a62162be05edf64f819925ea88d09d18b09bebf20971b363ce0c24e8b4aa14c0",
"sha256:b88fa3b8a3469f22b4f13d045d9bd3eda797aa4e406fde0a2644bc92bbdd4bdd",
"sha256:c13d311a4c4a8d671f5860317eb5f09591fbe8259676b86a85769423b544451e",
"sha256:c2c6c56ee97485a127555c9595c069201b5161de9d05495fbe2132b5ac104786",
"sha256:c3466a84fce42c2016113101018a9981804097bacbab029c2d5b4fcb224b89de",
"sha256:c8a2b7ccff330ae4c460aff36626f911f918555660cc28163417cb84ffb25789",
"sha256:cb905f3d2e290a8b8f1579d3984f2cfa7c3a29cc7cba608540ceeed18513f520",
"sha256:cfcf28ed4ce9ced47b9b9670a4f0d3d3c0e4d4779ad4dadb1ad468b097f808aa",
"sha256:dd3e6547ecf842a29cf25123fbf8d2461c53c8d37aa20d87ecee130c89b7079b",
"sha256:ea37320877d56a7f0a1e6a625d892cf963aa7f570013499f5b8d5ab8402b5625",
"sha256:f43109822df2d3faac7aad79613f5f02e4eab0fc8ad7932d2e70e2a83bd49c26"
],
"version": "==2020.10.15"
"version": "==2020.10.28"
},
"requirements-detector": {
"hashes": [
@@ -1710,24 +1709,33 @@
"hashes": [
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
"sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d",
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
"sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c",
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
"sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d",
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
"sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c",
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
"sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395",
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
"sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072",
"sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298",
"sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91",
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
"sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f",
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
],
"version": "==1.4.1"
@@ -1745,12 +1753,12 @@
"secure"
],
"hashes": [
"sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
"sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2",
"sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e"
],
"index": "pypi",
"markers": null,
"version": "==1.25.10"
"version": "==1.25.11"
},
"wrapt": {
"hashes": [

View File

@@ -19,7 +19,7 @@ services:
networks:
- internal
server:
image: beryju/passbook:${PASSBOOK_TAG:-0.12.1-stable}
image: beryju/passbook:${PASSBOOK_TAG:-0.12.8-stable}
command: server
environment:
PASSBOOK_REDIS__HOST: redis
@@ -40,7 +40,7 @@ services:
env_file:
- .env
worker:
image: beryju/passbook:${PASSBOOK_TAG:-0.12.1-stable}
image: beryju/passbook:${PASSBOOK_TAG:-0.12.8-stable}
command: worker
networks:
- internal
@@ -50,11 +50,11 @@ services:
PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS}
volumes:
- ./backups:/backups
- /var/run/docker.socket:/var/run/docker.socket
- /var/run/docker.sock:/var/run/docker.sock
env_file:
- .env
static:
image: beryju/passbook-static:${PASSBOOK_TAG:-0.12.1-stable}
image: beryju/passbook-static:${PASSBOOK_TAG:-0.12.8-stable}
networks:
- internal
labels:
@@ -68,7 +68,7 @@ services:
traefik:
image: traefik:2.3
command:
- "--accesslog=true"
- "--log.format=json"
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"

View File

@@ -117,7 +117,7 @@
},
"model": "passbook_stages_user_login.userloginstage",
"attrs": {
"session_duration": 0
"session_duration": "seconds=-1"
}
},
{

View File

@@ -136,7 +136,7 @@
},
"model": "passbook_stages_user_login.userloginstage",
"attrs": {
"session_duration": 0
"session_duration": "seconds=-1"
}
},
{

View File

@@ -20,7 +20,7 @@
},
"model": "passbook_stages_user_login.userloginstage",
"attrs": {
"session_duration": 0
"session_duration": "seconds=-1"
}
},
{

View File

@@ -20,7 +20,7 @@
},
"model": "passbook_stages_user_login.userloginstage",
"attrs": {
"session_duration": 0
"session_duration": "seconds=-1"
}
},
{
@@ -95,7 +95,8 @@
},
"model": "passbook_flows.flowstagebinding",
"attrs": {
"re_evaluate_policies": false
"evaluate_on_plan": false,
"re_evaluate_policies": true
}
},
{

View File

@@ -118,7 +118,7 @@
},
"model": "passbook_stages_user_login.userloginstage",
"attrs": {
"session_duration": 0
"session_duration": "seconds=-1"
}
},
{

View File

@@ -13,7 +13,7 @@ Download the latest `docker-compose.yml` from [here](https://raw.githubuserconte
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING__ENABLED=true >> .env`
To optionally deploy a different version run `echo PASSBOOK_TAG=0.12.1-stable >> .env`
To optionally deploy a different version run `echo PASSBOOK_TAG=0.12.8-stable >> .env`
If this is a fresh passbook install run the following commands to generate a password:

View File

@@ -11,9 +11,7 @@ This installation automatically applies database migrations on startup. After th
image:
name: beryju/passbook
name_static: beryju/passbook-static
tag: 0.12.1-stable
nameOverride: ""
tag: 0.12.8-stable
serverReplicas: 1
workerReplicas: 1
@@ -45,7 +43,6 @@ ingress:
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
path: /
hosts:
- passbook.k8s.local
tls: []

View File

@@ -34,7 +34,8 @@ server {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
# This needs to be set inside the location block, very important.
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}

View File

@@ -0,0 +1,59 @@
# Home-Assistant Integration
## What is Home-Assistant
From https://www.home-assistant.io/
!!! note ""
Open source home automation that puts local control and privacy first. Powered by a worldwide community of tinkerers and DIY enthusiasts. Perfect to run on a Raspberry Pi or a local server.
## Preparation
The following placeholders will be used:
- `hass.company` is the FQDN of the Home-Assistant install.
- `passbook.company` is the FQDN of the passbook install.
!!! note
This setup uses https://github.com/BeryJu/hass-auth-header and the passbook proxy for authentication. When this [PR](https://github.com/home-assistant/core/pull/32926) is merged, this will no longer be necessary.
## Home-Assistant
This guide requires https://github.com/BeryJu/hass-auth-header, which can be installed as described in the Readme.
Afterwards, make sure the `trusted_proxies` setting contains the IP(s) of the Host(s) passbook is running on.
With the default Header of `X-Forwarded-Preferred-Username` matching is done on a username basis, so your Name in Home-Assistant and your username in passbook have to match.
If this is not the case, you can simply add an additional header for your user, which contains the Home-Assistant Name and authenticate based on that.
For example add this to your user's properties and set the Header to `X-pb-hass-user`.
```yaml
additionalHeaders:
X-pb-hass-user: some other name
```
## passbook
Create a Proxy Provider with the following values
- Internal host
If Home-Assistant is running in docker, and you're deploying the passbook proxy on the same host, set the value to `http://homeassistant:8123`, where Home-Assistant is the name of your container.
If Home-Assistant is running on a different server than where you are deploying the passbook proxy, set the value to `http://hass.company:8123`.
- External host
Set this to the external URL you will be accessing Home-Assistant from.
Create an application in passbook and select the provider you've created above.
## 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 Home-Assistant.
The outpost will connect to passbook and configure itself.

View File

@@ -18,7 +18,7 @@ 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
Create a Proxy Provider with the following values
- Internal host
@@ -30,6 +30,8 @@ Create an application in passbook. Create a Proxy Provider with the following va
Set this to the external URL you will be accessing Sonarr from.
Create an application in passbook and select the provider you've created above.
## 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.

View File

@@ -6,6 +6,10 @@
### Backup
!!! notice
Local backups are **enabled** by default, and will be run daily at 00:00
Local backups can be created by running the following command in your passbook installation directory
```
@@ -14,15 +18,6 @@ docker-compose run --rm worker backup
This will dump the current database into the `./backups` folder. By defaults, the last 10 Backups are kept.
To schedule these backups, use the following snippet in a crontab
```
0 0 * * * bash -c "cd <passbook install location> && docker-compose run --rm worker backup" >/dev/null
```
!!! notice
passbook does support automatic backups on a schedule, however this is currently not recommended, as there is no way to monitor these scheduled tasks.
### Restore
@@ -42,11 +37,7 @@ After you've restored the backup, it is recommended to restart all services with
### S3 Configuration
!!! notice
To trigger backups with S3 enabled, use the same commands as above.
#### S3 Preparation
#### Preparation
passbook expects the bucket you select to already exist. The IAM User given to passbook should have the following permissions
@@ -101,11 +92,11 @@ Simply enable these options in your values.yaml file
```yaml
# Enable Database Backups to S3
backup:
access_key: access-key
secret_key: secret-key
accessKey: access-key
secretKey: secret-key
bucket: s3-bucket
region: eu-central-1
host: s3-host
```
Afterwards, run a `helm upgrade` to update the ConfigMap. Because passbook-scheduled backups are not recommended currently, a Kubernetes CronJob is created that runs the backup daily.
Afterwards, run a `helm upgrade` to update the ConfigMap. Backups are done automatically as above, at 00:00 every day.

View File

@@ -26,7 +26,11 @@ return False
- `request.obj`: A Django Model instance. This is only set if the policy is ran against an object.
- `request.context`: A dictionary with dynamic data. This depends on the origin of the execution.
- `pb_is_sso_flow`: Boolean which is true if request was initiated by authenticating through an external provider.
- `pb_client_ip`: Client's IP Address or '255.255.255.255' if no IP Address could be extracted. Can be [compared](../expressions/index.md#comparing-ip-addresses)
- `pb_client_ip`: Client's IP Address or 255.255.255.255 if no IP Address could be extracted. Can be [compared](../expressions/index.md#comparing-ip-addresses), for example
```python
return pb_client_ip in ip_network('10.0.0.0/24')
```
Additionally, when the policy is executed from a flow, every variable from the flow's current context is accessible under the `context` object.

View File

@@ -11,6 +11,14 @@ The Proxy these extra headers to the application:
Header Name | Value
-------------|-------
X-Auth-Request-User | The user's unique identifier
X-Auth-Request-Email | The user's email address
X-Auth-Request-Preferred-Username | The user's username
X-Forwarded-User | The user's unique identifier (**not the username**)
X-Forwarded-Email | The user's email address
X-Forwarded-Preferred-Username | The user's username
X-Auth-Username | The user's username
Additionally, you can add more custom headers using `additionalHeaders` in the User or Group Properties, for example
```yaml
additionalHeaders:
X-additional-header: bar
```

View File

@@ -8,7 +8,7 @@ from docker.types import Healthcheck
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
from e2e.utils import USER, SeleniumTestCase
from e2e.utils import USER, SeleniumTestCase, retry
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.stages.email.models import EmailStage, EmailTemplates
from passbook.stages.identification.models import IdentificationStage
@@ -23,7 +23,7 @@ class TestFlowsEnroll(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]:
return {
"image": "mailhog/mailhog:v1.0.1",
"image": "docker.beryju.org/proxy/mailhog/mailhog:v1.0.1",
"detach": True,
"network_mode": "host",
"auto_remove": True,
@@ -34,6 +34,7 @@ class TestFlowsEnroll(SeleniumTestCase):
),
}
@retry()
def test_enroll_2_step(self):
"""Test 2-step enroll flow"""
# First stage fields
@@ -119,6 +120,7 @@ class TestFlowsEnroll(SeleniumTestCase):
"foo@bar.baz",
)
@retry()
@override_settings(EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend")
def test_enroll_email(self):
"""Test enroll with Email verification"""

View File

@@ -5,13 +5,14 @@ from unittest.case import skipUnless
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from e2e.utils import USER, SeleniumTestCase
from e2e.utils import USER, SeleniumTestCase, retry
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestFlowsLogin(SeleniumTestCase):
"""test default login flow"""
@retry()
def test_login(self):
"""test default login flow"""
self.driver.get(f"{self.live_server_url}/flows/default-authentication-flow/")

View File

@@ -12,7 +12,7 @@ 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 e2e.utils import USER, SeleniumTestCase, retry
from passbook.flows.models import Flow, FlowStageBinding
from passbook.stages.otp_validate.models import OTPValidateStage
@@ -21,6 +21,7 @@ from passbook.stages.otp_validate.models import OTPValidateStage
class TestFlowsOTP(SeleniumTestCase):
"""test flow with otp stages"""
@retry()
def test_otp_validate(self):
"""test flow with otp stages"""
sleep(1)
@@ -52,6 +53,7 @@ class TestFlowsOTP(SeleniumTestCase):
USER().username,
)
@retry()
def test_otp_totp_setup(self):
"""test TOTP Setup stage"""
flow: Flow = Flow.objects.get(slug="default-authentication-flow")
@@ -98,6 +100,7 @@ class TestFlowsOTP(SeleniumTestCase):
self.assertTrue(TOTPDevice.objects.filter(user=USER(), confirmed=True).exists())
@retry()
def test_otp_static_setup(self):
"""test Static OTP Setup stage"""
flow: Flow = Flow.objects.get(slug="default-authentication-flow")

View File

@@ -5,7 +5,7 @@ from unittest.case import skipUnless
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from e2e.utils import USER, SeleniumTestCase
from e2e.utils import USER, SeleniumTestCase, retry
from passbook.core.models import User
from passbook.flows.models import Flow, FlowDesignation
from passbook.providers.oauth2.generators import generate_client_secret
@@ -16,6 +16,7 @@ from passbook.stages.password.models import PasswordStage
class TestFlowsStageSetup(SeleniumTestCase):
"""test stage setup flows"""
@retry()
def test_password_change(self):
"""test password change flow"""
# Ensure that password stage has change_flow set

View File

@@ -9,7 +9,7 @@ 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 e2e.utils import USER, SeleniumTestCase, retry
from passbook.core.models import Application
from passbook.flows.models import Flow
from passbook.policies.expression.models import ExpressionPolicy
@@ -33,7 +33,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]:
"""Setup client grafana container which we test OAuth against"""
return {
"image": "grafana/grafana:7.1.0",
"image": "docker.beryju.org/proxy/grafana/grafana:7.1.0",
"detach": True,
"network_mode": "host",
"auto_remove": True,
@@ -61,6 +61,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
},
}
@retry()
def test_authorization_consent_implied(self):
"""test OAuth Provider flow (default authorization flow with implied consent)"""
# Bootstrap all needed objects
@@ -115,6 +116,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
USER().username,
)
@retry()
def test_authorization_consent_explicit(self):
"""test OAuth Provider flow (default authorization flow with explicit consent)"""
# Bootstrap all needed objects
@@ -184,6 +186,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
USER().username,
)
@retry()
def test_denied(self):
"""test OAuth Provider flow (default authorization flow, denied)"""
# Bootstrap all needed objects

View File

@@ -10,7 +10,7 @@ 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
from e2e.utils import USER, SeleniumTestCase, retry
from passbook.core.models import Application
from passbook.crypto.models import CertificateKeyPair
from passbook.flows.models import Flow
@@ -47,7 +47,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]:
return {
"image": "grafana/grafana:7.1.0",
"image": "docker.beryju.org/proxy/grafana/grafana:7.1.0",
"detach": True,
"network_mode": "host",
"auto_remove": True,
@@ -80,6 +80,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
},
}
@retry()
def test_redirect_uri_error(self):
"""test OpenID Provider flow (invalid redirect URI, check error message)"""
sleep(1)
@@ -122,6 +123,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
"Redirect URI Error",
)
@retry()
def test_authorization_consent_implied(self):
"""test OpenID Provider flow (default authorization flow with implied consent)"""
sleep(1)
@@ -183,6 +185,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
USER().email,
)
@retry()
def test_authorization_logout(self):
"""test OpenID Provider flow with logout"""
sleep(1)
@@ -252,6 +255,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
)
self.driver.find_element(By.ID, "logout").click()
@retry()
def test_authorization_consent_explicit(self):
"""test OpenID Provider flow (default authorization flow with explicit consent)"""
sleep(1)
@@ -325,6 +329,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
USER().email,
)
@retry()
def test_authorization_denied(self):
"""test OpenID Provider flow (default authorization with access deny)"""
sleep(1)

View File

@@ -12,7 +12,7 @@ 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
from e2e.utils import USER, SeleniumTestCase, retry
from passbook.core.models import Application
from passbook.crypto.models import CertificateKeyPair
from passbook.flows.models import Flow
@@ -53,7 +53,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
client: DockerClient = from_env()
client.images.pull("beryju/oidc-test-client")
container = client.containers.run(
image="beryju/oidc-test-client",
image="docker.beryju.org/proxy/beryju/oidc-test-client",
detach=True,
network_mode="host",
auto_remove=True,
@@ -76,6 +76,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
LOGGER.info("Container failed healthcheck")
sleep(1)
@retry()
def test_redirect_uri_error(self):
"""test OpenID Provider flow (invalid redirect URI, check error message)"""
sleep(1)
@@ -119,6 +120,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
"Redirect URI Error",
)
@retry()
def test_authorization_consent_implied(self):
"""test OpenID Provider flow (default authorization flow with implied consent)"""
sleep(1)
@@ -169,6 +171,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
self.assertEqual(body["IDTokenClaims"]["email"], USER().email)
self.assertEqual(body["UserInfo"]["email"], USER().email)
@retry()
def test_authorization_consent_explicit(self):
"""test OpenID Provider flow (default authorization flow with explicit consent)"""
sleep(1)
@@ -229,6 +232,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
self.assertEqual(body["IDTokenClaims"]["email"], USER().email)
self.assertEqual(body["UserInfo"]["email"], USER().email)
@retry()
def test_authorization_denied(self):
"""test OpenID Provider flow (default authorization with access deny)"""
sleep(1)

View File

@@ -11,7 +11,7 @@ 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 e2e.utils import USER, SeleniumTestCase, retry
from passbook import __version__
from passbook.core.models import Application
from passbook.flows.models import Flow
@@ -36,7 +36,7 @@ class TestProviderProxy(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]:
return {
"image": "traefik/whoami:latest",
"image": "docker.beryju.org/proxy/traefik/whoami:latest",
"detach": True,
"network_mode": "host",
"auto_remove": True,
@@ -57,6 +57,7 @@ class TestProviderProxy(SeleniumTestCase):
)
return container
@retry()
def test_proxy_simple(self):
"""Test simple outpost setup with single provider"""
proxy: ProxyProvider = ProxyProvider.objects.create(
@@ -110,6 +111,7 @@ class TestProviderProxy(SeleniumTestCase):
class TestProviderProxyConnect(ChannelsLiveServerTestCase):
"""Test Proxy connectivity over websockets"""
@retry()
def test_proxy_connectivity(self):
"""Test proxy connectivity over websocket"""
SeleniumTestCase().apply_default_data()

View File

@@ -12,7 +12,7 @@ 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
from e2e.utils import USER, SeleniumTestCase, retry
from passbook.core.models import Application
from passbook.crypto.models import CertificateKeyPair
from passbook.flows.models import Flow
@@ -38,7 +38,7 @@ class TestProviderSAML(SeleniumTestCase):
client: DockerClient = from_env()
client.images.pull("beryju/oidc-test-client")
container = client.containers.run(
image="beryju/saml-test-sp",
image="docker.beryju.org/proxy/beryju/saml-test-sp",
detach=True,
network_mode="host",
auto_remove=True,
@@ -66,6 +66,7 @@ class TestProviderSAML(SeleniumTestCase):
LOGGER.info("Container failed healthcheck")
sleep(1)
@retry()
def test_sp_initiated_implicit(self):
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
@@ -105,6 +106,7 @@ class TestProviderSAML(SeleniumTestCase):
self.assertEqual(body["attr"]["mail"], [USER().email])
self.assertEqual(body["attr"]["uid"], [str(USER().pk)])
@retry()
def test_sp_initiated_explicit(self):
"""test SAML Provider flow SP-initiated flow (explicit consent)"""
# Bootstrap all needed objects
@@ -150,6 +152,7 @@ class TestProviderSAML(SeleniumTestCase):
self.assertEqual(body["attr"]["mail"], [USER().email])
self.assertEqual(body["attr"]["uid"], [str(USER().pk)])
@retry()
def test_idp_initiated_implicit(self):
"""test SAML Provider flow IdP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
@@ -195,6 +198,7 @@ class TestProviderSAML(SeleniumTestCase):
self.assertEqual(body["attr"]["mail"], [USER().email])
self.assertEqual(body["attr"]["uid"], [str(USER().pk)])
@retry()
def test_sp_initiated_denied(self):
"""test SAML Provider flow SP-initiated flow (Policy denies access)"""
# Bootstrap all needed objects

View File

@@ -14,7 +14,7 @@ from selenium.webdriver.support import expected_conditions as ec
from structlog import get_logger
from yaml import safe_dump
from e2e.utils import SeleniumTestCase
from e2e.utils import SeleniumTestCase, retry
from passbook.flows.models import Flow
from passbook.providers.oauth2.generators import (
generate_client_id,
@@ -106,6 +106,7 @@ class TestSourceOAuth2(SeleniumTestCase):
consumer_secret=self.client_secret,
)
@retry()
def test_oauth_enroll(self):
"""test OAuth Source With With OIDC"""
self.create_objects()
@@ -159,6 +160,7 @@ class TestSourceOAuth2(SeleniumTestCase):
"admin@example.com",
)
@retry()
@override_settings(SESSION_COOKIE_SAMESITE="strict")
def test_oauth_samesite_strict(self):
"""test OAuth Source With SameSite set to strict
@@ -195,6 +197,7 @@ class TestSourceOAuth2(SeleniumTestCase):
"Authentication Failed.",
)
@retry()
def test_oauth_enroll_auth(self):
"""test OAuth Source With With OIDC (enroll and authenticate again)"""
self.test_oauth_enroll()
@@ -255,7 +258,7 @@ class TestSourceOAuth1(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]:
return {
"image": "beryju/oauth1-test-server",
"image": "docker.beryju.org/proxy/beryju/oauth1-test-server",
"detach": True,
"network_mode": "host",
"auto_remove": True,
@@ -291,6 +294,7 @@ class TestSourceOAuth1(SeleniumTestCase):
consumer_secret=self.client_secret,
)
@retry()
def test_oauth_enroll(self):
"""test OAuth Source With With OIDC"""
self.create_objects()
@@ -317,6 +321,7 @@ class TestSourceOAuth1(SeleniumTestCase):
self.driver.find_element(By.CSS_SELECTOR, "[name='confirm']").click()
# Wait until we've loaded the user info page
sleep(2)
self.wait.until(ec.presence_of_element_located((By.ID, "user-settings")))
self.driver.get(self.url("passbook_core:user-settings"))

View File

@@ -10,7 +10,7 @@ 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 SeleniumTestCase
from e2e.utils import SeleniumTestCase, retry
from passbook.crypto.models import CertificateKeyPair
from passbook.flows.models import Flow
from passbook.sources.saml.models import SAMLBindingTypes, SAMLSource
@@ -75,7 +75,7 @@ class TestSourceSAML(SeleniumTestCase):
def get_container_specs(self) -> Optional[Dict[str, Any]]:
return {
"image": "kristophjunge/test-saml-idp:1.15",
"image": "docker.beryju.org/proxy/kristophjunge/test-saml-idp:1.15",
"detach": True,
"network_mode": "host",
"auto_remove": True,
@@ -92,6 +92,7 @@ class TestSourceSAML(SeleniumTestCase):
},
}
@retry()
def test_idp_redirect(self):
"""test SAML Source With redirect binding"""
# Bootstrap all needed objects
@@ -141,6 +142,7 @@ class TestSourceSAML(SeleniumTestCase):
self.driver.find_element(By.ID, "id_username").get_attribute("value"), ""
)
@retry()
def test_idp_post(self):
"""test SAML Source With post binding"""
# Bootstrap all needed objects
@@ -192,6 +194,7 @@ class TestSourceSAML(SeleniumTestCase):
self.driver.find_element(By.ID, "id_username").get_attribute("value"), ""
)
@retry()
def test_idp_post_auto(self):
"""test SAML Source With post binding (auto redirect)"""
# Bootstrap all needed objects

View File

@@ -1,19 +1,22 @@
"""passbook e2e testing utilities"""
from functools import wraps
from glob import glob
from importlib.util import module_from_spec, spec_from_file_location
from inspect import getmembers, isfunction
from os import environ, makedirs
from time import sleep, time
from typing import Any, Dict, Optional
from typing import Any, Callable, Dict, Optional
from django.apps import apps
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.db import connection, transaction
from django.db.utils import IntegrityError
from django.shortcuts import reverse
from django.test.testcases import TransactionTestCase
from docker import DockerClient, from_env
from docker.models.containers import Container
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
@@ -123,3 +126,41 @@ class SeleniumTestCase(StaticLiveServerTestCase):
func(apps, schema_editor)
except IntegrityError:
pass
def retry(max_retires=3, exceptions=None):
"""Retry test multiple times. Default to catching Selenium Timeout Exception"""
if not exceptions:
exceptions = [TimeoutException, NoSuchElementException]
logger = get_logger()
def retry_actual(func: Callable):
"""Retry test multiple times"""
count = 1
@wraps(func)
def wrapper(self: TransactionTestCase, *args, **kwargs):
"""Run test again if we're below max_retries, including tearDown and
setUp. Otherwise raise the error"""
nonlocal count
try:
return func(self, *args, **kwargs)
# pylint: disable=catching-non-exception
except tuple(exceptions) as exc:
count += 1
if count > max_retires:
logger.debug("Exceeded retry count", exc=exc, test=self)
# pylint: disable=raising-non-exception
raise exc
logger.debug("Retrying on error", exc=exc, test=self)
self.tearDown()
# pylint: disable=protected-access
self._post_teardown()
self.setUp()
return wrapper(self, *args, **kwargs)
return wrapper
return retry_actual

View File

@@ -1,9 +1,11 @@
apiVersion: v2
appVersion: "0.12.1-stable"
description: A Helm chart for passbook.
description: passbook is an open-source Identity Provider focused on flexibility and versatility. You can use passbook in an existing environment to add support for new protocols. passbook is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it.
name: passbook
version: "0.12.1-stable"
icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
home: https://passbook.beryju.org
sources:
- https://github.com/BeryJu/passbook
version: "0.12.8-stable"
icon: https://raw.githubusercontent.com/BeryJu/passbook/master/docs/images/logo.svg
dependencies:
- name: postgresql
version: 9.4.1

28
helm/README.md Normal file
View File

@@ -0,0 +1,28 @@
# passbook Helm Chart
| Name | Default | Description |
|-----------------------------------|-------------------------|-------------|
| image.name | beryju/passbook | Image used to run the passbook server and worker |
| image.name_static | beryju/passbook-static | Image used to run the passbook static server (CSS and JS Files) |
| image.tag | 0.12.5-stable | Image tag |
| serverReplicas | 1 | Replicas for the Server deployment |
| workerReplicas | 1 | Replicas for the Worker deployment |
| kubernetesIntegration | true | Enable/disable the Kubernetes integration for passbook. This will create a service account for passbook to create and update outposts in passbook |
| config.secretKey | | Secret key used to sign session cookies, generate with `pwgen 50 1` for example. |
| config.errorReporting.enabled | false | Enable/disable error reporting |
| config.errorReporting.environment | customer | Environment sent with the error reporting |
| config.errorReporting.sendPii | false | Whether to send Personally-identifiable data with the error reporting |
| config.logLevel | warning | Log level of passbook |
| backup.accessKey | | Optionally enable S3 Backup, Access Key |
| backup.secretKey | | Optionally enable S3 Backup, Secret Key |
| backup.bucket | | Optionally enable S3 Backup, Bucket |
| backup.region | | Optionally enable S3 Backup, Region |
| backup.host | | Optionally enable S3 Backup, to custom Endpoint like minio |
| ingress.annotations | {} | Annotations for the ingress object |
| ingress.hosts | [passbook.k8s.local] | Hosts which the ingress will match |
| ingress.tls | [] | TLS Configuration, same as Ingress objects |
| install.postgresql | true | Enables/disables the packaged PostgreSQL Chart
| install.redis | true | Enables/disables the packaged Redis Chart
| postgresql.postgresqlPassword | | Password used for PostgreSQL, generated automatically.
For more info, see https://passbook.beryju.org/ and https://passbook.beryju.org/installation/kubernetes/

View File

@@ -3,7 +3,7 @@
Expand the name of the chart.
*/}}
{{- define "passbook.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- default .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
@@ -12,17 +12,13 @@ We truncate at 63 chars because some Kubernetes name fields are limited to this
If release name contains chart name it will be used as a full name.
*/}}
{{- define "passbook.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- $name := default .Chart.Name -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
Create chart name and version as used by the chart label.

View File

@@ -1,42 +0,0 @@
{{- if .Values.backup }}
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: {{ include "passbook.fullname" . }}-backup
labels:
app.kubernetes.io/name: {{ include "passbook.name" . }}
helm.sh/chart: {{ include "passbook.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
schedule: "0 0 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.name }}:{{ .Values.image.tag }}"
args: [server]
envFrom:
- configMapRef:
name: {{ include "passbook.fullname" . }}-config
prefix: PASSBOOK_
env:
- name: PASSBOOK_SECRET_KEY
valueFrom:
secretKeyRef:
name: "{{ include "passbook.fullname" . }}-secret-key"
key: "secret_key"
- name: PASSBOOK_REDIS__PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-redis"
key: "redis-password"
- name: PASSBOOK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-postgresql"
key: "postgresql-password"
{{- end}}

View File

@@ -4,9 +4,7 @@
image:
name: beryju/passbook
name_static: beryju/passbook-static
tag: 0.12.1-stable
nameOverride: ""
tag: 0.12.8-stable
serverReplicas: 1
workerReplicas: 1
@@ -38,7 +36,6 @@ ingress:
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
path: /
hosts:
- passbook.k8s.local
tls: []
@@ -62,7 +59,5 @@ redis:
cluster:
enabled: false
master:
persistence:
enabled: false
# https://stackoverflow.com/a/59189742
disableCommands: []

View File

@@ -47,7 +47,9 @@ if __name__ == "__main__":
# pyright: reportGeneralTypeIssues=false
spec.loader.exec_module(mod)
for _, sub in getmembers(mod, isclass):
for name, sub in getmembers(mod, isclass):
if name != "Migration":
continue
migration = sub(curr, conn)
if migration.needs_migration():
LOGGER.info("Migration needs to be applied", migration=sub)

View File

@@ -25,7 +25,7 @@ delete from django_migrations where app = 'passbook_stages_password' and
name = '0002_passwordstage_change_flow';"""
class To010Migration(BaseMigration):
class Migration(BaseMigration):
def needs_migration(self) -> bool:
self.cur.execute(
"select * from information_schema.tables where table_name='oidc_provider_client'"

View File

@@ -1,28 +0,0 @@
from pickle import loads # nosec
from redis import Redis
from lifecycle.migrate import BaseMigration
from passbook.lib.config import CONFIG
class To012Migration(BaseMigration):
def __init__(self) -> None:
self.redis = Redis(
host=CONFIG.y("redis.host"),
port=6379,
db=CONFIG.y("redis.cache_db"),
password=CONFIG.y("redis.password"),
)
def needs_migration(self) -> bool:
keys = self.redis.keys(":1:outpost_*")
for key in keys:
value = loads(self.redis.get(key)) # nosec
if isinstance(value, str):
return True
return False
def run(self):
keys_to_delete = self.redis.keys(":1:outpost_*")
self.redis.delete(*keys_to_delete)

View File

@@ -1,2 +1,2 @@
"""passbook"""
__version__ = "0.12.1-stable"
__version__ = "0.12.8-stable"

View File

@@ -50,15 +50,23 @@ class TaskViewSet(ViewSet):
task = TaskInfo.by_name(pk)
if not task:
raise Http404
task_module = import_module(task.task_call_module)
task_func = getattr(task_module, task.task_call_func)
task_func.delay(*task.task_call_args, **task.task_call_kwargs)
messages.success(
self.request,
_("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}),
)
return Response(
{
"successful": True,
}
)
try:
task_module = import_module(task.task_call_module)
task_func = getattr(task_module, task.task_call_func)
task_func.delay(*task.task_call_args, **task.task_call_kwargs)
messages.success(
self.request,
_(
"Successfully re-scheduled Task %(name)s!"
% {"name": task.task_name}
),
)
return Response(
{
"successful": True,
}
)
except ImportError:
# if we get an import error, the module path has probably changed
task.delete()
return Response({"successful": False})

View File

@@ -49,7 +49,7 @@
</span>
</td>
{% with states=outpost.state %}
{% if states|length > 1 %}
{% if states|length > 0 %}
<td role="cell">
{% for state in states %}
<div>

View File

@@ -21,7 +21,7 @@
<tr role="row">
<th role="columnheader" scope="col">{% trans 'Identifier' %}</th>
<th role="columnheader" scope="col">{% trans 'Description' %}</th>
<th role="columnheader" scope="col">{% trans 'Last Status' %}</th>
<th role="columnheader" scope="col">{% trans 'Last Run' %}</th>
<th role="columnheader" scope="col">{% trans 'Status' %}</th>
<th role="columnheader" scope="col">{% trans 'Messages' %}</th>
<th role="cell"></th>

View File

@@ -25,10 +25,7 @@ def token_from_header(raw_header: bytes) -> Optional[Token]:
try:
auth_credentials = b64decode(auth_credentials.encode()).decode()
except UnicodeDecodeError:
# TODO: Remove this workaround
# temporary fallback for 0.11 to 0.12 upgrade
# 0.11 and below proxy sends authorization header not base64 encoded
pass
return None
# Accept credentials with username and without
if ":" in auth_credentials:
_, password = auth_credentials.split(":")

View File

@@ -68,6 +68,9 @@ router.register("core/tokens", TokenViewSet)
router.register("outposts/outposts", OutpostViewSet)
router.register("outposts/proxy", OutpostConfigViewSet)
router.register("flows/instances", FlowViewSet)
router.register("flows/bindings", FlowStageBindingViewSet)
router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet)
router.register("audit/events", EventViewSet)
@@ -114,9 +117,6 @@ router.register("stages/user_login", UserLoginStageViewSet)
router.register("stages/user_logout", UserLogoutStageViewSet)
router.register("stages/user_write", UserWriteStageViewSet)
router.register("flows/instances", FlowViewSet)
router.register("flows/bindings", FlowStageBindingViewSet)
router.register("stages/dummy", DummyStageViewSet)
router.register("policies/dummy", DummyPolicyViewSet)

View File

@@ -1,4 +1,12 @@
"""passbook core tasks"""
from datetime import datetime
from io import StringIO
from boto3.exceptions import Boto3Error
from botocore.exceptions import BotoCoreError, ClientError
from dbbackup.db.exceptions import CommandConnectorError
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.core import management
from django.utils.timezone import now
from structlog import get_logger
@@ -24,3 +32,31 @@ def clean_expired_models(self: MonitoredTask):
LOGGER.debug("Deleted expired models", model=cls, amount=amount)
messages.append(f"Deleted {amount} expired {cls._meta.verbose_name_plural}")
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
@CELERY_APP.task(bind=True, base=MonitoredTask)
def backup_database(self: MonitoredTask): # pragma: no cover
"""Database backup"""
try:
start = datetime.now()
out = StringIO()
management.call_command("dbbackup", quiet=True, stdout=out)
self.set_status(
TaskResult(
TaskResultStatus.SUCCESSFUL,
[
f"Successfully finished database backup {naturaltime(start)}",
out.getvalue(),
],
)
)
LOGGER.info("Successfully backed up database.")
except (
IOError,
BotoCoreError,
ClientError,
Boto3Error,
PermissionError,
CommandConnectorError,
) as exc:
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))

View File

@@ -53,7 +53,7 @@
{{ user.username }}
</a>
</div>
<img class="pf-c-avatar" src="{% gravatar user.email %}" alt="">
<img class="pf-c-avatar" src="{% avatar user %}" alt="">
</div>
</header>
{% block page_content %}

View File

@@ -7,7 +7,7 @@
<div class="pf-c-form__group">
<div class="form-control-static">
<div class="left">
<img class="pf-c-avatar" src="{% gravatar user.email %}" alt="">
<img class="pf-c-avatar" src="{% avatar user %}" alt="">
{{ user.username }}
</div>
<div class="right">

View File

@@ -27,7 +27,15 @@ class FlowStageBindingSerializer(ModelSerializer):
class Meta:
model = FlowStageBinding
fields = ["pk", "target", "stage", "re_evaluate_policies", "order", "policies"]
fields = [
"pk",
"target",
"stage",
"evaluate_on_plan",
"re_evaluate_policies",
"order",
"policies",
]
class FlowStageBindingViewSet(ModelViewSet):

View File

@@ -50,12 +50,10 @@ class FlowStageBindingForm(forms.ModelForm):
fields = [
"target",
"stage",
"evaluate_on_plan",
"re_evaluate_policies",
"order",
]
labels = {
"re_evaluate_policies": _("Re-evaluate Policies"),
}
widgets = {
"name": forms.TextInput(),
}

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
from django.http.request import HttpRequest
from structlog import get_logger
from passbook.core.models import User
@@ -20,7 +21,9 @@ class StageMarker:
"""Base stage marker class, no extra attributes, and has no special handler."""
# pylint: disable=unused-argument
def process(self, plan: "FlowPlan", stage: Stage) -> Optional[Stage]:
def process(
self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest]
) -> Optional[Stage]:
"""Process callback for this marker. This should be overridden by sub-classes.
If a stage should be removed, return None."""
return stage
@@ -33,10 +36,14 @@ class ReevaluateMarker(StageMarker):
binding: PolicyBinding
user: User
def process(self, plan: "FlowPlan", stage: Stage) -> Optional[Stage]:
def process(
self, plan: "FlowPlan", stage: Stage, http_request: Optional[HttpRequest]
) -> Optional[Stage]:
"""Re-evaluate policies bound to stage, and if they fail, remove from plan"""
engine = PolicyEngine(self.binding, self.user)
engine.use_cache = False
if http_request:
engine.request.http_request = http_request
engine.request.context = plan.context
engine.build()
result = engine.result

View File

@@ -0,0 +1,29 @@
# Generated by Django 3.1.2 on 2020-10-20 12:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0014_auto_20200925_2332"),
]
operations = [
migrations.AlterField(
model_name="flowstagebinding",
name="re_evaluate_policies",
field=models.BooleanField(
default=False,
help_text="Evaluate policies when the Stage is present to the user.",
),
),
migrations.AddField(
model_name="flowstagebinding",
name="evaluate_on_plan",
field=models.BooleanField(
default=True,
help_text="Evaluate policies during the Flow planning process. Disable this for input-based policies.",
),
),
]

View File

@@ -154,15 +154,19 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel):
target = models.ForeignKey("Flow", on_delete=models.CASCADE)
stage = InheritanceForeignKey(Stage, on_delete=models.CASCADE)
re_evaluate_policies = models.BooleanField(
default=False,
evaluate_on_plan = models.BooleanField(
default=True,
help_text=_(
(
"When this option is enabled, the planner will re-evaluate "
"policies bound to this binding."
"Evaluate policies during the Flow planning process. "
"Disable this for input-based policies."
)
),
)
re_evaluate_policies = models.BooleanField(
default=False,
help_text=_("Evaluate policies when the Stage is present to the user."),
)
order = models.IntegerField()

View File

@@ -46,7 +46,7 @@ class FlowPlan:
self.stages.append(stage)
self.markers.append(marker or StageMarker())
def next(self) -> Optional[Stage]:
def next(self, http_request: Optional[HttpRequest]) -> Optional[Stage]:
"""Return next pending stage from the bottom of the list"""
if not self.has_stages:
return None
@@ -55,7 +55,7 @@ class FlowPlan:
if marker.__class__ is not StageMarker:
LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker)
marked_stage = marker.process(self, stage)
marked_stage = marker.process(self, stage, http_request)
if not marked_stage:
LOGGER.debug("f(plan_inst): marker returned none, next stage", stage=stage)
self.stages.remove(stage)
@@ -63,7 +63,7 @@ class FlowPlan:
if not self.has_stages:
return None
# pylint: disable=not-callable
return self.next()
return self.next(http_request)
return marked_stage
def pop(self):
@@ -159,23 +159,41 @@ class FlowPlanner:
for binding in FlowStageBinding.objects.filter(
target__pk=self.flow.pk
).order_by("order"):
engine = PolicyEngine(binding, user, request)
engine.request.context = plan.context
engine.build()
if engine.passing:
binding: FlowStageBinding
stage = binding.stage
marker = StageMarker()
if binding.evaluate_on_plan:
LOGGER.debug(
"f(plan): Stage passing", stage=binding.stage, flow=self.flow
"f(plan): evaluating on plan",
stage=binding.stage,
flow=self.flow,
)
plan.stages.append(binding.stage)
marker = StageMarker()
if binding.re_evaluate_policies:
engine = PolicyEngine(binding, user, request)
engine.request.context = plan.context
engine.build()
if engine.passing:
LOGGER.debug(
"f(plan): Stage has re-evaluate marker",
"f(plan): Stage passing",
stage=binding.stage,
flow=self.flow,
)
marker = ReevaluateMarker(binding=binding, user=user)
plan.markers.append(marker)
else:
stage = None
else:
LOGGER.debug(
"f(plan): not evaluating on plan",
stage=binding.stage,
flow=self.flow,
)
if binding.re_evaluate_policies and stage:
LOGGER.debug(
"f(plan): Stage has re-evaluate marker",
stage=binding.stage,
flow=self.flow,
)
marker = ReevaluateMarker(binding=binding, user=user)
if stage:
plan.append(stage, marker)
LOGGER.debug(
"f(plan): Finished building",
flow=self.flow,

View File

@@ -86,7 +86,7 @@ class FlowExecutorView(View):
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()
next_stage = self.plan.next(self.request)
if not next_stage:
LOGGER.debug("f(exec): no more stages, flow is done.")
return self._flow_done()

View File

@@ -22,6 +22,7 @@ error_reporting:
send_pii: false
passbook:
avatars: gravatar # gravatar or none
branding:
title: passbook
title_show: true

View File

@@ -1,4 +1,5 @@
"""passbook sentry integration"""
from aioredis.errors import ReplyError
from billiard.exceptions import WorkerLostError
from botocore.client import ClientError
from celery.exceptions import CeleryError
@@ -8,7 +9,7 @@ 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 redis.exceptions import RedisError, ResponseError
from rest_framework.exceptions import APIException
from structlog import get_logger
from websockets.exceptions import WebSocketException
@@ -23,26 +24,36 @@ class SentryIgnoredException(Exception):
def before_send(event, hint):
"""Check if error is database error, and ignore if so"""
ignored_classes = (
# Inbuilt types
KeyboardInterrupt,
ConnectionResetError,
OSError,
# Django DB Errors
OperationalError,
InternalError,
ProgrammingError,
ConnectionInterrupted,
APIException,
ConnectionResetError,
RedisConnectionError,
WorkerLostError,
DisallowedHost,
ConnectionResetError,
KeyboardInterrupt,
ClientError,
ValidationError,
OSError,
# Redis errors
RedisConnectionError,
ConnectionInterrupted,
RedisError,
SentryIgnoredException,
CeleryError,
LDAPException,
ResponseError,
ReplyError,
# websocket errors
ChannelFull,
WebSocketException,
# rest_framework error
APIException,
# celery errors
WorkerLostError,
CeleryError,
# S3 errors
ClientError,
# custom baseclass
SentryIgnoredException,
# ldap errors
LDAPException,
)
if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"]

View File

@@ -62,13 +62,17 @@ class TaskInfo:
"""Get TaskInfo Object by name"""
return cache.get(f"task_{name}")
def delete(self):
"""Delete task info from cache"""
return cache.delete(f"task_{self.task_name}")
def save(self):
"""Save task into cache"""
key = f"task_{self.task_name}"
if self.result.uid:
key += f"_{self.result.uid}"
self.task_name += f"_{self.result.uid}"
cache.set(key, self, timeout=6 * 60 * 60)
cache.set(key, self, timeout=13 * 60 * 60)
class MonitoredTask(Task):
@@ -79,11 +83,18 @@ class MonitoredTask(Task):
_result: TaskResult
_uid: Optional[str]
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.save_on_success = True
self._uid = None
self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[])
def set_uid(self, uid: str):
"""Set UID, so in the case of an unexpected error its saved correctly"""
self._uid = uid
def set_status(self, result: TaskResult):
"""Set result for current run, will overwrite previous result."""
self._result = result
@@ -92,6 +103,8 @@ class MonitoredTask(Task):
def after_return(
self, status, retval, task_id, args: List[Any], kwargs: Dict[str, Any], einfo
):
if not self._result.uid:
self._result.uid = self._uid
if self.save_on_success:
TaskInfo(
task_name=self.__name__,
@@ -107,6 +120,8 @@ class MonitoredTask(Task):
# pylint: disable=too-many-arguments
def on_failure(self, exc, task_id, args, kwargs, einfo):
if not self._result.uid:
self._result.uid = self._uid
TaskInfo(
task_name=self.__name__,
task_description=self.__doc__,

View File

@@ -6,15 +6,19 @@ from django import template
from django.db.models import Model
from django.http.request import HttpRequest
from django.template import Context
from django.templatetags.static import static
from django.utils.html import escape, mark_safe
from structlog import get_logger
from passbook.core.models import User
from passbook.lib.config import CONFIG
from passbook.lib.utils.urls import is_url_absolute
register = template.Library()
LOGGER = get_logger()
GRAVATAR_URL = "https://secure.gravatar.com"
@register.simple_tag(takes_context=True)
def back(context: Context) -> str:
@@ -54,37 +58,23 @@ def css_class(field, css):
@register.simple_tag
def gravatar(email, size=None, rating=None):
"""
Generates a Gravatar URL for the given email address.
Syntax::
{% gravatar <email> [size] [rating] %}
Example::
{% gravatar someone@example.com 48 pg %}
"""
# gravatar uses md5 for their URLs, so md5 can't be avoided
gravatar_url = "%savatar/%s" % (
"https://secure.gravatar.com/",
md5(email.encode("utf-8")).hexdigest(), # nosec
)
parameters = [
p
for p in (
("s", size or "158"),
("r", rating or "g"),
def avatar(user: User) -> str:
"""Get avatar, depending on passbook.avatar setting"""
mode = CONFIG.raw.get("passbook").get("avatars")
if mode == "none":
return static("passbook/user-default.png")
if mode == "gravatar":
parameters = [
("s", "158"),
("r", "g"),
]
# gravatar uses md5 for their URLs, so md5 can't be avoided
mail_hash = md5(user.email.encode("utf-8")).hexdigest() # nosec
gravatar_url = (
f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
)
if p[1]
]
if parameters:
gravatar_url += "?" + urlencode(parameters, doseq=True)
return escape(gravatar_url)
return escape(gravatar_url)
raise ValueError(f"Invalid avatar mode {mode}")
@register.filter

View File

@@ -21,9 +21,7 @@ class BaseController:
def __init__(self, outpost: Outpost):
self.outpost = outpost
self.logger = get_logger(
controller=self.__class__.__name__, outpost=self.outpost
)
self.logger = get_logger()
self.deployment_ports = {}
# pylint: disable=invalid-name
@@ -35,7 +33,7 @@ class BaseController:
"""Call .up() but capture all log output and return it."""
with capture_logs() as logs:
self.up()
return [f"{x['controller']}: {x['event']}" for x in logs]
return [x["event"] for x in logs]
def down(self):
"""Handler to delete everything we've created"""

View File

@@ -24,7 +24,10 @@ class DockerController(BaseController):
def __init__(self, outpost: Outpost) -> None:
super().__init__(outpost)
self.client = from_env()
try:
self.client = from_env()
except DockerException as exc:
raise ControllerException from exc
def _get_labels(self) -> Dict[str, str]:
return {}

View File

@@ -35,9 +35,7 @@ class KubernetesObjectReconciler(Generic[T]):
def __init__(self, controller: "KubernetesController"):
self.controller = controller
self.namespace = controller.outpost.config.kubernetes_namespace
self.logger = get_logger(
controller=self.__class__.__name__, outpost=controller.outpost
)
self.logger = get_logger()
@property
def name(self) -> str:

View File

@@ -1,5 +1,5 @@
"""Kubernetes Deployment Reconciler"""
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Dict
from kubernetes.client import (
AppsV1Api,
@@ -41,7 +41,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
@property
def name(self) -> str:
return f"passbook-outpost-{self.outpost.name}"
return f"passbook-outpost-{self.controller.outpost.uuid.hex}"
def reconcile(self, current: V1Deployment, reference: V1Deployment):
if current.spec.replicas != reference.spec.replicas:
@@ -52,6 +52,14 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
):
raise NeedsUpdate()
def get_pod_meta(self) -> Dict[str, str]:
"""Get common object metadata"""
return {
"app.kubernetes.io/name": "passbook-outpost",
"app.kubernetes.io/managed-by": "passbook.beryju.org",
"passbook.beryju.org/outpost-uuid": self.controller.outpost.uuid.hex,
}
def get_reference_object(self) -> V1Deployment:
"""Get deployment object for outpost"""
# Generate V1ContainerPort objects
@@ -59,13 +67,14 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
for port_name, port in self.controller.deployment_ports.items():
container_ports.append(V1ContainerPort(container_port=port, name=port_name))
meta = self.get_object_meta(name=self.name)
secret_name = f"passbook-outpost-{self.controller.outpost.uuid.hex}-api"
return V1Deployment(
metadata=meta,
spec=V1DeploymentSpec(
replicas=self.outpost.config.kubernetes_replicas,
selector=V1LabelSelector(match_labels=meta.labels),
selector=V1LabelSelector(match_labels=self.get_pod_meta()),
template=V1PodTemplateSpec(
metadata=V1ObjectMeta(labels=meta.labels),
metadata=V1ObjectMeta(labels=self.get_pod_meta()),
spec=V1PodSpec(
containers=[
V1Container(
@@ -77,7 +86,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
name="PASSBOOK_HOST",
value_from=V1EnvVarSource(
secret_key_ref=V1SecretKeySelector(
name=f"passbook-outpost-{self.outpost.name}-api",
name=secret_name,
key="passbook_host",
)
),
@@ -86,7 +95,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
name="PASSBOOK_TOKEN",
value_from=V1EnvVarSource(
secret_key_ref=V1SecretKeySelector(
name=f"passbook-outpost-{self.outpost.name}-api",
name=secret_name,
key="token",
)
),
@@ -95,7 +104,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
name="PASSBOOK_INSECURE",
value_from=V1EnvVarSource(
secret_key_ref=V1SecretKeySelector(
name=f"passbook-outpost-{self.outpost.name}-api",
name=secret_name,
key="passbook_host_insecure",
)
),
@@ -117,9 +126,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
)
def retrieve(self) -> V1Deployment:
return self.api.read_namespaced_deployment(
f"passbook-outpost-{self.outpost.name}", self.namespace
)
return self.api.read_namespaced_deployment(self.name, self.namespace)
def update(self, current: V1Deployment, reference: V1Deployment):
return self.api.patch_namespaced_deployment(

View File

@@ -27,7 +27,7 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]):
@property
def name(self) -> str:
return f"passbook-outpost-{self.controller.outpost.name}-api"
return f"passbook-outpost-{self.controller.outpost.uuid.hex}-api"
def reconcile(self, current: V1Secret, reference: V1Secret):
for key in reference.data.keys():
@@ -59,9 +59,7 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]):
)
def retrieve(self) -> V1Secret:
return self.api.read_namespaced_secret(
f"passbook-outpost-{self.controller.outpost.name}-api", self.namespace
)
return self.api.read_namespaced_secret(self.name, self.namespace)
def update(self, current: V1Secret, reference: V1Secret):
return self.api.patch_namespaced_secret(

View File

@@ -7,6 +7,7 @@ from passbook.outposts.controllers.k8s.base import (
KubernetesObjectReconciler,
NeedsUpdate,
)
from passbook.outposts.controllers.k8s.deployment import DeploymentReconciler
if TYPE_CHECKING:
from passbook.outposts.controllers.kubernetes import KubernetesController
@@ -21,7 +22,7 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
@property
def name(self) -> str:
return f"passbook-outpost-{self.controller.outpost.name}"
return f"passbook-outpost-{self.controller.outpost.uuid.hex}"
def reconcile(self, current: V1Service, reference: V1Service):
if len(current.spec.ports) != len(reference.spec.ports):
@@ -36,9 +37,10 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
ports = []
for port_name, port in self.controller.deployment_ports.items():
ports.append(V1ServicePort(name=port_name, port=port))
selector_labels = DeploymentReconciler(self.controller).get_pod_meta()
return V1Service(
metadata=meta,
spec=V1ServiceSpec(ports=ports, selector=meta.labels, type="ClusterIP"),
spec=V1ServiceSpec(ports=ports, selector=selector_labels, type="ClusterIP"),
)
def create(self, reference: V1Service):
@@ -50,9 +52,7 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
)
def retrieve(self) -> V1Service:
return self.api.read_namespaced_service(
f"passbook-outpost-{self.controller.outpost.name}", self.namespace
)
return self.api.read_namespaced_service(self.name, self.namespace)
def update(self, current: V1Service, reference: V1Service):
return self.api.patch_namespaced_service(

View File

@@ -5,6 +5,7 @@ from typing import Dict, List, Type
from kubernetes.client import OpenApiException
from kubernetes.config import load_incluster_config, load_kube_config
from kubernetes.config.config_exception import ConfigException
from structlog.testing import capture_logs
from yaml import dump_all
from passbook.outposts.controllers.base import BaseController, ControllerException
@@ -43,6 +44,18 @@ class KubernetesController(BaseController):
except OpenApiException as exc:
raise ControllerException from exc
def up_with_logs(self) -> List[str]:
try:
all_logs = []
for reconcile_key in self.reconcile_order:
with capture_logs() as logs:
reconciler = self.reconcilers[reconcile_key](self)
reconciler.up()
all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs]
return all_logs
except OpenApiException as exc:
raise ControllerException from exc
def down(self):
try:
for reconcile_key in self.reconcile_order:

View File

@@ -204,7 +204,11 @@ class OutpostState:
def for_channel(outpost: Outpost, channel: str) -> "OutpostState":
"""Get state for a single channel"""
key = f"{outpost.state_cache_prefix}_{channel}"
data = cache.get(key, {"uid": channel})
default_data = {"uid": channel}
data = cache.get(key, default_data)
if isinstance(data, str):
cache.delete(key)
data = default_data
state = from_dict(OutpostState, data)
state.uid = channel
# pylint: disable=protected-access

View File

@@ -35,9 +35,10 @@ def outpost_controller_all():
@CELERY_APP.task(bind=True, base=MonitoredTask)
def outpost_controller(self: MonitoredTask, outpost_pk: str):
"""Launch controller deployment of Outpost"""
"""Create/update/monitor the deployment of an Outpost"""
logs = []
outpost: Outpost = Outpost.objects.get(pk=outpost_pk)
self.set_uid(slugify(outpost.name))
try:
if outpost.type == OutpostType.PROXY:
if outpost.deployment_type == OutpostDeploymentType.KUBERNETES:
@@ -45,15 +46,9 @@ def outpost_controller(self: MonitoredTask, outpost_pk: str):
if outpost.deployment_type == OutpostDeploymentType.DOCKER:
logs = ProxyDockerController(outpost).up_with_logs()
except ControllerException as exc:
self.set_status(
TaskResult(TaskResultStatus.ERROR, uid=slugify(outpost.name)).with_error(
exc
)
)
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
else:
self.set_status(
TaskResult(TaskResultStatus.SUCCESSFUL, logs, uid=slugify(outpost.name))
)
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, logs))
@CELERY_APP.task()
@@ -128,6 +123,9 @@ def outpost_send_update(model_instace: Model):
def _outpost_single_update(outpost: Outpost, layer=None):
"""Update outpost instances connected to a single outpost"""
# Ensure token again, because this function is called when anything related to an
# OutpostModel is saved, so we can be sure permissions are right
_ = outpost.token
if not layer: # pragma: no cover
layer = get_channel_layer()
for state in OutpostState.for_outpost(outpost):

View File

@@ -24,6 +24,7 @@
<label class="pf-c-form__label" for="help-text-simple-form-name">
<span class="pf-c-form__label-text">PASSBOOK_TOKEN</span>
</label>
{# TODO: Only load key on modal open #}
<input class="pf-c-form-control" data-pb-fetch-key="key" data-pb-fetch-fill="{% url 'passbook_api:token-view-key' identifier=outpost.token_identifier %}" readonly type="text" value="" />
</div>
<h3>{% trans 'If your passbook Instance is using a self-signed certificate, set this value.' %}</h3>

View File

@@ -1,9 +1,16 @@
"""outpost tests"""
from os import environ
from unittest.case import skipUnless
from unittest.mock import patch
from django.test import TestCase
from guardian.models import UserObjectPermission
from passbook.crypto.models import CertificateKeyPair
from passbook.flows.models import Flow
from passbook.outposts.controllers.k8s.base import NeedsUpdate
from passbook.outposts.controllers.k8s.deployment import DeploymentReconciler
from passbook.outposts.controllers.kubernetes import KubernetesController
from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType
from passbook.providers.proxy.models import ProxyProvider
@@ -58,3 +65,50 @@ class OutpostTests(TestCase):
permissions = UserObjectPermission.objects.filter(user=outpost.user)
self.assertEqual(len(permissions), 1)
self.assertEqual(permissions[0].object_pk, str(outpost.pk))
@skipUnless("PB_TEST_K8S" in environ, "Kubernetes test cluster required")
class OutpostKubernetesTests(TestCase):
"""Test Kubernetes Controllers"""
def setUp(self):
super().setUp()
self.provider: ProxyProvider = ProxyProvider.objects.create(
name="test",
internal_host="http://localhost",
external_host="http://localhost",
authorization_flow=Flow.objects.first(),
)
self.outpost: Outpost = Outpost.objects.create(
name="test",
type=OutpostType.PROXY,
deployment_type=OutpostDeploymentType.KUBERNETES,
)
self.outpost.providers.add(self.provider)
self.outpost.save()
def test_deployment_reconciler(self):
"""test that deployment requires update"""
controller = KubernetesController(self.outpost)
deployment_reconciler = DeploymentReconciler(controller)
self.assertIsNotNone(deployment_reconciler.retrieve())
config = self.outpost.config
config.kubernetes_replicas = 3
self.outpost.config = config
with self.assertRaises(NeedsUpdate):
deployment_reconciler.reconcile(
deployment_reconciler.retrieve(),
deployment_reconciler.get_reference_object(),
)
with patch.object(deployment_reconciler, "image_base", "test"):
with self.assertRaises(NeedsUpdate):
deployment_reconciler.reconcile(
deployment_reconciler.retrieve(),
deployment_reconciler.get_reference_object(),
)
deployment_reconciler.delete(deployment_reconciler.get_reference_object())

View File

@@ -1,5 +1,5 @@
"""passbook expression policy evaluator"""
from ipaddress import ip_address
from ipaddress import ip_address, ip_network
from typing import List
from django.http import HttpRequest
@@ -22,6 +22,8 @@ class PolicyEvaluator(BaseEvaluator):
super().__init__()
self._messages = []
self._context["pb_message"] = self.expr_func_message
self._context["ip_address"] = ip_address
self._context["ip_network"] = ip_network
self._filename = policy_name or "PolicyEvaluator"
def expr_func_message(self, message: str):

View File

@@ -1,5 +1,5 @@
"""Kubernetes Ingress Reconciler"""
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Dict
from urllib.parse import urlparse
from kubernetes.client import (
@@ -34,7 +34,7 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]):
@property
def name(self) -> str:
return f"passbook-outpost-{self.controller.outpost.name}"
return f"passbook-outpost-{self.controller.outpost.uuid.hex}"
def reconcile(
self, current: NetworkingV1beta1Ingress, reference: NetworkingV1beta1Ingress
@@ -67,11 +67,24 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]):
if have_hosts_tls != expected_hosts_tls:
raise NeedsUpdate()
def get_ingress_annotations(self) -> Dict[str, str]:
"""Get ingress annotations"""
annotations = {
# Ensure that with multiple proxy replicas deployed, the same CSRF request
# goes to the same pod
"nginx.ingress.kubernetes.io/affinity": "cookie",
"traefik.ingress.kubernetes.io/affinity": "true",
}
annotations.update(
self.controller.outpost.config.kubernetes_ingress_annotations
)
return dict()
def get_reference_object(self) -> NetworkingV1beta1Ingress:
"""Get deployment object for outpost"""
meta = self.get_object_meta(
name=self.name,
annotations=self.controller.outpost.config.kubernetes_ingress_annotations,
annotations=self.get_ingress_annotations(),
)
rules = []
tls_hosts = []
@@ -117,9 +130,7 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]):
)
def retrieve(self) -> NetworkingV1beta1Ingress:
return self.api.read_namespaced_ingress(
f"passbook-outpost-{self.controller.outpost.name}", self.namespace
)
return self.api.read_namespaced_ingress(self.name, self.namespace)
def update(
self, current: NetworkingV1beta1Ingress, reference: NetworkingV1beta1Ingress

View File

@@ -269,9 +269,14 @@ CELERY_TASK_SOFT_TIME_LIMIT = 600
CELERY_BEAT_SCHEDULE = {
"clean_expired_models": {
"task": "passbook.core.tasks.clean_expired_models",
"schedule": crontab(minute="*/5"), # Run every 5 minutes
"schedule": crontab(minute="*/5"),
"options": {"queue": "passbook_scheduled"},
}
},
"db_backup": {
"task": "passbook.core.tasks.backup_database",
"schedule": crontab(minute=0, hour=0),
"options": {"queue": "passbook_scheduled"},
},
}
CELERY_TASK_CREATE_MISSING_QUEUES = True
CELERY_TASK_DEFAULT_QUEUE = "passbook"
@@ -404,6 +409,7 @@ _LOGGING_HANDLER_MAP = {
"websockets": "WARNING",
"daphne": "WARNING",
"dbbackup": "ERROR",
"kubernetes": "INFO",
}
for handler_name, level in _LOGGING_HANDLER_MAP.items():
# pyright: reportGeneralTypeIssues=false
@@ -444,6 +450,7 @@ for _app in INSTALLED_APPS:
if DEBUG:
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
CELERY_TASK_ALWAYS_EAGER = True
INSTALLED_APPS.append("passbook.core.apps.PassbookCoreConfig")

View File

@@ -1,6 +1,8 @@
"""Integrate ./manage.py test with pytest"""
from django.conf import settings
from passbook.lib.config import CONFIG
class PytestTestRunner:
"""Runs pytest to discover and run tests."""
@@ -11,6 +13,7 @@ class PytestTestRunner:
self.keepdb = keepdb
settings.TEST = True
settings.CELERY_TASK_ALWAYS_EAGER = True
CONFIG.raw.get("passbook")["avatars"] = "none"
def run_tests(self, test_labels):
"""Run pytest and return the exitcode.

View File

@@ -20,8 +20,9 @@ def ldap_sync_all():
@CELERY_APP.task(bind=True, base=MonitoredTask)
def ldap_sync(self: MonitoredTask, source_pk: int):
"""Sync a single source"""
"""Synchronization of an LDAP Source"""
source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
self.set_uid(slugify(source.name))
try:
syncer = LDAPSynchronizer(source)
user_count = syncer.sync_users()
@@ -33,10 +34,7 @@ def ldap_sync(self: MonitoredTask, source_pk: int):
TaskResult(
TaskResultStatus.SUCCESSFUL,
[f"Synced {user_count} users", f"Synced {group_count} groups"],
uid=slugify(source.name),
)
)
except LDAPException as exc:
self.set_status(
TaskResult(TaskResultStatus.ERROR, uid=slugify(source.name)).with_error(exc)
)
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))

View File

@@ -13,7 +13,7 @@ LOGGER = get_logger()
@CELERY_APP.task(bind=True, base=MonitoredTask)
def clean_temporary_users(self: MonitoredTask):
"""Remove old temporary users"""
"""Remove temporary users created by SAML Sources"""
_now = now()
messages = []
deleted_users = 0

View File

@@ -37,6 +37,8 @@ def send_mails(stage: EmailStage, *messages: List[EmailMultiAlternatives]):
def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any]):
"""Send Email for Email Stage. Retries are scheduled automatically."""
self.save_on_success = False
message_id = make_msgid(domain=DNS_NAME)
self.set_uid(message_id)
try:
stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
backend = stage.backend
@@ -48,7 +50,6 @@ def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any])
setattr(message_object, key, value)
message_object.from_email = stage.from_address
# Because we use the Message-ID as UID for the task, manually assign it
message_id = make_msgid(domain=DNS_NAME)
message_object.extra_headers["Message-ID"] = message_id
LOGGER.debug("Sending mail", to=message_object.to)
@@ -57,7 +58,6 @@ def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any])
TaskResult(
TaskResultStatus.SUCCESSFUL,
messages=["Successfully sent Mail."],
uid=message_id,
)
)
except (SMTPException, ConnectionError) as exc:

View File

@@ -1,7 +1,6 @@
"""passbook password stage"""
from typing import Any, Dict, List, Optional
from django.contrib import messages
from django.contrib.auth import _clean_credentials
from django.contrib.auth.backends import BaseBackend
from django.contrib.auth.signals import user_login_failed
@@ -122,5 +121,4 @@ class PasswordStageView(FormView, StageView):
self.executor.plan.context[
PLAN_CONTEXT_AUTHENTICATION_BACKEND
] = user.backend
messages.success(self.request, _("Successfully logged in!"))
return self.executor.stage_ok()

View File

@@ -13,4 +13,5 @@ class UserLoginStageForm(forms.ModelForm):
fields = ["name", "session_duration"]
widgets = {
"name": forms.TextInput(),
"session_duration": forms.TextInput(),
}

View File

@@ -0,0 +1,38 @@
# Generated by Django 3.1.2 on 2020-10-26 20:21
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import passbook.lib.utils.time
def update_duration(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
UserLoginStage = apps.get_model("passbook_stages_user_login", "userloginstage")
db_alias = schema_editor.connection.alias
for stage in UserLoginStage.objects.using(db_alias).all():
if stage.session_duration.isdigit():
stage.session_duration = f"seconds={stage.session_duration}"
stage.save()
class Migration(migrations.Migration):
dependencies = [
("passbook_stages_user_login", "0002_userloginstage_session_duration"),
]
operations = [
migrations.AlterField(
model_name="userloginstage",
name="session_duration",
field=models.TextField(
default="seconds=0",
help_text="Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)",
validators=[passbook.lib.utils.time.timedelta_string_validator],
),
),
migrations.RunPython(update_duration),
]

View File

@@ -8,16 +8,19 @@ from django.views import View
from rest_framework.serializers import BaseSerializer
from passbook.flows.models import Stage
from passbook.lib.utils.time import timedelta_string_validator
class UserLoginStage(Stage):
"""Attaches the currently pending user to the current session."""
session_duration = models.PositiveIntegerField(
default=0,
session_duration = models.TextField(
default="seconds=0",
validators=[timedelta_string_validator],
help_text=_(
"Determines how long a session lasts, in seconds. Default of 0 means"
" that the sessions lasts until the browser is closed."
"Determines how long a session lasts. Default of 0 means "
"that the sessions lasts until the browser is closed. "
"(Format: hours=-1;minutes=-2;seconds=-3)"
),
)

View File

@@ -7,6 +7,7 @@ from structlog import get_logger
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import StageView
from passbook.lib.utils.time import timedelta_from_string
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
LOGGER = get_logger()
@@ -32,11 +33,16 @@ class UserLoginStageView(StageView):
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER],
backend=backend,
)
self.request.session.set_expiry(self.executor.current_stage.session_duration)
delta = timedelta_from_string(self.executor.current_stage.session_duration)
if delta.seconds == 0:
self.request.session.set_expiry(0)
else:
self.request.session.set_expiry(delta)
LOGGER.debug(
"Logged in",
user=self.executor.plan.context[PLAN_CONTEXT_PENDING_USER],
flow_slug=self.executor.flow.slug,
session_duration=self.executor.current_stage.session_duration,
)
messages.success(self.request, _("Successfully logged in!"))
return self.executor.stage_ok()

View File

@@ -105,5 +105,7 @@ class TestUserLoginStage(TestCase):
def test_form(self):
"""Test Form"""
data = {"name": "test", "session_duration": 0}
data = {"name": "test", "session_duration": "seconds=0"}
self.assertEqual(UserLoginStageForm(data).is_valid(), True)
data = {"name": "test", "session_duration": "123"}
self.assertEqual(UserLoginStageForm(data).is_valid(), False)

View File

@@ -34,9 +34,9 @@
"integrity": "sha512-OEdH7SyC1suTdhBGW91/zBfR6qaIhThbcN8PUXtXilY4GYnSBbVqOntdHbC1vXwsDnX0Qix2m2+DSU1J51ybOQ=="
},
"@patternfly/patternfly": {
"version": "4.50.4",
"resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.50.4.tgz",
"integrity": "sha512-eoJ/U11m+1uJMt8HTFCJeUNazoHC58Ot6gzfNnJvbX5kibpDdvrMvLk2iuGhEfwzQmiH7BSrxjZqMyevbSZ2Cw=="
"version": "4.59.1",
"resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.59.1.tgz",
"integrity": "sha512-zk3aqg62JXMTzzJMJsyVgt5fXlcxUUkRKkaxUv/hwpjhGiyLexZ1l3Gupb9ziYl74p38KzbbfcfdnlFCwJZfgg=="
},
"@rollup/pluginutils": {
"version": "3.1.0",
@@ -203,9 +203,9 @@
}
},
"codemirror": {
"version": "5.58.1",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.58.1.tgz",
"integrity": "sha512-UGb/ueu20U4xqWk8hZB3xIfV2/SFqnSLYONiM3wTMDqko0bsYrsAkGGhqUzbRkYm89aBKPyHtuNEbVWF9FTFzw=="
"version": "5.58.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.58.2.tgz",
"integrity": "sha512-K/hOh24cCwRutd1Mk3uLtjWzNISOkm4fvXiMO7LucCrqbh6aJDdtqUziim3MZUI6wOY0rvY1SlL1Ork01uMy6w=="
},
"color-convert": {
"version": "1.9.3",
@@ -442,9 +442,9 @@
}
},
"rollup": {
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.32.0.tgz",
"integrity": "sha512-0FIG1jY88uhCP2yP4CfvtKEqPDRmsUwfY1kEOOM+DH/KOGATgaIFd/is1+fQOxsvh62ELzcFfKonwKWnHhrqmw==",
"version": "2.32.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.32.1.tgz",
"integrity": "sha512-Op2vWTpvK7t6/Qnm1TTh7VjEZZkN8RWgf0DHbkKzQBwNf748YhXbozHVefqpPp/Fuyk/PQPAnYsBxAEtlMvpUw==",
"requires": {
"fsevents": "~2.1.2"
}

View File

@@ -6,12 +6,12 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.1",
"@patternfly/patternfly": "^4.50.4",
"@patternfly/patternfly": "^4.59.1",
"chart.js": "^2.9.4",
"codemirror": "^5.58.1",
"codemirror": "^5.58.2",
"lit-element": "^2.4.0",
"lit-html": "^1.3.0",
"rollup": "^2.32.0"
"rollup": "^2.32.1"
},
"devDependencies": {
"rollup-plugin-commonjs": "^10.1.0",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -1,7 +1,7 @@
import { LitElement, html } from 'lit-element';
import { updateMessages } from "./Messages.js";
class FetchFillSlot extends LitElement {
class FlowShellCard extends LitElement {
static get properties() {
return {
@@ -15,7 +15,19 @@ class FetchFillSlot extends LitElement {
}
firstUpdated() {
fetch(this.flowBodyUrl).then(r => r.json()).then(r => this.updateCard(r));
fetch(this.flowBodyUrl).then(r => {
if (!r.ok) {
throw Error(r.statusText);
}
return r;
}).then((r) => {
return r.json()
}).then((r) => {
this.updateCard(r)
}).catch((e) => {
// Catch JSON or Update errors
this.errorMessage(e);
});
}
async updateCard(data) {
@@ -83,14 +95,39 @@ class FetchFillSlot extends LitElement {
fetch(this.flowBodyUrl, {
method: 'post',
body: formData,
}).then(response => response.json()).then(data => {
}).then((response) => {
return response.json()
}).then(data => {
this.updateCard(data);
}).catch((e) => {
this.errorMessage(e);
});
});
form.classList.add("pb-flow-wrapped");
});
}
errorMessage(error) {
this.flowBody = `
<style>
.pb-exception {
font-family: monospace;
overflow-x: scroll;
}
</style>
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
Whoops!
</h1>
</header>
<div class="pf-c-login__main-body">
<h3>
Something went wrong! Please try again later.
</h3>
<pre class="pb-exception">${error}</pre>
</div>`;
}
loading() {
return html`
<div class="pf-c-login__main-body pb-loading">
@@ -110,4 +147,4 @@ class FetchFillSlot extends LitElement {
}
}
customElements.define('flow-shell-card', FetchFillSlot);
customElements.define('flow-shell-card', FlowShellCard);

View File

@@ -8,7 +8,7 @@ import (
type Claims struct {
Proxy struct {
UserAttributes map[string]string `json:"user_attributes"`
UserAttributes map[string]interface{} `json:"user_attributes"`
} `json:"pb_proxy"`
}

View File

@@ -0,0 +1,68 @@
package proxy
import (
"net"
"net/http"
"strings"
"time"
sessionsapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions"
"github.com/oauth2-proxy/oauth2-proxy/pkg/cookies"
)
// MakeCSRFCookie creates a cookie for CSRF
func (p *OAuthProxy) MakeCSRFCookie(req *http.Request, value string, expiration time.Duration, now time.Time) *http.Cookie {
return p.makeCookie(req, p.CSRFCookieName, value, expiration, now)
}
func (p *OAuthProxy) makeCookie(req *http.Request, name string, value string, expiration time.Duration, now time.Time) *http.Cookie {
cookieDomain := cookies.GetCookieDomain(req, p.CookieDomains)
if cookieDomain != "" {
domain := cookies.GetRequestHost(req)
if h, _, err := net.SplitHostPort(domain); err == nil {
domain = h
}
if !strings.HasSuffix(domain, cookieDomain) {
p.logger.Errorf("Warning: request host is %q but using configured cookie domain of %q", domain, cookieDomain)
}
}
return &http.Cookie{
Name: name,
Value: value,
Path: p.CookiePath,
Domain: cookieDomain,
HttpOnly: p.CookieHTTPOnly,
Secure: p.CookieSecure,
Expires: now.Add(expiration),
SameSite: cookies.ParseSameSite(p.CookieSameSite),
}
}
// ClearCSRFCookie creates a cookie to unset the CSRF cookie stored in the user's
// session
func (p *OAuthProxy) ClearCSRFCookie(rw http.ResponseWriter, req *http.Request) {
http.SetCookie(rw, p.MakeCSRFCookie(req, "", time.Hour*-1, time.Now()))
}
// SetCSRFCookie adds a CSRF cookie to the response
func (p *OAuthProxy) SetCSRFCookie(rw http.ResponseWriter, req *http.Request, val string) {
http.SetCookie(rw, p.MakeCSRFCookie(req, val, p.CookieExpire, time.Now()))
}
// ClearSessionCookie creates a cookie to unset the user's authentication cookie
// stored in the user's session
func (p *OAuthProxy) ClearSessionCookie(rw http.ResponseWriter, req *http.Request) error {
return p.sessionStore.Clear(rw, req)
}
// LoadCookiedSession reads the user's authentication details from the request
func (p *OAuthProxy) LoadCookiedSession(req *http.Request) (*sessionsapi.SessionState, error) {
return p.sessionStore.Load(req)
}
// SaveSession creates a new session cookie value and sets this on the response
func (p *OAuthProxy) SaveSession(rw http.ResponseWriter, req *http.Request, s *sessionsapi.SessionState) error {
return p.sessionStore.Save(rw, req, s)
}

233
proxy/pkg/proxy/oauth.go Normal file
View File

@@ -0,0 +1,233 @@
package proxy
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
sessionsapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions"
"github.com/oauth2-proxy/oauth2-proxy/pkg/encryption"
"github.com/oauth2-proxy/oauth2-proxy/pkg/ip"
)
// GetRedirectURI returns the redirectURL that the upstream OAuth Provider will
// redirect clients to once authenticated
func (p *OAuthProxy) GetRedirectURI(host string) string {
// default to the request Host if not set
if p.redirectURL.Host != "" {
return p.redirectURL.String()
}
u := *p.redirectURL
if u.Scheme == "" {
if p.CookieSecure {
u.Scheme = httpsScheme
} else {
u.Scheme = httpScheme
}
}
u.Host = host
return u.String()
}
func (p *OAuthProxy) redeemCode(ctx context.Context, host, code string) (s *sessionsapi.SessionState, err error) {
if code == "" {
return nil, errors.New("missing code")
}
redirectURI := p.GetRedirectURI(host)
s, err = p.provider.Redeem(ctx, redirectURI, code)
if err != nil {
return
}
if s.Email == "" {
s.Email, err = p.provider.GetEmailAddress(ctx, s)
}
if s.PreferredUsername == "" {
s.PreferredUsername, err = p.provider.GetPreferredUsername(ctx, s)
if err != nil && err.Error() == "not implemented" {
err = nil
}
}
if s.User == "" {
s.User, err = p.provider.GetUserName(ctx, s)
if err != nil && err.Error() == "not implemented" {
err = nil
}
}
return
}
// GetRedirect reads the query parameter to get the URL to redirect clients to
// once authenticated with the OAuthProxy
func (p *OAuthProxy) GetRedirect(req *http.Request) (redirect string, err error) {
err = req.ParseForm()
if err != nil {
return
}
redirect = req.Header.Get("X-Auth-Request-Redirect")
if req.Form.Get("rd") != "" {
redirect = req.Form.Get("rd")
}
if !p.IsValidRedirect(redirect) {
// Use RequestURI to preserve ?query
redirect = req.URL.RequestURI()
if strings.HasPrefix(redirect, p.ProxyPrefix) {
redirect = "/"
}
}
return
}
// IsValidRedirect checks whether the redirect URL is whitelisted
func (p *OAuthProxy) IsValidRedirect(redirect string) bool {
switch {
case redirect == "":
// The user didn't specify a redirect, should fallback to `/`
return false
case strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") && !invalidRedirectRegex.MatchString(redirect):
return true
case strings.HasPrefix(redirect, "http://") || strings.HasPrefix(redirect, "https://"):
redirectURL, err := url.Parse(redirect)
if err != nil {
p.logger.Printf("Rejecting invalid redirect %q: scheme unsupported or missing", redirect)
return false
}
redirectHostname := redirectURL.Hostname()
for _, domain := range p.whitelistDomains {
domainHostname, domainPort := splitHostPort(strings.TrimLeft(domain, "."))
if domainHostname == "" {
continue
}
if (redirectHostname == domainHostname) || (strings.HasPrefix(domain, ".") && strings.HasSuffix(redirectHostname, domainHostname)) {
// the domain names match, now validate the ports
// if the whitelisted domain's port is '*', allow all ports
// if the whitelisted domain contains a specific port, only allow that port
// if the whitelisted domain doesn't contain a port at all, only allow empty redirect ports ie http and https
redirectPort := redirectURL.Port()
if (domainPort == "*") ||
(domainPort == redirectPort) ||
(domainPort == "" && redirectPort == "") {
return true
}
}
}
p.logger.Printf("Rejecting invalid redirect %q: domain / port not in whitelist", redirect)
return false
default:
p.logger.Printf("Rejecting invalid redirect %q: not an absolute or relative URL", redirect)
return false
}
}
// IsWhitelistedRequest is used to check if auth should be skipped for this request
func (p *OAuthProxy) IsWhitelistedRequest(req *http.Request) bool {
isPreflightRequestAllowed := p.skipAuthPreflight && req.Method == "OPTIONS"
return isPreflightRequestAllowed || p.IsWhitelistedPath(req.URL.Path)
}
// IsWhitelistedPath is used to check if the request path is allowed without auth
func (p *OAuthProxy) IsWhitelistedPath(path string) bool {
for _, u := range p.compiledRegex {
if u.MatchString(path) {
return true
}
}
return false
}
// OAuthStart starts the OAuth2 authentication flow
func (p *OAuthProxy) OAuthStart(rw http.ResponseWriter, req *http.Request) {
prepareNoCache(rw)
nonce, err := encryption.Nonce()
if err != nil {
p.logger.Errorf("Error obtaining nonce: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
p.SetCSRFCookie(rw, req, nonce)
redirect, err := p.GetRedirect(req)
if err != nil {
p.logger.Errorf("Error obtaining redirect: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
redirectURI := p.GetRedirectURI(req.Host)
http.Redirect(rw, req, p.provider.GetLoginURL(redirectURI, fmt.Sprintf("%v:%v", nonce, redirect)), http.StatusFound)
}
// OAuthCallback is the OAuth2 authentication flow callback that finishes the
// OAuth2 authentication flow
func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) {
remoteAddr := ip.GetClientString(p.realClientIPParser, req, true)
// finish the oauth cycle
err := req.ParseForm()
if err != nil {
p.logger.Errorf("Error while parsing OAuth2 callback: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
errorString := req.Form.Get("error")
if errorString != "" {
p.logger.Errorf("Error while parsing OAuth2 callback: %s", errorString)
p.ErrorPage(rw, http.StatusForbidden, "Permission Denied", errorString)
return
}
session, err := p.redeemCode(req.Context(), req.Host, req.Form.Get("code"))
if err != nil {
p.logger.Errorf("Error redeeming code during OAuth2 callback: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", "Internal Error")
return
}
s := strings.SplitN(req.Form.Get("state"), ":", 2)
if len(s) != 2 {
p.logger.Error("Error while parsing OAuth2 state: invalid length")
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", "Invalid State")
return
}
nonce := s[0]
redirect := s[1]
c, err := req.Cookie(p.CSRFCookieName)
if err != nil {
p.logger.WithField("user", session.Email).WithField("status", "AuthFailure").Info("Invalid authentication via OAuth2: unable to obtain CSRF cookie")
p.ErrorPage(rw, http.StatusForbidden, "Permission Denied", err.Error())
return
}
p.ClearCSRFCookie(rw, req)
if c.Value != nonce {
p.logger.WithField("user", session.Email).WithField("status", "AuthFailure").Info("Invalid authentication via OAuth2: CSRF token mismatch, potential attack")
p.ErrorPage(rw, http.StatusForbidden, "Permission Denied", "CSRF Failed")
return
}
if !p.IsValidRedirect(redirect) {
redirect = "/"
}
// set cookie, or deny
if p.provider.ValidateGroup(session.Email) {
p.logger.WithField("user", session.Email).WithField("status", "AuthFailure").Infof("Authenticated via OAuth2: %s", session)
err := p.SaveSession(rw, req, session)
if err != nil {
p.logger.Printf("Error saving session state for %s: %v", remoteAddr, err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
http.Redirect(rw, req, redirect, http.StatusFound)
} else {
p.logger.WithField("user", session.Email).WithField("status", "AuthFailure").Info("Invalid authentication via OAuth2: unauthorized")
p.ErrorPage(rw, http.StatusForbidden, "Permission Denied", "Invalid Account")
}
}

View File

@@ -1,986 +0,0 @@
package proxy
import (
"context"
b64 "encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/coreos/go-oidc"
"github.com/justinas/alice"
ipapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/ip"
middlewareapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/middleware"
"github.com/oauth2-proxy/oauth2-proxy/pkg/apis/options"
sessionsapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions"
"github.com/oauth2-proxy/oauth2-proxy/pkg/authentication/basic"
"github.com/oauth2-proxy/oauth2-proxy/pkg/cookies"
"github.com/oauth2-proxy/oauth2-proxy/pkg/encryption"
"github.com/oauth2-proxy/oauth2-proxy/pkg/ip"
"github.com/oauth2-proxy/oauth2-proxy/pkg/middleware"
"github.com/oauth2-proxy/oauth2-proxy/pkg/sessions"
"github.com/oauth2-proxy/oauth2-proxy/pkg/upstream"
"github.com/oauth2-proxy/oauth2-proxy/providers"
log "github.com/sirupsen/logrus"
)
const (
httpScheme = "http"
httpsScheme = "https"
applicationJSON = "application/json"
)
var (
// ErrNeedsLogin means the user should be redirected to the login page
ErrNeedsLogin = errors.New("redirect to login page")
// Used to check final redirects are not susceptible to open redirects.
// Matches //, /\ and both of these with whitespace in between (eg / / or / \).
invalidRedirectRegex = regexp.MustCompile(`[/\\](?:[\s\v]*|\.{1,2})[/\\]`)
)
// OAuthProxy is the main authentication proxy
type OAuthProxy struct {
CookieSeed string
CookieName string
CSRFCookieName string
CookieDomains []string
CookiePath string
CookieSecure bool
CookieHTTPOnly bool
CookieExpire time.Duration
CookieRefresh time.Duration
CookieSameSite string
RobotsPath string
SignInPath string
SignOutPath string
OAuthStartPath string
OAuthCallbackPath string
AuthOnlyPath string
UserInfoPath string
redirectURL *url.URL // the url to receive requests at
whitelistDomains []string
provider providers.Provider
providerNameOverride string
sessionStore sessionsapi.SessionStore
ProxyPrefix string
SignInMessage string
basicAuthValidator basic.Validator
displayHtpasswdForm bool
serveMux http.Handler
SetXAuthRequest bool
PassBasicAuth bool
SetBasicAuth bool
SkipProviderButton bool
PassUserHeaders bool
BasicAuthUserAttribute string
BasicAuthPasswordAttribute string
PassAccessToken bool
SetAuthorization bool
PassAuthorization bool
PreferEmailToUser bool
skipAuthRegex []string
skipAuthPreflight bool
skipAuthStripHeaders bool
skipJwtBearerTokens bool
mainJwtBearerVerifier *oidc.IDTokenVerifier
extraJwtBearerVerifiers []*oidc.IDTokenVerifier
compiledRegex []*regexp.Regexp
templates *template.Template
realClientIPParser ipapi.RealClientIPParser
trustedIPs *ip.NetSet
Banner string
Footer string
sessionChain alice.Chain
logger *log.Entry
}
// NewOAuthProxy creates a new instance of OAuthProxy from the options provided
func NewOAuthProxy(opts *options.Options) (*OAuthProxy, error) {
logger := log.WithField("component", "proxy").WithField("client-id", opts.ClientID)
sessionStore, err := sessions.NewSessionStore(&opts.Session, &opts.Cookie)
if err != nil {
return nil, fmt.Errorf("error initialising session store: %v", err)
}
templates := getTemplates()
proxyErrorHandler := upstream.NewProxyErrorHandler(templates.Lookup("error.html"), opts.ProxyPrefix)
upstreamProxy, err := upstream.NewProxy(opts.UpstreamServers, opts.GetSignatureData(), proxyErrorHandler)
if err != nil {
return nil, fmt.Errorf("error initialising upstream proxy: %v", err)
}
for _, u := range opts.GetCompiledRegex() {
logger.Printf("compiled skip-auth-regex => %q", u)
}
if opts.SkipJwtBearerTokens {
logger.Printf("Skipping JWT tokens from configured OIDC issuer: %q", opts.OIDCIssuerURL)
for _, issuer := range opts.ExtraJwtIssuers {
logger.Printf("Skipping JWT tokens from extra JWT issuer: %q", issuer)
}
}
redirectURL := opts.GetRedirectURL()
if redirectURL.Path == "" {
redirectURL.Path = fmt.Sprintf("%s/callback", opts.ProxyPrefix)
}
logger.Printf("proxy instance configured for Client ID: %s", opts.ClientID)
trustedIPs := ip.NewNetSet()
for _, ipStr := range opts.TrustedIPs {
if ipNet := ip.ParseIPNet(ipStr); ipNet != nil {
trustedIPs.AddIPNet(*ipNet)
} else {
return nil, fmt.Errorf("could not parse IP network (%s)", ipStr)
}
}
var basicAuthValidator basic.Validator
if opts.HtpasswdFile != "" {
logger.Printf("using htpasswd file: %s", opts.HtpasswdFile)
var err error
basicAuthValidator, err = basic.NewHTPasswdValidator(opts.HtpasswdFile)
if err != nil {
return nil, fmt.Errorf("could not load htpasswdfile: %v", err)
}
}
sessionChain := buildSessionChain(opts, sessionStore, basicAuthValidator)
return &OAuthProxy{
CookieName: opts.Cookie.Name,
CSRFCookieName: fmt.Sprintf("%v_%v", opts.Cookie.Name, "csrf"),
CookieSeed: opts.Cookie.Secret,
CookieDomains: opts.Cookie.Domains,
CookiePath: opts.Cookie.Path,
CookieSecure: opts.Cookie.Secure,
CookieHTTPOnly: opts.Cookie.HTTPOnly,
CookieExpire: opts.Cookie.Expire,
CookieRefresh: opts.Cookie.Refresh,
CookieSameSite: opts.Cookie.SameSite,
RobotsPath: "/robots.txt",
SignInPath: fmt.Sprintf("%s/sign_in", opts.ProxyPrefix),
SignOutPath: fmt.Sprintf("%s/sign_out", opts.ProxyPrefix),
OAuthStartPath: fmt.Sprintf("%s/start", opts.ProxyPrefix),
OAuthCallbackPath: fmt.Sprintf("%s/callback", opts.ProxyPrefix),
AuthOnlyPath: fmt.Sprintf("%s/auth", opts.ProxyPrefix),
UserInfoPath: fmt.Sprintf("%s/userinfo", opts.ProxyPrefix),
ProxyPrefix: opts.ProxyPrefix,
provider: opts.GetProvider(),
providerNameOverride: opts.ProviderName,
sessionStore: sessionStore,
serveMux: upstreamProxy,
redirectURL: redirectURL,
whitelistDomains: opts.WhitelistDomains,
skipAuthRegex: opts.SkipAuthRegex,
skipAuthPreflight: opts.SkipAuthPreflight,
skipAuthStripHeaders: opts.SkipAuthStripHeaders,
skipJwtBearerTokens: opts.SkipJwtBearerTokens,
mainJwtBearerVerifier: opts.GetOIDCVerifier(),
extraJwtBearerVerifiers: opts.GetJWTBearerVerifiers(),
compiledRegex: opts.GetCompiledRegex(),
realClientIPParser: opts.GetRealClientIPParser(),
SetXAuthRequest: opts.SetXAuthRequest,
PassBasicAuth: opts.PassBasicAuth,
SetBasicAuth: opts.SetBasicAuth,
PassUserHeaders: opts.PassUserHeaders,
PassAccessToken: opts.PassAccessToken,
SetAuthorization: opts.SetAuthorization,
PassAuthorization: opts.PassAuthorization,
PreferEmailToUser: opts.PreferEmailToUser,
SkipProviderButton: opts.SkipProviderButton,
templates: templates,
trustedIPs: trustedIPs,
Banner: opts.Banner,
Footer: opts.Footer,
SignInMessage: buildSignInMessage(opts),
basicAuthValidator: basicAuthValidator,
displayHtpasswdForm: basicAuthValidator != nil,
sessionChain: sessionChain,
logger: logger,
}, nil
}
func buildSessionChain(opts *options.Options, sessionStore sessionsapi.SessionStore, validator basic.Validator) alice.Chain {
chain := alice.New(middleware.NewScope())
if opts.SkipJwtBearerTokens {
sessionLoaders := []middlewareapi.TokenToSessionLoader{}
if opts.GetOIDCVerifier() != nil {
sessionLoaders = append(sessionLoaders, middlewareapi.TokenToSessionLoader{
Verifier: opts.GetOIDCVerifier(),
TokenToSession: opts.GetProvider().CreateSessionStateFromBearerToken,
})
}
for _, verifier := range opts.GetJWTBearerVerifiers() {
sessionLoaders = append(sessionLoaders, middlewareapi.TokenToSessionLoader{
Verifier: verifier,
})
}
chain = chain.Append(middleware.NewJwtSessionLoader(sessionLoaders))
}
if validator != nil {
chain = chain.Append(middleware.NewBasicAuthSessionLoader(validator))
}
chain = chain.Append(middleware.NewStoredSessionLoader(&middleware.StoredSessionLoaderOptions{
SessionStore: sessionStore,
RefreshPeriod: opts.Cookie.Refresh,
RefreshSessionIfNeeded: opts.GetProvider().RefreshSessionIfNeeded,
ValidateSessionState: opts.GetProvider().ValidateSessionState,
}))
return chain
}
func buildSignInMessage(opts *options.Options) string {
var msg string
if len(opts.Banner) >= 1 {
if opts.Banner == "-" {
msg = ""
} else {
msg = opts.Banner
}
} else if len(opts.EmailDomains) != 0 && opts.AuthenticatedEmailsFile == "" {
if len(opts.EmailDomains) > 1 {
msg = fmt.Sprintf("Authenticate using one of the following domains: %v", strings.Join(opts.EmailDomains, ", "))
} else if opts.EmailDomains[0] != "*" {
msg = fmt.Sprintf("Authenticate using %v", opts.EmailDomains[0])
}
}
return msg
}
// GetRedirectURI returns the redirectURL that the upstream OAuth Provider will
// redirect clients to once authenticated
func (p *OAuthProxy) GetRedirectURI(host string) string {
// default to the request Host if not set
if p.redirectURL.Host != "" {
return p.redirectURL.String()
}
u := *p.redirectURL
if u.Scheme == "" {
if p.CookieSecure {
u.Scheme = httpsScheme
} else {
u.Scheme = httpScheme
}
}
u.Host = host
return u.String()
}
func (p *OAuthProxy) redeemCode(ctx context.Context, host, code string) (s *sessionsapi.SessionState, err error) {
if code == "" {
return nil, errors.New("missing code")
}
redirectURI := p.GetRedirectURI(host)
s, err = p.provider.Redeem(ctx, redirectURI, code)
if err != nil {
return
}
if s.Email == "" {
s.Email, err = p.provider.GetEmailAddress(ctx, s)
}
if s.PreferredUsername == "" {
s.PreferredUsername, err = p.provider.GetPreferredUsername(ctx, s)
if err != nil && err.Error() == "not implemented" {
err = nil
}
}
if s.User == "" {
s.User, err = p.provider.GetUserName(ctx, s)
if err != nil && err.Error() == "not implemented" {
err = nil
}
}
return
}
// MakeCSRFCookie creates a cookie for CSRF
func (p *OAuthProxy) MakeCSRFCookie(req *http.Request, value string, expiration time.Duration, now time.Time) *http.Cookie {
return p.makeCookie(req, p.CSRFCookieName, value, expiration, now)
}
func (p *OAuthProxy) makeCookie(req *http.Request, name string, value string, expiration time.Duration, now time.Time) *http.Cookie {
cookieDomain := cookies.GetCookieDomain(req, p.CookieDomains)
if cookieDomain != "" {
domain := cookies.GetRequestHost(req)
if h, _, err := net.SplitHostPort(domain); err == nil {
domain = h
}
if !strings.HasSuffix(domain, cookieDomain) {
p.logger.Errorf("Warning: request host is %q but using configured cookie domain of %q", domain, cookieDomain)
}
}
return &http.Cookie{
Name: name,
Value: value,
Path: p.CookiePath,
Domain: cookieDomain,
HttpOnly: p.CookieHTTPOnly,
Secure: p.CookieSecure,
Expires: now.Add(expiration),
SameSite: cookies.ParseSameSite(p.CookieSameSite),
}
}
// ClearCSRFCookie creates a cookie to unset the CSRF cookie stored in the user's
// session
func (p *OAuthProxy) ClearCSRFCookie(rw http.ResponseWriter, req *http.Request) {
http.SetCookie(rw, p.MakeCSRFCookie(req, "", time.Hour*-1, time.Now()))
}
// SetCSRFCookie adds a CSRF cookie to the response
func (p *OAuthProxy) SetCSRFCookie(rw http.ResponseWriter, req *http.Request, val string) {
http.SetCookie(rw, p.MakeCSRFCookie(req, val, p.CookieExpire, time.Now()))
}
// ClearSessionCookie creates a cookie to unset the user's authentication cookie
// stored in the user's session
func (p *OAuthProxy) ClearSessionCookie(rw http.ResponseWriter, req *http.Request) error {
return p.sessionStore.Clear(rw, req)
}
// LoadCookiedSession reads the user's authentication details from the request
func (p *OAuthProxy) LoadCookiedSession(req *http.Request) (*sessionsapi.SessionState, error) {
return p.sessionStore.Load(req)
}
// SaveSession creates a new session cookie value and sets this on the response
func (p *OAuthProxy) SaveSession(rw http.ResponseWriter, req *http.Request, s *sessionsapi.SessionState) error {
return p.sessionStore.Save(rw, req, s)
}
// RobotsTxt disallows scraping pages from the OAuthProxy
func (p *OAuthProxy) RobotsTxt(rw http.ResponseWriter) {
_, err := fmt.Fprintf(rw, "User-agent: *\nDisallow: /")
if err != nil {
p.logger.Printf("Error writing robots.txt: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
rw.WriteHeader(http.StatusOK)
}
// ErrorPage writes an error response
func (p *OAuthProxy) ErrorPage(rw http.ResponseWriter, code int, title string, message string) {
rw.WriteHeader(code)
t := struct {
Title string
Message string
ProxyPrefix string
}{
Title: fmt.Sprintf("%d %s", code, title),
Message: message,
ProxyPrefix: p.ProxyPrefix,
}
err := p.templates.ExecuteTemplate(rw, "error.html", t)
if err != nil {
p.logger.Printf("Error rendering error.html template: %v", err)
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
}
}
// SignInPage writes the sing in template to the response
func (p *OAuthProxy) SignInPage(rw http.ResponseWriter, req *http.Request, code int) {
prepareNoCache(rw)
err := p.ClearSessionCookie(rw, req)
if err != nil {
p.logger.Printf("Error clearing session cookie: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
rw.WriteHeader(code)
redirectURL, err := p.GetRedirect(req)
if err != nil {
p.logger.Errorf("Error obtaining redirect: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
if redirectURL == p.SignInPath {
redirectURL = "/"
}
// We allow unescaped template.HTML since it is user configured options
/* #nosec G203 */
t := struct {
ProviderName string
SignInMessage template.HTML
CustomLogin bool
Redirect string
Version string
ProxyPrefix string
Footer template.HTML
}{
ProviderName: p.provider.Data().ProviderName,
SignInMessage: template.HTML(p.SignInMessage),
CustomLogin: p.displayHtpasswdForm,
Redirect: redirectURL,
Version: "",
ProxyPrefix: p.ProxyPrefix,
Footer: template.HTML(p.Footer),
}
if p.providerNameOverride != "" {
t.ProviderName = p.providerNameOverride
}
err = p.templates.ExecuteTemplate(rw, "sign_in.html", t)
if err != nil {
p.logger.Printf("Error rendering sign_in.html template: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
}
}
// ManualSignIn handles basic auth logins to the proxy
func (p *OAuthProxy) ManualSignIn(req *http.Request) (string, bool) {
if req.Method != "POST" || p.basicAuthValidator == nil {
return "", false
}
user := req.FormValue("username")
passwd := req.FormValue("password")
if user == "" {
return "", false
}
// check auth
if p.basicAuthValidator.Validate(user, passwd) {
p.logger.WithField("user", user).WithField("status", "AuthSuccess").Info("Authenticated via HtpasswdFile")
return user, true
}
p.logger.WithField("user", user).WithField("status", "AuthFailure").Info("Invalid authentication via HtpasswdFile")
return "", false
}
// GetRedirect reads the query parameter to get the URL to redirect clients to
// once authenticated with the OAuthProxy
func (p *OAuthProxy) GetRedirect(req *http.Request) (redirect string, err error) {
err = req.ParseForm()
if err != nil {
return
}
redirect = req.Header.Get("X-Auth-Request-Redirect")
if req.Form.Get("rd") != "" {
redirect = req.Form.Get("rd")
}
if !p.IsValidRedirect(redirect) {
// Use RequestURI to preserve ?query
redirect = req.URL.RequestURI()
if strings.HasPrefix(redirect, p.ProxyPrefix) {
redirect = "/"
}
}
return
}
// splitHostPort separates host and port. If the port is not valid, it returns
// the entire input as host, and it doesn't check the validity of the host.
// Unlike net.SplitHostPort, but per RFC 3986, it requires ports to be numeric.
// *** taken from net/url, modified validOptionalPort() to accept ":*"
func splitHostPort(hostport string) (host, port string) {
host = hostport
colon := strings.LastIndexByte(host, ':')
if colon != -1 && validOptionalPort(host[colon:]) {
host, port = host[:colon], host[colon+1:]
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
return
}
// validOptionalPort reports whether port is either an empty string
// or matches /^:\d*$/
// *** taken from net/url, modified to accept ":*"
func validOptionalPort(port string) bool {
if port == "" || port == ":*" {
return true
}
if port[0] != ':' {
return false
}
for _, b := range port[1:] {
if b < '0' || b > '9' {
return false
}
}
return true
}
// IsValidRedirect checks whether the redirect URL is whitelisted
func (p *OAuthProxy) IsValidRedirect(redirect string) bool {
switch {
case redirect == "":
// The user didn't specify a redirect, should fallback to `/`
return false
case strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") && !invalidRedirectRegex.MatchString(redirect):
return true
case strings.HasPrefix(redirect, "http://") || strings.HasPrefix(redirect, "https://"):
redirectURL, err := url.Parse(redirect)
if err != nil {
p.logger.Printf("Rejecting invalid redirect %q: scheme unsupported or missing", redirect)
return false
}
redirectHostname := redirectURL.Hostname()
for _, domain := range p.whitelistDomains {
domainHostname, domainPort := splitHostPort(strings.TrimLeft(domain, "."))
if domainHostname == "" {
continue
}
if (redirectHostname == domainHostname) || (strings.HasPrefix(domain, ".") && strings.HasSuffix(redirectHostname, domainHostname)) {
// the domain names match, now validate the ports
// if the whitelisted domain's port is '*', allow all ports
// if the whitelisted domain contains a specific port, only allow that port
// if the whitelisted domain doesn't contain a port at all, only allow empty redirect ports ie http and https
redirectPort := redirectURL.Port()
if (domainPort == "*") ||
(domainPort == redirectPort) ||
(domainPort == "" && redirectPort == "") {
return true
}
}
}
p.logger.Printf("Rejecting invalid redirect %q: domain / port not in whitelist", redirect)
return false
default:
p.logger.Printf("Rejecting invalid redirect %q: not an absolute or relative URL", redirect)
return false
}
}
// IsWhitelistedRequest is used to check if auth should be skipped for this request
func (p *OAuthProxy) IsWhitelistedRequest(req *http.Request) bool {
isPreflightRequestAllowed := p.skipAuthPreflight && req.Method == "OPTIONS"
return isPreflightRequestAllowed || p.IsWhitelistedPath(req.URL.Path) || p.IsTrustedIP(req)
}
// IsWhitelistedPath is used to check if the request path is allowed without auth
func (p *OAuthProxy) IsWhitelistedPath(path string) bool {
for _, u := range p.compiledRegex {
if u.MatchString(path) {
return true
}
}
return false
}
// See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching?hl=en
var noCacheHeaders = map[string]string{
"Expires": time.Unix(0, 0).Format(time.RFC1123),
"Cache-Control": "no-cache, no-store, must-revalidate, max-age=0",
"X-Accel-Expires": "0", // https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/
}
// prepareNoCache prepares headers for preventing browser caching.
func prepareNoCache(w http.ResponseWriter) {
// Set NoCache headers
for k, v := range noCacheHeaders {
w.Header().Set(k, v)
}
}
// IsTrustedIP is used to check if a request comes from a trusted client IP address.
func (p *OAuthProxy) IsTrustedIP(req *http.Request) bool {
if p.trustedIPs == nil {
return false
}
remoteAddr, err := ip.GetClientIP(p.realClientIPParser, req)
if err != nil {
p.logger.Errorf("Error obtaining real IP for trusted IP list: %v", err)
// Possibly spoofed X-Real-IP header
return false
}
if remoteAddr == nil {
return false
}
return p.trustedIPs.Has(remoteAddr)
}
func (p *OAuthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path != p.AuthOnlyPath && strings.HasPrefix(req.URL.Path, p.ProxyPrefix) {
prepareNoCache(rw)
}
switch path := req.URL.Path; {
case path == p.RobotsPath:
p.RobotsTxt(rw)
case p.IsWhitelistedRequest(req):
p.SkipAuthProxy(rw, req)
case path == p.SignInPath:
p.SignIn(rw, req)
case path == p.SignOutPath:
p.SignOut(rw, req)
case path == p.OAuthStartPath:
p.OAuthStart(rw, req)
case path == p.OAuthCallbackPath:
p.OAuthCallback(rw, req)
case path == p.AuthOnlyPath:
p.AuthenticateOnly(rw, req)
case path == p.UserInfoPath:
p.UserInfo(rw, req)
default:
p.Proxy(rw, req)
}
}
// SignIn serves a page prompting users to sign in
func (p *OAuthProxy) SignIn(rw http.ResponseWriter, req *http.Request) {
redirect, err := p.GetRedirect(req)
if err != nil {
p.logger.Errorf("Error obtaining redirect: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
user, ok := p.ManualSignIn(req)
if ok {
session := &sessionsapi.SessionState{User: user}
err = p.SaveSession(rw, req, session)
if err != nil {
p.logger.Printf("Error saving session: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
http.Redirect(rw, req, redirect, http.StatusFound)
} else {
if p.SkipProviderButton {
p.OAuthStart(rw, req)
} else {
p.SignInPage(rw, req, http.StatusOK)
}
}
}
//UserInfo endpoint outputs session email and preferred username in JSON format
func (p *OAuthProxy) UserInfo(rw http.ResponseWriter, req *http.Request) {
session, err := p.getAuthenticatedSession(rw, req)
if err != nil {
http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
userInfo := struct {
Email string `json:"email"`
PreferredUsername string `json:"preferredUsername,omitempty"`
}{
Email: session.Email,
PreferredUsername: session.PreferredUsername,
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
err = json.NewEncoder(rw).Encode(userInfo)
if err != nil {
p.logger.Printf("Error encoding user info: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
}
}
// SignOut sends a response to clear the authentication cookie
func (p *OAuthProxy) SignOut(rw http.ResponseWriter, req *http.Request) {
redirect, err := p.GetRedirect(req)
if err != nil {
p.logger.Errorf("Error obtaining redirect: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
err = p.ClearSessionCookie(rw, req)
if err != nil {
p.logger.Errorf("Error clearing session cookie: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
http.Redirect(rw, req, redirect, http.StatusFound)
}
// OAuthStart starts the OAuth2 authentication flow
func (p *OAuthProxy) OAuthStart(rw http.ResponseWriter, req *http.Request) {
prepareNoCache(rw)
nonce, err := encryption.Nonce()
if err != nil {
p.logger.Errorf("Error obtaining nonce: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
p.SetCSRFCookie(rw, req, nonce)
redirect, err := p.GetRedirect(req)
if err != nil {
p.logger.Errorf("Error obtaining redirect: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
redirectURI := p.GetRedirectURI(req.Host)
http.Redirect(rw, req, p.provider.GetLoginURL(redirectURI, fmt.Sprintf("%v:%v", nonce, redirect)), http.StatusFound)
}
// OAuthCallback is the OAuth2 authentication flow callback that finishes the
// OAuth2 authentication flow
func (p *OAuthProxy) OAuthCallback(rw http.ResponseWriter, req *http.Request) {
remoteAddr := ip.GetClientString(p.realClientIPParser, req, true)
// finish the oauth cycle
err := req.ParseForm()
if err != nil {
p.logger.Errorf("Error while parsing OAuth2 callback: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
errorString := req.Form.Get("error")
if errorString != "" {
p.logger.Errorf("Error while parsing OAuth2 callback: %s", errorString)
p.ErrorPage(rw, http.StatusForbidden, "Permission Denied", errorString)
return
}
session, err := p.redeemCode(req.Context(), req.Host, req.Form.Get("code"))
if err != nil {
p.logger.Errorf("Error redeeming code during OAuth2 callback: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", "Internal Error")
return
}
s := strings.SplitN(req.Form.Get("state"), ":", 2)
if len(s) != 2 {
p.logger.Error("Error while parsing OAuth2 state: invalid length")
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", "Invalid State")
return
}
nonce := s[0]
redirect := s[1]
c, err := req.Cookie(p.CSRFCookieName)
if err != nil {
p.logger.WithField("user", session.Email).WithField("status", "AuthFailure").Info("Invalid authentication via OAuth2: unable to obtain CSRF cookie")
p.ErrorPage(rw, http.StatusForbidden, "Permission Denied", err.Error())
return
}
p.ClearCSRFCookie(rw, req)
if c.Value != nonce {
p.logger.WithField("user", session.Email).WithField("status", "AuthFailure").Info("Invalid authentication via OAuth2: CSRF token mismatch, potential attack")
p.ErrorPage(rw, http.StatusForbidden, "Permission Denied", "CSRF Failed")
return
}
if !p.IsValidRedirect(redirect) {
redirect = "/"
}
// set cookie, or deny
if p.provider.ValidateGroup(session.Email) {
p.logger.WithField("user", session.Email).WithField("status", "AuthFailure").Infof("Authenticated via OAuth2: %s", session)
err := p.SaveSession(rw, req, session)
if err != nil {
p.logger.Printf("Error saving session state for %s: %v", remoteAddr, err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
http.Redirect(rw, req, redirect, http.StatusFound)
} else {
p.logger.WithField("user", session.Email).WithField("status", "AuthFailure").Info("Invalid authentication via OAuth2: unauthorized")
p.ErrorPage(rw, http.StatusForbidden, "Permission Denied", "Invalid Account")
}
}
// AuthenticateOnly checks whether the user is currently logged in
func (p *OAuthProxy) AuthenticateOnly(rw http.ResponseWriter, req *http.Request) {
session, err := p.getAuthenticatedSession(rw, req)
if err != nil {
http.Error(rw, "unauthorized request", http.StatusUnauthorized)
return
}
// we are authenticated
p.addHeadersForProxying(rw, req, session)
rw.WriteHeader(http.StatusAccepted)
}
// SkipAuthProxy proxies whitelisted requests and skips authentication
func (p *OAuthProxy) SkipAuthProxy(rw http.ResponseWriter, req *http.Request) {
if p.skipAuthStripHeaders {
p.stripAuthHeaders(req)
}
p.serveMux.ServeHTTP(rw, req)
}
// Proxy proxies the user request if the user is authenticated else it prompts
// them to authenticate
func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
session, err := p.getAuthenticatedSession(rw, req)
switch err {
case nil:
// we are authenticated
p.addHeadersForProxying(rw, req, session)
p.serveMux.ServeHTTP(rw, req)
case ErrNeedsLogin:
// we need to send the user to a login screen
if isAjax(req) {
// no point redirecting an AJAX request
p.ErrorJSON(rw, http.StatusUnauthorized)
return
}
if p.SkipProviderButton {
p.OAuthStart(rw, req)
} else {
p.SignInPage(rw, req, http.StatusForbidden)
}
default:
// unknown error
p.logger.Errorf("Unexpected internal error: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError,
"Internal Error", "Internal Error")
}
}
// getAuthenticatedSession checks whether a user is authenticated and returns a session object and nil error if so
// Returns nil, ErrNeedsLogin if user needs to login.
// Set-Cookie headers may be set on the response as a side-effect of calling this method.
func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.Request) (*sessionsapi.SessionState, error) {
var session *sessionsapi.SessionState
getSession := p.sessionChain.Then(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
session = middleware.GetRequestScope(req).Session
}))
getSession.ServeHTTP(rw, req)
if session == nil {
return nil, ErrNeedsLogin
}
return session, nil
}
// addHeadersForProxying adds the appropriate headers the request / response for proxying
func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Request, session *sessionsapi.SessionState) {
req.Header["X-Forwarded-User"] = []string{session.User}
if session.Email != "" {
req.Header["X-Forwarded-Email"] = []string{session.Email}
}
if session.PreferredUsername != "" {
req.Header["X-Forwarded-Preferred-Username"] = []string{session.PreferredUsername}
req.Header["X-Auth-Username"] = []string{session.PreferredUsername}
} else {
req.Header.Del("X-Forwarded-Preferred-Username")
req.Header.Del("X-Auth-Username")
}
if session.Email != "" {
rw.Header().Set("X-Auth-Request-Email", session.Email)
} else {
rw.Header().Del("X-Auth-Request-Email")
}
if session.PreferredUsername != "" {
rw.Header().Set("X-Auth-Request-Preferred-Username", session.PreferredUsername)
} else {
rw.Header().Del("X-Auth-Request-Preferred-Username")
}
if p.SetBasicAuth {
claims := Claims{}
err := claims.FromIDToken(session.IDToken)
if err != nil {
log.WithError(err).Warning("Failed to parse IDToken")
}
userAttributes := claims.Proxy.UserAttributes
var ok bool
var password string
if password, ok = userAttributes[p.BasicAuthPasswordAttribute]; !ok {
password = ""
}
// Check if we should use email or a custom attribute as username
var username string
if username, ok = userAttributes[p.BasicAuthUserAttribute]; !ok {
username = session.Email
}
authVal := b64.StdEncoding.EncodeToString([]byte(username + ":" + password))
req.Header["Authorization"] = []string{fmt.Sprintf("Basic %s", authVal)}
}
if session.Email == "" {
rw.Header().Set("GAP-Auth", session.User)
} else {
rw.Header().Set("GAP-Auth", session.Email)
}
}
// stripAuthHeaders removes Auth headers for whitelisted routes from skipAuthRegex
func (p *OAuthProxy) stripAuthHeaders(req *http.Request) {
if p.PassBasicAuth {
req.Header.Del("X-Forwarded-User")
req.Header.Del("X-Forwarded-Email")
req.Header.Del("X-Forwarded-Preferred-Username")
req.Header.Del("Authorization")
}
if p.PassUserHeaders {
req.Header.Del("X-Forwarded-User")
req.Header.Del("X-Forwarded-Email")
req.Header.Del("X-Forwarded-Preferred-Username")
}
if p.PassAccessToken {
req.Header.Del("X-Forwarded-Access-Token")
}
if p.PassAuthorization {
req.Header.Del("Authorization")
}
}
// isAjax checks if a request is an ajax request
func isAjax(req *http.Request) bool {
acceptValues := req.Header.Values("Accept")
const ajaxReq = applicationJSON
for _, v := range acceptValues {
if v == ajaxReq {
return true
}
}
return false
}
// ErrorJSON returns the error code with an application/json mime type
func (p *OAuthProxy) ErrorJSON(rw http.ResponseWriter, code int) {
rw.Header().Set("Content-Type", applicationJSON)
rw.WriteHeader(code)
}

481
proxy/pkg/proxy/proxy.go Normal file
View File

@@ -0,0 +1,481 @@
package proxy
import (
b64 "encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/coreos/go-oidc"
"github.com/justinas/alice"
ipapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/ip"
"github.com/oauth2-proxy/oauth2-proxy/pkg/apis/options"
sessionsapi "github.com/oauth2-proxy/oauth2-proxy/pkg/apis/sessions"
"github.com/oauth2-proxy/oauth2-proxy/pkg/middleware"
"github.com/oauth2-proxy/oauth2-proxy/pkg/sessions"
"github.com/oauth2-proxy/oauth2-proxy/pkg/upstream"
"github.com/oauth2-proxy/oauth2-proxy/providers"
log "github.com/sirupsen/logrus"
)
const (
httpScheme = "http"
httpsScheme = "https"
applicationJSON = "application/json"
)
var (
// ErrNeedsLogin means the user should be redirected to the login page
ErrNeedsLogin = errors.New("redirect to login page")
// Used to check final redirects are not susceptible to open redirects.
// Matches //, /\ and both of these with whitespace in between (eg / / or / \).
invalidRedirectRegex = regexp.MustCompile(`[/\\](?:[\s\v]*|\.{1,2})[/\\]`)
)
// OAuthProxy is the main authentication proxy
type OAuthProxy struct {
CookieSeed string
CookieName string
CSRFCookieName string
CookieDomains []string
CookiePath string
CookieSecure bool
CookieHTTPOnly bool
CookieExpire time.Duration
CookieRefresh time.Duration
CookieSameSite string
RobotsPath string
SignInPath string
SignOutPath string
OAuthStartPath string
OAuthCallbackPath string
AuthOnlyPath string
UserInfoPath string
redirectURL *url.URL // the url to receive requests at
whitelistDomains []string
provider providers.Provider
sessionStore sessionsapi.SessionStore
ProxyPrefix string
serveMux http.Handler
SetXAuthRequest bool
SetBasicAuth bool
PassUserHeaders bool
BasicAuthUserAttribute string
BasicAuthPasswordAttribute string
PassAccessToken bool
SetAuthorization bool
PassAuthorization bool
PreferEmailToUser bool
skipAuthRegex []string
skipAuthPreflight bool
skipAuthStripHeaders bool
mainJwtBearerVerifier *oidc.IDTokenVerifier
extraJwtBearerVerifiers []*oidc.IDTokenVerifier
compiledRegex []*regexp.Regexp
templates *template.Template
realClientIPParser ipapi.RealClientIPParser
sessionChain alice.Chain
logger *log.Entry
}
// NewOAuthProxy creates a new instance of OAuthProxy from the options provided
func NewOAuthProxy(opts *options.Options) (*OAuthProxy, error) {
logger := log.WithField("component", "proxy").WithField("client-id", opts.ClientID)
sessionStore, err := sessions.NewSessionStore(&opts.Session, &opts.Cookie)
if err != nil {
return nil, fmt.Errorf("error initialising session store: %v", err)
}
templates := getTemplates()
proxyErrorHandler := upstream.NewProxyErrorHandler(templates.Lookup("error.html"), opts.ProxyPrefix)
upstreamProxy, err := upstream.NewProxy(opts.UpstreamServers, opts.GetSignatureData(), proxyErrorHandler)
if err != nil {
return nil, fmt.Errorf("error initialising upstream proxy: %v", err)
}
for _, u := range opts.GetCompiledRegex() {
logger.Printf("compiled skip-auth-regex => %q", u)
}
redirectURL := opts.GetRedirectURL()
if redirectURL.Path == "" {
redirectURL.Path = fmt.Sprintf("%s/callback", opts.ProxyPrefix)
}
logger.Printf("proxy instance configured for Client ID: %s", opts.ClientID)
sessionChain := buildSessionChain(opts, sessionStore)
return &OAuthProxy{
CookieName: opts.Cookie.Name,
CSRFCookieName: fmt.Sprintf("%v_%v", opts.Cookie.Name, "csrf"),
CookieSeed: opts.Cookie.Secret,
CookieDomains: opts.Cookie.Domains,
CookiePath: opts.Cookie.Path,
CookieSecure: opts.Cookie.Secure,
CookieHTTPOnly: opts.Cookie.HTTPOnly,
CookieExpire: opts.Cookie.Expire,
CookieRefresh: opts.Cookie.Refresh,
CookieSameSite: opts.Cookie.SameSite,
RobotsPath: "/robots.txt",
SignInPath: fmt.Sprintf("%s/sign_in", opts.ProxyPrefix),
SignOutPath: fmt.Sprintf("%s/sign_out", opts.ProxyPrefix),
OAuthStartPath: fmt.Sprintf("%s/start", opts.ProxyPrefix),
OAuthCallbackPath: fmt.Sprintf("%s/callback", opts.ProxyPrefix),
AuthOnlyPath: fmt.Sprintf("%s/auth", opts.ProxyPrefix),
UserInfoPath: fmt.Sprintf("%s/userinfo", opts.ProxyPrefix),
ProxyPrefix: opts.ProxyPrefix,
provider: opts.GetProvider(),
sessionStore: sessionStore,
serveMux: upstreamProxy,
redirectURL: redirectURL,
whitelistDomains: opts.WhitelistDomains,
skipAuthRegex: opts.SkipAuthRegex,
skipAuthPreflight: opts.SkipAuthPreflight,
skipAuthStripHeaders: opts.SkipAuthStripHeaders,
mainJwtBearerVerifier: opts.GetOIDCVerifier(),
extraJwtBearerVerifiers: opts.GetJWTBearerVerifiers(),
compiledRegex: opts.GetCompiledRegex(),
realClientIPParser: opts.GetRealClientIPParser(),
SetXAuthRequest: opts.SetXAuthRequest,
SetBasicAuth: opts.SetBasicAuth,
PassUserHeaders: opts.PassUserHeaders,
PassAccessToken: opts.PassAccessToken,
SetAuthorization: opts.SetAuthorization,
PassAuthorization: opts.PassAuthorization,
PreferEmailToUser: opts.PreferEmailToUser,
templates: templates,
sessionChain: sessionChain,
logger: logger,
}, nil
}
func buildSessionChain(opts *options.Options, sessionStore sessionsapi.SessionStore) alice.Chain {
chain := alice.New(middleware.NewScope())
chain = chain.Append(middleware.NewStoredSessionLoader(&middleware.StoredSessionLoaderOptions{
SessionStore: sessionStore,
RefreshPeriod: opts.Cookie.Refresh,
RefreshSessionIfNeeded: opts.GetProvider().RefreshSessionIfNeeded,
ValidateSessionState: opts.GetProvider().ValidateSessionState,
}))
return chain
}
// RobotsTxt disallows scraping pages from the OAuthProxy
func (p *OAuthProxy) RobotsTxt(rw http.ResponseWriter) {
_, err := fmt.Fprintf(rw, "User-agent: *\nDisallow: /")
if err != nil {
p.logger.Printf("Error writing robots.txt: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
rw.WriteHeader(http.StatusOK)
}
// ErrorPage writes an error response
func (p *OAuthProxy) ErrorPage(rw http.ResponseWriter, code int, title string, message string) {
rw.WriteHeader(code)
t := struct {
Title string
Message string
ProxyPrefix string
}{
Title: fmt.Sprintf("%d %s", code, title),
Message: message,
ProxyPrefix: p.ProxyPrefix,
}
err := p.templates.ExecuteTemplate(rw, "error.html", t)
if err != nil {
p.logger.Printf("Error rendering error.html template: %v", err)
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
}
}
// splitHostPort separates host and port. If the port is not valid, it returns
// the entire input as host, and it doesn't check the validity of the host.
// Unlike net.SplitHostPort, but per RFC 3986, it requires ports to be numeric.
// *** taken from net/url, modified validOptionalPort() to accept ":*"
func splitHostPort(hostport string) (host, port string) {
host = hostport
colon := strings.LastIndexByte(host, ':')
if colon != -1 && validOptionalPort(host[colon:]) {
host, port = host[:colon], host[colon+1:]
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
return
}
// validOptionalPort reports whether port is either an empty string
// or matches /^:\d*$/
// *** taken from net/url, modified to accept ":*"
func validOptionalPort(port string) bool {
if port == "" || port == ":*" {
return true
}
if port[0] != ':' {
return false
}
for _, b := range port[1:] {
if b < '0' || b > '9' {
return false
}
}
return true
}
// See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching?hl=en
var noCacheHeaders = map[string]string{
"Expires": time.Unix(0, 0).Format(time.RFC1123),
"Cache-Control": "no-cache, no-store, must-revalidate, max-age=0",
"X-Accel-Expires": "0", // https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/
}
// prepareNoCache prepares headers for preventing browser caching.
func prepareNoCache(w http.ResponseWriter) {
// Set NoCache headers
for k, v := range noCacheHeaders {
w.Header().Set(k, v)
}
}
func (p *OAuthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path != p.AuthOnlyPath && strings.HasPrefix(req.URL.Path, p.ProxyPrefix) {
prepareNoCache(rw)
}
switch path := req.URL.Path; {
case path == p.RobotsPath:
p.RobotsTxt(rw)
case p.IsWhitelistedRequest(req):
p.SkipAuthProxy(rw, req)
case path == p.SignInPath:
p.OAuthStart(rw, req)
case path == p.SignOutPath:
p.SignOut(rw, req)
case path == p.OAuthStartPath:
p.OAuthStart(rw, req)
case path == p.OAuthCallbackPath:
p.OAuthCallback(rw, req)
case path == p.AuthOnlyPath:
p.AuthenticateOnly(rw, req)
case path == p.UserInfoPath:
p.UserInfo(rw, req)
default:
p.Proxy(rw, req)
}
}
//UserInfo endpoint outputs session email and preferred username in JSON format
func (p *OAuthProxy) UserInfo(rw http.ResponseWriter, req *http.Request) {
session, err := p.getAuthenticatedSession(rw, req)
if err != nil {
http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
userInfo := struct {
Email string `json:"email"`
PreferredUsername string `json:"preferredUsername,omitempty"`
}{
Email: session.Email,
PreferredUsername: session.PreferredUsername,
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
err = json.NewEncoder(rw).Encode(userInfo)
if err != nil {
p.logger.Printf("Error encoding user info: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
}
}
// SignOut sends a response to clear the authentication cookie
func (p *OAuthProxy) SignOut(rw http.ResponseWriter, req *http.Request) {
redirect, err := p.GetRedirect(req)
if err != nil {
p.logger.Errorf("Error obtaining redirect: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
err = p.ClearSessionCookie(rw, req)
if err != nil {
p.logger.Errorf("Error clearing session cookie: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError, "Internal Server Error", err.Error())
return
}
http.Redirect(rw, req, redirect, http.StatusFound)
}
// AuthenticateOnly checks whether the user is currently logged in
func (p *OAuthProxy) AuthenticateOnly(rw http.ResponseWriter, req *http.Request) {
session, err := p.getAuthenticatedSession(rw, req)
if err != nil {
http.Error(rw, "unauthorized request", http.StatusUnauthorized)
return
}
// we are authenticated
p.addHeadersForProxying(rw, req, session)
rw.WriteHeader(http.StatusAccepted)
}
// SkipAuthProxy proxies whitelisted requests and skips authentication
func (p *OAuthProxy) SkipAuthProxy(rw http.ResponseWriter, req *http.Request) {
if p.skipAuthStripHeaders {
p.stripAuthHeaders(req)
}
p.serveMux.ServeHTTP(rw, req)
}
// Proxy proxies the user request if the user is authenticated else it prompts
// them to authenticate
func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) {
session, err := p.getAuthenticatedSession(rw, req)
switch err {
case nil:
// we are authenticated
p.addHeadersForProxying(rw, req, session)
p.serveMux.ServeHTTP(rw, req)
case ErrNeedsLogin:
// we need to send the user to a login screen
if isAjax(req) {
// no point redirecting an AJAX request
p.ErrorJSON(rw, http.StatusUnauthorized)
return
}
p.OAuthStart(rw, req)
default:
// unknown error
p.logger.Errorf("Unexpected internal error: %v", err)
p.ErrorPage(rw, http.StatusInternalServerError,
"Internal Error", "Internal Error")
}
}
// getAuthenticatedSession checks whether a user is authenticated and returns a session object and nil error if so
// Returns nil, ErrNeedsLogin if user needs to login.
// Set-Cookie headers may be set on the response as a side-effect of calling this method.
func (p *OAuthProxy) getAuthenticatedSession(rw http.ResponseWriter, req *http.Request) (*sessionsapi.SessionState, error) {
var session *sessionsapi.SessionState
getSession := p.sessionChain.Then(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
session = middleware.GetRequestScope(req).Session
}))
getSession.ServeHTTP(rw, req)
if session == nil {
return nil, ErrNeedsLogin
}
return session, nil
}
// addHeadersForProxying adds the appropriate headers the request / response for proxying
func (p *OAuthProxy) addHeadersForProxying(rw http.ResponseWriter, req *http.Request, session *sessionsapi.SessionState) {
req.Header["X-Forwarded-User"] = []string{session.User}
if session.Email != "" {
req.Header["X-Forwarded-Email"] = []string{session.Email}
}
if session.PreferredUsername != "" {
req.Header["X-Forwarded-Preferred-Username"] = []string{session.PreferredUsername}
req.Header["X-Auth-Username"] = []string{session.PreferredUsername}
} else {
req.Header.Del("X-Forwarded-Preferred-Username")
req.Header.Del("X-Auth-Username")
}
claims := Claims{}
err := claims.FromIDToken(session.IDToken)
if err != nil {
log.WithError(err).Warning("Failed to parse IDToken")
}
userAttributes := claims.Proxy.UserAttributes
// Attempt to set basic auth based on user's attributes
if p.SetBasicAuth {
var ok bool
var password string
if password, ok = userAttributes[p.BasicAuthPasswordAttribute].(string); !ok {
password = ""
}
// Check if we should use email or a custom attribute as username
var username string
if username, ok = userAttributes[p.BasicAuthUserAttribute].(string); !ok {
username = session.Email
}
authVal := b64.StdEncoding.EncodeToString([]byte(username + ":" + password))
req.Header["Authorization"] = []string{fmt.Sprintf("Basic %s", authVal)}
}
// Check if user has additional headers set that we should sent
if additionalHeaders, ok := userAttributes["additionalHeaders"].(map[string]string); ok {
if additionalHeaders == nil {
return
}
for key, value := range additionalHeaders {
req.Header.Set(key, value)
}
}
}
// stripAuthHeaders removes Auth headers for whitelisted routes from skipAuthRegex
func (p *OAuthProxy) stripAuthHeaders(req *http.Request) {
if p.PassUserHeaders {
req.Header.Del("X-Forwarded-User")
req.Header.Del("X-Forwarded-Email")
req.Header.Del("X-Forwarded-Preferred-Username")
}
if p.PassAccessToken {
req.Header.Del("X-Forwarded-Access-Token")
}
if p.PassAuthorization {
req.Header.Del("Authorization")
}
}
// isAjax checks if a request is an ajax request
func isAjax(req *http.Request) bool {
acceptValues := req.Header.Values("Accept")
const ajaxReq = applicationJSON
for _, v := range acceptValues {
if v == ajaxReq {
return true
}
}
return false
}
// ErrorJSON returns the error code with an application/json mime type
func (p *OAuthProxy) ErrorJSON(rw http.ResponseWriter, code int) {
rw.Header().Set("Content-Type", applicationJSON)
rw.WriteHeader(code)
}

View File

@@ -7,148 +7,7 @@ import (
)
func getTemplates() *template.Template {
t, err := template.New("foo").Parse(`{{define "sign_in.html"}}
<!DOCTYPE html>
<html lang="en" charset="utf-8">
<head>
<title>Sign In</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
body {
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
font-size: 14px;
line-height: 1.42857143;
color: #333;
background: #f0f0f0;
}
.signin {
display:block;
margin:20px auto;
max-width:400px;
background: #fff;
border:1px solid #ccc;
border-radius: 10px;
padding: 20px;
}
.center {
text-align:center;
}
.btn {
color: #fff;
background-color: #428bca;
border: 1px solid #357ebd;
-webkit-border-radius: 4;
-moz-border-radius: 4;
border-radius: 4px;
font-size: 14px;
padding: 6px 12px;
text-decoration: none;
cursor: pointer;
}
.btn:hover {
background-color: #3071a9;
border-color: #285e8e;
text-decoration: none;
}
label {
display: inline-block;
max-width: 100%;
margin-bottom: 5px;
font-weight: 700;
}
input {
display: block;
width: 100%;
height: 34px;
padding: 6px 12px;
font-size: 14px;
line-height: 1.42857143;
color: #555;
background-color: #fff;
background-image: none;
border: 1px solid #ccc;
border-radius: 4px;
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
-webkit-transition: border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;
-o-transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
margin:0;
box-sizing: border-box;
}
footer {
display:block;
font-size:10px;
color:#aaa;
text-align:center;
margin-bottom:10px;
}
footer a {
display:inline-block;
height:25px;
line-height:25px;
color:#aaa;
text-decoration:underline;
}
footer a:hover {
color:#aaa;
}
</style>
</head>
<body>
<div class="signin center">
<form method="GET" action="{{.ProxyPrefix}}/start">
<input type="hidden" name="rd" value="{{.Redirect}}">
{{ if .SignInMessage }}
<p>{{.SignInMessage}}</p>
{{ end}}
<button type="submit" class="btn">Sign in with {{.ProviderName}}</button><br/>
</form>
</div>
{{ if .CustomLogin }}
<div class="signin">
<form method="POST" action="{{.ProxyPrefix}}/sign_in">
<input type="hidden" name="rd" value="{{.Redirect}}">
<label for="username">Username:</label><input type="text" name="username" id="username" size="10"><br/>
<label for="password">Password:</label><input type="password" name="password" id="password" size="10"><br/>
<button type="submit" class="btn">Sign In</button>
</form>
</div>
{{ end }}
<script>
if (window.location.hash) {
(function() {
var inputs = document.getElementsByName('rd');
for (var i = 0; i < inputs.length; i++) {
// Add hash, but make sure it is only added once
var idx = inputs[i].value.indexOf('#');
if (idx >= 0) {
// Remove existing hash from URL
inputs[i].value = inputs[i].value.substr(0, idx);
}
inputs[i].value += window.location.hash;
}
})();
}
</script>
<footer>
{{ if eq .Footer "-" }}
{{ else if eq .Footer ""}}
Secured with <a href="https://github.com/oauth2-proxy/oauth2-proxy#oauth2_proxy">OAuth2 Proxy</a> version {{.Version}}
{{ else }}
{{.Footer}}
{{ end }}
</footer>
</body>
</html>
{{end}}`)
if err != nil {
log.Fatalf("failed parsing template %s", err)
}
t, err = t.Parse(`{{define "error.html"}}
t, err := template.New("foo").Parse(`{{define "error.html"}}
<!DOCTYPE html>
<html lang="en" charset="utf-8">
<head>

View File

@@ -1,32 +0,0 @@
package proxy
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoadTemplates(t *testing.T) {
data := struct {
TestString string
}{
TestString: "Testing",
}
templates := getTemplates()
assert.NotEqual(t, templates, nil)
var defaultSignin bytes.Buffer
templates.ExecuteTemplate(&defaultSignin, "sign_in.html", data)
assert.Equal(t, "\n<!DOCTYPE html>", defaultSignin.String()[0:16])
var defaultError bytes.Buffer
templates.ExecuteTemplate(&defaultError, "error.html", data)
assert.Equal(t, "\n<!DOCTYPE html>", defaultError.String()[0:16])
}
func TestTemplatesCompile(t *testing.T) {
templates := getTemplates()
assert.NotEqual(t, templates, nil)
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/BeryJu/passbook/proxy/pkg"
"github.com/BeryJu/passbook/proxy/pkg/client"
"github.com/BeryJu/passbook/proxy/pkg/client/outposts"
"github.com/getsentry/sentry-go"
@@ -50,7 +51,6 @@ func getCommonOptions() *options.Options {
commonOpts.EmailDomains = []string{"*"}
commonOpts.ProviderType = "oidc"
commonOpts.ProxyPrefix = "/pbprox"
commonOpts.SkipProviderButton = true
commonOpts.Logging.SilencePing = true
commonOpts.SetAuthorization = false
commonOpts.Scope = "openid email profile pb_proxy"
@@ -70,6 +70,7 @@ func doGlobalSetup(config map[string]interface{}) {
default:
log.SetLevel(log.DebugLevel)
}
log.WithField("version", pkg.VERSION).Info("Starting passbook proxy")
var dsn string
if config[ConfigErrorReportingEnabled].(bool) {
@@ -166,7 +167,7 @@ func (a *APIController) bundleProviders() ([]*providerBundle, error) {
}
bundles[idx] = &providerBundle{
a: a,
Host: externalHost.Hostname(),
Host: externalHost.Host,
}
bundles[idx].Build(provider)
}

View File

@@ -1,3 +1,3 @@
package pkg
const VERSION = "0.12.1-stable"
const VERSION = "0.12.8-stable"

3
pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
[tool.black]
target-version = ['py38']
exclude = 'node_modules'

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