master
Jens L 2020-09-03 00:04:12 +02:00 committed by GitHub
parent 14e47f3195
commit 268de20872
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
105 changed files with 6243 additions and 497 deletions

View File

@ -1,7 +1,7 @@
[run]
source = passbook
omit =
*/wsgi.py
*/asgi.py
manage.py
*/migrations/*
*/apps.py

View File

@ -23,7 +23,7 @@ jobs:
run: docker push beryju/passbook:0.9.0-stable
- name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook:latest
build-gatekeeper:
build-proxy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
@ -34,16 +34,16 @@ jobs:
run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
- name: Building Docker Image
run: |
cd gatekeeper
cd proxy
docker build \
--no-cache \
-t beryju/passbook-gatekeeper:0.9.0-stable \
-t beryju/passbook-gatekeeper:latest \
-t beryju/passbook-proxy:0.9.0-stable \
-t beryju/passbook-proxy:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/passbook-gatekeeper:0.9.0-stable
run: docker push beryju/passbook-proxy:0.9.0-stable
- name: Push Docker Container to Registry (latest)
run: docker push beryju/passbook-gatekeeper:latest
run: docker push beryju/passbook-proxy:latest
build-static:
runs-on: ubuntu-latest
services:

View File

@ -17,14 +17,16 @@ COPY --from=locker /app/requirements-dev.txt /app/
WORKDIR /app/
RUN apt-get update && \
apt-get install -y --no-install-recommends postgresql-client-11 && \
apt-get install -y --no-install-recommends postgresql-client-11 build-essential && \
rm -rf /var/lib/apt/ && \
pip install -r requirements.txt --no-cache-dir && \
apt-get remove --purge -y build-essential && \
apt-get autoremove --purge && \
adduser --system --no-create-home --uid 1000 --group --home /app passbook
COPY ./passbook/ /app/passbook
COPY ./manage.py /app/
COPY ./docker/uwsgi.ini /app/
COPY ./docker/gunicorn.conf.py /app/
COPY ./docker/bootstrap.sh /bootstrap.sh
COPY ./docker/wait_for_db.py /app/wait_for_db.py

20
Makefile Normal file
View File

@ -0,0 +1,20 @@
all: lint-fix lint coverage gen
coverage:
coverage run --concurrency=multiprocessing manage.py test passbook --failfast
coverage combine
coverage html
coverage report
lint-fix:
isort -rc .
black .
lint:
pyright
bandit -r .
pylint passbook
prospector
gen: coverage
./manage.py generate_swagger -o swagger.yaml -f yaml

View File

@ -28,7 +28,8 @@ packaging = "*"
psycopg2-binary = "*"
pycryptodome = "*"
pyjwkest = "*"
pyuwsgi = "*"
uvicorn = "*"
gunicorn = "*"
pyyaml = "*"
qrcode = "*"
requests-oauthlib = "*"
@ -39,6 +40,9 @@ structlog = "*"
swagger-spec-validator = "*"
urllib3 = {extras = ["secure"],version = "*"}
dacite = "*"
channels = "*"
channels-redis = "*"
kubernetes = "*"
[requires]
python_version = "3.8"

