Compare commits
32 Commits
version/0.
...
version/0.
| Author | SHA1 | Date | |
|---|---|---|---|
| 82eade3eb1 | |||
| 56a9dcc88d | |||
| fe70d80189 | |||
| e97e22c58a | |||
| bb4e39aab6 | |||
| a8744f443c | |||
| 7fe9b8f0b4 | |||
| 696aa7e5f6 | |||
| e1d82aee1d | |||
| 151374f565 | |||
| bebeff9f7f | |||
| 8b99afa34d | |||
| b317852e8a | |||
| 24ae35c35a | |||
| 8e6bb48227 | |||
| 7a4e8af1ae | |||
| 0161205c82 | |||
| ca0ba85023 | |||
| c2ebaa7f64 | |||
| 23cccebb96 | |||
| 3f5d30e6fe | |||
| ca735349f9 | |||
| 25ce8c6dc7 | |||
| 081ac0bcdb | |||
| 8a07b349ee | |||
| b3468bc265 | |||
| 4edfad869f | |||
| 404f5d7912 | |||
| 8bea99a953 | |||
| 0b0ba33dce | |||
| e3627b2cd9 | |||
| 37fac3ae00 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.10.0-rc4
|
||||
current_version = 0.10.0-stable
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
|
||||
@ -19,6 +19,8 @@ values =
|
||||
|
||||
[bumpversion:file:docs/installation/docker-compose.md]
|
||||
|
||||
[bumpversion:file:docs/installation/kubernetes.md]
|
||||
|
||||
[bumpversion:file:docker-compose.yml]
|
||||
|
||||
[bumpversion:file:helm/values.yaml]
|
||||
|
||||
31
.github/workflows/release.yml
vendored
31
.github/workflows/release.yml
vendored
@ -1,6 +1,8 @@
|
||||
name: passbook-release
|
||||
name: passbook-on-release
|
||||
|
||||
on:
|
||||
release
|
||||
release:
|
||||
types: [published, created]
|
||||
|
||||
jobs:
|
||||
# Build
|
||||
@ -16,17 +18,26 @@ jobs:
|
||||
- name: Building Docker Image
|
||||
run: docker build
|
||||
--no-cache
|
||||
-t beryju/passbook:0.10.0-rc4
|
||||
-t beryju/passbook:0.10.0-stable
|
||||
-t beryju/passbook:latest
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook:0.10.0-rc4
|
||||
run: docker push beryju/passbook:0.10.0-stable
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook:latest
|
||||
build-proxy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: "^1.15"
|
||||
- name: prepare go api client
|
||||
run: |
|
||||
cd proxy
|
||||
go get -u github.com/go-swagger/go-swagger/cmd/swagger
|
||||
swagger generate client -f ../swagger.yaml -A passbook -t pkg/
|
||||
go build -v .
|
||||
- name: Docker Login Registry
|
||||
env:
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@ -37,11 +48,11 @@ jobs:
|
||||
cd proxy
|
||||
docker build \
|
||||
--no-cache \
|
||||
-t beryju/passbook-proxy:0.10.0-rc4 \
|
||||
-t beryju/passbook-proxy:0.10.0-stable \
|
||||
-t beryju/passbook-proxy:latest \
|
||||
-f Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-proxy:0.10.0-rc4
|
||||
run: docker push beryju/passbook-proxy:0.10.0-stable
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-proxy:latest
|
||||
build-static:
|
||||
@ -66,11 +77,11 @@ jobs:
|
||||
run: docker build
|
||||
--no-cache
|
||||
--network=$(docker network ls | grep github | awk '{print $1}')
|
||||
-t beryju/passbook-static:0.10.0-rc4
|
||||
-t beryju/passbook-static:0.10.0-stable
|
||||
-t beryju/passbook-static:latest
|
||||
-f static.Dockerfile .
|
||||
- name: Push Docker Container to Registry (versioned)
|
||||
run: docker push beryju/passbook-static:0.10.0-rc4
|
||||
run: docker push beryju/passbook-static:0.10.0-stable
|
||||
- name: Push Docker Container to Registry (latest)
|
||||
run: docker push beryju/passbook-static:latest
|
||||
test-release:
|
||||
@ -85,7 +96,7 @@ jobs:
|
||||
docker-compose pull -q
|
||||
docker-compose up --no-start
|
||||
docker-compose start postgresql redis
|
||||
docker-compose run -u root server bash -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test"
|
||||
docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook"
|
||||
sentry-release:
|
||||
needs:
|
||||
- test-release
|
||||
@ -100,5 +111,5 @@ jobs:
|
||||
SENTRY_PROJECT: passbook
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
tagName: 0.10.0-rc4
|
||||
tagName: 0.10.0-stable
|
||||
environment: beryjuorg-prod
|
||||
|
||||
8
.github/workflows/tag.yml
vendored
8
.github/workflows/tag.yml
vendored
@ -1,10 +1,10 @@
|
||||
name: passbook-on-tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'version/*'
|
||||
|
||||
name: passbook-version-tag
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Create Release from Tag
|
||||
@ -21,7 +21,7 @@ jobs:
|
||||
-f Dockerfile .
|
||||
docker-compose up --no-start
|
||||
docker-compose start postgresql redis
|
||||
docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test"
|
||||
docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test passbook"
|
||||
- name: Install Helm
|
||||
run: |
|
||||
apt update && apt install -y curl
|
||||
@ -31,7 +31,7 @@ jobs:
|
||||
helm dependency update helm/
|
||||
helm package helm/
|
||||
mv passbook-*.tgz passbook-chart.tgz
|
||||
- name: Extract verison number
|
||||
- name: Extract version number
|
||||
id: get_version
|
||||
uses: actions/github-script@0.2.0
|
||||
with:
|
||||
|
||||
8
Makefile
8
Makefile
@ -1,7 +1,7 @@
|
||||
all: lint-fix lint coverage gen
|
||||
|
||||
coverage:
|
||||
coverage run --concurrency=multiprocessing manage.py test passbook --failfast
|
||||
coverage run --concurrency=multiprocessing manage.py test --failfast
|
||||
coverage combine
|
||||
coverage html
|
||||
coverage report
|
||||
@ -18,3 +18,9 @@ lint:
|
||||
|
||||
gen: coverage
|
||||
./manage.py generate_swagger -o swagger.yaml -f yaml
|
||||
|
||||
local-stack:
|
||||
export PASSBOOK_TAG=testing
|
||||
docker build -t beryju/passbook:testng .
|
||||
docker-compose up -d
|
||||
docker-compose run --rm server migrate
|
||||
|
||||
3
Pipfile
3
Pipfile
@ -59,5 +59,6 @@ docker = "*"
|
||||
pylint = "*"
|
||||
pylint-django = "*"
|
||||
selenium = "*"
|
||||
unittest-xml-reporting = "*"
|
||||
prospector = "*"
|
||||
pytest = "*"
|
||||
pytest-django = "*"
|
||||
|
||||
87
Pipfile.lock
generated
87
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "a798bbd0b97857cac136c1743b8d6ad8bf8c3d95e2760c71d324bb2a7f47f678"
|
||||
"sha256": "80570636236962f4b934a884817292de9f7bb48520aa964afc2959b0f795fb57"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@ -74,18 +74,18 @@
|
||||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:2ab73b0c400ab8c7df84bee7564ef8a0813021da28dd7a05fcbffb77a8ae9de9",
|
||||
"sha256:bb2222fa02fcd09b39e581e532d4f013ea850742d8cd46e9c10a21028b6d2ef5"
|
||||
"sha256:20edd03ae4c4e141b0d8a9a9afc773af4345d54b68202b6aa502956b57b18b3f",
|
||||
"sha256:b596a80181fecd775ccc009286400f4d785136f250967895cb34beeeef65eb1f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.14.56"
|
||||
"version": "==1.14.59"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:37cc3f1013c00dc0f061582198d6b785dadf147bd99307d41c5c0e47debca65c",
|
||||
"sha256:acd2df778a5e12b2a16ac040ce6e91a6c6f2d7ac67bd4f966472ce5c68b5b62d"
|
||||
"sha256:193f193a66ac79106725e14dd73e28ed36bcec99b37156538a2202d061056a58",
|
||||
"sha256:e55a4fc652537f5ccb2362133f3928ebeafb04ee9fe15ea11c2df80ba4ef8a12"
|
||||
],
|
||||
"version": "==1.17.58"
|
||||
"version": "==1.17.60"
|
||||
},
|
||||
"cachetools": {
|
||||
"hashes": [
|
||||
@ -1373,6 +1373,13 @@
|
||||
],
|
||||
"version": "==2.10"
|
||||
},
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
"sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
|
||||
"sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"isort": {
|
||||
"hashes": [
|
||||
"sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
|
||||
@ -1413,6 +1420,21 @@
|
||||
],
|
||||
"version": "==0.6.1"
|
||||
},
|
||||
"more-itertools": {
|
||||
"hashes": [
|
||||
"sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
|
||||
"sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
|
||||
],
|
||||
"version": "==8.5.0"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
|
||||
"sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.4"
|
||||
},
|
||||
"pathspec": {
|
||||
"hashes": [
|
||||
"sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
|
||||
@ -1434,6 +1456,13 @@
|
||||
],
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
|
||||
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
|
||||
],
|
||||
"version": "==0.13.1"
|
||||
},
|
||||
"prospector": {
|
||||
"hashes": [
|
||||
"sha256:43e5e187c027336b0e4c4aa6a82d66d3b923b5ec5b51968126132e32f9d14a2f"
|
||||
@ -1441,6 +1470,13 @@
|
||||
"index": "pypi",
|
||||
"version": "==1.3.0"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
"sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
|
||||
"sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
|
||||
],
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
|
||||
@ -1497,6 +1533,29 @@
|
||||
],
|
||||
"version": "==0.6"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||
],
|
||||
"version": "==2.4.7"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:0e37f61339c4578776e090c3b8f6b16ce4db333889d65d0efb305243ec544b40",
|
||||
"sha256:c8f57c2a30983f469bf03e68cdfa74dc474ce56b8f280ddcb080dfd91df01043"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==6.0.2"
|
||||
},
|
||||
"pytest-django": {
|
||||
"hashes": [
|
||||
"sha256:64f99d565dd9497af412fcab2989fe40982c1282d4118ff422b407f3f7275ca5",
|
||||
"sha256:664e5f42242e5e182519388f01b9f25d824a9feb7cd17d8f863c8d776f38baf9"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.9.0"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
|
||||
@ -1604,10 +1663,10 @@
|
||||
},
|
||||
"stevedore": {
|
||||
"hashes": [
|
||||
"sha256:a34086819e2c7a7f86d5635363632829dab8014e5fd7be2454c7cba84ac7514e",
|
||||
"sha256:ddc09a744dc224c84ec8e8efcb70595042d21c97c76df60daee64c9ad53bc7ee"
|
||||
"sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62",
|
||||
"sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0"
|
||||
],
|
||||
"version": "==3.2.1"
|
||||
"version": "==3.2.2"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
@ -1642,14 +1701,6 @@
|
||||
],
|
||||
"version": "==1.4.1"
|
||||
},
|
||||
"unittest-xml-reporting": {
|
||||
"hashes": [
|
||||
"sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca",
|
||||
"sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"urllib3": {
|
||||
"extras": [
|
||||
"secure"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<img src="passbook/static/static/passbook/logo.svg" height="50" alt="passbook logo"><img src="passbook/static/static/passbook/brand_inverted.svg" height="50" alt="passbook">
|
||||
<img src="docs/images/logo.svg" height="50" alt="passbook logo"><img src="docs/images/brand_inverted.svg" height="50" alt="passbook">
|
||||
|
||||
[](https://dev.azure.com/beryjuorg/passbook/_build?definitionId=1)
|
||||

|
||||
@ -20,7 +20,7 @@ wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml
|
||||
# Optionally enable Error-reporting
|
||||
# export PASSBOOK_ERROR_REPORTING=true
|
||||
# Optionally deploy a different version
|
||||
# export PASSBOOK_TAG=0.10.0-rc4
|
||||
# export PASSBOOK_TAG=0.10.0-stable
|
||||
# If this is a productive installation, set a different PostgreSQL Password
|
||||
# export PG_PASS=$(pwgen 40 1)
|
||||
docker-compose pull
|
||||
|
||||
@ -150,7 +150,7 @@ stages:
|
||||
publishLocation: 'pipeline'
|
||||
- job: coverage_e2e
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
name: coventry
|
||||
steps:
|
||||
- task: UsePythonVersion@0
|
||||
inputs:
|
||||
|
||||
@ -21,7 +21,7 @@ services:
|
||||
labels:
|
||||
- traefik.enable=false
|
||||
server:
|
||||
image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-rc4}
|
||||
image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-stable}
|
||||
command: server
|
||||
environment:
|
||||
PASSBOOK_REDIS__HOST: redis
|
||||
@ -38,7 +38,7 @@ services:
|
||||
- traefik.docker.network=internal
|
||||
- traefik.frontend.rule=PathPrefix:/
|
||||
worker:
|
||||
image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-rc4}
|
||||
image: beryju/passbook:${PASSBOOK_TAG:-0.10.0-stable}
|
||||
command: worker
|
||||
networks:
|
||||
- internal
|
||||
@ -51,7 +51,7 @@ services:
|
||||
PASSBOOK_POSTGRESQL__PASSWORD: ${PG_PASS:-thisisnotagoodpassword}
|
||||
PASSBOOK_LOG_LEVEL: debug
|
||||
static:
|
||||
image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.0-rc4}
|
||||
image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.0-stable}
|
||||
networks:
|
||||
- internal
|
||||
labels:
|
||||
|
||||
@ -16,7 +16,7 @@ wget https://raw.githubusercontent.com/BeryJu/passbook/master/docker-compose.yml
|
||||
# Optionally enable Error-reporting
|
||||
# export PASSBOOK_ERROR_REPORTING=true
|
||||
# Optionally deploy a different version
|
||||
# export PASSBOOK_TAG=0.10.0-rc4
|
||||
# export PASSBOOK_TAG=0.10.0-stable
|
||||
# If this is a productive installation, set a different PostgreSQL Password
|
||||
# export PG_PASS=$(pwgen 40 1)
|
||||
docker-compose pull
|
||||
|
||||
@ -11,7 +11,7 @@ This installation automatically applies database migrations on startup. After th
|
||||
image:
|
||||
name: beryju/passbook
|
||||
name_static: beryju/passbook-static
|
||||
tag: 0.9.0-stable
|
||||
tag: 0.10.0-stable
|
||||
|
||||
nameOverride: ""
|
||||
|
||||
|
||||
20
docs/outposts/deploy-docker-compose.md
Normal file
20
docs/outposts/deploy-docker-compose.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Outpost deployment in docker-compose
|
||||
|
||||
To deploy an outpost with docker-compose, use this snippet in your docker-compose file.
|
||||
|
||||
You can also run the outpost in a separate docker-compose project, you just have to ensure that the outpost container can reach your application container.
|
||||
|
||||
```yaml
|
||||
version: 3.5
|
||||
|
||||
services:
|
||||
passbook_proxy:
|
||||
image: beryju/passbook-proxy:0.10.0-stable
|
||||
ports:
|
||||
- 4180:4180
|
||||
- 4443:4443
|
||||
environment:
|
||||
PASSBOOK_HOST: https://your-passbook.tld
|
||||
PASSBOOK_INSECURE: 'true'
|
||||
PASSBOOK_TOKEN: token-generated-by-passbook
|
||||
```
|
||||
99
docs/outposts/deploy-kubernetes.md
Normal file
99
docs/outposts/deploy-kubernetes.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Outpost deployment on Kubernetes
|
||||
|
||||
Use the following manifest, replacing all values surrounded with `__`.
|
||||
|
||||
Afterwards, configure the proxy provider to connect to `<service name>.<namespace>.svc.cluster.local`, and update your Ingress to connect to the `passbook-outpost` service.
|
||||
|
||||
```yaml
|
||||
api_version: v1
|
||||
kind: secret
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: test
|
||||
app.kubernetes.io/managed-by: passbook.beryju.org
|
||||
app.kubernetes.io/name: passbook-proxy
|
||||
app.kubernetes.io/version: 0.10.0
|
||||
name: passbook-outpost-api
|
||||
string_data:
|
||||
passbook_host: '__PASSBOOK_URL__'
|
||||
passbook_host_insecure: 'true'
|
||||
token: '__PASSBOOK_TOKEN__'
|
||||
type: Opaque
|
||||
---
|
||||
api_version: apps/v1
|
||||
kind: deployment
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: test
|
||||
app.kubernetes.io/managed-by: passbook.beryju.org
|
||||
app.kubernetes.io/name: passbook-proxy
|
||||
app.kubernetes.io/version: 0.10.0
|
||||
name: passbook-outpost
|
||||
spec:
|
||||
selector:
|
||||
match_labels:
|
||||
app.kubernetes.io/instance: test
|
||||
app.kubernetes.io/managed-by: passbook.beryju.org
|
||||
app.kubernetes.io/name: passbook-proxy
|
||||
app.kubernetes.io/version: 0.10.0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: test
|
||||
app.kubernetes.io/managed-by: passbook.beryju.org
|
||||
app.kubernetes.io/name: passbook-proxy
|
||||
app.kubernetes.io/version: 0.10.0
|
||||
spec:
|
||||
containers:
|
||||
- env:
|
||||
- name: PASSBOOK_HOST
|
||||
value_from:
|
||||
secret_key_ref:
|
||||
key: passbook_host
|
||||
name: passbook-outpost-api
|
||||
- name: PASSBOOK_TOKEN
|
||||
value_from:
|
||||
secret_key_ref:
|
||||
key: token
|
||||
name: passbook-outpost-api
|
||||
- name: PASSBOOK_INSECURE
|
||||
value_from:
|
||||
secret_key_ref:
|
||||
key: passbook_host_insecure
|
||||
name: passbook-outpost-api
|
||||
image: beryju/passbook-proxy:0.10.0
|
||||
name: proxy
|
||||
ports:
|
||||
- containerPort: 4180
|
||||
name: http
|
||||
protocol: TCP
|
||||
- containerPort: 4443
|
||||
name: http
|
||||
protocol: TCP
|
||||
---
|
||||
api_version: v1
|
||||
kind: service
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: test
|
||||
app.kubernetes.io/managed-by: passbook.beryju.org
|
||||
app.kubernetes.io/name: passbook-proxy
|
||||
app.kubernetes.io/version: 0.10.0
|
||||
name: passbook-outpost
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
port: 4180
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
- name: https
|
||||
port: 4443
|
||||
protocol: TCP
|
||||
targetPort: https
|
||||
selector:
|
||||
app.kubernetes.io/instance: test
|
||||
app.kubernetes.io/managed-by: passbook.beryju.org
|
||||
app.kubernetes.io/name: passbook-proxy
|
||||
app.kubernetes.io/version: 0.10.0
|
||||
type: ClusterIP
|
||||
```
|
||||
@ -6,21 +6,9 @@ An outpost is a single deployment of a passbook component, which can be deployed
|
||||
|
||||
Upon creation, a service account and a token is generated. The service account only has permissions to read the outpost and provider configuration. This token is used by the Outpost to connect to passbook.
|
||||
|
||||
To deploy an outpost, you can for example use this docker-compose snippet:
|
||||
To deploy an outpost, see: <a name="deploy">
|
||||
|
||||
```yaml
|
||||
version: 3.5
|
||||
- [Kubernetes](deploy-kubernetes.md)
|
||||
- [docker-compose](deploy-docker-compose.md)
|
||||
|
||||
services:
|
||||
passbook_proxy:
|
||||
image: beryju/passbook-proxy:0.10.0-stable
|
||||
ports:
|
||||
- 4180:4180
|
||||
- 4443:4443
|
||||
environment:
|
||||
PASSBOOK_HOST: https://your-passbook.tld
|
||||
PASSBOOK_INSECURE: 'true'
|
||||
PASSBOOK_TOKEN: token-generated-by-passbook
|
||||
```
|
||||
|
||||
In future versions, this snippet will be automatically generated. You will also be able to deploy an outpost directly into a kubernetes cluster.w
|
||||
In future versions, this snippet will be automatically generated. You will also be able to deploy an outpost directly into a kubernetes cluster.
|
||||
|
||||
@ -2,7 +2,7 @@ version: '3.7'
|
||||
|
||||
services:
|
||||
chrome:
|
||||
image: selenium/standalone-chrome-debug:3.141.59-20200525
|
||||
image: selenium/standalone-chrome-debug:3.141.59-20200719
|
||||
volumes:
|
||||
- /dev/shm:/dev/shm
|
||||
network_mode: host
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
"""Test Enroll flow"""
|
||||
from time import sleep
|
||||
from sys import platform
|
||||
from typing import Any, Dict, Optional
|
||||
from unittest.case import skipUnless
|
||||
|
||||
from django.test import override_settings
|
||||
from docker import DockerClient, from_env
|
||||
from docker.models.containers import Container
|
||||
from docker.types import Healthcheck
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as ec
|
||||
from structlog import get_logger
|
||||
|
||||
from e2e.utils import USER, SeleniumTestCase
|
||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||
@ -18,41 +17,23 @@ from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
||||
from passbook.stages.user_login.models import UserLoginStage
|
||||
from passbook.stages.user_write.models import UserWriteStage
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||
class TestFlowsEnroll(SeleniumTestCase):
|
||||
"""Test Enroll flow"""
|
||||
|
||||
def setUp(self):
|
||||
self.container = self.setup_client()
|
||||
super().setUp()
|
||||
|
||||
def setup_client(self) -> Container:
|
||||
"""Setup test IdP container"""
|
||||
client: DockerClient = from_env()
|
||||
container = client.containers.run(
|
||||
image="mailhog/mailhog:v1.0.1",
|
||||
detach=True,
|
||||
network_mode="host",
|
||||
auto_remove=True,
|
||||
healthcheck=Healthcheck(
|
||||
def get_container_specs(self) -> Optional[Dict[str, Any]]:
|
||||
return {
|
||||
"image": "mailhog/mailhog:v1.0.1",
|
||||
"detach": True,
|
||||
"network_mode": "host",
|
||||
"auto_remove": True,
|
||||
"healthcheck": Healthcheck(
|
||||
test=["CMD", "wget", "--spider", "http://localhost:8025"],
|
||||
interval=5 * 100 * 1000000,
|
||||
start_period=1 * 100 * 1000000,
|
||||
),
|
||||
)
|
||||
while True:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
LOGGER.info("Container failed healthcheck")
|
||||
sleep(1)
|
||||
|
||||
def tearDown(self):
|
||||
self.container.kill()
|
||||
super().tearDown()
|
||||
}
|
||||
|
||||
def test_enroll_2_step(self):
|
||||
"""Test 2-step enroll flow"""
|
||||
@ -220,21 +201,25 @@ class TestFlowsEnroll(SeleniumTestCase):
|
||||
self.driver.find_element(By.ID, "id_name").send_keys("some name")
|
||||
self.driver.find_element(By.ID, "id_email").send_keys("foo@bar.baz")
|
||||
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
|
||||
sleep(3)
|
||||
# Wait for the success message so we know the email is sent
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located((By.CSS_SELECTOR, ".pf-c-form > p"))
|
||||
)
|
||||
|
||||
# Open Mailhog
|
||||
self.driver.get("http://localhost:8025")
|
||||
|
||||
# Click on first message
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located((By.CLASS_NAME, "msglist-message"))
|
||||
)
|
||||
self.driver.find_element(By.CLASS_NAME, "msglist-message").click()
|
||||
sleep(3)
|
||||
self.driver.switch_to.frame(self.driver.find_element(By.CLASS_NAME, "tab-pane"))
|
||||
self.driver.find_element(By.ID, "confirm").click()
|
||||
self.driver.close()
|
||||
self.driver.switch_to.window(self.driver.window_handles[0])
|
||||
|
||||
# We're now logged in
|
||||
sleep(3)
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.XPATH, "//a[contains(@href, '/-/user/')]")
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
"""test default login flow"""
|
||||
from sys import platform
|
||||
from unittest.case import skipUnless
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from e2e.utils import USER, SeleniumTestCase
|
||||
|
||||
|
||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||
class TestFlowsLogin(SeleniumTestCase):
|
||||
"""test default login flow"""
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"""test stage setup flows (password change)"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
from time import sleep
|
||||
from sys import platform
|
||||
from unittest.case import skipUnless
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
@ -9,9 +8,11 @@ from selenium.webdriver.common.keys import Keys
|
||||
from e2e.utils import USER, SeleniumTestCase
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.models import Flow, FlowDesignation
|
||||
from passbook.providers.oauth2.generators import generate_client_secret
|
||||
from passbook.stages.password.models import PasswordStage
|
||||
|
||||
|
||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||
class TestFlowsStageSetup(SeleniumTestCase):
|
||||
"""test stage setup flows"""
|
||||
|
||||
@ -27,10 +28,7 @@ class TestFlowsStageSetup(SeleniumTestCase):
|
||||
stage.change_flow = flow
|
||||
stage.save()
|
||||
|
||||
new_password = "".join(
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits)
|
||||
for _ in range(8)
|
||||
)
|
||||
new_password = generate_client_secret()
|
||||
|
||||
self.driver.get(
|
||||
f"{self.live_server_url}/flows/default-authentication-flow/?next=%2F"
|
||||
@ -48,7 +46,7 @@ class TestFlowsStageSetup(SeleniumTestCase):
|
||||
self.driver.find_element(By.ID, "id_password_repeat").send_keys(new_password)
|
||||
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-button").click()
|
||||
|
||||
sleep(2)
|
||||
self.wait_for_url(self.url("passbook_core:user-settings"))
|
||||
# Because USER() is cached, we need to get the user manually here
|
||||
user = User.objects.get(username=USER().username)
|
||||
self.assertTrue(user.check_password(new_password))
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
"""test OAuth Provider flow"""
|
||||
from time import sleep
|
||||
from sys import platform
|
||||
from typing import Any, Dict, Optional
|
||||
from unittest.case import skipUnless
|
||||
|
||||
from docker import DockerClient, from_env
|
||||
from docker.models.containers import Container
|
||||
from docker.types import Healthcheck
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from structlog import get_logger
|
||||
|
||||
from e2e.utils import USER, SeleniumTestCase
|
||||
from passbook.core.models import Application
|
||||
@ -19,32 +18,29 @@ from passbook.providers.oauth2.generators import (
|
||||
)
|
||||
from passbook.providers.oauth2.models import ClientTypes, OAuth2Provider, ResponseTypes
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||
class TestProviderOAuth2Github(SeleniumTestCase):
|
||||
"""test OAuth Provider flow"""
|
||||
|
||||
def setUp(self):
|
||||
self.client_id = generate_client_id()
|
||||
self.client_secret = generate_client_secret()
|
||||
self.container = self.setup_client()
|
||||
super().setUp()
|
||||
|
||||
def setup_client(self) -> Container:
|
||||
def get_container_specs(self) -> Optional[Dict[str, Any]]:
|
||||
"""Setup client grafana container which we test OAuth against"""
|
||||
client: DockerClient = from_env()
|
||||
container = client.containers.run(
|
||||
image="grafana/grafana:7.1.0",
|
||||
detach=True,
|
||||
network_mode="host",
|
||||
auto_remove=True,
|
||||
healthcheck=Healthcheck(
|
||||
return {
|
||||
"image": "grafana/grafana:7.1.0",
|
||||
"detach": True,
|
||||
"network_mode": "host",
|
||||
"auto_remove": True,
|
||||
"healthcheck": Healthcheck(
|
||||
test=["CMD", "wget", "--spider", "http://localhost:3000"],
|
||||
interval=5 * 100 * 1000000,
|
||||
start_period=1 * 100 * 1000000,
|
||||
),
|
||||
environment={
|
||||
"environment": {
|
||||
"GF_AUTH_GITHUB_ENABLED": "true",
|
||||
"GF_AUTH_GITHUB_ALLOW_SIGN_UP": "true",
|
||||
"GF_AUTH_GITHUB_CLIENT_ID": self.client_id,
|
||||
@ -61,22 +57,10 @@ class TestProviderOAuth2Github(SeleniumTestCase):
|
||||
),
|
||||
"GF_LOG_LEVEL": "debug",
|
||||
},
|
||||
)
|
||||
while True:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
LOGGER.info("Container failed healthcheck")
|
||||
sleep(1)
|
||||
|
||||
def tearDown(self):
|
||||
self.container.kill()
|
||||
super().tearDown()
|
||||
}
|
||||
|
||||
def test_authorization_consent_implied(self):
|
||||
"""test OAuth Provider flow (default authorization flow with implied consent)"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authorization_flow = Flow.objects.get(
|
||||
slug="default-provider-authorization-implicit-consent"
|
||||
@ -129,7 +113,6 @@ class TestProviderOAuth2Github(SeleniumTestCase):
|
||||
|
||||
def test_authorization_consent_explicit(self):
|
||||
"""test OAuth Provider flow (default authorization flow with explicit consent)"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authorization_flow = Flow.objects.get(
|
||||
slug="default-provider-authorization-explicit-consent"
|
||||
@ -167,8 +150,13 @@ class TestProviderOAuth2Github(SeleniumTestCase):
|
||||
By.XPATH, "/html/body/div[2]/div/main/div/form/div[2]/ul/li[1]"
|
||||
).text,
|
||||
)
|
||||
sleep(1)
|
||||
self.driver.find_element(By.CSS_SELECTOR, "[type=submit]").click()
|
||||
self.driver.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
(
|
||||
"form[action='/flows/b/default-provider-authorization-explicit-consent/'] "
|
||||
"[type=submit]"
|
||||
),
|
||||
).click()
|
||||
|
||||
self.wait_for_url("http://localhost:3000/?orgId=1")
|
||||
self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click()
|
||||
@ -197,7 +185,6 @@ class TestProviderOAuth2Github(SeleniumTestCase):
|
||||
|
||||
def test_denied(self):
|
||||
"""test OAuth Provider flow (default authorization flow, denied)"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authorization_flow = Flow.objects.get(
|
||||
slug="default-provider-authorization-explicit-consent"
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
"""test OAuth2 OpenID Provider flow"""
|
||||
from sys import platform
|
||||
from time import sleep
|
||||
from typing import Any, Dict, Optional
|
||||
from unittest.case import skipUnless
|
||||
|
||||
from docker import DockerClient, from_env
|
||||
from docker.models.containers import Container
|
||||
from docker.types import Healthcheck
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
@ -34,29 +35,27 @@ from passbook.providers.oauth2.models import (
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||
class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
"""test OAuth with OpenID Provider flow"""
|
||||
|
||||
def setUp(self):
|
||||
self.client_id = generate_client_id()
|
||||
self.client_secret = generate_client_secret()
|
||||
self.container = self.setup_client()
|
||||
super().setUp()
|
||||
|
||||
def setup_client(self) -> Container:
|
||||
"""Setup client grafana container which we test OIDC against"""
|
||||
client: DockerClient = from_env()
|
||||
container = client.containers.run(
|
||||
image="grafana/grafana:7.1.0",
|
||||
detach=True,
|
||||
network_mode="host",
|
||||
auto_remove=True,
|
||||
healthcheck=Healthcheck(
|
||||
def get_container_specs(self) -> Optional[Dict[str, Any]]:
|
||||
return {
|
||||
"image": "grafana/grafana:7.1.0",
|
||||
"detach": True,
|
||||
"network_mode": "host",
|
||||
"auto_remove": True,
|
||||
"healthcheck": Healthcheck(
|
||||
test=["CMD", "wget", "--spider", "http://localhost:3000"],
|
||||
interval=5 * 100 * 1000000,
|
||||
start_period=1 * 100 * 1000000,
|
||||
),
|
||||
environment={
|
||||
"environment": {
|
||||
"GF_AUTH_GENERIC_OAUTH_ENABLED": "true",
|
||||
"GF_AUTH_GENERIC_OAUTH_CLIENT_ID": self.client_id,
|
||||
"GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET": self.client_secret,
|
||||
@ -72,18 +71,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
),
|
||||
"GF_LOG_LEVEL": "debug",
|
||||
},
|
||||
)
|
||||
while True:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
LOGGER.info("Container failed healthcheck")
|
||||
sleep(1)
|
||||
|
||||
def tearDown(self):
|
||||
self.container.kill()
|
||||
super().tearDown()
|
||||
}
|
||||
|
||||
def test_redirect_uri_error(self):
|
||||
"""test OpenID Provider flow (invalid redirect URI, check error message)"""
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""test SAML Provider flow"""
|
||||
from sys import platform
|
||||
from time import sleep
|
||||
from unittest.case import skipUnless
|
||||
|
||||
from docker import DockerClient, from_env
|
||||
from docker.models.containers import Container
|
||||
@ -23,6 +25,7 @@ from passbook.providers.saml.models import (
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||
class TestProviderSAML(SeleniumTestCase):
|
||||
"""test SAML Provider flow"""
|
||||
|
||||
@ -60,10 +63,6 @@ class TestProviderSAML(SeleniumTestCase):
|
||||
LOGGER.info("Container failed healthcheck")
|
||||
sleep(1)
|
||||
|
||||
def tearDown(self):
|
||||
self.container.kill()
|
||||
super().tearDown()
|
||||
|
||||
def test_sp_initiated_implicit(self):
|
||||
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
|
||||
# Bootstrap all needed objects
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
"""test OAuth Source"""
|
||||
from os.path import abspath
|
||||
from sys import platform
|
||||
from time import sleep
|
||||
from typing import Any, Dict, Optional
|
||||
from unittest.case import skipUnless
|
||||
|
||||
from docker import DockerClient, from_env
|
||||
from django.test import override_settings
|
||||
from docker.models.containers import Container
|
||||
from docker.types import Healthcheck
|
||||
from selenium.webdriver.common.by import By
|
||||
@ -21,6 +24,7 @@ CONFIG_PATH = "/tmp/dex.yml"
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||
class TestSourceOAuth(SeleniumTestCase):
|
||||
"""test OAuth Source flow"""
|
||||
|
||||
@ -28,7 +32,7 @@ class TestSourceOAuth(SeleniumTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client_secret = generate_client_secret()
|
||||
self.container = self.setup_client()
|
||||
self.prepare_dex_config()
|
||||
super().setUp()
|
||||
|
||||
def prepare_dex_config(self):
|
||||
@ -66,34 +70,23 @@ class TestSourceOAuth(SeleniumTestCase):
|
||||
with open(CONFIG_PATH, "w+") as _file:
|
||||
safe_dump(config, _file)
|
||||
|
||||
def setup_client(self) -> Container:
|
||||
"""Setup test Dex container"""
|
||||
self.prepare_dex_config()
|
||||
client: DockerClient = from_env()
|
||||
container = client.containers.run(
|
||||
image="quay.io/dexidp/dex:v2.24.0",
|
||||
detach=True,
|
||||
network_mode="host",
|
||||
auto_remove=True,
|
||||
command="serve /config.yml",
|
||||
healthcheck=Healthcheck(
|
||||
def get_container_specs(self) -> Optional[Dict[str, Any]]:
|
||||
return {
|
||||
"image": "quay.io/dexidp/dex:v2.24.0",
|
||||
"detach": True,
|
||||
"network_mode": "host",
|
||||
"auto_remove": True,
|
||||
"command": "serve /config.yml",
|
||||
"healthcheck": Healthcheck(
|
||||
test=["CMD", "wget", "--spider", "http://localhost:5556/dex/healthz"],
|
||||
interval=5 * 100 * 1000000,
|
||||
start_period=1 * 100 * 1000000,
|
||||
),
|
||||
volumes={abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro"}},
|
||||
)
|
||||
while True:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
LOGGER.info("Container failed healthcheck")
|
||||
sleep(1)
|
||||
"volumes": {abspath(CONFIG_PATH): {"bind": "/config.yml", "mode": "ro"}},
|
||||
}
|
||||
|
||||
def create_objects(self):
|
||||
"""Create required objects"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||
@ -111,10 +104,6 @@ class TestSourceOAuth(SeleniumTestCase):
|
||||
consumer_secret=self.client_secret,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.container.kill()
|
||||
super().tearDown()
|
||||
|
||||
def test_oauth_enroll(self):
|
||||
"""test OAuth Source With With OIDC"""
|
||||
self.create_objects()
|
||||
@ -141,6 +130,7 @@ class TestSourceOAuth(SeleniumTestCase):
|
||||
)
|
||||
self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
|
||||
|
||||
self.wait.until(ec.presence_of_element_located((By.NAME, "username")))
|
||||
# At this point we've been redirected back
|
||||
# and we're asked for the username
|
||||
self.driver.find_element(By.NAME, "username").click()
|
||||
@ -167,6 +157,42 @@ class TestSourceOAuth(SeleniumTestCase):
|
||||
"admin@example.com",
|
||||
)
|
||||
|
||||
@override_settings(SESSION_COOKIE_SAMESITE="strict")
|
||||
def test_oauth_samesite_strict(self):
|
||||
"""test OAuth Source With SameSite set to strict
|
||||
(=will fail because session is not carried over)"""
|
||||
self.create_objects()
|
||||
self.driver.get(self.live_server_url)
|
||||
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
)
|
||||
)
|
||||
self.driver.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the login field
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "login")))
|
||||
self.driver.find_element(By.ID, "login").send_keys("admin@example.com")
|
||||
self.driver.find_element(By.ID, "password").send_keys("password")
|
||||
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)
|
||||
|
||||
# Wait until we're logged in
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located((By.CSS_SELECTOR, "button[type=submit]"))
|
||||
)
|
||||
self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
|
||||
|
||||
self.wait.until(
|
||||
ec.presence_of_element_located((By.CSS_SELECTOR, ".pf-c-alert__title"))
|
||||
)
|
||||
self.assertEqual(
|
||||
self.driver.find_element(By.CSS_SELECTOR, ".pf-c-alert__title").text,
|
||||
"Authentication Failed.",
|
||||
)
|
||||
|
||||
def test_oauth_enroll_auth(self):
|
||||
"""test OAuth Source With With OIDC (enroll and authenticate again)"""
|
||||
self.test_oauth_enroll()
|
||||
@ -178,10 +204,11 @@ class TestSourceOAuth(SeleniumTestCase):
|
||||
(By.CLASS_NAME, "pf-c-login__main-footer-links-item-link")
|
||||
)
|
||||
)
|
||||
sleep(1)
|
||||
self.driver.find_element(
|
||||
By.CLASS_NAME, "pf-c-login__main-footer-links-item-link"
|
||||
).click()
|
||||
|
||||
sleep(1)
|
||||
# Now we should be at the IDP, wait for the login field
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "login")))
|
||||
self.driver.find_element(By.ID, "login").send_keys("admin@example.com")
|
||||
@ -1,8 +1,9 @@
|
||||
"""test SAML Source"""
|
||||
from sys import platform
|
||||
from time import sleep
|
||||
from typing import Any, Dict, Optional
|
||||
from unittest.case import skipUnless
|
||||
|
||||
from docker import DockerClient, from_env
|
||||
from docker.models.containers import Container
|
||||
from docker.types import Healthcheck
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
@ -68,48 +69,31 @@ Sm75WXsflOxuTn08LbgGc4s=
|
||||
-----END PRIVATE KEY-----"""
|
||||
|
||||
|
||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||
class TestSourceSAML(SeleniumTestCase):
|
||||
"""test SAML Source flow"""
|
||||
|
||||
def setUp(self):
|
||||
self.container = self.setup_client()
|
||||
super().setUp()
|
||||
|
||||
def setup_client(self) -> Container:
|
||||
"""Setup test IdP container"""
|
||||
client: DockerClient = from_env()
|
||||
container = client.containers.run(
|
||||
image="kristophjunge/test-saml-idp:1.15",
|
||||
detach=True,
|
||||
network_mode="host",
|
||||
auto_remove=True,
|
||||
healthcheck=Healthcheck(
|
||||
def get_container_specs(self) -> Optional[Dict[str, Any]]:
|
||||
return {
|
||||
"image": "kristophjunge/test-saml-idp:1.15",
|
||||
"detach": True,
|
||||
"network_mode": "host",
|
||||
"auto_remove": True,
|
||||
"healthcheck": Healthcheck(
|
||||
test=["CMD", "curl", "http://localhost:8080"],
|
||||
interval=5 * 100 * 1000000,
|
||||
start_period=1 * 100 * 1000000,
|
||||
),
|
||||
environment={
|
||||
"environment": {
|
||||
"SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id",
|
||||
"SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": (
|
||||
f"{self.live_server_url}/source/saml/saml-idp-test/acs/"
|
||||
),
|
||||
},
|
||||
)
|
||||
while True:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
LOGGER.info("Container failed healthcheck")
|
||||
sleep(1)
|
||||
|
||||
def tearDown(self):
|
||||
self.container.kill()
|
||||
super().tearDown()
|
||||
}
|
||||
|
||||
def test_idp_redirect(self):
|
||||
"""test SAML Source With redirect binding"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||
@ -161,7 +145,6 @@ class TestSourceSAML(SeleniumTestCase):
|
||||
|
||||
def test_idp_post(self):
|
||||
"""test SAML Source With post binding"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||
@ -215,7 +198,6 @@ class TestSourceSAML(SeleniumTestCase):
|
||||
|
||||
def test_idp_post_auto(self):
|
||||
"""test SAML Source With post binding (auto redirect)"""
|
||||
sleep(1)
|
||||
# Bootstrap all needed objects
|
||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||
|
||||
31
e2e/utils.py
31
e2e/utils.py
@ -4,13 +4,16 @@ from glob import glob
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from inspect import getmembers, isfunction
|
||||
from os import environ, makedirs
|
||||
from time import time
|
||||
from time import sleep, time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
||||
from django.db import connection, transaction
|
||||
from django.db.utils import IntegrityError
|
||||
from django.shortcuts import reverse
|
||||
from docker import DockerClient, from_env
|
||||
from docker.models.containers import Container
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
@ -30,15 +33,35 @@ def USER() -> User: # noqa
|
||||
class SeleniumTestCase(StaticLiveServerTestCase):
|
||||
"""StaticLiveServerTestCase which automatically creates a Webdriver instance"""
|
||||
|
||||
container: Optional[Container] = None
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
makedirs("selenium_screenshots/", exist_ok=True)
|
||||
self.driver = self._get_driver()
|
||||
self.driver.maximize_window()
|
||||
self.driver.implicitly_wait(30)
|
||||
self.wait = WebDriverWait(self.driver, 50)
|
||||
self.driver.implicitly_wait(10)
|
||||
self.wait = WebDriverWait(self.driver, 30)
|
||||
self.apply_default_data()
|
||||
self.logger = get_logger()
|
||||
if specs := self.get_container_specs():
|
||||
self.container = self._start_container(specs)
|
||||
|
||||
def _start_container(self, specs: Dict[str, Any]) -> Container:
|
||||
client: DockerClient = from_env()
|
||||
container = client.containers.run(**specs)
|
||||
while True:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
self.logger.info("Container failed healthcheck")
|
||||
sleep(1)
|
||||
|
||||
def get_container_specs(self) -> Optional[Dict[str, Any]]:
|
||||
"""Optionally get container specs which will launched on setup, wait for the container to
|
||||
be healthy, and deleted again on tearDown"""
|
||||
return None
|
||||
|
||||
def _get_driver(self) -> WebDriver:
|
||||
return webdriver.Remote(
|
||||
@ -57,6 +80,8 @@ class SeleniumTestCase(StaticLiveServerTestCase):
|
||||
self.logger.warning(
|
||||
line["message"], source=line["source"], level=line["level"]
|
||||
)
|
||||
if self.container:
|
||||
self.container.kill()
|
||||
self.driver.quit()
|
||||
super().tearDown()
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
apiVersion: v2
|
||||
appVersion: "0.10.0-rc4"
|
||||
appVersion: "0.10.0-stable"
|
||||
description: A Helm chart for passbook.
|
||||
name: passbook
|
||||
version: "0.10.0-rc4"
|
||||
icon: https://github.com/BeryJu/passbook/blob/master/passbook/static/static/passbook/logo.svg
|
||||
version: "0.10.0-stable"
|
||||
icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 9.3.2
|
||||
|
||||
@ -9,7 +9,7 @@ metadata:
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
k8s.passbook.beryju.org/component: web
|
||||
spec:
|
||||
replicas: {{ serverReplicas }}
|
||||
replicas: {{ .Values.serverReplicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "passbook.name" . }}
|
||||
|
||||
@ -9,7 +9,7 @@ metadata:
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
k8s.passbook.beryju.org/component: worker
|
||||
spec:
|
||||
replicas: {{ workerReplicas }}
|
||||
replicas: {{ .Values.workerReplicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "passbook.name" . }}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
image:
|
||||
name: beryju/passbook
|
||||
name_static: beryju/passbook-static
|
||||
tag: 0.10.0-rc4
|
||||
tag: 0.10.0-stable
|
||||
|
||||
nameOverride: ""
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
"""Gunicorn config"""
|
||||
from multiprocessing import cpu_count
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
bind = "0.0.0.0:8000"
|
||||
workers = 2
|
||||
threads = 4
|
||||
|
||||
user = "passbook"
|
||||
group = "passbook"
|
||||
@ -40,3 +41,11 @@ logconfig_dict = {
|
||||
"gunicorn": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
||||
},
|
||||
}
|
||||
|
||||
# if we're running in kubernetes, use fixed workers because we can scale with more pods
|
||||
# otherwise (assume docker-compose), use as much as we can
|
||||
if Path("/var/run/secrets/kubernetes.io").exists():
|
||||
workers = 2
|
||||
else:
|
||||
worker = cpu_count()
|
||||
threads = 4
|
||||
|
||||
@ -30,7 +30,10 @@ nav:
|
||||
- OAuth2: providers/oauth2.md
|
||||
- SAML: providers/saml.md
|
||||
- Proxy: providers/proxy.md
|
||||
- Outposts: outposts/outposts.md
|
||||
- Outposts:
|
||||
- Overview: outposts/outposts.md
|
||||
- Deploy on docker-compose: outposts/deploy-docker-compose.md
|
||||
- Deploy on Kubernetes: outposts/deploy-kubernetes.md
|
||||
- Expressions:
|
||||
- Overview: expressions/index.md
|
||||
- Reference:
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
"""passbook"""
|
||||
__version__ = "0.10.0-rc4"
|
||||
__version__ = "0.10.0-stable"
|
||||
|
||||
@ -69,6 +69,7 @@
|
||||
<td>
|
||||
<a class="pf-c-button pf-m-secondary" href="{% url 'passbook_admin:outpost-update' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Edit' %}</a>
|
||||
<a class="pf-c-button pf-m-danger" href="{% url 'passbook_admin:outpost-delete' pk=outpost.pk %}?back={{ request.get_full_path }}">{% trans 'Delete' %}</a>
|
||||
<a href="https://passbook.beryju.org/outposts/outposts/#deploy">{% trans 'Deploy' %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ from django.contrib.auth.mixins import (
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import DetailView, FormView, ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import FormView
|
||||
from django.views.generic.detail import DetailView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
@ -6,7 +6,7 @@ from django.contrib.auth.mixins import (
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from passbook.admin.views.utils import (
|
||||
|
||||
@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from passbook.admin.views.utils import (
|
||||
|
||||
@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from passbook.admin.views.utils import (
|
||||
|
||||
@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
from passbook.admin.views.utils import (
|
||||
|
||||
@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ from django.contrib.auth.mixins import (
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ from django.contrib.auth.mixins import (
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView, UpdateView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""passbook Token administration"""
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView
|
||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
from guardian.mixins import (
|
||||
PermissionListMixin,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"""api v2 urls"""
|
||||
from django.conf.urls import url
|
||||
from django.urls import path
|
||||
from django.urls import path, re_path
|
||||
from drf_yasg import openapi
|
||||
from drf_yasg.views import get_schema_view
|
||||
from rest_framework import routers
|
||||
@ -119,7 +118,7 @@ SchemaView = get_schema_view(
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
re_path(
|
||||
r"^swagger(?P<format>\.json|\.yaml)$",
|
||||
SchemaView.without_ui(cache_timeout=0),
|
||||
name="schema-json",
|
||||
|
||||
@ -20,8 +20,12 @@
|
||||
</button>
|
||||
</div>
|
||||
<a class="pf-c-page__header-brand-link">
|
||||
<img class="pf-c-brand" src="{% static 'passbook/logo.png' %}" alt="" />
|
||||
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" alt="passbook" />
|
||||
<div class="pf-c-brand pb-brand">
|
||||
<img src="{{ config.passbook.branding.logo }}" alt="passbook icon">
|
||||
{% if config.passbook.branding.title_show %}
|
||||
<small><small>{{ config.passbook.branding.title }}</small></small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="pf-c-page__header-nav">
|
||||
|
||||
@ -6,15 +6,17 @@
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preload" href="{% static 'passbook/fonts/DINEngschriftStd.woff2' %}" as="font" type="font/woff2">
|
||||
<link rel="preload" href="{% static 'passbook/fonts/DINEngschriftStd.woff' %}" as="font" type="font/woff">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>{% block title %}{% trans title|default:"passbook" %}{% endblock %}</title>
|
||||
<title>{% block title %}{% trans title|default:config.passbook.branding.title %}{% endblock %}</title>
|
||||
<link rel="icon" type="image/png" href="{% static 'passbook/logo.png' %}">
|
||||
<link rel="shortcut icon" type="image/png" href="{% static 'passbook/logo.png' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'node_modules/@patternfly/patternfly/patternfly-addons.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'passbook/pf.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'passbook/passbook.css' %}">
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
@ -35,6 +37,6 @@
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
<script src="{% static 'passbook/pf.js' %}"></script>
|
||||
<script src="{% static 'passbook/passbook.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -22,8 +22,12 @@
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<header class="pf-c-login__header">
|
||||
<img class="pf-c-brand" src="{% static 'passbook/logo.svg' %}" style="height: 60px;" alt="passbook icon" />
|
||||
<img class="pf-c-brand" src="{% static 'passbook/brand.svg' %}" style="height: 60px;" alt="passbook branding" />
|
||||
<div class="pf-c-brand pb-brand">
|
||||
<img src="{{ config.passbook.branding.logo }}" alt="passbook icon" />
|
||||
{% if config.passbook.branding.title_show %}
|
||||
<p>{{ config.passbook.branding.title }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
{% block main_container %}
|
||||
<main class="pf-c-login__main">
|
||||
@ -47,6 +51,13 @@
|
||||
<a href="{{ link.href }}">{{ link.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if config.passbook.branding.title != "passbook" %}
|
||||
<li>
|
||||
<a href="https://github.com/beryju/passbook">
|
||||
{% trans 'Powered by passbook' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""passbook core utils view"""
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
|
||||
|
||||
@ -52,7 +52,7 @@ def create_default_source_enrollment_flow(
|
||||
|
||||
# PromptStage to ask user for their username
|
||||
prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create(
|
||||
name="default-source-enrollment-username-prompt",
|
||||
name="Welcome to passbook! Please select a username.",
|
||||
)
|
||||
prompt, _ = Prompt.objects.using(db_alias).update_or_create(
|
||||
field_key="username",
|
||||
|
||||
@ -115,11 +115,12 @@ const updateFormAction = (form) => {
|
||||
for (let index = 0; index < form.elements.length; index++) {
|
||||
const element = form.elements[index];
|
||||
if (element.value === form.action) {
|
||||
console.log("Found Form action URL in form elements, not changing form action.");
|
||||
console.log("pb-flow: Found Form action URL in form elements, not changing form action.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
form.action = flowBodyUrl;
|
||||
console.log(`pb-flow: updated form.action ${flowBodyUrl}`);
|
||||
return true;
|
||||
};
|
||||
const checkAutosubmit = (form) => {
|
||||
@ -129,11 +130,11 @@ const checkAutosubmit = (form) => {
|
||||
};
|
||||
const setFormSubmitHandlers = () => {
|
||||
document.querySelectorAll("#flow-body form").forEach(form => {
|
||||
console.log(`Checking for autosubmit attribute ${form}`);
|
||||
console.log(`pb-flow: Checking for autosubmit attribute ${form}`);
|
||||
checkAutosubmit(form);
|
||||
console.log(`Setting action for form ${form}`);
|
||||
console.log(`pb-flow: Setting action for form ${form}`);
|
||||
updateFormAction(form);
|
||||
console.log(`Adding handler for form ${form}`);
|
||||
console.log(`pb-flow: Adding handler for form ${form}`);
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
let formData = new FormData(form);
|
||||
@ -145,6 +146,7 @@ const setFormSubmitHandlers = () => {
|
||||
updateCard(data);
|
||||
});
|
||||
});
|
||||
form.classList.add("pb-flow-wrapped");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||
from passbook.flows.markers import ReevaluateMarker, StageMarker
|
||||
@ -247,7 +247,7 @@ class TestFlowExecutor(TestCase):
|
||||
response = self.client.post(exec_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
@ -293,7 +293,7 @@ class TestFlowExecutor(TestCase):
|
||||
# First request, run the planner
|
||||
response = self.client.get(exec_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("dummy1", force_text(response.content))
|
||||
self.assertIn("dummy1", force_str(response.content))
|
||||
|
||||
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
|
||||
|
||||
@ -316,13 +316,13 @@ class TestFlowExecutor(TestCase):
|
||||
# but it won't save it, hence we cant' check the plan
|
||||
response = self.client.get(exec_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("dummy4", force_text(response.content))
|
||||
self.assertIn("dummy4", force_str(response.content))
|
||||
|
||||
# fourth request, this confirms the last stage (dummy4)
|
||||
# We do this request without the patch, so the policy results in false
|
||||
response = self.client.post(exec_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
@ -21,6 +21,10 @@ error_reporting:
|
||||
send_pii: false
|
||||
|
||||
passbook:
|
||||
branding:
|
||||
title: passbook
|
||||
title_show: true
|
||||
logo: /static/passbook/logo.svg
|
||||
# Optionally add links to the footer on the login page
|
||||
footer_links:
|
||||
- name: Documentation
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
"""Generic models"""
|
||||
import re
|
||||
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import models
|
||||
from django.utils.regex_helper import _lazy_re_compile
|
||||
from model_utils.managers import InheritanceManager
|
||||
from rest_framework.serializers import BaseSerializer
|
||||
|
||||
@ -48,3 +52,21 @@ class InheritanceForeignKey(models.ForeignKey):
|
||||
"""Custom ForeignKey that uses InheritanceForwardManyToOneDescriptor"""
|
||||
|
||||
forward_related_accessor_class = InheritanceForwardManyToOneDescriptor
|
||||
|
||||
|
||||
class DomainlessURLValidator(URLValidator):
|
||||
"""Subclass of URLValidator which doesn't check the domain
|
||||
(to allow hostnames without domain)"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.host_re = "(" + self.hostname_re + self.domain_re + "|localhost)"
|
||||
self.regex = _lazy_re_compile(
|
||||
r"^(?:[a-z0-9.+-]*)://" # scheme is validated separately
|
||||
r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?" # user:pass authentication
|
||||
r"(?:" + self.ipv4_re + "|" + self.ipv6_re + "|" + self.host_re + ")"
|
||||
r"(?::\d{2,5})?" # port
|
||||
r"(?:[/?#][^\s]*)?" # resource path
|
||||
r"\Z",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""Kubernetes deployment controller"""
|
||||
from base64 import b64encode
|
||||
from io import StringIO
|
||||
|
||||
from kubernetes.client import (
|
||||
@ -24,6 +25,11 @@ from passbook import __version__
|
||||
from passbook.outposts.controllers.base import BaseController
|
||||
|
||||
|
||||
def b64encode_str(input_string: str) -> str:
|
||||
"""base64 encode string"""
|
||||
return b64encode(input_string.encode()).decode()
|
||||
|
||||
|
||||
class KubernetesController(BaseController):
|
||||
"""Manage deployment of outpost in kubernetes"""
|
||||
|
||||
@ -37,9 +43,9 @@ class KubernetesController(BaseController):
|
||||
with StringIO() as _str:
|
||||
dump_all(
|
||||
[
|
||||
self.get_deployment_secret(),
|
||||
self.get_deployment(),
|
||||
self.get_service(),
|
||||
self.get_deployment_secret().to_dict(),
|
||||
self.get_deployment().to_dict(),
|
||||
self.get_service().to_dict(),
|
||||
],
|
||||
stream=_str,
|
||||
default_flow_style=False,
|
||||
@ -63,15 +69,18 @@ class KubernetesController(BaseController):
|
||||
def get_deployment_secret(self) -> V1Secret:
|
||||
"""Get secret with token and passbook host"""
|
||||
return V1Secret(
|
||||
api_version="v1",
|
||||
kind="secret",
|
||||
type="Opaque",
|
||||
metadata=self.get_object_meta(
|
||||
name=f"passbook-outpost-{self.outpost.name}-api"
|
||||
),
|
||||
data={
|
||||
"passbook_host": self.outpost.config.passbook_host,
|
||||
"passbook_host_insecure": str(
|
||||
self.outpost.config.passbook_host_insecure
|
||||
"passbook_host": b64encode_str(self.outpost.config.passbook_host),
|
||||
"passbook_host_insecure": b64encode_str(
|
||||
str(self.outpost.config.passbook_host_insecure)
|
||||
),
|
||||
"token": self.outpost.token.token_uuid.hex,
|
||||
"token": b64encode_str(self.outpost.token.token_uuid.hex),
|
||||
},
|
||||
)
|
||||
|
||||
@ -82,6 +91,8 @@ class KubernetesController(BaseController):
|
||||
for port_name, port in self.deployment_ports.items():
|
||||
ports.append(V1ServicePort(name=port_name, port=port))
|
||||
return V1Service(
|
||||
api_version="v1",
|
||||
kind="service",
|
||||
metadata=meta,
|
||||
spec=V1ServiceSpec(ports=ports, selector=meta.labels, type="ClusterIP"),
|
||||
)
|
||||
@ -94,6 +105,8 @@ class KubernetesController(BaseController):
|
||||
container_ports.append(V1ContainerPort(container_port=port, name=port_name))
|
||||
meta = self.get_object_meta(name=f"passbook-outpost-{self.outpost.name}")
|
||||
return V1Deployment(
|
||||
api_version="apps/v1",
|
||||
kind="deployment",
|
||||
metadata=meta,
|
||||
spec=V1DeploymentSpec(
|
||||
replicas=1,
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"""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
|
||||
|
||||
@ -9,6 +8,7 @@ from dacite import from_dict
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.db.models.base import Model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from guardian.shortcuts import assign_perm
|
||||
|
||||
@ -30,13 +30,17 @@ class OutpostConfig:
|
||||
)
|
||||
|
||||
|
||||
class OutpostModel:
|
||||
class OutpostModel(Model):
|
||||
"""Base model for providers that need more objects than just themselves"""
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model]:
|
||||
"""Return a list of all required objects"""
|
||||
return [self]
|
||||
|
||||
class Meta:
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
class OutpostType(models.TextChoices):
|
||||
"""Outpost types, currently only the reverse proxy is available"""
|
||||
@ -79,12 +83,12 @@ class Outpost(models.Model):
|
||||
@property
|
||||
def config(self) -> OutpostConfig:
|
||||
"""Load config as OutpostConfig object"""
|
||||
return from_dict(OutpostConfig, loads(self._config))
|
||||
return from_dict(OutpostConfig, self._config)
|
||||
|
||||
@config.setter
|
||||
def config(self, value):
|
||||
"""Dump config into json"""
|
||||
self._config = dumps(asdict(value))
|
||||
self._config = asdict(value)
|
||||
|
||||
@property
|
||||
def health_cache_key(self) -> str:
|
||||
|
||||
@ -1,31 +1,31 @@
|
||||
"""passbook outpost signals"""
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.utils.reflection import class_to_path
|
||||
from passbook.outposts.models import Outpost, OutpostModel
|
||||
from passbook.outposts.tasks import outpost_send_update
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Outpost)
|
||||
# pylint: disable=unused-argument
|
||||
def ensure_user_and_token(sender, instance, **_):
|
||||
def ensure_user_and_token(sender, instance: Model, **_):
|
||||
"""Ensure that token is created/updated on save"""
|
||||
_ = instance.token
|
||||
|
||||
|
||||
@receiver(post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def post_save_update(sender, instance, **_):
|
||||
def post_save_update(sender, instance: Model, **_):
|
||||
"""If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved,
|
||||
we send a message down the relevant OutpostModels WS connection to trigger an update"""
|
||||
if isinstance(instance, OutpostModel):
|
||||
LOGGER.debug("triggering outpost update from outpostmodel", instance=instance)
|
||||
_send_update(instance)
|
||||
outpost_send_update.delay(class_to_path(instance.__class__), instance.pk)
|
||||
return
|
||||
|
||||
for field in instance._meta.get_fields():
|
||||
@ -46,13 +46,4 @@ def post_save_update(sender, instance, **_):
|
||||
# Because the Outpost Model has an M2M to Provider,
|
||||
# we have to iterate over the entire QS
|
||||
for reverse in getattr(instance, field_name).all():
|
||||
_send_update(reverse)
|
||||
|
||||
|
||||
def _send_update(outpost_model: Model):
|
||||
"""Send update trigger for each channel of an outpost model"""
|
||||
for outpost in outpost_model.outpost_set.all():
|
||||
channel_layer = get_channel_layer()
|
||||
for channel in outpost.channels:
|
||||
LOGGER.debug("sending update", channel=channel)
|
||||
async_to_sync(channel_layer.send)(channel, {"type": "event.update"})
|
||||
outpost_send_update(class_to_path(reverse.__class__), reverse.pk)
|
||||
|
||||
@ -1,8 +1,22 @@
|
||||
"""outpost tasks"""
|
||||
from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType
|
||||
from typing import Any
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.utils.reflection import path_to_class
|
||||
from passbook.outposts.models import (
|
||||
Outpost,
|
||||
OutpostDeploymentType,
|
||||
OutpostModel,
|
||||
OutpostType,
|
||||
)
|
||||
from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController
|
||||
from passbook.root.celery import CELERY_APP
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True)
|
||||
# pylint: disable=unused-argument
|
||||
@ -20,3 +34,16 @@ def outpost_k8s_controller_single(self, outpost: str, outpost_type: str):
|
||||
"""Launch Kubernetes manager and reconcile deployment/service/etc"""
|
||||
if outpost_type == OutpostType.PROXY:
|
||||
ProxyKubernetesController(outpost).run()
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def outpost_send_update(model_class: str, model_pk: Any):
|
||||
"""Send outpost update to all registered outposts, irregardless to which passbook
|
||||
instance they are connected"""
|
||||
model = path_to_class(model_class)
|
||||
outpost_model: OutpostModel = model.objects.get(model_pk)
|
||||
for outpost in outpost_model.outpost_set.all():
|
||||
channel_layer = get_channel_layer()
|
||||
for channel in outpost.channels:
|
||||
LOGGER.debug("sending update", channel=channel)
|
||||
async_to_sync(channel_layer.send)(channel, {"type": "event.update"})
|
||||
|
||||
@ -14,7 +14,7 @@ from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from django.utils import dateformat, timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key
|
||||
from jwkest.jws import JWS
|
||||
|
||||
|
||||
@ -82,7 +82,7 @@ def extract_client_auth(request: HttpRequest) -> Tuple[str, str]:
|
||||
b64_user_pass = auth_header.split()[1]
|
||||
try:
|
||||
user_pass = b64decode(b64_user_pass).decode("utf-8").split(":")
|
||||
client_id, client_secret = tuple(user_pass)
|
||||
client_id, client_secret = user_pass
|
||||
except (ValueError, Error):
|
||||
client_id = client_secret = ""
|
||||
else:
|
||||
|
||||
@ -93,9 +93,9 @@ class OAuthAuthorizationParams:
|
||||
if response_type in [ResponseTypes.CODE]:
|
||||
grant_type = GrantTypes.AUTHORIZATION_CODE
|
||||
elif response_type in [
|
||||
ResponseTypes.id_token,
|
||||
ResponseTypes.id_token_token,
|
||||
ResponseTypes.token,
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.TOKEN,
|
||||
]:
|
||||
grant_type = GrantTypes.IMPLICIT
|
||||
elif response_type in [
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-13 19:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import passbook.lib.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_proxy", "0003_proxyprovider_certificate"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="proxyprovider",
|
||||
name="external_host",
|
||||
field=models.TextField(
|
||||
validators=[
|
||||
passbook.lib.models.DomainlessURLValidator(
|
||||
schemes=("http", "https")
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="proxyprovider",
|
||||
name="internal_host",
|
||||
field=models.TextField(
|
||||
validators=[
|
||||
passbook.lib.models.DomainlessURLValidator(
|
||||
schemes=("http", "https")
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -4,12 +4,12 @@ from random import SystemRandom
|
||||
from typing import Iterable, Type
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.crypto.models import CertificateKeyPair
|
||||
from passbook.lib.models import DomainlessURLValidator
|
||||
from passbook.outposts.models import OutpostModel
|
||||
from passbook.providers.oauth2.constants import (
|
||||
SCOPE_OPENID,
|
||||
@ -41,10 +41,10 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
Protocols by using a Reverse-Proxy."""
|
||||
|
||||
internal_host = models.TextField(
|
||||
validators=[URLValidator(schemes=("http", "https"))]
|
||||
validators=[DomainlessURLValidator(schemes=("http", "https"))]
|
||||
)
|
||||
external_host = models.TextField(
|
||||
validators=[URLValidator(schemes=("http", "https"))]
|
||||
validators=[DomainlessURLValidator(schemes=("http", "https"))]
|
||||
)
|
||||
|
||||
cookie_secret = models.TextField(default=get_cookie_secret)
|
||||
|
||||
@ -5,7 +5,7 @@ from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import PropertyMapping, Provider
|
||||
|
||||
@ -1,13 +1,22 @@
|
||||
"""Test AuthN Request generator and parser"""
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.http.request import QueryDict
|
||||
from django.test import RequestFactory, TestCase
|
||||
from guardian.utils import get_anonymous_user
|
||||
|
||||
from passbook.crypto.models import CertificateKeyPair
|
||||
from passbook.flows.models import Flow
|
||||
from passbook.providers.saml.models import SAMLProvider
|
||||
from passbook.providers.saml.processors.assertion import AssertionProcessor
|
||||
from passbook.providers.saml.processors.request_parser import AuthNRequestParser
|
||||
from passbook.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from passbook.sources.saml.exceptions import MismatchedRequestID
|
||||
from passbook.sources.saml.models import SAMLSource
|
||||
from passbook.sources.saml.processors.request import RequestProcessor
|
||||
from passbook.sources.saml.processors.request import (
|
||||
SESSION_REQUEST_ID,
|
||||
RequestProcessor,
|
||||
)
|
||||
from passbook.sources.saml.processors.response import ResponseProcessor
|
||||
|
||||
|
||||
class TestAuthNRequest(TestCase):
|
||||
@ -31,6 +40,11 @@ class TestAuthNRequest(TestCase):
|
||||
def test_signed_valid(self):
|
||||
"""Test generated AuthNRequest with valid signature"""
|
||||
http_request = self.factory.get("/")
|
||||
|
||||
middleware = SessionMiddleware()
|
||||
middleware.process_request(http_request)
|
||||
http_request.session.save()
|
||||
|
||||
# First create an AuthNRequest
|
||||
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
||||
request = request_proc.build_auth_n()
|
||||
@ -44,6 +58,11 @@ class TestAuthNRequest(TestCase):
|
||||
def test_signed_valid_detached(self):
|
||||
"""Test generated AuthNRequest with valid signature (detached)"""
|
||||
http_request = self.factory.get("/")
|
||||
|
||||
middleware = SessionMiddleware()
|
||||
middleware.process_request(http_request)
|
||||
http_request.session.save()
|
||||
|
||||
# First create an AuthNRequest
|
||||
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
||||
params = request_proc.build_auth_n_detached()
|
||||
@ -53,3 +72,37 @@ class TestAuthNRequest(TestCase):
|
||||
)
|
||||
self.assertEqual(parsed_request.id, request_proc.request_id)
|
||||
self.assertEqual(parsed_request.relay_state, "test_state")
|
||||
|
||||
def test_request_id_invalid(self):
|
||||
"""Test generated AuthNRequest with invalid request ID"""
|
||||
http_request = self.factory.get("/")
|
||||
http_request.user = get_anonymous_user()
|
||||
|
||||
middleware = SessionMiddleware()
|
||||
middleware.process_request(http_request)
|
||||
http_request.session.save()
|
||||
|
||||
# First create an AuthNRequest
|
||||
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
||||
request = request_proc.build_auth_n()
|
||||
|
||||
# change the request ID
|
||||
http_request.session[SESSION_REQUEST_ID] = "test"
|
||||
http_request.session.save()
|
||||
|
||||
# To get an assertion we need a parsed request (parsed by provider)
|
||||
parsed_request = AuthNRequestParser(self.provider).parse(
|
||||
deflate_and_base64_encode(request), "test_state"
|
||||
)
|
||||
# Now create a response and convert it to string (provider)
|
||||
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
|
||||
response = response_proc.build_response()
|
||||
|
||||
# Now parse the response (source)
|
||||
http_request.POST = QueryDict(mutable=True)
|
||||
http_request.POST["SAMLResponse"] = deflate_and_base64_encode(response)
|
||||
|
||||
response_parser = ResponseProcessor(self.source)
|
||||
|
||||
with self.assertRaises(MismatchedRequestID):
|
||||
response_parser.parse(http_request)
|
||||
|
||||
@ -32,6 +32,11 @@ Send = typing.Callable[[Message], typing.Awaitable[None]]
|
||||
|
||||
ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]]
|
||||
|
||||
ASGI_IP_HEADERS = (
|
||||
b"x-forwarded-for",
|
||||
b"x-real-ip",
|
||||
)
|
||||
|
||||
LOGGER = get_logger("passbook.asgi")
|
||||
|
||||
|
||||
@ -51,7 +56,6 @@ class ASGILogger:
|
||||
"""ASGI Logger, instantiated for each request"""
|
||||
|
||||
app: ASGIApp
|
||||
send: Send
|
||||
|
||||
scope: Scope
|
||||
headers: Dict[ByteString, Any]
|
||||
@ -64,25 +68,11 @@ class ASGILogger:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
self.send = send
|
||||
self.scope = scope
|
||||
self.content_length = 0
|
||||
self.headers = dict(scope.get("headers", []))
|
||||
|
||||
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()
|
||||
if scope["type"] == "lifespan":
|
||||
# https://code.djangoproject.com/ticket/31508
|
||||
# https://github.com/encode/uvicorn/issues/266
|
||||
return
|
||||
await self.app(scope, receive, self.send_hooked)
|
||||
|
||||
async def send_hooked(self, message: Message) -> None:
|
||||
async def send_hooked(message: Message) -> None:
|
||||
"""Hooked send method, which records status code and content-length, and for the final
|
||||
requests logs it"""
|
||||
headers = dict(message.get("headers", []))
|
||||
@ -96,9 +86,25 @@ class ASGILogger:
|
||||
if message["type"] == "http.response.body" and not message["more_body"]:
|
||||
runtime = int((time() - self.start) * 10 ** 6)
|
||||
self.log(runtime)
|
||||
return await self.send(message)
|
||||
await send(message)
|
||||
|
||||
if self.headers.get(b"host", b"") == b"kubernetes-healthcheck-host":
|
||||
# Don't log kubernetes health/readiness requests
|
||||
await send({"type": "http.response.start", "status": 204, "headers": []})
|
||||
await send({"type": "http.response.body", "body": ""})
|
||||
return
|
||||
|
||||
self.start = time()
|
||||
if scope["type"] == "lifespan":
|
||||
# https://code.djangoproject.com/ticket/31508
|
||||
# https://github.com/encode/uvicorn/issues/266
|
||||
return
|
||||
await self.app(scope, receive, send_hooked)
|
||||
|
||||
def _get_ip(self) -> str:
|
||||
for header in ASGI_IP_HEADERS:
|
||||
if header in self.headers:
|
||||
return self.headers[header].decode()
|
||||
client_ip, _ = self.scope.get("client", ("", 0))
|
||||
return client_ip
|
||||
|
||||
@ -119,6 +125,6 @@ class ASGILogger:
|
||||
)
|
||||
|
||||
|
||||
application = SentryAsgiMiddleware(
|
||||
ASGILogger(guarantee_single_callable(get_default_application()))
|
||||
application = ASGILogger(
|
||||
guarantee_single_callable(SentryAsgiMiddleware(get_default_application()))
|
||||
)
|
||||
|
||||
@ -12,7 +12,6 @@ https://docs.djangoproject.com/en/2.1/ref/settings/
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from json import dumps
|
||||
|
||||
import structlog
|
||||
@ -156,6 +155,7 @@ DJANGO_REDIS_IGNORE_EXCEPTIONS = True
|
||||
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
SESSION_COOKIE_SAMESITE = "lax"
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
@ -372,15 +372,9 @@ LOGGING = {
|
||||
}
|
||||
|
||||
TEST = False
|
||||
TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner"
|
||||
TEST_RUNNER = "passbook.root.test_runner.PytestTestRunner"
|
||||
LOG_LEVEL = CONFIG.y("log_level").upper()
|
||||
|
||||
TEST_OUTPUT_FILE_NAME = "unittest.xml"
|
||||
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "test":
|
||||
LOG_LEVEL = "DEBUG"
|
||||
TEST = True
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
|
||||
_LOGGING_HANDLER_MAP = {
|
||||
"": LOG_LEVEL,
|
||||
@ -431,7 +425,6 @@ for _app in INSTALLED_APPS:
|
||||
pass
|
||||
|
||||
if DEBUG:
|
||||
SESSION_COOKIE_SAMESITE = None
|
||||
INSTALLED_APPS.append("debug_toolbar")
|
||||
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
|
||||
|
||||
|
||||
35
passbook/root/test_runner.py
Normal file
35
passbook/root/test_runner.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""Integrate ./manage.py test with pytest"""
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class PytestTestRunner:
|
||||
"""Runs pytest to discover and run tests."""
|
||||
|
||||
def __init__(self, verbosity=1, failfast=False, keepdb=False, **_):
|
||||
self.verbosity = verbosity
|
||||
self.failfast = failfast
|
||||
self.keepdb = keepdb
|
||||
settings.TEST = True
|
||||
settings.CELERY_TASK_ALWAYS_EAGER = True
|
||||
|
||||
def run_tests(self, test_labels):
|
||||
"""Run pytest and return the exitcode.
|
||||
|
||||
It translates some of Django's test command option to pytest's.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
argv = []
|
||||
if self.verbosity == 0:
|
||||
argv.append("--quiet")
|
||||
if self.verbosity == 2:
|
||||
argv.append("--verbose")
|
||||
if self.verbosity == 3:
|
||||
argv.append("-vv")
|
||||
if self.failfast:
|
||||
argv.append("--exitfirst")
|
||||
if self.keepdb:
|
||||
argv.append("--reuse-db")
|
||||
|
||||
argv.extend(test_labels)
|
||||
return pytest.main(argv)
|
||||
@ -15,7 +15,7 @@ admin.site.login = RedirectView.as_view(
|
||||
pattern_name="passbook_flows:default-authentication"
|
||||
)
|
||||
admin.site.logout = RedirectView.as_view(
|
||||
pattern_name="passbook_flows:default-invalidate"
|
||||
pattern_name="passbook_flows:default-invalidation"
|
||||
)
|
||||
|
||||
handler400 = error.BadRequestView.as_view()
|
||||
|
||||
27
passbook/sources/ldap/migrations/0005_auto_20200913_1947.py
Normal file
27
passbook/sources/ldap/migrations/0005_auto_20200913_1947.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-13 19:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import passbook.lib.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_sources_ldap", "0004_auto_20200524_1146"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ldapsource",
|
||||
name="server_uri",
|
||||
field=models.TextField(
|
||||
validators=[
|
||||
passbook.lib.models.DomainlessURLValidator(
|
||||
schemes=["ldap", "ldaps"]
|
||||
)
|
||||
],
|
||||
verbose_name="Server URI",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,20 +1,20 @@
|
||||
"""passbook LDAP Models"""
|
||||
from typing import Optional, Type
|
||||
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from ldap3 import Connection, Server
|
||||
|
||||
from passbook.core.models import Group, PropertyMapping, Source
|
||||
from passbook.lib.models import DomainlessURLValidator
|
||||
|
||||
|
||||
class LDAPSource(Source):
|
||||
"""Federate LDAP Directory with passbook, or create new accounts in LDAP."""
|
||||
|
||||
server_uri = models.TextField(
|
||||
validators=[URLValidator(schemes=["ldap", "ldaps"])],
|
||||
validators=[DomainlessURLValidator(schemes=["ldap", "ldaps"])],
|
||||
verbose_name=_("Server URI"),
|
||||
)
|
||||
bind_cn = models.TextField(verbose_name=_("Bind CN"))
|
||||
|
||||
@ -5,7 +5,7 @@ from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.utils.crypto import constant_time_compare, get_random_string
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
from requests import Session
|
||||
from requests.exceptions import RequestException
|
||||
from requests_oauthlib import OAuth1
|
||||
@ -111,7 +111,7 @@ class OAuthClient(BaseOAuthClient):
|
||||
|
||||
def get_request_token(self, request, callback):
|
||||
"Fetch the OAuth request token. Only required for OAuth 1.0."
|
||||
callback = force_text(request.build_absolute_uri(callback))
|
||||
callback = force_str(request.build_absolute_uri(callback))
|
||||
try:
|
||||
response = self.session.request(
|
||||
"post",
|
||||
@ -128,7 +128,7 @@ class OAuthClient(BaseOAuthClient):
|
||||
|
||||
def get_redirect_args(self, request, callback):
|
||||
"Get request parameters for redirect url."
|
||||
callback = force_text(request.build_absolute_uri(callback))
|
||||
callback = force_str(request.build_absolute_uri(callback))
|
||||
raw_token = self.get_request_token(request, callback)
|
||||
token, secret = self.parse_raw_token(raw_token)
|
||||
if token is not None and secret is not None:
|
||||
|
||||
@ -6,7 +6,7 @@ from django.contrib import messages
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
from structlog import get_logger
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import TemplateView, View
|
||||
|
||||
from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
|
||||
@ -15,9 +15,10 @@ class SAMLSourceSerializer(ModelSerializer):
|
||||
fields = SOURCE_FORM_FIELDS + [
|
||||
"issuer",
|
||||
"sso_url",
|
||||
"slo_url",
|
||||
"allow_idp_initiated",
|
||||
"name_id_policy",
|
||||
"binding_type",
|
||||
"slo_url",
|
||||
"temporary_user_delete_after",
|
||||
"signing_kp",
|
||||
]
|
||||
|
||||
@ -8,3 +8,7 @@ class MissingSAMLResponse(SentryIgnoredException):
|
||||
|
||||
class UnsupportedNameIDFormat(SentryIgnoredException):
|
||||
"""Exception raised when SAML Response contains NameID Format not supported."""
|
||||
|
||||
|
||||
class MismatchedRequestID(SentryIgnoredException):
|
||||
"""Exception raised when the returned request ID doesn't match the saved ID."""
|
||||
|
||||
@ -30,9 +30,10 @@ class SAMLSourceForm(forms.ModelForm):
|
||||
fields = SOURCE_FORM_FIELDS + [
|
||||
"issuer",
|
||||
"sso_url",
|
||||
"name_id_policy",
|
||||
"binding_type",
|
||||
"slo_url",
|
||||
"binding_type",
|
||||
"name_id_policy",
|
||||
"allow_idp_initiated",
|
||||
"temporary_user_delete_after",
|
||||
"signing_kp",
|
||||
]
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.1.1 on 2020-09-11 22:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_sources_saml", "0005_samlsource_name_id_policy"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlsource",
|
||||
name="allow_idp_initiated",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Allows authentication flows initiated by the IdP. This can be a security risk, as no validation of the request ID is done.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -53,6 +53,21 @@ class SAMLSource(Source):
|
||||
verbose_name=_("SSO URL"),
|
||||
help_text=_("URL that the initial Login request is sent to."),
|
||||
)
|
||||
slo_url = models.URLField(
|
||||
default=None,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("SLO URL"),
|
||||
help_text=_("Optional URL if your IDP supports Single-Logout."),
|
||||
)
|
||||
|
||||
allow_idp_initiated = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Allows authentication flows initiated by the IdP. This can be a security risk, "
|
||||
"as no validation of the request ID is done."
|
||||
),
|
||||
)
|
||||
name_id_policy = models.TextField(
|
||||
choices=SAMLNameIDPolicy.choices,
|
||||
default=SAMLNameIDPolicy.TRANSIENT,
|
||||
@ -66,14 +81,6 @@ class SAMLSource(Source):
|
||||
default=SAMLBindingTypes.Redirect,
|
||||
)
|
||||
|
||||
slo_url = models.URLField(
|
||||
default=None,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("SLO URL"),
|
||||
help_text=_("Optional URL if your IDP supports Single-Logout."),
|
||||
)
|
||||
|
||||
temporary_user_delete_after = models.TextField(
|
||||
default="days=1",
|
||||
verbose_name=_("Delete temporary users after"),
|
||||
|
||||
@ -20,6 +20,8 @@ from passbook.sources.saml.processors.constants import (
|
||||
NS_SAML_PROTOCOL,
|
||||
)
|
||||
|
||||
SESSION_REQUEST_ID = "passbook_source_saml_request_id"
|
||||
|
||||
|
||||
class RequestProcessor:
|
||||
"""SAML AuthnRequest Processor"""
|
||||
@ -37,6 +39,7 @@ class RequestProcessor:
|
||||
self.http_request = request
|
||||
self.relay_state = relay_state
|
||||
self.request_id = get_random_id()
|
||||
self.http_request.session[SESSION_REQUEST_ID] = self.request_id
|
||||
self.issue_instant = get_time_string()
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
from defusedxml import ElementTree
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from signxml import XMLVerifier
|
||||
from structlog import get_logger
|
||||
@ -18,6 +20,7 @@ from passbook.lib.utils.urls import redirect_with_qs
|
||||
from passbook.policies.utils import delete_none_keys
|
||||
from passbook.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
from passbook.sources.saml.exceptions import (
|
||||
MismatchedRequestID,
|
||||
MissingSAMLResponse,
|
||||
UnsupportedNameIDFormat,
|
||||
)
|
||||
@ -29,12 +32,15 @@ from passbook.sources.saml.processors.constants import (
|
||||
SAML_NAME_ID_FORMAT_WINDOWS,
|
||||
SAML_NAME_ID_FORMAT_X509,
|
||||
)
|
||||
from passbook.sources.saml.processors.request import SESSION_REQUEST_ID
|
||||
from passbook.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||
from passbook.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
LOGGER = get_logger()
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element # nosec
|
||||
|
||||
CACHE_SEEN_REQUEST_ID = "passbook_saml_seen_ids_%s"
|
||||
DEFAULT_BACKEND = "django.contrib.auth.backends.ModelBackend"
|
||||
|
||||
|
||||
@ -59,8 +65,9 @@ class ResponseProcessor:
|
||||
# Check if response is compressed, b64 decode it
|
||||
self._root_xml = decode_base64_and_inflate(raw_response)
|
||||
self._root = ElementTree.fromstring(self._root_xml)
|
||||
# Verify signed XML
|
||||
|
||||
self._verify_signed()
|
||||
self._verify_request_id(request)
|
||||
|
||||
def _verify_signed(self):
|
||||
"""Verify SAML Response's Signature"""
|
||||
@ -70,6 +77,26 @@ class ResponseProcessor:
|
||||
)
|
||||
LOGGER.debug("Successfully verified signautre")
|
||||
|
||||
def _verify_request_id(self, request: HttpRequest):
|
||||
if self._source.allow_idp_initiated:
|
||||
# If IdP-initiated SSO flows are enabled, we want to cache the Response ID
|
||||
# somewhat mitigate replay attacks
|
||||
seen_ids = cache.get(CACHE_SEEN_REQUEST_ID % self._source.pk, [])
|
||||
if self._root.attrib["ID"] in seen_ids:
|
||||
raise SuspiciousOperation("Replay attack detected")
|
||||
seen_ids.append(self._root.attrib["ID"])
|
||||
cache.set(CACHE_SEEN_REQUEST_ID % self._source.pk, seen_ids)
|
||||
return
|
||||
if (
|
||||
SESSION_REQUEST_ID not in request.session
|
||||
or "InResponseTo" not in self._root.attrib
|
||||
):
|
||||
raise MismatchedRequestID(
|
||||
"Missing InResponseTo and IdP-initiated Logins are not allowed"
|
||||
)
|
||||
if request.session[SESSION_REQUEST_ID] != self._root.attrib["InResponseTo"]:
|
||||
raise MismatchedRequestID("Mismatched request ID")
|
||||
|
||||
def _handle_name_id_transient(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Handle a NameID with the Format of Transient. This is a bit more complex than other
|
||||
formats, as we need to create a temporary User that is used in the session. This
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
from django.conf import settings
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.markers import StageMarker
|
||||
@ -50,6 +50,6 @@ class TestCaptchaStage(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
@ -3,7 +3,7 @@ from time import sleep
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.core.models import Application, User
|
||||
from passbook.core.tasks import clean_expired_models
|
||||
@ -49,7 +49,7 @@ class TestConsentStage(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
self.assertFalse(UserConsent.objects.filter(user=self.user).exists())
|
||||
@ -80,7 +80,7 @@ class TestConsentStage(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
self.assertTrue(
|
||||
@ -117,7 +117,7 @@ class TestConsentStage(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
self.assertTrue(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""dummy tests"""
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||
@ -44,7 +44,7 @@ class TestDummyStage(TestCase):
|
||||
response = self.client.post(url, {})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role="presentation" class="main">
|
||||
<img src="{% inline_static_binary "passbook/logo.svg" %}" alt="">
|
||||
<img src="{% inline_static_binary config.passbook.branding.logo %}" alt="">
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""passbook core inlining template tags"""
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
|
||||
from django import template
|
||||
@ -9,16 +10,22 @@ register = template.Library()
|
||||
|
||||
@register.simple_tag()
|
||||
def inline_static_ascii(path: str) -> str:
|
||||
"""Inline static asset. Doesn't check file contents, plain text is assumed"""
|
||||
result = finders.find(path)
|
||||
"""Inline static asset. Doesn't check file contents, plain text is assumed.
|
||||
If no file could be found, original path is returned"""
|
||||
result = Path(finders.find(path))
|
||||
if result:
|
||||
with open(result) as _file:
|
||||
return _file.read()
|
||||
return path
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def inline_static_binary(path: str) -> str:
|
||||
"""Inline static asset. Uses file extension for base64 block"""
|
||||
result = finders.find(path)
|
||||
suffix = Path(path).suffix
|
||||
"""Inline static asset. Uses file extension for base64 block. If no file could be found,
|
||||
path is returned."""
|
||||
result = Path(finders.find(path))
|
||||
if result and result.is_file():
|
||||
with open(result) as _file:
|
||||
return f"data:image/{suffix};base64," + _file.read()
|
||||
b64content = b64encode(_file.read().encode())
|
||||
return f"data:image/{result.suffix};base64,{b64content.decode('utf-8')}"
|
||||
return path
|
||||
|
||||
@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
from django.core import mail
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.core.models import Token, User
|
||||
from passbook.flows.markers import StageMarker
|
||||
@ -114,7 +114,7 @@ class TestEmailStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""identification tests"""
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||
@ -56,7 +56,7 @@ class TestIdentificationStage(TestCase):
|
||||
response = self.client.post(url, form_data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
@ -101,7 +101,7 @@ class TestIdentificationStage(TestCase):
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(flow.slug, force_text(response.content))
|
||||
self.assertIn(flow.slug, force_str(response.content))
|
||||
|
||||
def test_recovery_flow(self):
|
||||
"""Test that recovery flow is linked correctly"""
|
||||
@ -122,4 +122,4 @@ class TestIdentificationStage(TestCase):
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(flow.slug, force_text(response.content))
|
||||
self.assertIn(flow.slug, force_str(response.content))
|
||||
|
||||
@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from passbook.core.models import User
|
||||
@ -59,7 +59,7 @@ class TestUserLoginStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||
)
|
||||
|
||||
@ -86,7 +86,7 @@ class TestUserLoginStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
@ -125,6 +125,6 @@ class TestUserLoginStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
from django.views.generic import FormView
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from lxml.etree import tostring # nosec
|
||||
@ -35,7 +35,7 @@ class OTPTimeStageView(FormView, StageView):
|
||||
"""Get QR Code SVG as string based on `device`"""
|
||||
qr_code = QRCode(image_factory=SvgFillImage)
|
||||
qr_code.add_data(device.config_url)
|
||||
return force_text(tostring(qr_code.make_image().get_image()))
|
||||
return force_str(tostring(qr_code.make_image().get_image()))
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
|
||||
@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.markers import StageMarker
|
||||
@ -61,7 +61,7 @@ class TestPasswordStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||
)
|
||||
|
||||
@ -84,7 +84,7 @@ class TestPasswordStage(TestCase):
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(flow.slug, force_text(response.content))
|
||||
self.assertIn(flow.slug, force_str(response.content))
|
||||
|
||||
def test_valid_password(self):
|
||||
"""Test with a valid pending user and valid password"""
|
||||
@ -106,7 +106,7 @@ class TestPasswordStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
@ -154,6 +154,6 @@ class TestPasswordStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||
)
|
||||
|
||||
@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.markers import StageMarker
|
||||
@ -110,9 +110,9 @@ class TestPromptStage(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
for prompt in self.stage.fields.all():
|
||||
self.assertIn(prompt.field_key, force_text(response.content))
|
||||
self.assertIn(prompt.label, force_text(response.content))
|
||||
self.assertIn(prompt.placeholder, force_text(response.content))
|
||||
self.assertIn(prompt.field_key, force_str(response.content))
|
||||
self.assertIn(prompt.label, force_str(response.content))
|
||||
self.assertIn(prompt.placeholder, force_str(response.content))
|
||||
|
||||
def test_valid_form_with_policy(self) -> PromptForm:
|
||||
"""Test form validation"""
|
||||
@ -164,7 +164,7 @@ class TestPromptStage(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""delete tests"""
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.markers import StageMarker
|
||||
@ -44,7 +44,7 @@ class TestUserDeleteStage(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||
)
|
||||
|
||||
@ -83,7 +83,7 @@ class TestUserDeleteStage(TestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""login tests"""
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.markers import StageMarker
|
||||
@ -50,7 +50,7 @@ class TestUserLoginStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
@ -71,7 +71,7 @@ class TestUserLoginStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||
)
|
||||
|
||||
@ -93,7 +93,7 @@ class TestUserLoginStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_flows:denied")},
|
||||
)
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""logout tests"""
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.flows.markers import StageMarker
|
||||
@ -50,7 +50,7 @@ class TestUserLogoutStage(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
force_text(response.content),
|
||||
force_str(response.content),
|
||||
{"type": "redirect", "to": reverse("passbook_core:overview")},
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user