488
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "8f099b73d5993a0693261bf3d2b0e696d4f4d7ddd69a10d3db8ffe59a8ebd805"
"sha256": "a798bbd0b97857cac136c1743b8d6ad8bf8c3d95e2760c71d324bb2a7f47f678"
},
"pipfile-spec": 6,
"requires": {
@ -16,11 +16,19 @@
]
},
"default": {
"aioredis": {
"hashes": [
"sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a",
"sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"
],
"version": "==1.3.1"
},
"amqp": {
"hashes": [
"sha256:70cdb10628468ff14e57ec2f751c7aa9e48e7e3651cfd62d431213c0c4e58f21",
"sha256:aa7f313fb887c91f15474c1229907a04dac0b8135822d6603437803424c0aa59"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.6.1"
},
"asgiref": {
@ -28,15 +36,40 @@
"sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a",
"sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed"
],
"markers": "python_version >= '3.5'",
"version": "==3.2.10"
},
"async-timeout": {
"hashes": [
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
"sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
],
"markers": "python_full_version >= '3.5.3'",
"version": "==3.0.1"
},
"attrs": {
"hashes": [
"sha256:0ef97238856430dcf9228e07f316aefc17e8939fc8507e18c6501b761ef1a42a",
"sha256:2867b7b9f8326499ab5b0e2d12801fa5c98842d2cbd22b35112ae04bf85b4dff"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.1.0"
},
"autobahn": {
"hashes": [
"sha256:24ce276d313e84d68241c3aef30d484f352b90a40168981b3640312c821df77b",
"sha256:86bbce30cdd407137c57670993a8f9bfdfe3f8e994b889181d85e844d5aa8dfb"
],
"markers": "python_version >= '3.5'",
"version": "==20.7.1"
},
"automat": {
"hashes": [
"sha256:7979803c74610e11ef0c0d68a2942b152df52da55336e0c9d58daf1831cbdf33",
"sha256:b6feb6455337df834f6c9962d6ccf771515b7d939bca142b29c20c2376bc6111"
],
"version": "==20.2.0"
},
"billiard": {
"hashes": [
"sha256:bff575450859a6e0fbc2f9877d9b715b0bbc07c3565bb7ed2280526a0cdf5ede",
@ -46,17 +79,26 @@
},
"boto3": {
"hashes": [
"sha256:b240ac281de363e25a8e1a4c862559d6a056d98dcb9f487fc94d73c6f6599dfc"
"sha256:4196b418598851ffd10cf9d1606694673cbfeca4ddf8b25d4e50addbd2fc60bf",
"sha256:69ad8f2184979e223e12ee3071674fdf910983cf9f4d6f34f7ec407b089064b5"
],
"index": "pypi",
"version": "==1.14.53"
"version": "==1.14.54"
},
"botocore": {
"hashes": [
"sha256:7e0272ceeb7747ed259a392e8d7b624cfd037085a8c59ef2b9f8916e7c556267",
"sha256:d37a83ac23257c85c48b74ab81173980234f8fc078e7a9d312d0ee7d057f90e6"
"sha256:6fe05837646447d61acdaf1e3401b92cd9309f00b19c577a50d0ade7735a3403",
"sha256:9e493a21e6a8d45c631eb2952ae8e1d0a31b9984546d4268ea10c0c33e2435ce"
],
"version": "==1.17.53"
"version": "==1.17.54"
},
"cachetools": {
"hashes": [
"sha256:513d4ff98dd27f85743a8dc0e92f55ddb1b49e060c2d5961512855cda2c01a98",
"sha256:bbaa39c3dede00175df2dc2b03d0cf18dd2d32a7de7beb68072d13043c9edb20"
],
"markers": "python_version ~= '3.5'",
"version": "==4.1.1"
},
"celery": {
"hashes": [
@ -106,6 +148,22 @@
],
"version": "==1.14.2"
},
"channels": {
"hashes": [
"sha256:08e756406d7165cb32f6fc3090c0643f41ca9f7e0f7fada0b31194662f20f414",
"sha256:80a5ad1962ae039a3dcc0a5cb5212413e66e2f11ad9e9db8004834436daf3400"
],
"index": "pypi",
"version": "==2.4.0"
},
"channels-redis": {
"hashes": [
"sha256:b4bcee949032cd838abdffd10da056930fca1a5a7ebc52139f8537aa622ac8d5",
"sha256:be7c14526ab924a091a66ad72a8be57a34900440b1126d520ac7742c0e2add03"
],
"index": "pypi",
"version": "==3.0.1"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
@ -113,6 +171,21 @@
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==7.1.2"
},
"constantly": {
"hashes": [
"sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35",
"sha256:dd2fa9d6b1a51a83f0d7dd76293d734046aa176e384bf6e33b7e44880eb37c5d"
],
"version": "==15.1.0"
},
"coreapi": {
"hashes": [
"sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb",
@ -159,6 +232,13 @@
"index": "pypi",
"version": "==1.5.1"
},
"daphne": {
"hashes": [
"sha256:1ca46d7419103958bbc9576fb7ba3b25b053006e22058bc97084ee1a7d44f4ba",
"sha256:aa64840015709bbc9daa3c4464a4a4d437937d6cda10a9b51e913eb319272553"
],
"version": "==2.5.0"
},
"defusedxml": {
"hashes": [
"sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93",
@ -265,6 +345,7 @@
"sha256:6dd02d5a4bd2516fb93f80360673bf540c3b6641fec8766b1da2870a5aa00b32",
"sha256:8b1ac62c581dbc5799b03e535854b92fc4053ecfe74bad3f9c05782063d4196b"
],
"markers": "python_version >= '3.5'",
"version": "==3.11.1"
},
"djangorestframework-guardian": {
@ -281,6 +362,7 @@
"sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827",
"sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.15.2"
},
"drf-yasg": {
@ -310,8 +392,109 @@
"hashes": [
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.18.2"
},
"google-auth": {
"hashes": [
"sha256:982e1f82cace752134660b4c0ff660761b32146a55abb3ad6d225529012af87c",
"sha256:f2498ad9cac3d2942d6c509ba18c4639656b366681881a1805f44f2a0c2d46f1"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.21.0"
},
"gunicorn": {
"hashes": [
"sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
"sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
],
"index": "pypi",
"version": "==20.0.4"
},
"h11": {
"hashes": [
"sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1",
"sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"
],
"version": "==0.9.0"
},
"hiredis": {
"hashes": [
"sha256:06a039208f83744a702279b894c8cf24c14fd63c59cd917dcde168b79eef0680",
"sha256:0a909bf501459062aa1552be1461456518f367379fdc9fdb1f2ca5e4a1fdd7c0",
"sha256:18402d9e54fb278cb9a8c638df6f1550aca36a009d47ecf5aa263a38600f35b0",
"sha256:1e4cbbc3858ec7e680006e5ca590d89a5e083235988f26a004acf7244389ac01",
"sha256:23344e3c2177baf6975fbfa361ed92eb7d36d08f454636e5054b3faa7c2aff8a",
"sha256:289b31885b4996ce04cadfd5fc03d034dce8e2a8234479f7c9e23b9e245db06b",
"sha256:2c1c570ae7bf1bab304f29427e2475fe1856814312c4a1cf1cd0ee133f07a3c6",
"sha256:2c227c0ed371771ffda256034427320870e8ea2e4fd0c0a618c766e7c49aad73",
"sha256:3bb9b63d319402cead8bbd9dd55dca3b667d2997e9a0d8a1f9b6cc274db4baee",
"sha256:3ef2183de67b59930d2db8b8e8d4d58e00a50fcc5e92f4f678f6eed7a1c72d55",
"sha256:43b8ed3dbfd9171e44c554cb4acf4ee4505caa84c5e341858b50ea27dd2b6e12",
"sha256:47bcf3c5e6c1e87ceb86cdda2ee983fa0fe56a999e6185099b3c93a223f2fa9b",
"sha256:5263db1e2e1e8ae30500cdd75a979ff99dcc184201e6b4b820d0de74834d2323",
"sha256:5b1451727f02e7acbdf6aae4e06d75f66ee82966ff9114550381c3271a90f56c",
"sha256:6996883a8a6ff9117cbb3d6f5b0dcbbae6fb9e31e1a3e4e2f95e0214d9a1c655",
"sha256:6c96f64a54f030366657a54bb90b3093afc9c16c8e0dfa29fc0d6dbe169103a5",
"sha256:7332d5c3e35154cd234fd79573736ddcf7a0ade7a986db35b6196b9171493e75",
"sha256:7885b6f32c4a898e825bb7f56f36a02781ac4a951c63e4169f0afcf9c8c30dfb",
"sha256:7b0f63f10a166583ab744a58baad04e0f52cfea1ac27bfa1b0c21a48d1003c23",
"sha256:819f95d4eba3f9e484dd115ab7ab72845cf766b84286a00d4ecf76d33f1edca1",
"sha256:8968eeaa4d37a38f8ca1f9dbe53526b69628edc9c42229a5b2f56d98bb828c1f",
"sha256:89ebf69cb19a33d625db72d2ac589d26e936b8f7628531269accf4a3196e7872",
"sha256:8daecd778c1da45b8bd54fd41ffcd471a86beed3d8e57a43acf7a8d63bba4058",
"sha256:955ba8ea73cf3ed8bd2f963b4cb9f8f0dcb27becd2f4b3dd536fd24c45533454",
"sha256:964f18a59f5a64c0170f684c417f4fe3e695a536612e13074c4dd5d1c6d7c882",
"sha256:969843fbdfbf56cdb71da6f0bdf50f9985b8b8aeb630102945306cf10a9c6af2",
"sha256:996021ef33e0f50b97ff2d6b5f422a0fe5577de21a8873b58a779a5ddd1c3132",
"sha256:9e9c9078a7ce07e6fce366bd818be89365a35d2e4b163268f0ca9ba7e13bb2f6",
"sha256:a04901757cb0fb0f5602ac11dda48f5510f94372144d06c2563ba56c480b467c",
"sha256:a7bf1492429f18d205f3a818da3ff1f242f60aa59006e53dee00b4ef592a3363",
"sha256:aa0af2deb166a5e26e0d554b824605e660039b161e37ed4f01b8d04beec184f3",
"sha256:abfb15a6a7822f0fae681785cb38860e7a2cb1616a708d53df557b3d76c5bfd4",
"sha256:b253fe4df2afea4dfa6b1fa8c5fef212aff8bcaaeb4207e81eed05cb5e4a7919",
"sha256:b27f082f47d23cffc4cf1388b84fdc45c4ef6015f906cd7e0d988d9e35d36349",
"sha256:b33aea449e7f46738811fbc6f0b3177c6777a572207412bbbf6f525ffed001ae",
"sha256:b44f9421c4505c548435244d74037618f452844c5d3c67719d8a55e2613549da",
"sha256:bcc371151d1512201d0214c36c0c150b1dc64f19c2b1a8c9cb1d7c7c15ebd93f",
"sha256:c2851deeabd96d3f6283e9c6b26e0bfed4de2dc6fb15edf913e78b79fc5909ed",
"sha256:cdfd501c7ac5b198c15df800a3a34c38345f5182e5f80770caf362bccca65628",
"sha256:d2c0caffa47606d6d7c8af94ba42547bd2a441f06c74fd90a1ffe328524a6c64",
"sha256:dcb2db95e629962db5a355047fb8aefb012df6c8ae608930d391619dbd96fd86",
"sha256:e0eeb9c112fec2031927a1745788a181d0eecbacbed941fc5c4f7bc3f7b273bf",
"sha256:e154891263306200260d7f3051982774d7b9ef35af3509d5adbbe539afd2610c",
"sha256:e2e023a42dcbab8ed31f97c2bcdb980b7fbe0ada34037d87ba9d799664b58ded",
"sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390",
"sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.0"
},
"httptools": {
"hashes": [
"sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be",
"sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d",
"sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce",
"sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2",
"sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6",
"sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f",
"sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009",
"sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce",
"sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a",
"sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c",
"sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4",
"sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437"
],
"markers": "sys_platform != 'win32' and sys_platform != 'cygwin' and platform_python_implementation != 'PyPy'",
"version": "==0.1.1"
},
"hyperlink": {
"hashes": [
"sha256:47fcc7cd339c6cb2444463ec3277bdcfe142c8b1daf2160bdd52248deec815af",
"sha256:c528d405766f15a2c536230de7e160b65a08e20264d8891b3eb03307b0df3c63"
],
"version": "==20.0.1"
},
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
@ -319,11 +502,19 @@
],
"version": "==2.10"
},
"incremental": {
"hashes": [
"sha256:717e12246dddf231a349175f48d74d93e2897244939173b01974ab6661406b9f",
"sha256:7b751696aaf36eebfab537e458929e194460051ccad279c72b755a167eebd4b3"
],
"version": "==17.5.0"
},
"inflection": {
"hashes": [
"sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
"sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"
],
"markers": "python_version >= '3.5'",
"version": "==0.5.1"
},
"itypes": {
@ -338,6 +529,7 @@
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.11.2"
},
"jmespath": {
@ -345,6 +537,7 @@
"sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9",
"sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.0"
},
"jsonschema": {
@ -359,11 +552,23 @@
"sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a",
"sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==4.6.11"
},
"kubernetes": {
"hashes": [
"sha256:1a2472f8b01bc6aa87e3a34781f859bded5a5c8ff791a53d889a8bd6cc550430",
"sha256:4af81201520977139a143f96123fb789fa351879df37f122916b9b6ed050bbaf"
],
"index": "pypi",
"version": "==11.0.0"
},
"ldap3": {
"hashes": [
"sha256:59d1adcd5ead263387039e2a37d7cd772a2006b1cdb3ecfcbaab5192a601c515",
"sha256:7abbb3e5f4522114e0230ec175b60ae968b938d1f8a7d8bce7789f78d871fb9f",
"sha256:b399c39e80b6459e349b33fbe9787c1bcbf86de05994d41806a05c06f3e7574d",
"sha256:bdaf568cd30fc0006c8bb4f5e6014554afeb0c4bbea1677de9706e278a4057e7",
"sha256:df27407f4991f25bd669b5bb1bc8cb9ddf44a3e713ff6b3afeb3b3c26502f88f"
],
"index": "pypi",
@ -442,13 +647,38 @@
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.1"
},
"msgpack": {
"hashes": [
"sha256:002a0d813e1f7b60da599bdf969e632074f9eec1b96cbed8fb0973a63160a408",
"sha256:25b3bc3190f3d9d965b818123b7752c5dfb953f0d774b454fd206c18fe384fb8",
"sha256:271b489499a43af001a2e42f42d876bb98ccaa7e20512ff37ca78c8e12e68f84",
"sha256:39c54fdebf5fa4dda733369012c59e7d085ebdfe35b6cf648f09d16708f1be5d",
"sha256:4233b7f86c1208190c78a525cd3828ca1623359ef48f78a6fea4b91bb995775a",
"sha256:5bea44181fc8e18eed1d0cd76e355073f00ce232ff9653a0ae88cb7d9e643322",
"sha256:5dba6d074fac9b24f29aaf1d2d032306c27f04187651511257e7831733293ec2",
"sha256:7a22c965588baeb07242cb561b63f309db27a07382825fc98aecaf0827c1538e",
"sha256:908944e3f038bca67fcfedb7845c4a257c7749bf9818632586b53bcf06ba4b97",
"sha256:9534d5cc480d4aff720233411a1f765be90885750b07df772380b34c10ecb5c0",
"sha256:aa5c057eab4f40ec47ea6f5a9825846be2ff6bf34102c560bad5cad5a677c5be",
"sha256:b3758dfd3423e358bbb18a7cccd1c74228dffa7a697e5be6cb9535de625c0dbf",
"sha256:c901e8058dd6653307906c5f157f26ed09eb94a850dddd989621098d347926ab",
"sha256:cec8bf10981ed70998d98431cd814db0ecf3384e6b113366e7f36af71a0fca08",
"sha256:db685187a415f51d6b937257474ca72199f393dad89534ebbdd7d7a3b000080e",
"sha256:e35b051077fc2f3ce12e7c6a34cf309680c63a842db3a0616ea6ed25ad20d272",
"sha256:e7bbdd8e2b277b77782f3ce34734b0dfde6cbe94ddb74de8d733d603c7f9e2b1",
"sha256:ea41c9219c597f1d2bf6b374d951d310d58684b5de9dc4bd2976db9e1e22c140"
],
"version": "==1.0.0"
},
"oauthlib": {
"hashes": [
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.1.0"
},
"packaging": {
@ -504,15 +734,37 @@
},
"pyasn1": {
"hashes": [
"sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359",
"sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576",
"sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf",
"sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7",
"sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d",
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"
"sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00",
"sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8",
"sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86",
"sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12",
"sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776",
"sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba",
"sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2",
"sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"
],
"version": "==0.4.8"
},
"pyasn1-modules": {
"hashes": [
"sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8",
"sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199",
"sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811",
"sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed",
"sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4",
"sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e",
"sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"
"sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74",
"sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb",
"sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45",
"sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd",
"sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0",
"sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d",
"sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405"
],
"version": "==0.2.8"
},
@ -521,6 +773,7 @@
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.20"
},
"pycryptodome": {
@ -592,8 +845,17 @@
"sha256:ea4d4b58f9bc34e224ef4b4604a6be03d72ef1f8c486391f970205f6733dbc46",
"sha256:f60b3484ce4be04f5da3777c51c5140d3fe21cdd6674f2b6568f41c8130bcdeb"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.9.8"
},
"pyhamcrest": {
"hashes": [
"sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316",
"sha256:7ead136e03655af85069b6f47b23eb7c3e5c221aa9f022a4fbb499f5b7308f29"
],
"markers": "python_version >= '3.5'",
"version": "==2.0.2"
},
"pyjwkest": {
"hashes": [
"sha256:5560fd5ba08655f29ff6ad1df1e15dc05abc9d976fcbcec8d2b5167f49b70222"
@ -613,6 +875,7 @@
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.4.7"
},
"pyrsistent": {
@ -626,6 +889,7 @@
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.1"
},
"pytz": {
@ -635,24 +899,6 @@
],
"version": "==2020.1"
},
"pyuwsgi": {
"hashes": [
"sha256:1a4dd8d99b8497f109755e09484b0bd2aeaa533f7621e7c7e2a120a72111219d",
"sha256:206937deaebbac5c87692657c3151a5a9d40ecbc9b051b94154205c50a48e963",
"sha256:2cf35d9145208cc7c96464d688caa3de745bfc969e1a1ae23cb046fc10b0ac7e",
"sha256:3ab84a168633eeb55847d59475d86e9078d913d190c2a1aed804c562a10301a3",
"sha256:430406d1bcf288a87f14fde51c66877eaf5e98516838a1c6f761af5d814936fc",
"sha256:72be25ce7aa86c5616c59d12c2961b938e7bde47b7ff6a996ff83b89f7c5cd27",
"sha256:aa4d615de430e2066a1c76d9cc2a70abf2dfc703a82c21aee625b445866f2c3b",
"sha256:aadd231256a672cf4342ef9fb976051949e4d5b616195e696bcb7b8a9c07789e",
"sha256:b15ee6a7759b0465786d856334b8231d882deda5291cf243be6a343a8f3ef910",
"sha256:bd1d0a8d4cb87eb63417a72e6b1bac47053f9b0be550adc6d2a375f4cbaa22f0",
"sha256:d5787779ec24b67ac8898be9dc2b2b4e35f17d79f14361f6cf303d6283a848f2",
"sha256:ecfae85d6504e0ecbba100a795032a88ce8f110b62b93243f2df1bd116eca67f"
],
"index": "pypi",
"version": "==2.0.19.1"
},
"pyyaml": {
"hashes": [
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
@ -683,6 +929,7 @@
"sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
"sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==3.5.3"
},
"requests": {
@ -690,16 +937,26 @@
"sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
"sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.24.0"
},
"requests-oauthlib": {
"hashes": [
"sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d",
"sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"
"sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a",
"sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"
],
"index": "pypi",
"version": "==1.3.0"
},
"rsa": {
"hashes": [
"sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa",
"sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233"
],
"markers": "python_version >= '3.5'",
"version": "==4.6"
},
"ruamel.yaml": {
"hashes": [
"sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b",
@ -729,7 +986,7 @@
"sha256:ed5b3698a2bb241b7f5cbbe277eaa7fe48b07a58784fba4f75224fd066d253ad",
"sha256:f9dcc1ae73f36e8059589b601e8e4776b9976effd76c21ad6a855a74318efd6e"
],
"markers": "platform_python_implementation == 'CPython' and python_version < '3.9'",
"markers": "python_version < '3.9' and platform_python_implementation == 'CPython'",
"version": "==0.2.0"
},
"s3transfer": {
@ -741,11 +998,11 @@
},
"sentry-sdk": {
"hashes": [
"sha256:5b884a391da04696c1d81d636d2ad728fd838370db1acdfda3acbad1fe5be830",
"sha256:bbfe5633aee4dacb53d79d303ab6bfacf1749fb717750c112fb1658e5accce0d"
"sha256:0af429c221670e602f960fca85ca3f607c85510a91f11e8be8f742a978127f78",
"sha256:a088a1054673c6a19ea590045c871c38da029ef743b61a07bfee95e9f3c060f7"
],
"index": "pypi",
"version": "==0.17.2"
"version": "==0.17.3"
},
"service-identity": {
"hashes": [
@ -768,6 +1025,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"sqlparse": {
@ -775,6 +1033,7 @@
"sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e",
"sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.3.1"
},
"structlog": {
@ -793,11 +1052,52 @@
"index": "pypi",
"version": "==2.7.3"
},
"twisted": {
"extras": [
"tls"
],
"hashes": [
"sha256:040eb6641125d2a9a09cf198ec7b83dd8858c6f51f6770325ed9959c00f5098f",
"sha256:147780b8caf21ba2aef3688628eaf13d7e7fe02a86747cd54bfaf2140538f042",
"sha256:158ddb80719a4813d292293ac44ba41d8b56555ed009d90994a278237ee63d2c",
"sha256:2182000d6ffc05d269e6c03bfcec8b57e20259ca1086180edaedec3f1e689292",
"sha256:25ffcf37944bdad4a99981bc74006d735a678d2b5c193781254fbbb6d69e3b22",
"sha256:3281d9ce889f7b21bdb73658e887141aa45a102baf3b2320eafcfba954fcefec",
"sha256:356e8d8dd3590e790e3dba4db139eb8a17aca64b46629c622e1b1597a4a92478",
"sha256:70952c56e4965b9f53b180daecf20a9595cf22b8d0935cd3bd664c90273c3ab2",
"sha256:7408c6635ee1b96587289283ebe90ee15dbf9614b05857b446055116bc822d29",
"sha256:7c547fd0215db9da8a1bc23182b309e84a232364cc26d829e9ee196ce840b114",
"sha256:894f6f3cfa57a15ea0d0714e4283913a5f2511dbd18653dd148eba53b3919797",
"sha256:94ac3d55a58c90e2075c5fe1853f2aa3892b73e3bf56395f743aefde8605eeaa",
"sha256:a58e61a2a01e5bcbe3b575c0099a2bcb8d70a75b1a087338e0c48dd6e01a5f15",
"sha256:c09c47ff9750a8e3aa60ad169c4b95006d455a29b80ad0901f031a103b2991cd",
"sha256:ca3a0b8c9110800e576d89b5337373e52018b41069bc879f12fa42b7eb2d0274",
"sha256:cd1dc5c85b58494138a3917752b54bb1daa0045d234b7c132c37a61d5483ebad",
"sha256:cdbc4c7f0cd7a2218b575844e970f05a1be1861c607b0e048c9bceca0c4d42f7",
"sha256:d267125cc0f1e8a0eed6319ba4ac7477da9b78a535601c49ecd20c875576433a",
"sha256:d72c55b5d56e176563b91d11952d13b01af8725c623e498db5507b6614fc1e10",
"sha256:d95803193561a243cb0401b0567c6b7987d3f2a67046770e1dccd1c9e49a9780",
"sha256:e92703bed0cc21d6cb5c61d66922b3b1564015ca8a51325bd164a5e33798d504",
"sha256:f058bd0168271de4dcdc39845b52dd0a4a2fecf5f1246335f13f5e96eaebb467",
"sha256:f3c19e5bd42bbe4bf345704ad7c326c74d3fd7a1b3844987853bef180be638d4"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==20.3.0"
},
"txaio": {
"hashes": [
"sha256:17938f2bca4a9cabce61346758e482ca4e600160cbc28e861493eac74a19539d",
"sha256:38a469daf93c37e5527cb062653d6393ae11663147c42fab7ddc3f6d00d434ae"
],
"markers": "python_version >= '3.5'",
"version": "==20.4.1"
},
"uritemplate": {
"hashes": [
"sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f",
"sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.0.1"
},
"urllib3": {
@ -809,15 +1109,119 @@
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
],
"index": "pypi",
"markers": null,
"version": "==1.25.10"
},
"uvicorn": {
"hashes": [
"sha256:46a83e371f37ea7ff29577d00015f02c942410288fb57def6440f2653fff1d26",
"sha256:4b70ddb4c1946e39db9f3082d53e323dfd50634b95fd83625d778729ef1730ef"
],
"index": "pypi",
"version": "==0.11.8"
},
"uvloop": {
"hashes": [
"sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd",
"sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e",
"sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09",
"sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726",
"sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891",
"sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7",
"sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5",
"sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95",
"sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362"
],
"markers": "sys_platform != 'win32' and sys_platform != 'cygwin' and platform_python_implementation != 'PyPy'",
"version": "==0.14.0"
},
"vine": {
"hashes": [
"sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87",
"sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.3.0"
},
"websocket-client": {
"hashes": [
"sha256:0fc45c961324d79c781bab301359d5a1b00b13ad1b10415a4780229ef71a5549",
"sha256:d735b91d6d1692a6a181f2a8c9e0238e5f6373356f561bb9dc4c7af36f452010"
],
"version": "==0.57.0"
},
"websockets": {
"hashes": [
"sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5",
"sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5",
"sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308",
"sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb",
"sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a",
"sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c",
"sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170",
"sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422",
"sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8",
"sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485",
"sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f",
"sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8",
"sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc",
"sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779",
"sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989",
"sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1",
"sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092",
"sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824",
"sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d",
"sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55",
"sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36",
"sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==8.1"
},
"zope.interface": {
"hashes": [
"sha256:0103cba5ed09f27d2e3de7e48bb320338592e2fabc5ce1432cf33808eb2dfd8b",
"sha256:14415d6979356629f1c386c8c4249b4d0082f2ea7f75871ebad2e29584bd16c5",
"sha256:1ae4693ccee94c6e0c88a4568fb3b34af8871c60f5ba30cf9f94977ed0e53ddd",
"sha256:1b87ed2dc05cb835138f6a6e3595593fea3564d712cb2eb2de963a41fd35758c",
"sha256:269b27f60bcf45438e8683269f8ecd1235fa13e5411de93dae3b9ee4fe7f7bc7",
"sha256:27d287e61639d692563d9dab76bafe071fbeb26818dd6a32a0022f3f7ca884b5",
"sha256:39106649c3082972106f930766ae23d1464a73b7d30b3698c986f74bf1256a34",
"sha256:40e4c42bd27ed3c11b2c983fecfb03356fae1209de10686d03c02c8696a1d90e",
"sha256:461d4339b3b8f3335d7e2c90ce335eb275488c587b61aca4b305196dde2ff086",
"sha256:4f98f70328bc788c86a6a1a8a14b0ea979f81ae6015dd6c72978f1feff70ecda",
"sha256:558a20a0845d1a5dc6ff87cd0f63d7dac982d7c3be05d2ffb6322a87c17fa286",
"sha256:562dccd37acec149458c1791da459f130c6cf8902c94c93b8d47c6337b9fb826",
"sha256:5e86c66a6dea8ab6152e83b0facc856dc4d435fe0f872f01d66ce0a2131b7f1d",
"sha256:60a207efcd8c11d6bbeb7862e33418fba4e4ad79846d88d160d7231fcb42a5ee",
"sha256:645a7092b77fdbc3f68d3cc98f9d3e71510e419f54019d6e282328c0dd140dcd",
"sha256:6874367586c020705a44eecdad5d6b587c64b892e34305bb6ed87c9bbe22a5e9",
"sha256:74bf0a4f9091131de09286f9a605db449840e313753949fe07c8d0fe7659ad1e",
"sha256:7b726194f938791a6691c7592c8b9e805fc6d1b9632a833b9c0640828cd49cbc",
"sha256:8149ded7f90154fdc1a40e0c8975df58041a6f693b8f7edcd9348484e9dc17fe",
"sha256:8cccf7057c7d19064a9e27660f5aec4e5c4001ffcf653a47531bde19b5aa2a8a",
"sha256:911714b08b63d155f9c948da2b5534b223a1a4fc50bb67139ab68b277c938578",
"sha256:a5f8f85986197d1dd6444763c4a15c991bfed86d835a1f6f7d476f7198d5f56a",
"sha256:a744132d0abaa854d1aad50ba9bc64e79c6f835b3e92521db4235a1991176813",
"sha256:af2c14efc0bb0e91af63d00080ccc067866fb8cbbaca2b0438ab4105f5e0f08d",
"sha256:b054eb0a8aa712c8e9030065a59b5e6a5cf0746ecdb5f087cca5ec7685690c19",
"sha256:b0becb75418f8a130e9d465e718316cd17c7a8acce6fe8fe07adc72762bee425",
"sha256:b1d2ed1cbda2ae107283befd9284e650d840f8f7568cb9060b5466d25dc48975",
"sha256:ba4261c8ad00b49d48bbb3b5af388bb7576edfc0ca50a49c11dcb77caa1d897e",
"sha256:d1fe9d7d09bb07228650903d6a9dc48ea649e3b8c69b1d263419cc722b3938e8",
"sha256:d7804f6a71fc2dda888ef2de266727ec2f3915373d5a785ed4ddc603bbc91e08",
"sha256:da2844fba024dd58eaa712561da47dcd1e7ad544a257482392472eae1c86d5e5",
"sha256:dcefc97d1daf8d55199420e9162ab584ed0893a109f45e438b9794ced44c9fd0",
"sha256:dd98c436a1fc56f48c70882cc243df89ad036210d871c7427dc164b31500dc11",
"sha256:e74671e43ed4569fbd7989e5eecc7d06dc134b571872ab1d5a88f4a123814e9f",
"sha256:eb9b92f456ff3ec746cd4935b73c1117538d6124b8617bc0fe6fda0b3816e345",
"sha256:ebb4e637a1fb861c34e48a00d03cffa9234f42bef923aec44e5625ffb9a8e8f9",
"sha256:ef739fe89e7f43fb6494a43b1878a36273e5924869ba1d866f752c5812ae8d58",
"sha256:f40db0e02a8157d2b90857c24d89b6310f9b6c3642369852cdc3b5ac49b92afc",
"sha256:f68bf937f113b88c866d090fea0bc52a098695173fc613b055a17ff0cf9683b6",
"sha256:fb55c182a3f7b84c1a2d6de5fa7b1a05d4660d866b91dbf8d74549c57a1499e8"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==5.1.0"
}
},
"develop": {
@ -833,6 +1237,7 @@
"sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a",
"sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed"
],
"markers": "python_version >= '3.5'",
"version": "==3.2.10"
},
"astroid": {
@ -840,6 +1245,7 @@
"sha256:4c17cea3e592c21b6e222f673868961bad77e1f985cb1694ed077475a89229c1",
"sha256:d8506842a3faf734b81599c8b98dcc423de863adcc1999248480b18bd31a0f38"
],
"markers": "python_version >= '3.5'",
"version": "==2.4.1"
},
"attrs": {
@ -847,6 +1253,7 @@
"sha256:0ef97238856430dcf9228e07f316aefc17e8939fc8507e18c6501b761ef1a42a",
"sha256:2867b7b9f8326499ab5b0e2d12801fa5c98842d2cbd22b35112ae04bf85b4dff"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.1.0"
},
"autopep8": {
@ -877,6 +1284,7 @@
"sha256:477f0e18a0d58e50bb3dbc9af7fcda464fd0ebfc7a6151d8888602d7153171a0",
"sha256:cd4f3a231305e405ed8944d8ff35bd742d9bc740ad62f483bd0ca21ce7131984"
],
"markers": "python_version >= '3.5'",
"version": "==1.0.0"
},
"bumpversion": {
@ -906,6 +1314,7 @@
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==7.1.2"
},
"colorama": {
@ -992,6 +1401,7 @@
"sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
"sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.8.3"
},
"flake8-polyfill": {
@ -1006,6 +1416,7 @@
"sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac",
"sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"
],
"markers": "python_version >= '3.4'",
"version": "==4.0.5"
},
"gitpython": {
@ -1013,6 +1424,7 @@
"sha256:2db287d71a284e22e5c2846042d0602465c7434d910406990d5b74df4afb0858",
"sha256:fa3b92da728a457dd75d62bb5f3eb2816d99a7fe6c67398e260637a40e3fafb5"
],
"markers": "python_version >= '3.4'",
"version": "==3.1.7"
},
"idna": {
@ -1027,6 +1439,7 @@
"sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
"sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==4.3.21"
},
"lazy-object-proxy": {
@ -1053,6 +1466,7 @@
"sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4",
"sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.4.3"
},
"mccabe": {
@ -1074,6 +1488,7 @@
"sha256:14bfd98f51c78a3dd22a1ef45cf194ad79eee4a19e8e1a0d5c7f8e81ffe182ea",
"sha256:5adc0f9fc64319d8df5ca1e4e06eea674c26b80e6f00c530b18ce6a6592ead15"
],
"markers": "python_version >= '2.6'",
"version": "==5.5.0"
},
"pep8-naming": {
@ -1095,6 +1510,7 @@
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.6.0"
},
"pydocstyle": {
@ -1102,6 +1518,7 @@
"sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325",
"sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"
],
"markers": "python_version >= '3.5'",
"version": "==5.1.1"
},
"pyflakes": {
@ -1109,6 +1526,7 @@
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.2.0"
},
"pylint": {
@ -1201,6 +1619,7 @@
"sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
"sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.24.0"
},
"requirements-detector": {
@ -1228,6 +1647,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"smmap": {
@ -1235,6 +1655,7 @@
"sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4",
"sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==3.0.4"
},
"snowballstemmer": {
@ -1249,6 +1670,7 @@
"sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e",
"sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.3.1"
},
"stevedore": {
@ -1256,6 +1678,7 @@
"sha256:a34086819e2c7a7f86d5635363632829dab8014e5fd7be2454c7cba84ac7514e",
"sha256:ddc09a744dc224c84ec8e8efcb70595042d21c97c76df60daee64c9ad53bc7ee"
],
"markers": "python_version >= '3.6'",
"version": "==3.2.1"
},
"toml": {
@ -1308,7 +1731,6 @@
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
],
"index": "pypi",
"markers": null,
"version": "==1.25.10"
},
"websocket-client": {

View File

@ -4,7 +4,6 @@
![Tests](https://img.shields.io/azure-devops/tests/beryjuorg/passbook/1?compact_message&style=flat-square)
[![Code Coverage](https://img.shields.io/codecov/c/gh/beryju/passbook?style=flat-square)](https://codecov.io/gh/BeryJu/passbook)
![Docker pulls](https://img.shields.io/docker/pulls/beryju/passbook.svg?style=flat-square)
![Docker pulls (gatekeeper)](https://img.shields.io/docker/pulls/beryju/passbook-gatekeeper.svg?style=flat-square)
![Latest version](https://img.shields.io/docker/v/beryju/passbook?sort=semver&style=flat-square)
![LGTM Grade](https://img.shields.io/lgtm/grade/python/github/BeryJu/passbook?style=flat-square)

View File

@ -261,18 +261,6 @@ stages:
command: 'buildAndPush'
Dockerfile: 'Dockerfile'
tags: 'gh-$(Build.SourceBranchName)'
- job: build_gatekeeper
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Docker@2
inputs:
containerRegistry: 'dockerhub'
repository: 'beryju/passbook-gatekeeper'
command: 'buildAndPush'
Dockerfile: 'gatekeeper/Dockerfile'
buildContext: 'gatekeeper/'
tags: 'gh-$(Build.SourceBranchName)'
- job: build_static
pool:
vmImage: 'ubuntu-latest'

View File

@ -22,14 +22,13 @@ services:
- traefik.enable=false
server:
image: beryju/passbook:${PASSBOOK_TAG:-latest}
command:
- uwsgi
- uwsgi.ini
command: server
environment:
- PASSBOOK_REDIS__HOST=redis
- PASSBOOK_ERROR_REPORTING=${PASSBOOK_ERROR_REPORTING:-false}
- PASSBOOK_POSTGRESQL__HOST=postgresql
- PASSBOOK_POSTGRESQL__PASSWORD=${PG_PASS:-thisisnotagoodpassword}
PASSBOOK_REDIS__HOST: redis
PASSBOOK_ERROR_REPORTING: ${PASSBOOK_ERROR_REPORTING:-false}
PASSBOOK_POSTGRESQL__HOST: postgresql
PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS:-thisisnotagoodpassword}
PASSBOOK_LOG_LEVEL: debug
ports:
- 8000
networks:
@ -40,23 +39,17 @@ services:
- traefik.frontend.rule=PathPrefix:/
worker:
image: beryju/passbook:${PASSBOOK_TAG:-latest}
command:
- celery
- worker
- --autoscale=10,3
- -E
- -B
- -A=passbook.root.celery
- -s=/tmp/celerybeat-schedule
command: worker
networks:
- internal
labels:
- traefik.enable=false
environment:
- PASSBOOK_REDIS__HOST=redis
- PASSBOOK_ERROR_REPORTING=${PASSBOOK_ERROR_REPORTING:-false}
- PASSBOOK_POSTGRESQL__HOST=postgresql
- PASSBOOK_POSTGRESQL__PASSWORD=${PG_PASS:-thisisnotagoodpassword}
PASSBOOK_REDIS__HOST: redis
PASSBOOK_ERROR_REPORTING: ${PASSBOOK_ERROR_REPORTING:-false}
PASSBOOK_POSTGRESQL__HOST: postgresql
PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS:-thisisnotagoodpassword}
PASSBOOK_LOG_LEVEL: debug
static:
image: beryju/passbook-static:latest
networks:

View File

@ -1,3 +1,10 @@
#!/bin/bash -ex
#!/bin/bash -e
/app/wait_for_db.py
"$@"
printf '{"event": "Bootstrap completed", "level": "info", "logger": "bootstrap", "command": "%s"}\n' "$@"
if [[ "$1" == "server" ]]; then
gunicorn -c gunicorn.conf.py passbook.root.asgi:application
elif [[ "$1" == "worker" ]]; then
celery worker --autoscale=10,3 -E -B -A=passbook.root.celery -s=/tmp/celerybeat-schedule
else
./manage.py "$@"
fi

38
docker/gunicorn.conf.py Normal file
View File

@ -0,0 +1,38 @@
"""Gunicorn config"""
import multiprocessing
import structlog
bind = "0.0.0.0:8000"
workers = multiprocessing.cpu_count() * 2 + 1
workers = 1
user = "passbook"
group = "passbook"
worker_class = "uvicorn.workers.UvicornWorker"
logconfig_dict = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json_formatter": {
"()": structlog.stdlib.ProcessorFormatter,
"processor": structlog.processors.JSONRenderer(),
"foreign_pre_chain": [
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
],
}
},
"handlers": {
"error_console": {
"class": "logging.StreamHandler",
"formatter": "json_formatter",
},
"console": {"class": "logging.StreamHandler", "formatter": "json_formatter"},
},
}

View File

@ -1,10 +0,0 @@
[uwsgi]
http = 0.0.0.0:8000
wsgi-file = passbook/root/wsgi.py
processes = 2
master = true
threads = 2
enable-threads = true
uid = passbook
gid = passbook
disable-logging = True

View File

@ -6,3 +6,4 @@ services:
volumes:
- /dev/shm:/dev/shm
network_mode: host
restart: always

View File

@ -1,8 +0,0 @@
FROM quay.io/oauth2-proxy/oauth2-proxy
ENV OAUTH2_PROXY_EMAIL_DOMAINS=*
ENV OAUTH2_PROXY_PROVIDER=oidc
ENV OAUTH2_PROXY_HTTP_ADDRESS=:4180
# TODO: If service is access over HTTPS, this needs to be set to true (default), otherwise needs to be false
# ENV OAUTH2_PROXY_COOKIE_SECURE=true
ENV OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true

View File

@ -53,9 +53,7 @@ spec:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.name }}:{{ .Values.image.tag }}"
imagePullPolicy: Always
args:
- uwsgi
- uwsgi.ini
args: server
envFrom:
- configMapRef:
name: {{ include "passbook.fullname" . }}-config

View File

@ -26,14 +26,7 @@ spec:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.name }}:{{ .Values.image.tag }}"
imagePullPolicy: IfNotPresent
args:
- celery
- worker
- --autoscale=10,3
- -E
- -B
- -A=passbook.root.celery
- -s=/tmp/celerybeat-schedule
args: worker
envFrom:
- configMapRef:
name: "{{ include "passbook.fullname" . }}-config"

View File

@ -46,6 +46,12 @@
{% trans 'Providers' %}
</a>
</li>
<li class="pf-c-nav__item">
<a href="{% url 'passbook_admin:outposts' %}"
class="pf-c-nav__link {% is_active 'passbook_admin:outposts' 'passbook_admin:outpost-create' 'passbook_admin:outpost-update' 'passbook_admin:outpost-delete' %}">
{% trans 'Outposts' %}
</a>
</li>
<li class="pf-c-nav__item">
<a href="{% url 'passbook_admin:property-mappings' %}"
class="pf-c-nav__link {% is_active 'passbook_admin:property-mappings' 'passbook_admin:property-mapping-create' 'passbook_admin:property-mapping-update' 'passbook_admin:property-mapping-delete' %}">

View File

@ -0,0 +1,96 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load humanize %}
{% load passbook_utils %}
{% block head %}
{{ block.super }}
<style>
.pf-m-success {
color: var(--pf-global--success-color--100);
}
.pf-m-danger {
color: var(--pf-global--danger-color--100);
}
</style>
{% endblock %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="fas fa-map-marker"></i>
{% trans 'Outposts' %}
</h1>
<p>{% trans "Outposts are deployments of passbook components to support different environments and protocols, like reverse proxies." %}</p>
</div>
</section>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
{% if object_list %}
<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
<div class="pf-c-toolbar__bulk-select">
<a href="{% url 'passbook_admin:flow-create' %}?back={{ request.get_full_path }}" class="pf-c-button pf-m-primary" type="button">{% trans 'Create' %}</a>
</div>
{% include 'partials/pagination.html' %}
</div>
</div>
<table class="pf-c-table pf-m-compact pf-m-grid-xl" role="grid">
<thead>
<tr role="row">
<th role="columnheader" scope="col">{% trans 'Name' %}</th>
<th role="columnheader" scope="col">{% trans 'Providers' %}</th>
<th role="columnheader" scope="col">{% trans 'Health' %}</th>
<th role="cell"></th>
</tr>
</thead>
<tbody role="rowgroup">
{% for outpost in object_list %}
<tr role="row">
<th role="columnheader">
<a href="{% url 'passbook_outposts:setup' outpost_pk=outpost.pk %}">{{ outpost.name }}</a>
</th>
<td role="cell">
<span>
{{ outpost.providers.all.select_subclasses|join:", " }}
</span>
</td>
<td role="cell">
{% with health=outpost.health %}
{% if health %}
<i class="fas fa-check pf-m-success"></i> {{ health|naturaltime }}
{% else %}
<i class="fas fa-times pf-m-danger"></i> Unhealthy
{% endif %}
{% endwith %}
</td>
<td>
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:outpost-update' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:outpost-delete' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pf-c-toolbar" id="page-layout-table-simple-toolbar-bottom">
{% include 'partials/pagination.html' %}
</div>
{% else %}
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">
{% trans 'No Outposts.' %}
</h1>
<div class="pf-c-empty-state__body">
{% trans 'Currently no outposts exist. Click the button below to create one.' %}
</div>
<a href="{% url 'passbook_admin:outpost-create' %}?back={{ request.get_full_path }}" class="pf-c-button pf-m-primary" type="button">{% trans 'Create' %}</a>
</div>
</div>
{% endif %}
</div>
</section>
{% endblock %}

View File

@ -36,7 +36,7 @@
<tr role="row">
<th role="columnheader">
<div>
<div>{{ token.pk }}</div>
<div>{{ token.pk.hex }}</div>
</div>
</th>
<td role="cell">
@ -51,7 +51,11 @@
</td>
<td role="cell">
<span>
{% if not token.expiring %}
-
{% else %}
{{ token.expires }}
{% endif %}
</span>
</td>
<td>

View File

@ -6,6 +6,7 @@ from passbook.admin.views import (
certificate_key_pair,
flows,
groups,
outposts,
overview,
policies,
policies_bindings,
@ -271,4 +272,19 @@ urlpatterns = [
certificate_key_pair.CertificateKeyPairDeleteView.as_view(),
name="certificatekeypair-delete",
),
# Outposts
path("outposts/", outposts.OutpostListView.as_view(), name="outposts",),
path(
"outposts/create/", outposts.OutpostCreateView.as_view(), name="outpost-create",
),
path(
"outposts/<uuid:pk>/update/",
outposts.OutpostUpdateView.as_view(),
name="outpost-update",
),
path(
"outposts/<uuid:pk>/delete/",
outposts.OutpostDeleteView.as_view(),
name="outpost-delete",
),
]

View File

@ -0,0 +1,67 @@
"""passbook Outpost administration"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import ugettext as _
from django.views.generic import ListView, UpdateView
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
from passbook.admin.views.utils import DeleteMessageView
from passbook.lib.views import CreateAssignPermView
from passbook.outposts.forms import OutpostForm
from passbook.outposts.models import Outpost
class OutpostListView(LoginRequiredMixin, PermissionListMixin, ListView):
"""Show list of all outposts"""
model = Outpost
permission_required = "passbook_outposts.view_outpost"
ordering = "name"
paginate_by = 40
template_name = "administration/outpost/list.html"
class OutpostCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
"""Create new Outpost"""
model = Outpost
form_class = OutpostForm
permission_required = "passbook_outposts.add_outpost"
template_name = "generic/create.html"
success_url = reverse_lazy("passbook_admin:outposts")
success_message = _("Successfully created Outpost")
class OutpostUpdateView(
SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView
):
"""Update outpost"""
model = Outpost
form_class = OutpostForm
permission_required = "passbook_outposts.change_outpost"
template_name = "generic/update.html"
success_url = reverse_lazy("passbook_admin:outposts")
success_message = _("Successfully updated Certificate-Key Pair")
class OutpostDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView):
"""Delete outpost"""
model = Outpost
permission_required = "passbook_outposts.delete_outpost"
template_name = "generic/delete.html"
success_url = reverse_lazy("passbook_admin:outposts")
success_message = _("Successfully deleted Certificate-Key Pair")

View File

@ -6,15 +6,17 @@ from drf_yasg.views import get_schema_view
from rest_framework import routers
from passbook.api.permissions import CustomObjectPermissions
from passbook.api.v2.messages import MessagesViewSet
from passbook.audit.api import EventViewSet
from passbook.core.api.applications import ApplicationViewSet
from passbook.core.api.groups import GroupViewSet
from passbook.core.api.messages import MessagesViewSet
from passbook.core.api.propertymappings import PropertyMappingViewSet
from passbook.core.api.providers import ProviderViewSet
from passbook.core.api.sources import SourceViewSet
from passbook.core.api.users import UserViewSet
from passbook.crypto.api import CertificateKeyPairViewSet
from passbook.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet
from passbook.outposts.api import OutpostViewSet
from passbook.policies.api import PolicyBindingViewSet, PolicyViewSet
from passbook.policies.dummy.api import DummyPolicyViewSet
from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet
@ -24,7 +26,7 @@ from passbook.policies.hibp.api import HaveIBeenPwendPolicyViewSet
from passbook.policies.password.api import PasswordPolicyViewSet
from passbook.policies.reputation.api import ReputationPolicyViewSet
from passbook.providers.oauth2.api import OAuth2ProviderViewSet, ScopeMappingViewSet
from passbook.providers.proxy.api import ProxyProviderViewSet
from passbook.providers.proxy.api import OutpostConfigViewSet, ProxyProviderViewSet
from passbook.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet
from passbook.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
from passbook.sources.oauth.api import OAuthSourceViewSet
@ -47,10 +49,14 @@ from passbook.stages.user_write.api import UserWriteStageViewSet
router = routers.DefaultRouter()
router.register("root/messages", MessagesViewSet, basename="messages")
router.register("core/applications", ApplicationViewSet)
router.register("core/groups", GroupViewSet)
router.register("core/users", UserViewSet)
router.register("core/messages", MessagesViewSet, basename="messages")
router.register("outposts/outposts", OutpostViewSet)
router.register("outposts/proxy", OutpostConfigViewSet)
router.register("crypto/certificatekeypairs", CertificateKeyPairViewSet)
router.register("audit/events", EventViewSet)

View File

@ -9,10 +9,11 @@ from passbook.lib.widgets import GroupedModelChoiceField
class ApplicationForm(forms.ModelForm):
"""Application Form"""
provider = GroupedModelChoiceField(
queryset=Provider.objects.all().order_by("pk").select_subclasses(),
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["provider"].queryset = (
Provider.objects.all().order_by("pk").select_subclasses()
)
class Meta:
@ -32,6 +33,7 @@ class ApplicationForm(forms.ModelForm):
"meta_icon_url": forms.TextInput(),
"meta_publisher": forms.TextInput(),
}
field_classes = {"provider": GroupedModelChoiceField}
labels = {
"meta_launch_url": _("Launch URL"),
"meta_icon_url": _("Icon URL"),

View File

@ -0,0 +1,36 @@
# Generated by Django 3.1 on 2020-08-24 15:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("passbook_core", "0007_auto_20200815_1841"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="groups",
field=models.ManyToManyField(to="passbook_core.Group"),
),
migrations.AddField(
model_name="user",
name="groups",
field=models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.Group",
verbose_name="groups",
),
),
migrations.AddField(
model_name="user",
name="pb_groups",
field=models.ManyToManyField(to="passbook_core.Group"),
),
]

View File

@ -58,7 +58,7 @@ class User(GuardianUserMixin, AbstractUser):
name = models.TextField(help_text=_("User's display name."))
sources = models.ManyToManyField("Source", through="UserSourceConnection")
groups = models.ManyToManyField("Group")
pb_groups = models.ManyToManyField("Group")
password_change_date = models.DateTimeField(auto_now_add=True)
attributes = models.JSONField(default=dict, blank=True)

View File

@ -8,6 +8,10 @@
{% trans card_title %}
{% endblock %}
{% block card_title %}
{% trans card_title %}
{% endblock %}
{% block card %}
<form method="POST" class="pf-c-form">
{% if message %}

View File

@ -29,7 +29,7 @@
<main class="pf-c-login__main">
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
{% block title %}
{% block card_title %}
{% endblock %}
</h1>
</header>

View File

@ -4,6 +4,10 @@
{% load i18n %}
{% load passbook_utils %}
{% block card_title %}
{% trans 'Permission denied' %}
{% endblock %}
{% block title %}
{% trans 'Permission denied' %}
{% endblock %}

47
passbook/crypto/api.py Normal file
View File

@ -0,0 +1,47 @@
"""Crypto API Views"""
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import load_pem_x509_certificate
from rest_framework.serializers import ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet
from passbook.crypto.models import CertificateKeyPair
class CertificateKeyPairSerializer(ModelSerializer):
"""CertificateKeyPair Serializer"""
def validate_certificate_data(self, value):
"""Verify that input is a valid PEM x509 Certificate"""
try:
load_pem_x509_certificate(value.encode("utf-8"), default_backend())
except ValueError:
raise ValidationError("Unable to load certificate.")
return value
def validate_key_data(self, value):
"""Verify that input is a valid PEM RSA Key"""
# Since this field is optional, data can be empty.
if value == "":
return value
try:
load_pem_private_key(
str.encode("\n".join([x.strip() for x in value.split("\n")])),
password=None,
backend=default_backend(),
)
except ValueError:
raise ValidationError("Unable to load private key.")
return value
class Meta:
model = CertificateKeyPair
fields = ["pk", "name", "certificate_data", "key_data"]
class CertificateKeyPairViewSet(ModelViewSet):
"""CertificateKeyPair Viewset"""
queryset = CertificateKeyPair.objects.all()
serializer_class = CertificateKeyPairSerializer

View File

@ -13,5 +13,4 @@ class PassbookFlowsConfig(AppConfig):
verbose_name = "passbook Flows"
def ready(self):
"""Flow signals that clear the cache"""
import_module("passbook.flows.signals")

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1 on 2020-08-30 10:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_flows", "0011_flow_title"),
]
operations = [
migrations.AlterField(
model_name="flow",
name="title",
field=models.TextField(blank=True, default=""),
),
]

View File

@ -3,18 +3,17 @@ import os
from collections.abc import Mapping
from contextlib import contextmanager
from glob import glob
from json import dumps
from typing import Any, Dict
from urllib.parse import urlparse
import yaml
from django.conf import ImproperlyConfigured
from django.http import HttpRequest
from structlog import get_logger
SEARCH_PATHS = ["passbook/lib/default.yml", "/etc/passbook/config.yml", ""] + glob(
"/etc/passbook/config.d/*.yml", recursive=True
)
LOGGER = get_logger()
ENV_PREFIX = "PASSBOOK"
ENVIRONMENT = os.getenv(f"{ENV_PREFIX}_ENV", "local")
@ -58,6 +57,13 @@ class ConfigLoader:
self.update_from_file(env_file)
self.update_from_env()
def _log(self, level: str, message: str, **kwargs):
"""Custom Log method, we want to ensure ConfigLoader always logs JSON even when
'structlog' or 'logging' hasn't been configured yet."""
output = {"event": message, "level": level, "logger": self.__class__.__module__}
output.update(kwargs)
print(dumps(output))
def update(self, root, updatee):
"""Recursively update dictionary"""
for key, value in updatee.items():
@ -82,12 +88,14 @@ class ConfigLoader:
with open(path) as file:
try:
self.update(self.__config, yaml.safe_load(file))
LOGGER.debug("Loaded config", file=path)
self._log("debug", "Loaded config", file=path)
self.loaded_file.append(path)
except yaml.YAMLError as exc:
raise ImproperlyConfigured from exc
except PermissionError as exc:
LOGGER.warning("Permission denied while reading file", path=path, error=exc)
self._log(
"warning", "Permission denied while reading file", path=path, error=exc
)
def update_from_dict(self, update: dict):
"""Update config from dict"""
@ -111,7 +119,7 @@ class ConfigLoader:
current_obj[dot_parts[-1]] = value
idx += 1
if idx > 0:
LOGGER.debug("Loaded environment variables", count=idx)
self._log("debug", "Loaded environment variables", count=idx)
self.update(self.__config, outer)
@contextmanager

View File

@ -12,7 +12,7 @@ redis:
message_queue_db: 1
debug: false
log_level: warning
log_level: info
# Error reporting, sends stacktrace to sentry.beryju.org
error_reporting:

View File

23
passbook/outposts/api.py Normal file
View File

@ -0,0 +1,23 @@
"""Outpost API Views"""
from rest_framework.serializers import JSONField, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.outposts.models import Outpost
class OutpostSerializer(ModelSerializer):
"""Outpost Serializer"""
_config = JSONField()
class Meta:
model = Outpost
fields = ["pk", "name", "providers", "_config"]
class OutpostViewSet(ModelViewSet):
"""Outpost Viewset"""
queryset = Outpost.objects.all()
serializer_class = OutpostSerializer

16
passbook/outposts/apps.py Normal file
View File

@ -0,0 +1,16 @@
"""passbook outposts app config"""
from importlib import import_module
from django.apps import AppConfig
class PassbookOutpostConfig(AppConfig):
"""passbook outposts app config"""
name = "passbook.outposts"
label = "passbook_outposts"
mountpoint = "outposts/"
verbose_name = "passbook Outpost"
def ready(self):
import_module("passbook.outposts.signals")

View File

@ -0,0 +1,100 @@
"""Outpost websocket handler"""
from dataclasses import asdict, dataclass, field
from enum import IntEnum
from time import time
from typing import Any, Dict
from channels.generic.websocket import JsonWebsocketConsumer
from dacite import from_dict
from dacite.data import Data
from django.core.cache import cache
from django.core.exceptions import ValidationError
from structlog import get_logger
from passbook.core.models import Token, TokenIntents
from passbook.outposts.models import Outpost
LOGGER = get_logger()
class WebsocketMessageInstruction(IntEnum):
"""Commands which can be triggered over Websocket"""
# Simple message used by either side when a message is acknowledged
ACK = 0
# Message used by outposts to report their alive status
HELLO = 1
# Message sent by us to trigger an Update
TRIGGER_UPDATE = 2
@dataclass
class WebsocketMessage:
"""Complete Websocket Message that is being sent"""
instruction: int
args: Dict[str, Any] = field(default_factory=dict)
class OutpostConsumer(JsonWebsocketConsumer):
"""Handler for Outposts that connect over websockets for health checks and live updates"""
outpost: Outpost
def connect(self):
# TODO: This authentication block could be handeled in middleware
headers = dict(self.scope["headers"])
if b"authorization" not in headers:
LOGGER.warning("WS Request without authorization header")
self.close()
token = headers[b"authorization"]
try:
token_uuid = token.decode("utf-8")
tokens = Token.filter_not_expired(
token_uuid=token_uuid, intent=TokenIntents.INTENT_API
)
if not tokens.exists():
LOGGER.warning("WS Request with invalid token")
self.close()
except ValidationError:
LOGGER.warning("WS Invalid UUID")
self.close()
uuid = self.scope["url_route"]["kwargs"]["pk"]
outpost = Outpost.objects.filter(pk=uuid)
if not outpost.exists():
self.close()
return
self.accept()
self.outpost = outpost.first()
self.outpost.channels.append(self.channel_name)
LOGGER.debug("added channel to outpost", channel_name=self.channel_name)
self.outpost.save()
# pylint: disable=unused-argument
def disconnect(self, close_code):
self.outpost.channels.remove(self.channel_name)
self.outpost.save()
LOGGER.debug("removed channel from outpost", channel_name=self.channel_name)
def receive_json(self, content: Data):
msg = from_dict(WebsocketMessage, content)
if msg.instruction == WebsocketMessageInstruction.HELLO:
cache.set(self.outpost.health_cache_key, time(), timeout=60)
elif msg.instruction == WebsocketMessageInstruction.ACK:
return
response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
self.send_json(asdict(response))
# pylint: disable=unused-argument
def event_update(self, event):
"""Event handler which is called by post_save signals"""
self.send_json(
asdict(
WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE)
)
)

View File

@ -0,0 +1,29 @@
"""Base Controller"""
from typing import Dict
from structlog import get_logger
from passbook.outposts.models import Outpost
class BaseController:
"""Base Outpost deployment controller"""
deployment_ports: Dict[str, int]
outpost: Outpost
def __init__(self, outpost_pk: str):
self.outpost = Outpost.objects.get(pk=outpost_pk)
self.logger = get_logger(
controller=self.__class__.__name__, outpost=self.outpost
)
self.deployment_ports = {}
def run(self):
"""Called by scheduled task to reconcile deployment/service/etc"""
raise NotImplementedError
def get_static_deployment(self) -> str:
"""Return a static deployment configuration"""
raise NotImplementedError

View File

@ -0,0 +1,36 @@
"""Docker Compose controller"""
from yaml import safe_dump
from passbook import __version__
from passbook.outposts.controllers.base import BaseController
class DockerComposeController(BaseController):
"""Docker Compose controller"""
image_base = "beryju/passbook"
def run(self):
self.logger.warning("DockerComposeController does not implement run")
raise NotImplementedError
def get_static_deployment(self) -> str:
"""Generate docker-compose yaml for proxy, version 3.5"""
ports = [f"{x}:{x}" for _, x in self.deployment_ports.items()]
compose = {
"version": "3.5",
"services": {
f"passbook_{self.outpost.type}": {
"image": f"{self.image_base}-{self.outpost.type}:{__version__}",
"ports": ports,
"environment": {
"PASSBOOK_HOST": self.outpost.config.passbook_host,
"PASSBOOK_INSECURE": str(
self.outpost.config.passbook_host_insecure
),
"PASSBOOK_TOKEN": self.outpost.token.token_uuid.hex,
},
}
},
}
return safe_dump(compose, default_flow_style=False)

View File

@ -0,0 +1,143 @@
"""Kubernetes deployment controller"""
from io import StringIO
from kubernetes.client import (
V1Container,
V1ContainerPort,
V1Deployment,
V1DeploymentSpec,
V1EnvVar,
V1EnvVarSource,
V1LabelSelector,
V1ObjectMeta,
V1PodSpec,
V1PodTemplateSpec,
V1Secret,
V1SecretKeySelector,
V1Service,
V1ServicePort,
V1ServiceSpec,
)
from yaml import dump_all
from passbook import __version__
from passbook.outposts.controllers.base import BaseController
class KubernetesController(BaseController):
"""Manage deployment of outpost in kubernetes"""
image_base = "beryju/passbook"
def run(self):
"""Called by scheduled task to reconcile deployment/service/etc"""
# TODO
def get_static_deployment(self) -> str:
with StringIO() as _str:
dump_all(
[
self.get_deployment_secret(),
self.get_deployment(),
self.get_service(),
],
stream=_str,
default_flow_style=False,
)
return _str.getvalue()
def get_object_meta(self, **kwargs) -> V1ObjectMeta:
"""Get common object metadata"""
return V1ObjectMeta(
namespace="self.instance.namespace",
labels={
"app.kubernetes.io/name": f"passbook-{self.outpost.type.lower()}",
"app.kubernetes.io/instance": self.outpost.name,
"app.kubernetes.io/version": __version__,
"app.kubernetes.io/managed-by": "passbook.beryju.org",
"passbook.beryju.org/outpost/uuid": self.outpost.uuid.hex,
},
**kwargs,
)
def get_deployment_secret(self) -> V1Secret:
"""Get secret with token and passbook host"""
return V1Secret(
metadata=self.get_object_meta(
name=f"passbook-outpost-{self.outpost.name}-api"
),
data={
"passbook_host": self.outpost.config.passbook_host,
"passbook_host_insecure": str(
self.outpost.config.passbook_host_insecure
),
"token": self.outpost.token.token_uuid.hex,
},
)
def get_service(self) -> V1Service:
"""Get service object for outpost based on ports defined"""
meta = self.get_object_meta(name=f"passbook-outpost-{self.outpost.name}")
ports = []
for port_name, port in self.deployment_ports.items():
ports.append(V1ServicePort(name=port_name, port=port))
return V1Service(
metadata=meta,
spec=V1ServiceSpec(ports=ports, selector=meta.labels, type="ClusterIP"),
)
def get_deployment(self) -> V1Deployment:
"""Get deployment object for outpost"""
# Generate V1ContainerPort objects
container_ports = []
for port_name, port in self.deployment_ports.items():
container_ports.append(V1ContainerPort(container_port=port, name=port_name))
meta = self.get_object_meta(name=f"passbook-outpost-{self.outpost.name}")
return V1Deployment(
metadata=meta,
spec=V1DeploymentSpec(
replicas=1,
selector=V1LabelSelector(match_labels=meta.labels),
template=V1PodTemplateSpec(
metadata=V1ObjectMeta(labels=meta.labels),
spec=V1PodSpec(
containers=[
V1Container(
name=self.outpost.type,
image=f"{self.image_base}-{self.outpost.type}:{__version__}",
ports=container_ports,
env=[
V1EnvVar(
name="PASSBOOK_HOST",
value_from=V1EnvVarSource(
secret_key_ref=V1SecretKeySelector(
name=f"passbook-outpost-{self.outpost.name}-api",
key="passbook_host",
)
),
),
V1EnvVar(
name="PASSBOOK_TOKEN",
value_from=V1EnvVarSource(
secret_key_ref=V1SecretKeySelector(
name=f"passbook-outpost-{self.outpost.name}-api",
key="token",
)
),
),
V1EnvVar(
name="PASSBOOK_INSECURE",
value_from=V1EnvVarSource(
secret_key_ref=V1SecretKeySelector(
name=f"passbook-outpost-{self.outpost.name}-api",
key="passbook_host_insecure",
)
),
),
],
)
]
),
),
),
)

View File

@ -0,0 +1,35 @@
"""Outpost forms"""
from django import forms
from django.utils.translation import gettext_lazy as _
from passbook.admin.fields import CodeMirrorWidget, YAMLField
from passbook.core.models import Provider
from passbook.outposts.models import Outpost
class OutpostForm(forms.ModelForm):
"""Outpost Form"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["providers"].queryset = Provider.objects.all().select_subclasses()
class Meta:
model = Outpost
fields = [
"name",
"type",
"deployment_type",
"providers",
"_config",
]
widgets = {
"name": forms.TextInput(),
"_config": CodeMirrorWidget,
}
field_classes = {
"_config": YAMLField,
}
labels = {"_config": _("Configuration")}

View File

@ -0,0 +1,40 @@
# Generated by Django 3.1 on 2020-08-25 20:45
import uuid
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_core", "0008_auto_20200824_1532"),
]
operations = [
migrations.CreateModel(
name="Outpost",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("name", models.TextField()),
(
"channels",
django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), size=None
),
),
("providers", models.ManyToManyField(to="passbook_core.Provider")),
],
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 3.1 on 2020-08-26 13:06
from django.db import migrations, models
import passbook.outposts.models
class Migration(migrations.Migration):
dependencies = [
("passbook_outposts", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="outpost",
name="_config",
field=models.JSONField(
default=passbook.outposts.models.default_outpost_config
),
),
migrations.AddField(
model_name="outpost",
name="type",
field=models.TextField(choices=[("proxy", "Proxy")], default="proxy"),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 3.1 on 2020-08-27 21:08
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_outposts", "0002_auto_20200826_1306"),
]
operations = [
migrations.AddField(
model_name="outpost",
name="deployment_type",
field=models.TextField(
choices=[
("docker_compose", "Docker Compose"),
("kubernetes", "Kubernetes"),
("custom", "Custom"),
],
default="custom",
help_text="Select between passbook-managed deployment types or a custom deployment.",
),
),
migrations.AlterField(
model_name="outpost",
name="channels",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), default=list, size=None
),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 3.1 on 2020-08-30 10:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_outposts", "0003_auto_20200827_2108"),
]
operations = [
migrations.AlterField(
model_name="outpost",
name="deployment_type",
field=models.TextField(
choices=[("kubernetes", "Kubernetes"), ("custom", "Custom")],
default="custom",
help_text="Select between passbook-managed deployment types or a custom deployment.",
),
),
]

View File

148
passbook/outposts/models.py Normal file
View File

@ -0,0 +1,148 @@
"""Outpost models"""
from dataclasses import asdict, dataclass
from datetime import datetime
from json import dumps, loads
from typing import Iterable, Optional
from uuid import uuid4
from dacite import from_dict
from django.contrib.postgres.fields import ArrayField
from django.core.cache import cache
from django.db import models
from django.utils.translation import gettext_lazy as _
from guardian.shortcuts import assign_perm
from passbook.core.models import Provider, Token, TokenIntents, User
from passbook.lib.config import CONFIG
@dataclass
class OutpostConfig:
"""Configuration an outpost uses to configure it self"""
passbook_host: str
passbook_host_insecure: bool = False
log_level: str = CONFIG.y("log_level")
error_reporting_enabled: bool = CONFIG.y_bool("error_reporting.enabled")
error_reporting_environment: str = CONFIG.y(
"error_reporting.environment", "customer"
)
class OutpostModel:
"""Base model for providers that need more objects than just themselves"""
def get_required_objects(self) -> Iterable[models.Model]:
"""Return a list of all required objects"""
return [self]
class OutpostType(models.TextChoices):
"""Outpost types, currently only the reverse proxy is available"""
PROXY = "proxy"
class OutpostDeploymentType(models.TextChoices):
"""Deployment types that are managed through passbook"""
KUBERNETES = "kubernetes"
CUSTOM = "custom"
def default_outpost_config():
"""Get default outpost config"""
return asdict(OutpostConfig(passbook_host=""))
class Outpost(models.Model):
"""Outpost instance which manages a service user and token"""
uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
name = models.TextField()
type = models.TextField(choices=OutpostType.choices, default=OutpostType.PROXY)
deployment_type = models.TextField(
choices=OutpostDeploymentType.choices,
default=OutpostDeploymentType.CUSTOM,
help_text=_(
"Select between passbook-managed deployment types or a custom deployment."
),
)
_config = models.JSONField(default=default_outpost_config)
providers = models.ManyToManyField(Provider)
channels = ArrayField(models.TextField(), default=list)
@property
def config(self) -> OutpostConfig:
"""Load config as OutpostConfig object"""
return from_dict(OutpostConfig, loads(self._config))
@config.setter
def config(self, value):
"""Dump config into json"""
self._config = dumps(asdict(value))
@property
def health_cache_key(self) -> str:
"""Key by which the outposts health status is saved"""
return f"outpost_{self.uuid.hex}_health"
@property
def health(self) -> Optional[datetime]:
"""Get outpost's health status"""
key = self.health_cache_key
value = cache.get(key, None)
if value:
return datetime.fromtimestamp(value)
return None
def _create_user(self) -> User:
"""Create user and assign permissions for all required objects"""
user: User = User.objects.create(username=f"pb-outpost-{self.uuid.hex}")
user.set_unusable_password()
user.save()
for model in self.get_required_objects():
assign_perm(
f"{model._meta.app_label}.view_{model._meta.model_name}", user, model
)
return user
@property
def user(self) -> User:
"""Get/create user with access to all required objects"""
user = User.objects.filter(username=f"pb-outpost-{self.uuid.hex}")
if user.exists():
return user.first()
return self._create_user()
@property
def token(self) -> Token:
"""Get/create token for auto-generated user"""
token = Token.filter_not_expired(user=self.user, intent=TokenIntents.INTENT_API)
if token.exists():
return token.first()
return Token.objects.create(
user=self.user,
intent=TokenIntents.INTENT_API,
description=f"Autogenerated by passbook for Outpost {self.name}",
expiring=False,
)
def get_required_objects(self) -> Iterable[models.Model]:
"""Get an iterator of all objects the user needs read access to"""
objects = [self]
for provider in (
Provider.objects.filter(outpost=self).select_related().select_subclasses()
):
if isinstance(provider, OutpostModel):
objects.extend(provider.get_required_objects())
else:
objects.append(provider)
return objects
def __str__(self) -> str:
return f"Outpost {self.name}"

View File

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

View File

@ -0,0 +1,58 @@
"""passbook outpost signals"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db.models import Model
from django.db.models.signals import post_save
from django.dispatch import receiver
from structlog import get_logger
from passbook.outposts.models import Outpost, OutpostModel
LOGGER = get_logger()
@receiver(post_save, sender=Outpost)
# pylint: disable=unused-argument
def ensure_user_and_token(sender, instance, **_):
"""Ensure that token is created/updated on save"""
_ = instance.token
@receiver(post_save)
# pylint: disable=unused-argument
def post_save_update(sender, instance, **_):
"""If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved,
we send a message down the relevant OutpostModels WS connection to trigger an update"""
if isinstance(instance, OutpostModel):
LOGGER.debug("triggering outpost update from outpostmodel", instance=instance)
_send_update(instance)
return
for field in instance._meta.get_fields():
# Each field is checked if it has a `related_model` attribute (when ForeginKeys or M2Ms)
# are used, and if it has a value
if not hasattr(field, "related_model"):
continue
if not field.related_model:
continue
if not issubclass(field.related_model, OutpostModel):
continue
field_name = f"{field.name}_set"
if not hasattr(instance, field_name):
continue
LOGGER.debug("triggering outpost update from from field", field=field.name)
# Because the Outpost Model has an M2M to Provider,
# we have to iterate over the entire QS
for reverse in getattr(instance, field_name).all():
_send_update(reverse)
def _send_update(outpost_model: Model):
"""Send update trigger for each channel of an outpost model"""
for outpost in outpost_model.outpost_set.all():
channel_layer = get_channel_layer()
for channel in outpost.channels:
print(f"sending update to channel {channel}")
async_to_sync(channel_layer.send)(channel, {"type": "event.update"})

View File

@ -0,0 +1,22 @@
"""outpost tasks"""
from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType
from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController
from passbook.root.celery import CELERY_APP
@CELERY_APP.task(bind=True)
# pylint: disable=unused-argument
def outpost_k8s_controller(self):
"""Launch Kubernetes Controller for all Outposts which are deployed in kubernetes"""
for outpost in Outpost.objects.filter(
deployment_type=OutpostDeploymentType.KUBERNETES
):
outpost_k8s_controller_single.delay(outpost.pk.hex, outpost.type)
@CELERY_APP.task(bind=True)
# pylint: disable=unused-argument
def outpost_k8s_controller_single(self, outpost: str, outpost_type: str):
"""Launch Kubernetes manager and reconcile deployment/service/etc"""
if outpost_type == OutpostType.PROXY:
ProxyKubernetesController(outpost).run()

View File

@ -45,8 +45,8 @@
<h1 class="pf-c-title pf-m-2xl">{% trans 'Setup with Kubernetes' %}</h1>
</div>
<div class="pf-c-modal-box__body">
<p>{% trans 'Download the manifest to create the Gatekeeper deployment and service:' %}</p>
<a href="{% url 'passbook_providers_app_gw:k8s-manifest' provider=provider.pk %}">{% trans 'Here' %}</a>
<p>{% trans 'Download the manifest to create the Proxy deployment and service:' %}</p>
<a href="{% url 'passbook_providers_proxy:k8s-manifest' provider=provider.pk %}">{% trans 'Here' %}</a>
<p>{% trans 'Afterwards, add the following annotations to the Ingress you want to secure:' %}</p>
<textarea class="codemirror" readonly data-cm-mode="yaml">
nginx.ingress.kubernetes.io/auth-signin: https://$host/oauth2/start?rd=$escaped_request_uri

View File

@ -0,0 +1,59 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load humanize %}
{% load passbook_utils %}
{% block head %}
{{ block.super }}
<style>
.pf-m-success {
color: var(--pf-global--success-color--100);
}
.pf-m-danger {
color: var(--pf-global--danger-color--100);
}
</style>
{% endblock %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="fas fa-map-marker"></i>
{% trans 'Outpost Setup' %}
</h1>
<p>{% trans "Outposts are deployments of passbook components to support different environments and protocols, like reverse proxies." %}</p>
</div>
</section>
<div class="pf-c-tabs pf-m-fill" id="filled-example">
<button class="pf-c-tabs__scroll-button" disabled aria-hidden="true" aria-label="Scroll left">
<i class="fas fa-angle-left" aria-hidden="true"></i>
</button>
<ul class="pf-c-tabs__list">
<li class="pf-c-tabs__item">
<button class="pf-c-tabs__link" id="filled-example-users-link">
<span class="pf-c-tabs__item-text">Users</span>
</button>
</li>
<li class="pf-c-tabs__item pf-m-current">
<button class="pf-c-tabs__link" id="filled-example-containers-link">
<span class="pf-c-tabs__item-text">Containers</span>
</button>
</li>
<li class="pf-c-tabs__item">
<button class="pf-c-tabs__link" id="filled-example-database-link">
<span class="pf-c-tabs__item-text">Database</span>
</button>
</li>
</ul>
<button class="pf-c-tabs__scroll-button" disabled aria-hidden="true" aria-label="Scroll right">
<i class="fas fa-angle-right" aria-hidden="true"></i>
</button>
</div>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,59 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load humanize %}
{% load passbook_utils %}
{% block head %}
{{ block.super }}
<style>
.pf-m-success {
color: var(--pf-global--success-color--100);
}
.pf-m-danger {
color: var(--pf-global--danger-color--100);
}
</style>
{% endblock %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="fas fa-map-marker"></i>
{% trans 'Outpost Setup' %}
</h1>
<p>{% trans "Outposts are deployments of passbook components to support different environments and protocols, like reverse proxies." %}</p>
</div>
</section>
<div class="pf-c-tabs pf-m-fill" id="filled-example">
<button class="pf-c-tabs__scroll-button" disabled aria-hidden="true" aria-label="Scroll left">
<i class="fas fa-angle-left" aria-hidden="true"></i>
</button>
<ul class="pf-c-tabs__list">
<li class="pf-c-tabs__item">
<button class="pf-c-tabs__link" id="filled-example-users-link">
<span class="pf-c-tabs__item-text">Users</span>
</button>
</li>
<li class="pf-c-tabs__item pf-m-current">
<button class="pf-c-tabs__link" id="filled-example-containers-link">
<span class="pf-c-tabs__item-text">Containers</span>
</button>
</li>
<li class="pf-c-tabs__item">
<button class="pf-c-tabs__link" id="filled-example-database-link">
<span class="pf-c-tabs__item-text">Database</span>
</button>
</li>
</ul>
<button class="pf-c-tabs__scroll-button" disabled aria-hidden="true" aria-label="Scroll right">
<i class="fas fa-angle-right" aria-hidden="true"></i>
</button>
</div>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,96 @@
{% extends "administration/base.html" %}
{% load i18n %}
{% load humanize %}
{% load passbook_utils %}
{% block content %}
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
<i class="fas fa-map-marker"></i>
{% trans 'Outpost Setup' %}
</h1>
<p>{% trans "Outposts are deployments of passbook components to support different environments and protocols, like reverse proxies." %}</p>
</div>
</section>
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-c-card">
<pre>apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app.kubernetes.io/name: "passbook-{{ outpost.type }}"
app.kubernetes.io/instance: "{{ outpost.name }}"
passbook.beryju.org/outpost: "{{ outpost.pk.hex }}"
name: "passbook-{{ outpost.type }}-{{ outpost.name }}"
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: "passbook-{{ outpost.type }}"
app.kubernetes.io/instance: "{{ outpost.name }}"
passbook.beryju.org/outpost: "{{ outpost.pk.hex }}"
template:
metadata:
labels:
app.kubernetes.io/name: "passbook-{{ outpost.type }}"
app.kubernetes.io/instance: "{{ outpost.name }}"
passbook.beryju.org/outpost: "{{ outpost.pk.hex }}"
spec:
containers:
- env:
- name: PASSBOOK_HOST
value: "{{ host }}"
- name: PASSBOOK_TOKEN
value: "{{ outpost.token.pk.hex }}"
image: beryju/passbook-{{ outpost.type }}:{{ version }}
name: "passbook-{{ outpost.type }}"
ports:
- containerPort: 4180
protocol: TCP
name: http
- containerPort: 4443
protocol: TCP
name: https
---
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/name: "passbook-{{ outpost.type }}"
app.kubernetes.io/instance: "{{ outpost.name }}"
passbook.beryju.org/outpost: "{{ outpost.pk.hex }}"
name: "passbook-{{ outpost.type }}-{{ outpost.name }}"
spec:
ports:
- name: http
port: 4180
protocol: TCP
targetPort: 4180
- name: https
port: 4443
protocol: TCP
targetPort: 4443
selector:
app.kubernetes.io/name: "passbook-{{ outpost.type }}"
app.kubernetes.io/instance: "{{ outpost.name }}"
passbook.beryju.org/outpost: "{{ outpost.pk.hex }}"
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: "passbook-{{ outpost.type }}-{{ outpost.name }}"
spec:
rules:
- host: "{{ provider.external_host }}"
http:
paths:
- backend:
serviceName: "passbook-{{ outpost.type }}-{{ outpost.name }}"
servicePort: 4180
path: "/pbprox"
</pre>
</div>
</section>
{% endblock %}

11
passbook/outposts/urls.py Normal file
View File

@ -0,0 +1,11 @@
"""passbook outposts urls"""
from django.urls import path
from passbook.outposts.views import KubernetesManifestView, SetupView
urlpatterns = [
path(
"<uuid:outpost_pk>/k8s/", KubernetesManifestView.as_view(), name="k8s-manifest"
),
path("<uuid:outpost_pk>/", SetupView.as_view(), name="setup"),
]

View File

@ -0,0 +1,78 @@
"""passbook outpost views"""
from typing import Any, Dict, List
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Model
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.views import View
from django.views.generic import TemplateView
from guardian.shortcuts import get_objects_for_user
from structlog import get_logger
from passbook.core.models import User
from passbook.outposts.controllers.compose import DockerComposeController
from passbook.outposts.models import Outpost, OutpostType
from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController
LOGGER = get_logger()
def get_object_for_user_or_404(user: User, perm: str, **filters) -> Model:
"""Wrapper that combines get_objects_for_user and get_object_or_404"""
return get_object_or_404(get_objects_for_user(user, perm), **filters)
class DockerComposeView(LoginRequiredMixin, View):
"""Generate docker-compose yaml"""
def get(self, request: HttpRequest, outpost_pk: str) -> HttpResponse:
"""Render docker-compose file"""
outpost: Outpost = get_object_for_user_or_404(
request.user, "passbook_outposts.view_outpost", pk=outpost_pk,
)
manifest = ""
if outpost.type == OutpostType.PROXY:
controller = DockerComposeController(outpost_pk)
manifest = controller.get_static_deployment()
return HttpResponse(manifest, content_type="text/vnd.yaml")
class KubernetesManifestView(LoginRequiredMixin, View):
"""Generate Kubernetes Deployment and SVC for proxy"""
def get(self, request: HttpRequest, outpost_pk: str) -> HttpResponse:
"""Render deployment template"""
outpost: Outpost = get_object_for_user_or_404(
request.user, "passbook_outposts.view_outpost", pk=outpost_pk,
)
manifest = ""
if outpost.type == OutpostType.PROXY:
controller = ProxyKubernetesController(outpost_pk)
manifest = controller.get_static_deployment()
return HttpResponse(manifest, content_type="text/vnd.yaml")
class SetupView(LoginRequiredMixin, TemplateView):
"""Setup view"""
def get_template_names(self) -> List[str]:
allowed = ["dc", "custom", "k8s_manual", "k8s_integration"]
setup_type = self.request.GET.get("type", "dc")
if setup_type not in allowed:
setup_type = allowed[0]
return [f"outposts/setup_{setup_type}.html"]
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
kwargs = super().get_context_data(**kwargs)
outpost: Outpost = get_object_for_user_or_404(
self.request.user,
"passbook_outposts.view_outpost",
pk=self.kwargs["outpost_pk"],
)
kwargs.update(
{"host": self.request.build_absolute_uri("/"), "outpost": outpost}
)
return kwargs

View File

@ -5,9 +5,11 @@ CELERY_BEAT_SCHEDULE = {
"policies_reputation_ip_save": {
"task": "passbook.policies.reputation.tasks.save_ip_reputation",
"schedule": crontab(minute="*/5"),
"options": {"queue": "passbook_scheduled"},
},
"policies_reputation_user_save": {
"task": "passbook.policies.reputation.tasks.save_user_reputation",
"schedule": crontab(minute="*/5"),
"options": {"queue": "passbook_scheduled"},
},
}

View File

@ -236,7 +236,7 @@ class OAuth2Provider(Provider):
return OAuth2ProviderForm
def __str__(self):
return self.name
return f"OAuth2 Provider {self.name}"
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with URLs for authorize, token, openid-config, etc"""

View File

@ -1,4 +1,6 @@
"""passbook OAuth2 OpenID well-known views"""
from typing import Any, Dict
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, reverse
from django.views import View
@ -16,6 +18,41 @@ PLAN_CONTEXT_SCOPES = "scopes"
class ProviderInfoView(View):
"""OpenID-compliant Provider Info"""
def get_info(self, provider: OAuth2Provider) -> Dict[str, Any]:
"""Get dictionary for OpenID Connect information"""
return {
"issuer": provider.get_issuer(self.request),
"authorization_endpoint": self.request.build_absolute_uri(
reverse("passbook_providers_oauth2:authorize")
),
"token_endpoint": self.request.build_absolute_uri(
reverse("passbook_providers_oauth2:token")
),
"userinfo_endpoint": self.request.build_absolute_uri(
reverse("passbook_providers_oauth2:userinfo")
),
"end_session_endpoint": self.request.build_absolute_uri(
reverse("passbook_providers_oauth2:end-session")
),
"introspection_endpoint": self.request.build_absolute_uri(
reverse("passbook_providers_oauth2:token-introspection")
),
"response_types_supported": [provider.response_type],
"jwks_uri": self.request.build_absolute_uri(
reverse(
"passbook_providers_oauth2:jwks",
kwargs={"application_slug": provider.application.slug},
)
),
"id_token_signing_alg_values_supported": [provider.jwt_alg],
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
"subject_types_supported": ["public"],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic",
],
}
# pylint: disable=unused-argument
def get(
self, request: HttpRequest, application_slug: str, *args, **kwargs
@ -26,40 +63,7 @@ class ProviderInfoView(View):
provider: OAuth2Provider = get_object_or_404(
OAuth2Provider, pk=application.provider_id
)
response = JsonResponse(
{
"issuer": provider.get_issuer(request),
"authorization_endpoint": request.build_absolute_uri(
reverse("passbook_providers_oauth2:authorize")
),
"token_endpoint": request.build_absolute_uri(
reverse("passbook_providers_oauth2:token")
),
"userinfo_endpoint": request.build_absolute_uri(
reverse("passbook_providers_oauth2:userinfo")
),
"end_session_endpoint": request.build_absolute_uri(
reverse("passbook_providers_oauth2:end-session")
),
"introspection_endpoint": request.build_absolute_uri(
reverse("passbook_providers_oauth2:token-introspection")
),
"response_types_supported": [provider.response_type],
"jwks_uri": request.build_absolute_uri(
reverse(
"passbook_providers_oauth2:jwks",
kwargs={"application_slug": application.slug},
)
),
"id_token_signing_alg_values_supported": [provider.jwt_alg],
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
"subject_types_supported": ["public"],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic",
],
}
)
response = JsonResponse(self.get_info(provider))
response["Access-Control-Allow-Origin"] = "*"
return response

View File

@ -1,10 +1,38 @@
"""ProxyProvider API Views"""
from rest_framework.serializers import ModelSerializer
from drf_yasg.utils import swagger_serializer_method
from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ModelViewSet
from passbook.providers.oauth2.views.provider import ProviderInfoView
from passbook.providers.proxy.models import ProxyProvider
class OpenIDConnectConfigurationSerializer(Serializer):
"""rest_framework Serializer for OIDC Configuration"""
issuer = CharField()
authorization_endpoint = CharField()
token_endpoint = CharField()
userinfo_endpoint = CharField()
end_session_endpoint = CharField()
introspection_endpoint = CharField()
jwks_uri = CharField()
response_types_supported = ListField(child=CharField())
id_token_signing_alg_values_supported = ListField(child=CharField())
subject_types_supported = ListField(child=CharField())
token_endpoint_auth_methods_supported = ListField(child=CharField())
def create(self, request: Request) -> Response:
raise NotImplementedError
def update(self, request: Request) -> Response:
raise NotImplementedError
class ProxyProviderSerializer(ModelSerializer):
"""ProxyProvider Serializer"""
@ -21,7 +49,13 @@ class ProxyProviderSerializer(ModelSerializer):
class Meta:
model = ProxyProvider
fields = ["pk", "name", "internal_host", "external_host"]
fields = [
"pk",
"name",
"internal_host",
"external_host",
"certificate",
]
class ProxyProviderViewSet(ModelViewSet):
@ -29,3 +63,47 @@ class ProxyProviderViewSet(ModelViewSet):
queryset = ProxyProvider.objects.all()
serializer_class = ProxyProviderSerializer
class ProxyOutpostConfigSerializer(ModelSerializer):
"""ProxyProvider Serializer"""
oidc_configuration = SerializerMethodField()
def create(self, validated_data):
instance: ProxyProvider = super().create(validated_data)
instance.set_oauth_defaults()
instance.save()
return instance
def update(self, instance: ProxyProvider, validated_data):
instance.set_oauth_defaults()
return super().update(instance, validated_data)
class Meta:
model = ProxyProvider
fields = [
"pk",
"name",
"internal_host",
"external_host",
"client_id",
"client_secret",
"oidc_configuration",
"cookie_secret",
"certificate",
]
@swagger_serializer_method(serializer_or_field=OpenIDConnectConfigurationSerializer)
def get_oidc_configuration(self, obj: ProxyProvider):
"""Embed OpenID Connect provider information"""
# pylint: disable=protected-access
return ProviderInfoView(request=self.context["request"]._request).get_info(obj)
class OutpostConfigViewSet(ModelViewSet):
"""ProxyProvider Viewset"""
queryset = ProxyProvider.objects.filter(application__isnull=False)
serializer_class = ProxyOutpostConfigSerializer

View File

@ -8,4 +8,3 @@ class PassbookProviderProxyConfig(AppConfig):
name = "passbook.providers.proxy"
label = "passbook_providers_proxy"
verbose_name = "passbook Providers.Proxy"
mountpoint = "application/proxy/"

View File

@ -0,0 +1,13 @@
"""Proxy Provider Kubernetes Contoller"""
from passbook.outposts.controllers.kubernetes import KubernetesController
class ProxyKubernetesController(KubernetesController):
"""Proxy Provider Kubernetes Contoller"""
def __init__(self, outpost_pk: str):
super().__init__(outpost_pk)
self.deployment_ports = {
"http": 4180,
"https": 4443,
}

View File

@ -1,6 +1,8 @@
"""passbook Proxy Provider Forms"""
from django import forms
from passbook.crypto.models import CertificateKeyPair
from passbook.flows.models import Flow, FlowDesignation
from passbook.providers.proxy.models import ProxyProvider
@ -9,14 +11,31 @@ class ProxyProviderForm(forms.ModelForm):
instance: ProxyProvider
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["authorization_flow"].queryset = Flow.objects.filter(
designation=FlowDesignation.AUTHORIZATION
)
self.fields["certificate"].queryset = CertificateKeyPair.objects.filter(
key_data__isnull=False
)
def save(self, *args, **kwargs):
actual_save = super().save(*args, **kwargs)
self.instance.set_oauth_defaults()
return super().save(*args, **kwargs)
self.instance.save()
return actual_save
class Meta:
model = ProxyProvider
fields = ["name", "authorization_flow", "internal_host", "external_host"]
fields = [
"name",
"authorization_flow",
"internal_host",
"external_host",
"certificate",
]
widgets = {
"name": forms.TextInput(),
"internal_host": forms.TextInput(),

View File

@ -0,0 +1,22 @@
# Generated by Django 3.1 on 2020-08-19 14:50
from django.db import migrations, models
import passbook.providers.proxy.models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_proxy", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="proxyprovider",
name="cookie_secret",
field=models.TextField(
default=passbook.providers.proxy.models.get_cookie_secret
),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.1 on 2020-08-23 22:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_crypto", "0002_create_self_signed_kp"),
("passbook_providers_proxy", "0002_proxyprovider_cookie_secret"),
]
operations = [
migrations.AddField(
model_name="proxyprovider",
name="certificate",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="passbook_crypto.certificatekeypair",
),
),
]

View File

@ -1,13 +1,16 @@
"""passbook proxy models"""
from typing import Optional, Type
import string
from random import SystemRandom
from typing import Iterable, Type
from urllib.parse import urljoin
from django.core.validators import URLValidator
from django.db import models
from django.forms import ModelForm
from django.http import HttpRequest
from django.utils.translation import gettext as _
from passbook.lib.utils.template import render_to_string
from passbook.crypto.models import CertificateKeyPair
from passbook.outposts.models import OutpostModel
from passbook.providers.oauth2.constants import (
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
@ -22,7 +25,18 @@ from passbook.providers.oauth2.models import (
)
class ProxyProvider(OAuth2Provider):
def get_cookie_secret():
"""Generate random 32-character string for cookie-secret"""
return "".join(
SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32)
)
def _get_callback_url(uri: str) -> str:
return urljoin(uri, "/pbprox/callback")
class ProxyProvider(OutpostModel, OAuth2Provider):
"""Protect applications that don't support any of the other
Protocols by using a Reverse-Proxy."""
@ -33,39 +47,42 @@ class ProxyProvider(OAuth2Provider):
validators=[URLValidator(schemes=("http", "https"))]
)
cookie_secret = models.TextField(default=get_cookie_secret)
certificate = models.ForeignKey(
CertificateKeyPair, on_delete=models.SET_NULL, null=True
)
def form(self) -> Type[ModelForm]:
from passbook.providers.proxy.forms import ProxyProviderForm
return ProxyProviderForm
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
from passbook.providers.proxy.views import DockerComposeView
docker_compose_yaml = DockerComposeView(request=request).get_compose(self)
return render_to_string(
"providers/proxy/setup_modal.html",
{"provider": self, "docker_compose": docker_compose_yaml},
)
def set_oauth_defaults(self):
"""Ensure all OAuth2-related settings are correct"""
self.client_type = ClientTypes.CONFIDENTIAL
self.response_type = ResponseTypes.CODE
self.jwt_alg = JWTAlgorithms.HS256
self.jwt_alg = JWTAlgorithms.RS256
self.rsa_key = CertificateKeyPair.objects.first()
scopes = ScopeMapping.objects.filter(
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_PROFILE, SCOPE_OPENID_EMAIL]
)
self.property_mappings.set(scopes)
self.redirect_uris = "\n".join(
[
f"{self.external_host}/oauth2/callback",
f"{self.internal_host}/oauth2/callback",
_get_callback_url(self.external_host),
_get_callback_url(self.internal_host),
]
)
def __str__(self):
return self.name
return f"Proxy Provider {self.name}"
def get_required_objects(self) -> Iterable[models.Model]:
required_models = [self]
if self.certificate is not None:
required_models.append(self.certificate)
return required_models
class Meta:

View File

@ -1,72 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app.kubernetes.io/name: "passbook-gatekeeper-{{ provider.name }}"
passbook.beryju.org/gatekeeper/provider: "{{ provider.pk }}"
name: passbook-gatekeeper
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: passbook-gatekeeper
passbook.beryju.org/gatekeeper/provider: "{{ provider.pk }}"
template:
metadata:
labels:
app.kubernetes.io/name: passbook-gatekeeper
passbook.beryju.org/gatekeeper/provider: "{{ provider.pk }}"
spec:
containers:
- args:
- --upstream=file:///dev/null
env:
- name: OAUTH2_PROXY_CLIENT_ID
value: "{{ provider.client.client_id }}"
- name: OAUTH2_PROXY_CLIENT_SECRET
value: "{{ provider.client.client_secret }}"
- name: OAUTH2_PROXY_COOKIE_SECRET
value: "{{ cookie_secret }}"
- name: OAUTH2_PROXY_OIDC_ISSUER_URL
value: "{{ issuer }}"
- name: OAUTH2_PROXY_SET_XAUTHREQUEST
value: "true"
- name: OAUTH2_PROXY_SET_AUTHORIZATION_HEADER
value: "true"
image: beryju/passbook-gatekeeper:{{ version }}
imagePullPolicy: Always
name: passbook-gatekeeper
ports:
- containerPort: 4180
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/name: "passbook-gatekeeper-{{ provider.name }}"
passbook.beryju.org/gatekeeper/provider: "{{ provider.pk }}"
name: passbook-gatekeeper
spec:
ports:
- name: http
port: 4180
protocol: TCP
targetPort: 4180
selector:
app.kubernetes.io/name: passbook-gatekeeper
passbook.beryju.org/gatekeeper/provider: "{{ provider.pk }}"
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: passbook-gatekeeper-{{ provider.name }}
spec:
rules:
- host: {{ provider.external_host }}
http:
paths:
- backend:
serviceName: "passbook-gatekeeper-{{ provider.name }}"
servicePort: 4180
path: /oauth2

View File

@ -1,10 +0,0 @@
"""passbook proxy urls"""
from django.urls import path
from passbook.providers.proxy.views import K8sManifestView
urlpatterns = [
path(
"<int:provider>/k8s-manifest/", K8sManifestView.as_view(), name="k8s-manifest"
),
]

View File

@ -1,96 +0,0 @@
"""passbook proxy views"""
import string
from random import SystemRandom
from urllib.parse import urlparse
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Model
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, render
from django.views import View
from guardian.shortcuts import get_objects_for_user
from structlog import get_logger
from yaml import safe_dump
from passbook import __version__
from passbook.core.models import User
from passbook.providers.proxy.models import ProxyProvider
ORIGINAL_URL = "HTTP_X_ORIGINAL_URL"
LOGGER = get_logger()
def get_object_for_user_or_404(user: User, perm: str, **filters) -> Model:
"""Wrapper that combines get_objects_for_user and get_object_or_404"""
return get_object_or_404(get_objects_for_user(user, perm), **filters)
def get_cookie_secret():
"""Generate random 32-character string for cookie-secret"""
return "".join(
SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(32)
)
class DockerComposeView(LoginRequiredMixin, View):
"""Generate docker-compose yaml"""
def get_compose(self, provider: ProxyProvider) -> str:
"""Generate docker-compose yaml, version 3.5"""
issuer = provider.get_issuer(self.request)
env = {
"OAUTH2_PROXY_CLIENT_ID": provider.client_id,
"OAUTH2_PROXY_CLIENT_SECRET": provider.client_secret,
"OAUTH2_PROXY_REDIRECT_URL": f"{provider.external_host}/oauth2/callback",
"OAUTH2_PROXY_OIDC_ISSUER_URL": issuer,
"OAUTH2_PROXY_COOKIE_SECRET": get_cookie_secret(),
"OAUTH2_PROXY_UPSTREAMS": provider.internal_host,
}
if urlparse(provider.external_host).scheme != "https":
env["OAUTH2_PROXY_COOKIE_SECURE"] = "false"
compose = {
"version": "3.5",
"services": {
"passbook_gatekeeper": {
"image": f"beryju/passbook-proxy:{__version__}",
"ports": ["4180:4180"],
"environment": env,
}
},
}
return safe_dump(compose, default_flow_style=False)
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
"""Render docker-compose file"""
provider: ProxyProvider = get_object_for_user_or_404(
request.user,
"passbook_providers_proxy.view_applicationgatewayprovider",
pk=provider_pk,
)
response = HttpResponse()
response.content_type = "application/x-yaml"
response.content = self.get_compose(provider.pk)
return response
class K8sManifestView(LoginRequiredMixin, View):
"""Generate K8s Deployment and SVC for gatekeeper"""
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
"""Render deployment template"""
provider: ProxyProvider = get_object_for_user_or_404(
request.user,
"passbook_providers_app_gw.view_applicationgatewayprovider",
pk=provider_pk,
)
return render(
request,
"providers/proxy/k8s-manifest.yaml",
{
"provider": provider,
"cookie_secret": get_cookie_secret(),
"version": __version__,
"issuer": provider.get_issuer(request),
},
content_type="text/yaml",
)

View File

@ -108,7 +108,7 @@ class SAMLProvider(Provider):
return SAMLProviderForm
def __str__(self):
return self.name
return f"SAML Provider {self.name}"
def link_download_metadata(self):
"""Get link to download XML metadata for admin interface"""

120
passbook/root/asgi.py Normal file
View File

@ -0,0 +1,120 @@
"""
ASGI config for passbook project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
"""
import os
import typing
from time import time
from typing import Any, ByteString, Dict
import django
from asgiref.compatibility import guarantee_single_callable
from channels.routing import get_default_application
from defusedxml import defuse_stdlib
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
from structlog import get_logger
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passbook.root.settings")
defuse_stdlib()
django.setup()
# See https://github.com/encode/starlette/blob/master/starlette/types.py
Scope = typing.MutableMapping[str, typing.Any]
Message = typing.MutableMapping[str, typing.Any]
Receive = typing.Callable[[], typing.Awaitable[Message]]
Send = typing.Callable[[Message], typing.Awaitable[None]]
ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]]
LOGGER = get_logger("passbook.asgi")
class ASGILoggerMiddleware:
"""Main ASGI Logger middleware, starts an ASGILogger for each request"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
responder = ASGILogger(self.app)
await responder(scope, receive, send)
return
class ASGILogger:
"""ASGI Logger, instantiated for each request"""
app: ASGIApp
send: Send
scope: Scope
headers: Dict[ByteString, Any]
status_code: int
start: float
content_length: int
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
self.send = send
self.scope = scope
self.content_length = 0
self.headers = dict(scope.get("headers", []))
if self.headers.get(b"host", b"") == b"kubernetes-healthcheck-host":
# Don't log kubernetes health/readiness requests
await send({"type": "http.response.start", "status": 204, "headers": []})
await send({"type": "http.response.body", "body": ""})
return
self.start = time()
await self.app(scope, receive, self.send_hooked)
async def send_hooked(self, message: Message) -> None:
"""Hooked send method, which records status code and content-length, and for the final
requests logs it"""
headers = dict(message.get("headers", []))
if "status" in message:
self.status_code = message["status"]
if b"Content-Length" in headers:
self.content_length += int(headers.get(b"Content-Length", b"0"))
if message["type"] == "http.response.body" and not message["more_body"]:
runtime = int((time() - self.start) * 10 ** 6)
self.log(runtime)
return await self.send(message)
def _get_ip(self) -> str:
client_ip, _ = self.scope.get("client", ("", 0))
return client_ip
def log(self, runtime: float):
"""Outpot access logs in a structured format"""
host = self._get_ip()
query_string = ""
if self.scope.get("query_string", b"") != b"":
query_string = f"?{self.scope.get('query_string').decode()}"
LOGGER.info(
f"{self.scope.get('path', '')}{query_string}",
host=host,
method=self.scope.get("method", ""),
scheme=self.scope.get("scheme", ""),
status=self.status_code,
size=self.content_length / 1000 if self.content_length > 0 else "-",
runtime=runtime,
)
application = SentryAsgiMiddleware(
ASGILogger(guarantee_single_callable(get_default_application()))
)

12
passbook/root/routing.py Normal file
View File

@ -0,0 +1,12 @@
"""root Websocket URLS"""
from channels.routing import ProtocolTypeRouter, URLRouter
from django.urls import path
from passbook.outposts.channels import OutpostConsumer
application = ProtocolTypeRouter(
{
# (http->django views is added by default)
"websocket": URLRouter([path("ws/outpost/<uuid:pk>/", OutpostConsumer)]),
}
)

View File

@ -73,11 +73,13 @@ INSTALLED_APPS = [
"drf_yasg",
"guardian",
"django_prometheus",
"channels",
"passbook.admin.apps.PassbookAdminConfig",
"passbook.api.apps.PassbookAPIConfig",
"passbook.audit.apps.PassbookAuditConfig",
"passbook.crypto.apps.PassbookCryptoConfig",
"passbook.flows.apps.PassbookFlowsConfig",
"passbook.outposts.apps.PassbookOutpostConfig",
"passbook.lib.apps.PassbookLibConfig",
"passbook.policies.apps.PassbookPoliciesConfig",
"passbook.policies.dummy.apps.PassbookPolicyDummyConfig",
@ -125,13 +127,13 @@ REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
"PAGE_SIZE": 100,
"DEFAULT_FILTER_BACKENDS": [
"rest_framework_guardian.filters.ObjectPermissionsFilter",
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.OrderingFilter",
"rest_framework.filters.SearchFilter",
],
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.DjangoObjectPermissions",
"passbook.api.permissions.CustomObjectPermissions",
),
"DEFAULT_AUTHENTICATION_CLASSES": (
"passbook.api.auth.PassbookTokenAuthentication",
@ -185,7 +187,15 @@ TEMPLATES = [
},
]
WSGI_APPLICATION = "passbook.root.wsgi.application"
ASGI_APPLICATION = "passbook.root.routing.application"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {"hosts": [(CONFIG.y("redis.host"), 6379)]},
},
}
# Database
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
@ -234,6 +244,7 @@ CELERY_BEAT_SCHEDULE = {
"clean_expired_models": {
"task": "passbook.core.tasks.clean_expired_models",
"schedule": crontab(minute="*/5"), # Run every 5 minutes
"options": {"queue": "passbook_scheduled"},
}
}
CELERY_CREATE_MISSING_QUEUES = True
@ -364,6 +375,7 @@ _LOGGING_HANDLER_MAP = {
"grpc": LOG_LEVEL,
"docker": "WARNING",
"urllib3": "WARNING",
"websockets": "WARNING",
}
for handler_name, level in _LOGGING_HANDLER_MAP.items():
# pyright: reportGeneralTypeIssues=false

View File

@ -1,78 +0,0 @@
"""
WSGI config for passbook project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/
"""
import os
from time import time
from defusedxml import defuse_stdlib
from django.core.wsgi import get_wsgi_application
from structlog import get_logger
from passbook.lib.utils.http import _get_client_ip_from_meta
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passbook.root.settings")
defuse_stdlib()
class WSGILogger:
""" This is the generalized WSGI middleware for any style request logging. """
def __init__(self, _application):
self.application = _application
self.logger = get_logger("passbook.wsgi")
def __healthcheck(self, start_response):
start_response("204 OK", [])
return [b""]
def __call__(self, environ, start_response):
start = time()
status_codes = []
content_lengths = []
if environ.get("HTTP_HOST", "").startswith("kubernetes-healthcheck-host"):
# Don't log kubernetes health/readiness requests
return self.__healthcheck(start_response)
def custom_start_response(status, response_headers, exc_info=None):
status_codes.append(int(status.partition(" ")[0]))
for name, value in response_headers:
if name.lower() == "content-length":
content_lengths.append(int(value))
break
return start_response(status, response_headers, exc_info)
retval = self.application(environ, custom_start_response)
runtime = int((time() - start) * 10 ** 6)
content_length = content_lengths[0] if content_lengths else 0
self.log(status_codes[0], environ, content_length, runtime=runtime)
return retval
def log(self, status_code, environ, content_length, **kwargs):
"""
Apache log format 'NCSA extended/combined log':
"%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\""
see http://httpd.apache.org/docs/current/mod/mod_log_config.html#formats
"""
host = _get_client_ip_from_meta(environ)
query_string = ""
if environ.get("QUERY_STRING") != "":
query_string = f"?{environ.get('QUERY_STRING')}"
self.logger.info(
"request",
path=f"{environ.get('PATH_INFO', '')}{query_string}",
host=host,
method=environ.get("REQUEST_METHOD", ""),
protocol=environ.get("SERVER_PROTOCOL", ""),
status=status_code,
size=content_length / 1000 if content_length > 0 else "-",
runtime=kwargs.get("runtime"),
)
application = WSGILogger(get_wsgi_application())

View File

@ -9,5 +9,6 @@ CELERY_BEAT_SCHEDULE = {
"sources_ldap_sync": {
"task": "passbook.sources.ldap.tasks.sync",
"schedule": crontab(minute=0), # Run every hour
"options": {"queue": "passbook_scheduled"},
}
}

View File

@ -5,5 +5,6 @@ CELERY_BEAT_SCHEDULE = {
"saml_source_cleanup": {
"task": "passbook.sources.saml.tasks.clean_temporary_users",
"schedule": crontab(minute="*/5"),
"options": {"queue": "passbook_scheduled"},
}
}

View File

@ -1,4 +1,4 @@
# Generated by Django 3.1 on 2020-08-28 13:14
# Generated by Django 3.1 on 2020-08-23 22:46
from django.db import migrations, models

2
proxy/.dockerignore Normal file
View File

@ -0,0 +1,2 @@
Dockerfile.*
.git

2
proxy/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
pkg/client/
pkg/models/

12
proxy/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM golang:1.15 AS builder
WORKDIR /work
COPY . .
RUN go build -o /work/proxy .
# Copy binary to alpine
FROM gcr.io/distroless/base-debian10
COPY --from=builder /work/proxy /
ENTRYPOINT ["/proxy"]

6
proxy/Makefile Normal file
View File

@ -0,0 +1,6 @@
generate:
go get -u github.com/go-swagger/go-swagger/cmd/swagger
swagger generate client -f ../swagger.yaml -A passbook -t pkg/
run:
go run -v .

24
proxy/README.md Normal file
View File

@ -0,0 +1,24 @@
# passbook Proxy
[![CI Build status](https://img.shields.io/azure-devops/build/beryjuorg/passbook/3?style=flat-square)](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=3)
![Docker pulls (proxy)](https://img.shields.io/docker/pulls/beryju/passbook-proxy.svg?style=flat-square)
Reverse Proxy based on [oauth2_proxy](https://github.com/oauth2-proxy/oauth2-proxy), completely managed and monitored by passbook.
## Usage
passbook Proxy is built to be configured by passbook itself, hence the only options you can directly give it are connection params.
The following environment variable are implemented:
`PASSBOOK_HOST`: Full URL to the passbook instance with protocol, i.e. "https://passbook.company.tld"
`PASSBOOK_TOKEN`: Token used to authenticate against passbook. This is generated after an Outpost instance is created.
`PASSBOOK_INSECURE`: This environment variable can optionally be set to ignore the SSL Certificate of the passbook instance. Applies to both HTTP and WS connections.
## Development
passbook Proxy uses an auto-generated API Client to communicate with passbook. This client is not kept in git. To generate the client locally, run `make generate`.
Afterwards you can build the proxy like any other Go project, using `go build`.

90
proxy/azure-pipelines.yml Normal file
View File

@ -0,0 +1,90 @@
trigger:
- master
stages:
- stage: generate
jobs:
- job: swagger_generate
pool:
vmImage: 'ubuntu-latest'
steps:
- task: GoTool@0
inputs:
version: '1.15'
- task: Go@0
inputs:
command: 'get'
arguments: '-u github.com/go-swagger/go-swagger/cmd/swagger'
- task: CmdLine@2
inputs:
script: |
$(go list -f {{.Target}} github.com/go-swagger/go-swagger/cmd/swagger) generate client -f ../swagger.yaml -A passbook -t pkg/
workingDirectory: 'proxy/'
- task: PublishPipelineArtifact@1
inputs:
targetPath: 'proxy/pkg/'
artifact: 'swagger_client'
publishLocation: 'pipeline'
- stage: lint
jobs:
- job: golint
pool:
vmImage: 'ubuntu-latest'
steps:
- task: GoTool@0
inputs:
version: '1.15'
- task: Go@0
inputs:
command: 'get'
arguments: '-u golang.org/x/lint/golint'
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'current'
artifactName: 'swagger_client'
path: "proxy/pkg/"
- task: CmdLine@2
inputs:
script: |
$(go list -f {{.Target}} golang.org/x/lint/golint) ./...
workingDirectory: 'proxy/'
- stage: build_go
jobs:
- job: build_go
pool:
vmImage: 'ubuntu-latest'
steps:
- task: GoTool@0
inputs:
version: '1.15'
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'current'
artifactName: 'swagger_client'
path: "proxy/pkg/"
- task: Go@0
inputs:
command: 'build'
workingDirectory: 'proxy/'
- stage: build_docker
jobs:
- job: build_proxy
pool:
vmImage: 'ubuntu-latest'
steps:
- task: GoTool@0
inputs:
version: '1.15'
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'current'
artifactName: 'swagger_client'
path: "proxy/pkg/"
- task: Docker@2
inputs:
containerRegistry: 'dockerhub'
repository: 'beryju/passbook-proxy'
command: 'buildAndPush'
Dockerfile: 'proxy/Dockerfile'
buildContext: 'proxy/'
tags: 'gh-$(Build.SourceBranchName)'

45
proxy/cmd/server.go Normal file
View File

@ -0,0 +1,45 @@
package cmd
import (
"math/rand"
"net/url"
"os"
"os/signal"
"time"
"github.com/BeryJu/passbook/proxy/pkg/server"
)
// RunServer main entrypoint, runs the full server
func RunServer() {
pbURL, found := os.LookupEnv("PASSBOOK_HOST")
if !found {
panic("env PASSBOOK_HOST not set!")
}
pbToken, found := os.LookupEnv("PASSBOOK_TOKEN")
if !found {
panic("env PASSBOOK_TOKEN not set!")
}
pbURLActual, err := url.Parse(pbURL)
if err != nil {
panic(err)
}
rand.Seed(time.Now().UnixNano())
ac := server.NewAPIController(*pbURLActual, pbToken)
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
ac.Start()
for {
select {
case <-interrupt:
ac.Shutdown()
os.Exit(0)
}
}
}

44
proxy/go.mod Normal file
View File

@ -0,0 +1,44 @@
module github.com/BeryJu/passbook/proxy
go 1.14
require (
cloud.google.com/go v0.64.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20200819183940-29e1ff8eb0bb // indirect
github.com/coreos/go-oidc v2.2.1+incompatible
github.com/getsentry/sentry-go v0.7.0
github.com/go-openapi/errors v0.19.6
github.com/go-openapi/runtime v0.19.21
github.com/go-openapi/spec v0.19.9 // indirect
github.com/go-openapi/strfmt v0.19.5
github.com/go-openapi/swag v0.19.9
github.com/go-openapi/validate v0.19.10
github.com/go-redis/redis/v7 v7.4.0 // indirect
github.com/go-swagger/go-swagger v0.25.0 // indirect
github.com/gorilla/handlers v1.5.0 // indirect
github.com/gorilla/websocket v1.4.2
github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a
github.com/justinas/alice v1.2.0
github.com/kr/pretty v0.2.1 // indirect
github.com/magiconair/properties v1.8.2 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mitchellh/mapstructure v1.3.3 // indirect
github.com/oauth2-proxy/oauth2-proxy v1.1.2-0.20200817154438-5fa5b3186f39
github.com/pelletier/go-toml v1.8.0 // indirect
github.com/pquerna/cachecontrol v0.0.0-20200819021114-67c6ae64274f // indirect
github.com/recws-org/recws v1.2.1
github.com/sirupsen/logrus v1.4.2
github.com/spf13/afero v1.3.4 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.7.1 // indirect
github.com/stretchr/testify v1.6.1
go.mongodb.org/mongo-driver v1.4.0 // indirect
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de // indirect
golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect
golang.org/x/sys v0.0.0-20200828194041-157a740278f4 // indirect
golang.org/x/tools v0.0.0-20200828161849-5deb26317202 // indirect
gopkg.in/ini.v1 v1.60.2 // indirect
gopkg.in/square/go-jose.v2 v2.5.1 // indirect
)

1042
proxy/go.sum Normal file

File diff suppressed because it is too large Load Diff

11
proxy/main.go Normal file
View File

@ -0,0 +1,11 @@
package main
import (
"github.com/BeryJu/passbook/proxy/cmd"
log "github.com/sirupsen/logrus"
)
func main() {
log.SetLevel(log.DebugLevel)
cmd.RunServer()
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,187 @@
package proxy
import (
"html/template"
"path"
"strings"
"github.com/oauth2-proxy/oauth2-proxy/pkg/logger"
)
func loadTemplates(dir string) *template.Template {
if dir == "" {
return getTemplates()
}
logger.Printf("using custom template directory %q", dir)
funcMap := template.FuncMap{
"ToUpper": strings.ToUpper,
"ToLower": strings.ToLower,
}
t, err := template.New("").Funcs(funcMap).ParseFiles(path.Join(dir, "sign_in.html"), path.Join(dir, "error.html"))
if err != nil {
logger.Fatalf("failed parsing template %s", err)
}
return t
}
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 {
logger.Fatalf("failed parsing template %s", err)
}
t, err = t.Parse(`{{define "error.html"}}
<!DOCTYPE html>
<html lang="en" charset="utf-8">
<head>
<title>{{.Title}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
</head>
<body>
<h2>{{.Title}}</h2>
<p>{{.Message}}</p>
<hr>
<p><a href="{{.ProxyPrefix}}/sign_in">Sign In</a></p>
</body>
</html>{{end}}`)
if err != nil {
logger.Fatalf("failed parsing template %s", err)
}
return t
}

View File

@ -0,0 +1,62 @@
package proxy
import (
"bytes"
"io/ioutil"
"log"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoadTemplates(t *testing.T) {
data := struct {
TestString string
}{
TestString: "Testing",
}
templates := loadTemplates("")
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])
dir, err := ioutil.TempDir("", "templatetest")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
templateHTML := `{{.TestString}} {{.TestString | ToLower}} {{.TestString | ToUpper}}`
signInFile := filepath.Join(dir, "sign_in.html")
if err := ioutil.WriteFile(signInFile, []byte(templateHTML), 0666); err != nil {
log.Fatal(err)
}
errorFile := filepath.Join(dir, "error.html")
if err := ioutil.WriteFile(errorFile, []byte(templateHTML), 0666); err != nil {
log.Fatal(err)
}
templates = loadTemplates(dir)
assert.NotEqual(t, templates, nil)
var sitpl bytes.Buffer
templates.ExecuteTemplate(&sitpl, "sign_in.html", data)
assert.Equal(t, "Testing testing TESTING", sitpl.String())
var errtpl bytes.Buffer
templates.ExecuteTemplate(&errtpl, "error.html", data)
assert.Equal(t, "Testing testing TESTING", errtpl.String())
}
func TestTemplatesCompile(t *testing.T) {
templates := getTemplates()
assert.NotEqual(t, templates, nil)
}

212
proxy/pkg/server/api.go Normal file
View File

@ -0,0 +1,212 @@
package server
import (
"crypto/sha512"
"encoding/hex"
"net/http"
"net/url"
"os"
"time"
"github.com/BeryJu/passbook/proxy/pkg/client"
"github.com/BeryJu/passbook/proxy/pkg/client/outposts"
"github.com/getsentry/sentry-go"
"github.com/go-openapi/runtime"
"github.com/recws-org/recws"
httptransport "github.com/go-openapi/runtime/client"
"github.com/go-openapi/strfmt"
"github.com/oauth2-proxy/oauth2-proxy/pkg/apis/options"
log "github.com/sirupsen/logrus"
)
const ConfigLogLevel = "log_level"
const ConfigErrorReportingEnabled = "error_reporting_enabled"
const ConfigErrorReportingEnvironment = "error_reporting_environment"
// APIController main controller which connects to the passbook api via http and ws
type APIController struct {
client *client.Passbook
auth runtime.ClientAuthInfoWriter
token string
server *Server
commonOpts *options.Options
lastBundleHash string
logger *log.Entry
wsConn recws.RecConn
}
func getCommonOptions() *options.Options {
commonOpts := options.NewOptions()
commonOpts.Cookie.Name = "passbook_proxy"
commonOpts.EmailDomains = []string{"*"}
commonOpts.ProviderType = "oidc"
commonOpts.ProxyPrefix = "/pbprox"
commonOpts.SkipProviderButton = true
commonOpts.Logging.SilencePing = true
commonOpts.SetXAuthRequest = true
commonOpts.SetAuthorization = true
return commonOpts
}
func doGlobalSetup(config map[string]interface{}) {
switch config[ConfigLogLevel].(string) {
case "debug":
log.SetLevel(log.DebugLevel)
case "info":
log.SetLevel(log.InfoLevel)
case "warning":
log.SetLevel(log.WarnLevel)
case "error":
log.SetLevel(log.ErrorLevel)
default:
log.SetLevel(log.DebugLevel)
}
var dsn string
if config[ConfigErrorReportingEnabled].(bool) {
dsn = "https://33cdbcb23f8b436dbe0ee06847410b67@sentry.beryju.org/3"
log.Debug("Error reporting enabled")
}
err := sentry.Init(sentry.ClientOptions{
Dsn: dsn,
Environment: config[ConfigErrorReportingEnvironment].(string),
})
if err != nil {
log.Fatalf("sentry.Init: %s", err)
}
defer sentry.Flush(2 * time.Second)
}
func getTLSTransport() http.RoundTripper {
_, set := os.LookupEnv("PASSBOOK_INSECURE")
tlsTransport, err := httptransport.TLSTransport(httptransport.TLSClientOptions{
InsecureSkipVerify: set,
})
if err != nil {
panic(err)
}
return tlsTransport
}
// NewAPIController initialise new API Controller instance from URL and API token
func NewAPIController(pbURL url.URL, token string) *APIController {
transport := httptransport.New(pbURL.Host, client.DefaultBasePath, []string{pbURL.Scheme})
transport.Transport = getTLSTransport()
// create the transport
auth := httptransport.BasicAuth("", token)
// create the API client, with the transport
apiClient := client.New(transport, strfmt.Default)
// Because we don't know the outpost UUID, we simply do a list and pick the first
// The service account this token belongs to should only have access to a single outpost
outposts, err := apiClient.Outposts.OutpostsOutpostsList(outposts.NewOutpostsOutpostsListParams(), auth)
if err != nil {
panic(err)
}
outpost := outposts.Payload.Results[0]
doGlobalSetup(outpost.Config.(map[string]interface{}))
ac := &APIController{
client: apiClient,
auth: auth,
token: token,
logger: log.WithField("component", "api-controller"),
commonOpts: getCommonOptions(),
server: NewServer(),
lastBundleHash: "",
}
ac.initWS(pbURL, outpost.Pk)
return ac
}
func (a *APIController) bundleProviders() ([]*providerBundle, error) {
providers, err := a.client.Outposts.OutpostsProxyList(outposts.NewOutpostsProxyListParams(), a.auth)
if err != nil {
a.logger.WithError(err).Error("Failed to fetch providers")
return nil, err
}
// Check provider hash to see if anything is changed
hasher := sha512.New()
bin, _ := providers.Payload.MarshalBinary()
hash := hex.EncodeToString(hasher.Sum(bin))
if hash == a.lastBundleHash {
return nil, nil
}
a.lastBundleHash = hash
bundles := make([]*providerBundle, len(providers.Payload.Results))
for idx, provider := range providers.Payload.Results {
externalHost, err := url.Parse(*provider.ExternalHost)
if err != nil {
log.WithError(err).Warning("Failed to parse URL, skipping provider")
}
bundles[idx] = &providerBundle{
a: a,
Host: externalHost.Hostname(),
}
bundles[idx].Build(provider)
}
return bundles, nil
}
func (a *APIController) updateHTTPServer(bundles []*providerBundle) {
newMap := make(map[string]*providerBundle)
for _, bundle := range bundles {
newMap[bundle.Host] = bundle
}
a.logger.Debug("Swapped maps")
a.server.Handlers = newMap
}
// UpdateIfRequired Updates the HTTP Server config if required, automatically swaps the handlers
func (a *APIController) UpdateIfRequired() error {
bundles, err := a.bundleProviders()
if err != nil {
return err
}
if bundles == nil {
a.logger.Debug("Providers have not changed, not updating")
return nil
}
a.updateHTTPServer(bundles)
return nil
}
// Start Starts all handlers, non-blocking
func (a *APIController) Start() error {
err := a.UpdateIfRequired()
if err != nil {
return err
}
go func() {
a.logger.Debug("Starting HTTP Server...")
a.server.ServeHTTP()
}()
go func() {
a.logger.Debug("Starting HTTPs Server...")
a.server.ServeHTTPS()
}()
go func() {
a.logger.Debug("Starting WS Handler...")
a.startWSHandler()
}()
go func() {
a.logger.Debug("Starting WS Health notifier...")
a.startWSHealth()
}()
return nil
}

View File

@ -0,0 +1,123 @@
package server
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/url"
"os"
"github.com/BeryJu/passbook/proxy/pkg/client/crypto"
"github.com/BeryJu/passbook/proxy/pkg/models"
"github.com/BeryJu/passbook/proxy/pkg/proxy"
"github.com/jinzhu/copier"
"github.com/justinas/alice"
"github.com/oauth2-proxy/oauth2-proxy/pkg/apis/options"
"github.com/oauth2-proxy/oauth2-proxy/pkg/middleware"
"github.com/oauth2-proxy/oauth2-proxy/pkg/validation"
log "github.com/sirupsen/logrus"
)
type providerBundle struct {
http.Handler
a *APIController
proxy *proxy.OAuthProxy
Host string
cert *tls.Certificate
}
func (pb *providerBundle) prepareOpts(provider *models.ProxyOutpostConfig) *options.Options {
externalHost, err := url.Parse(*provider.ExternalHost)
if err != nil {
log.WithError(err).Warning("Failed to parse URL, skipping provider")
return nil
}
providerOpts := &options.Options{}
copier.Copy(&providerOpts, &pb.a.commonOpts)
providerOpts.ClientID = provider.ClientID
providerOpts.ClientSecret = provider.ClientSecret
providerOpts.Cookie.Secret = provider.CookieSecret
providerOpts.Cookie.Secure = externalHost.Scheme == "https"
providerOpts.SkipOIDCDiscovery = true
providerOpts.OIDCIssuerURL = *provider.OidcConfiguration.Issuer
providerOpts.LoginURL = *provider.OidcConfiguration.AuthorizationEndpoint
providerOpts.RedeemURL = *provider.OidcConfiguration.TokenEndpoint
providerOpts.OIDCJwksURL = *provider.OidcConfiguration.JwksURI
providerOpts.ProfileURL = *provider.OidcConfiguration.UserinfoEndpoint
providerOpts.UpstreamServers = []options.Upstream{
{
ID: "default",
URI: *provider.InternalHost,
Path: "/",
},
}
if provider.Certificate != nil {
pb.a.logger.WithField("provider", provider.ClientID).Debug("Enabling TLS")
cert, err := pb.a.client.Crypto.CryptoCertificatekeypairsRead(&crypto.CryptoCertificatekeypairsReadParams{
Context: context.Background(),
KpUUID: *provider.Certificate,
}, pb.a.auth)
if err != nil {
pb.a.logger.WithField("provider", provider.ClientID).WithError(err).Warning("Failed to fetch certificate")
return providerOpts
}
x509cert, err := tls.X509KeyPair([]byte(*cert.Payload.CertificateData), []byte(cert.Payload.KeyData))
if err != nil {
pb.a.logger.WithField("provider", provider.ClientID).WithError(err).Warning("Failed to parse certificate")
return providerOpts
}
pb.cert = &x509cert
pb.a.logger.WithField("provider", provider.ClientID).WithField("certificate-key-pair", *cert.Payload.Name).Debug("Loaded certificates")
}
return providerOpts
}
func (pb *providerBundle) Build(provider *models.ProxyOutpostConfig) {
opts := pb.prepareOpts(provider)
chain := alice.New()
if opts.ForceHTTPS {
_, httpsPort, err := net.SplitHostPort(opts.HTTPSAddress)
if err != nil {
log.Fatalf("FATAL: invalid HTTPS address %q: %v", opts.HTTPAddress, err)
}
chain = chain.Append(middleware.NewRedirectToHTTPS(httpsPort))
}
healthCheckPaths := []string{opts.PingPath}
healthCheckUserAgents := []string{opts.PingUserAgent}
if opts.GCPHealthChecks {
healthCheckPaths = append(healthCheckPaths, "/liveness_check", "/readiness_check")
healthCheckUserAgents = append(healthCheckUserAgents, "GoogleHC/1.0")
}
// To silence logging of health checks, register the health check handler before
// the logging handler
if opts.Logging.SilencePing {
chain = chain.Append(middleware.NewHealthCheck(healthCheckPaths, healthCheckUserAgents), LoggingHandler)
} else {
chain = chain.Append(LoggingHandler, middleware.NewHealthCheck(healthCheckPaths, healthCheckUserAgents))
}
err := validation.Validate(opts)
if err != nil {
log.Printf("%s", err)
os.Exit(1)
}
oauthproxy, err := proxy.NewOAuthProxy(opts)
if err != nil {
log.Errorf("ERROR: Failed to initialise OAuth2 Proxy: %v", err)
os.Exit(1)
}
pb.proxy = oauthproxy
pb.Handler = chain.Then(oauthproxy)
}

View File

@ -0,0 +1,85 @@
package server
import (
"crypto/tls"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/go-openapi/strfmt"
"github.com/gorilla/websocket"
"github.com/recws-org/recws"
)
func (ac *APIController) initWS(pbURL url.URL, outpostUUID strfmt.UUID) {
pathTemplate := "%s://%s/ws/outpost/%s/"
scheme := strings.ReplaceAll(pbURL.Scheme, "http", "ws")
header := http.Header{
"Authorization": []string{ac.token},
}
_, set := os.LookupEnv("PASSBOOK_INSECURE")
ws := recws.RecConn{
// KeepAliveTimeout: 10 * time.Second,
NonVerbose: true,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: set,
},
}
ws.Dial(fmt.Sprintf(pathTemplate, scheme, pbURL.Host, outpostUUID.String()), header)
ac.logger.WithField("outpost", outpostUUID.String()).Debug("connecting to passbook")
ac.wsConn = ws
}
// Shutdown Gracefully stops all workers, disconnects from websocket
func (ac *APIController) Shutdown() {
// Cleanly close the connection by sending a close message and then
// waiting (with timeout) for the server to close the connection.
err := ac.wsConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
if err != nil {
ac.logger.Println("write close:", err)
return
}
return
}
func (ac *APIController) startWSHandler() {
for {
var wsMsg websocketMessage
err := ac.wsConn.ReadJSON(&wsMsg)
if err != nil {
ac.logger.Println("read:", err)
return
}
if wsMsg.Instruction != WebsocketInstructionAck {
ac.logger.Debugf("%+v\n", wsMsg)
}
if wsMsg.Instruction == WebsocketInstructionTriggerUpdate {
err := ac.UpdateIfRequired()
if err != nil {
ac.logger.WithError(err).Debug("Failed to update")
}
}
}
}
func (ac *APIController) startWSHealth() {
for ; true; <-time.Tick(time.Second * 10) {
aliveMsg := websocketMessage{
Instruction: WebsocketInstructionHello,
Args: make(map[string]interface{}),
}
err := ac.wsConn.WriteJSON(aliveMsg)
if err != nil {
ac.logger.Println("write:", err)
return
}
}
}

View File

@ -0,0 +1,17 @@
package server
type websocketInstruction int
const (
// WebsocketInstructionAck Code used to acknowledge a previous message
WebsocketInstructionAck websocketInstruction = 0
// WebsocketInstructionHello Code used to send a healthcheck keepalive
WebsocketInstructionHello websocketInstruction = 1
// WebsocketInstructionTriggerUpdate Code received to trigger a config update
WebsocketInstructionTriggerUpdate websocketInstruction = 2
)
type websocketMessage struct {
Instruction websocketInstruction `json:"instruction"`
Args map[string]interface{} `json:"args"`
}

63
proxy/pkg/server/cert.go Normal file
View File

@ -0,0 +1,63 @@
package server
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"time"
log "github.com/sirupsen/logrus"
)
func generateSelfSignedCert() (tls.Certificate, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Fatalf("Failed to generate private key: %v", err)
return tls.Certificate{}, err
}
keyUsage := x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Fatalf("Failed to generate serial number: %v", err)
return tls.Certificate{}, err
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"passbook"},
CommonName: "passbook Proxy default certificate",
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: keyUsage,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
template.DNSNames = []string{"*"}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
log.Warning(err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
log.Warning(err)
}
privPemByes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
return tls.X509KeyPair(pemBytes, privPemByes)
}

View File

@ -0,0 +1,123 @@
package server
import (
"bufio"
"errors"
"fmt"
"net"
"net/http"
"time"
"github.com/oauth2-proxy/oauth2-proxy/pkg/logger"
log "github.com/sirupsen/logrus"
)
// responseLogger is wrapper of http.ResponseWriter that keeps track of its HTTP status
// code and body size
type responseLogger struct {
w http.ResponseWriter
status int
size int
upstream string
authInfo string
}
// Header returns the ResponseWriter's Header
func (l *responseLogger) Header() http.Header {
return l.w.Header()
}
// Support Websocket
func (l *responseLogger) Hijack() (rwc net.Conn, buf *bufio.ReadWriter, err error) {
if hj, ok := l.w.(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, errors.New("http.Hijacker is not available on writer")
}
// ExtractGAPMetadata extracts and removes GAP headers from the ResponseWriter's
// Header
func (l *responseLogger) ExtractGAPMetadata() {
upstream := l.w.Header().Get("GAP-Upstream-Address")
if upstream != "" {
l.upstream = upstream
l.w.Header().Del("GAP-Upstream-Address")
}
authInfo := l.w.Header().Get("GAP-Auth")
if authInfo != "" {
l.authInfo = authInfo
l.w.Header().Del("GAP-Auth")
}
}
// Write writes the response using the ResponseWriter
func (l *responseLogger) Write(b []byte) (int, error) {
if l.status == 0 {
// The status will be StatusOK if WriteHeader has not been called yet
l.status = http.StatusOK
}
l.ExtractGAPMetadata()
size, err := l.w.Write(b)
l.size += size
return size, err
}
// WriteHeader writes the status code for the Response
func (l *responseLogger) WriteHeader(s int) {
l.ExtractGAPMetadata()
l.w.WriteHeader(s)
l.status = s
}
// Status returns the response status code
func (l *responseLogger) Status() int {
return l.status
}
// Size returns the response size
func (l *responseLogger) Size() int {
return l.size
}
// Flush sends any buffered data to the client
func (l *responseLogger) Flush() {
if flusher, ok := l.w.(http.Flusher); ok {
flusher.Flush()
}
}
// loggingHandler is the http.Handler implementation for LoggingHandler
type loggingHandler struct {
handler http.Handler
logger *log.Entry
}
// LoggingHandler provides an http.Handler which logs requests to the HTTP server
func LoggingHandler(h http.Handler) http.Handler {
return loggingHandler{
handler: h,
logger: log.WithField("component", "http-server"),
}
}
func (h loggingHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
t := time.Now()
url := *req.URL
responseLogger := &responseLogger{w: w}
h.handler.ServeHTTP(responseLogger, req)
duration := float64(time.Since(t)) / float64(time.Second)
h.logger.WithFields(log.Fields{
"Client": req.RemoteAddr,
"Host": req.Host,
"Protocol": req.Proto,
"RequestDuration": fmt.Sprintf("%0.3f", duration),
"RequestMethod": req.Method,
"ResponseSize": responseLogger.Size(),
"StatusCode": responseLogger.Status(),
"Timestamp": logger.FormatTimestamp(t),
"Upstream": responseLogger.upstream,
"UserAgent": req.UserAgent(),
"Username": responseLogger.authInfo,
}).Info(url.RequestURI())
// logger.PrintReq(responseLogger.authInfo, responseLogger.upstream, req, url, t, , )
}

152
proxy/pkg/server/server.go Normal file
View File

@ -0,0 +1,152 @@
package server
import (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"time"
log "github.com/sirupsen/logrus"
sentryhttp "github.com/getsentry/sentry-go/http"
)
// Server represents an HTTP server
type Server struct {
Handlers map[string]*providerBundle
stop chan struct{} // channel for waiting shutdown
logger *log.Entry
defaultCert tls.Certificate
}
// NewServer initialise a new HTTP Server
func NewServer() *Server {
defaultCert, err := generateSelfSignedCert()
if err != nil {
log.Warning(err)
}
return &Server{
Handlers: make(map[string]*providerBundle),
logger: log.WithField("component", "http-server"),
defaultCert: defaultCert,
}
}
// ServeHTTP constructs a net.Listener and starts handling HTTP requests
func (s *Server) ServeHTTP() {
// TODO: make this a setting
listenAddress := "localhost:4180"
listener, err := net.Listen("tcp", listenAddress)
if err != nil {
s.logger.Fatalf("FATAL: listen (%s) failed - %s", listenAddress, err)
}
s.logger.Printf("listening on %s", listener.Addr())
s.serve(listener)
s.logger.Printf("closing %s", listener.Addr())
}
func (s *Server) getCertificates(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
handler, ok := s.Handlers[info.ServerName]
if !ok {
s.logger.WithField("server-name", info.ServerName).Debug("Handler does not exist")
return &s.defaultCert, nil
}
if handler.cert == nil {
s.logger.WithField("server-name", info.ServerName).Debug("Handler does not have a certificate")
return &s.defaultCert, nil
}
return handler.cert, nil
}
// ServeHTTPS constructs a net.Listener and starts handling HTTPS requests
func (s *Server) ServeHTTPS() {
// TODO: make this a setting
listenAddress := "localhost:4443"
config := &tls.Config{
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS12,
GetCertificate: s.getCertificates,
}
ln, err := net.Listen("tcp", listenAddress)
if err != nil {
s.logger.Fatalf("FATAL: listen (%s) failed - %s", listenAddress, err)
}
s.logger.Printf("listening on %s", ln.Addr())
tlsListener := tls.NewListener(tcpKeepAliveListener{ln.(*net.TCPListener)}, config)
s.serve(tlsListener)
s.logger.Printf("closing %s", tlsListener.Addr())
}
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
handler, ok := s.Handlers[r.Host]
if !ok {
// If we only have one handler, host name switching doesn't matter
if len(s.Handlers) == 1 {
for k := range s.Handlers {
s.Handlers[k].ServeHTTP(w, r)
return
}
}
s.logger.WithField("host", r.Host).Debug("Host header does not match any we know of")
s.logger.Printf("%v+\n", s.Handlers)
w.WriteHeader(400)
return
}
s.logger.WithField("host", r.Host).Debug("passing request from host head")
handler.ServeHTTP(w, r)
}
func (s *Server) serve(listener net.Listener) {
sentryHandler := sentryhttp.New(sentryhttp.Options{})
srv := &http.Server{Handler: sentryHandler.HandleFunc(s.handler)}
// See https://golang.org/pkg/net/http/#Server.Shutdown
idleConnsClosed := make(chan struct{})
go func() {
<-s.stop // wait notification for stopping server
// We received an interrupt signal, shut down.
if err := srv.Shutdown(context.Background()); err != nil {
// Error from closing listeners, or context timeout:
s.logger.Printf("HTTP server Shutdown: %v", err)
}
close(idleConnsClosed)
}()
err := srv.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Errorf("ERROR: http.Serve() - %s", err)
}
<-idleConnsClosed
}
// tcpKeepAliveListener sets TCP keep-alive timeouts on accepted
// connections. It's used by ListenAndServe and ListenAndServeTLS so
// dead TCP connections (e.g. closing laptop mid-download) eventually
// go away.
type tcpKeepAliveListener struct {
*net.TCPListener
}
func (ln tcpKeepAliveListener) Accept() (net.Conn, error) {
tc, err := ln.AcceptTCP()
if err != nil {
return nil, err
}
err = tc.SetKeepAlive(true)
if err != nil {
log.Printf("Error setting Keep-Alive: %v", err)
}
err = tc.SetKeepAlivePeriod(3 * time.Minute)
if err != nil {
log.Printf("Error setting Keep-Alive period: %v", err)
}
return tc, nil
}

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