Compare commits
	
		
			173 Commits
		
	
	
		
			version/20
			...
			version-20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| c15e4b24a1 | |||
| b6f518ffe6 | |||
| 4e476fd4e9 | |||
| 03503363e5 | |||
| 22d6621b02 | |||
| 0023df64c8 | |||
| 59a259e43a | |||
| c6f39f5eb4 | |||
| e3c0aad48a | |||
| 91dd33cee6 | |||
| 5a2c367e89 | |||
| 3b05c9cb1a | |||
| 6e53f1689d | |||
| e3be0f2550 | |||
| 294f2243c1 | |||
| 7b1373e8d6 | |||
| e70b486f20 | |||
| b90174f153 | |||
| 7d7acd8494 | |||
| 4d9d7c5efb | |||
| d614b3608d | |||
| beb2715fa7 | |||
| 5769ff45b5 | |||
| 9d6f79558f | |||
| 41d5bff9d3 | |||
| ec84ba9b6d | |||
| 042a62f99e | |||
| 907f02cfee | |||
| 53fe412bf9 | |||
| ef9e177fe9 | |||
| 28e675596b | |||
| 9b7f57cc75 | |||
| 935a8f4d58 | |||
| 01fcbb325b | |||
| 7d3d17acb9 | |||
| e434321f7c | |||
| ebd476be14 | |||
| 31ba543c62 | |||
| a101d48b5a | |||
| 4c166dcf52 | |||
| 47b1f025e1 | |||
| 8f44c792ac | |||
| e57b6f2347 | |||
| 275d0dfd03 | |||
| f18cbace7a | |||
| 212220554f | |||
| a596392bc3 | |||
| 3e22740eac | |||
| d18a691f63 | |||
| 3cd5e68bc1 | |||
| c741c13132 | |||
| 924f6f104a | |||
| 454594025b | |||
| e72097292c | |||
| ab17a12184 | |||
| 776f3f69a5 | |||
| 8560c7150a | |||
| 301386fb4a | |||
| 68e8b6990b | |||
| 4f800c4758 | |||
| 90c31c2214 | |||
| 50e3d317b2 | |||
| 3eed7bb010 | |||
| 0ef8edc9f1 | |||
| a6373ebb33 | |||
| bf8ce55eea | |||
| 61b4fcb5f3 | |||
| 81275e3bd1 | |||
| 7988bf7748 | |||
| 00d8eec360 | |||
| 82150c8e84 | |||
| 1dbd749a74 | |||
| a96479f16c | |||
| 5d5fb1f37e | |||
| b6f4d6a5eb | |||
| 8ab5c04c2c | |||
| 386944117e | |||
| 9154b9b85d | |||
| fc19372709 | |||
| e5d9c6537c | |||
| bf5cbac314 | |||
| 5cca637a3d | |||
| 5bfb8b454b | |||
| 4d96437972 | |||
| d03b0b8152 | |||
| c249b55ff5 | |||
| 1e1876b34c | |||
| a27493ad1b | |||
| 95b1ab820e | |||
| 5cf9f0002b | |||
| fc7a452b0c | |||
| 25ee0e4b45 | |||
| 46f12e62e8 | |||
| 4245dea25a | |||
| 908db3df81 | |||
| ef4f9aa437 | |||
| 902dd83c67 | |||
| 1c4b78b5f4 | |||
| d854d819d1 | |||
| f246da6b73 | |||
| 4a56b5e827 | |||
| 53b10e64f8 | |||
| 27e4c7027c | |||
| 410d1b97cd | |||
| f93f7e635b | |||
| 74eba04735 | |||
| 01bdaffe36 | |||
| f6b556713a | |||
| abe38bb16a | |||
| f2b8d45999 | |||
| 3f61dff1cb | |||
| b19da6d774 | |||
| 7c55616e29 | |||
| 952a7f07c1 | |||
| 6510b97c1e | |||
| 19b707a0fb | |||
| 320a600349 | |||
| 10110deae5 | |||
| 884c546f32 | |||
| abec906677 | |||
| 22d1dd801c | |||
| 03891cbe09 | |||
| 3c5157dfd4 | |||
| d241e8d51d | |||
| 7ba15884ed | |||
| 47356915b1 | |||
| 2520c92b78 | |||
| e7e0e6d213 | |||
| ca0250e19f | |||
| cf4c7c1bcb | |||
| 670af8789a | |||
| 5c5634830f | |||
| b6b0edb7ad | |||
| 45440abc80 | |||
| 9c42b75567 | |||
| e9a477c1eb | |||
| fa60655a5d | |||
| 5d729b4878 | |||
| 8692f7233f | |||
| 457e17fec3 | |||
| 87e99625e6 | |||
| 6f32eeea43 | |||
| dfcf8b2d40 | |||
| 846006f2e3 | |||
| f557b2129f | |||
| 6dc2003e34 | |||
| 0149c89003 | |||
| f458cae954 | |||
| f01d117ce6 | |||
| 2bde43e5dc | |||
| 84cc0b5490 | |||
| 2f3026084e | |||
| 89696edbee | |||
| c1f0833c09 | |||
| c77f804b77 | |||
| 8e83209631 | |||
| 2e48e0cc2f | |||
| e72f0ab160 | |||
| a3c681cc44 | |||
| 5b3a9e29fb | |||
| 15803dc67d | |||
| ff37e064c9 | |||
| ef8e922e2a | |||
| 34b11524f1 | |||
| 9e2492be5c | |||
| b3ba083ff0 | |||
| 22a8603892 | |||
| d83d058a4b | |||
| ec3fd4a3ab | |||
| 0764668b14 | |||
| 16b6c17305 | |||
| e60509697a | |||
| 85364af9e9 | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2021.12.3 | current_version = 2021.12.5 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) | ||||||
| @ -17,7 +17,7 @@ values = | |||||||
| 	beta | 	beta | ||||||
| 	stable | 	stable | ||||||
|  |  | ||||||
| [bumpversion:file:website/docs/installation/docker-compose.md] | [bumpversion:file:pyproject.toml] | ||||||
|  |  | ||||||
| [bumpversion:file:docker-compose.yml] | [bumpversion:file:docker-compose.yml] | ||||||
|  |  | ||||||
| @ -30,7 +30,3 @@ values = | |||||||
| [bumpversion:file:internal/constants/constants.go] | [bumpversion:file:internal/constants/constants.go] | ||||||
|  |  | ||||||
| [bumpversion:file:web/src/constants.ts] | [bumpversion:file:web/src/constants.ts] | ||||||
|  |  | ||||||
| [bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md] |  | ||||||
|  |  | ||||||
| [bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md] |  | ||||||
|  | |||||||
							
								
								
									
										120
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										120
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -33,40 +33,36 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v2 | ||||||
|         with: |  | ||||||
|           python-version: '3.9' |  | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v2 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
|       - id: cache-pipenv |       - id: cache-poetry | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.local/share/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} |           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|         run: scripts/ci_prepare.sh |         run: scripts/ci_prepare.sh | ||||||
|       - name: run job |       - name: run job | ||||||
|         run: pipenv run make ci-${{ matrix.job }} |         run: poetry run make ci-${{ matrix.job }} | ||||||
|   test-migrations: |   test-migrations: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v2 | ||||||
|         with: |       - id: cache-poetry | ||||||
|           python-version: '3.9' |  | ||||||
|       - id: cache-pipenv |  | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.local/share/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} |           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|         run: scripts/ci_prepare.sh |         run: scripts/ci_prepare.sh | ||||||
|       - name: run migrations |       - name: run migrations | ||||||
|         run: pipenv run python -m lifecycle.migrate |         run: poetry run python -m lifecycle.migrate | ||||||
|   test-migrations-from-stable: |   test-migrations-from-stable: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
| @ -74,75 +70,79 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           fetch-depth: 0 |           fetch-depth: 0 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v2 | ||||||
|         with: |  | ||||||
|           python-version: '3.9' |  | ||||||
|       - name: prepare variables |       - name: prepare variables | ||||||
|         id: ev |         id: ev | ||||||
|         run: | |         run: | | ||||||
|           python ./scripts/gh_env.py |           python ./scripts/gh_env.py | ||||||
|       - id: cache-pipenv |           sudo pip install -U pipenv | ||||||
|  |       - id: cache-poetry | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.local/share/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} |           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: checkout stable |       - name: checkout stable | ||||||
|         id: stable |  | ||||||
|         run: | |         run: | | ||||||
|           # Save current branch |  | ||||||
|           current=$(git branch --show) |  | ||||||
|           echo ##[set-output name=originalBranch]$current |  | ||||||
|           # Copy current, latest config to local |           # Copy current, latest config to local | ||||||
|           cp authentik/lib/default.yml local.env.yml |           cp authentik/lib/default.yml local.env.yml | ||||||
|           cp -R .github .. |           cp -R .github .. | ||||||
|           cp -R scripts .. |           cp -R scripts .. | ||||||
|  |           cp -R poetry.lock pyproject.toml .. | ||||||
|           git checkout $(git describe --abbrev=0 --match 'version/*') |           git checkout $(git describe --abbrev=0 --match 'version/*') | ||||||
|           rm -rf .github/ scripts/ |           rm -rf .github/ scripts/ | ||||||
|           mv ../.github ../scripts . |           mv ../.github ../scripts ../poetry.lock ../pyproject.toml . | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|         run: | |         run: | | ||||||
|           scripts/ci_prepare.sh |           scripts/ci_prepare.sh | ||||||
|           # Sync anyways since stable will have different dependencies |           # Sync anyways since stable will have different dependencies | ||||||
|           pipenv sync --dev |           # TODO: Remove after next stable release | ||||||
|  |           if [[ -f "Pipfile.lock" ]]; then | ||||||
|  |             pipenv install --dev | ||||||
|  |           fi | ||||||
|  |           poetry install | ||||||
|       - name: run migrations to stable |       - name: run migrations to stable | ||||||
|         run: pipenv run python -m lifecycle.migrate |         run: poetry run python -m lifecycle.migrate | ||||||
|       - name: checkout current code |       - name: checkout current code | ||||||
|         run: | |         run: | | ||||||
|           set -x |           set -x | ||||||
|           git fetch |           git fetch | ||||||
|           git reset --hard HEAD |           git reset --hard HEAD | ||||||
|           git checkout ${{ steps.stable.outputs.originalBranch }} |           # TODO: Remove after next stable release | ||||||
|           pipenv sync --dev |           rm -f poetry.lock | ||||||
|  |           git checkout $GITHUB_SHA | ||||||
|  |           # TODO: Remove after next stable release | ||||||
|  |           if [[ -f "Pipfile.lock" ]]; then | ||||||
|  |             pipenv install --dev | ||||||
|  |           fi | ||||||
|  |           poetry install | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|         run: scripts/ci_prepare.sh |         run: scripts/ci_prepare.sh | ||||||
|       - name: migrate to latest |       - name: migrate to latest | ||||||
|         run: pipenv run python -m lifecycle.migrate |         run: poetry run python -m lifecycle.migrate | ||||||
|   test-unittest: |   test-unittest: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v2 | ||||||
|         with: |       - id: cache-poetry | ||||||
|           python-version: '3.9' |  | ||||||
|       - id: cache-pipenv |  | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.local/share/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} |           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|         run: scripts/ci_prepare.sh |         run: scripts/ci_prepare.sh | ||||||
|       - uses: testspace-com/setup-testspace@v1 |       - uses: testspace-com/setup-testspace@v1 | ||||||
|         with: |         with: | ||||||
|           domain: ${{github.repository_owner}} |           domain: ${{github.repository_owner}} | ||||||
|       - name: run unittest |       - name: run unittest | ||||||
|         run: | |         run: | | ||||||
|           pipenv run make test |           poetry run make test | ||||||
|           pipenv run coverage xml |           poetry run coverage xml | ||||||
|       - name: run testspace |       - name: run testspace | ||||||
|         if: ${{ always() }} |         if: ${{ always() }} | ||||||
|         run: | |         run: | | ||||||
| @ -154,16 +154,14 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v2 | ||||||
|         with: |       - id: cache-poetry | ||||||
|           python-version: '3.9' |  | ||||||
|       - id: cache-pipenv |  | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.local/share/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} |           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|         run: scripts/ci_prepare.sh |         run: scripts/ci_prepare.sh | ||||||
|       - uses: testspace-com/setup-testspace@v1 |       - uses: testspace-com/setup-testspace@v1 | ||||||
|         with: |         with: | ||||||
| @ -172,8 +170,8 @@ jobs: | |||||||
|         uses: helm/kind-action@v1.2.0 |         uses: helm/kind-action@v1.2.0 | ||||||
|       - name: run integration |       - name: run integration | ||||||
|         run: | |         run: | | ||||||
|           pipenv run make test-integration |           poetry run make test-integration | ||||||
|           pipenv run coverage xml |           poetry run coverage xml | ||||||
|       - name: run testspace |       - name: run testspace | ||||||
|         if: ${{ always() }} |         if: ${{ always() }} | ||||||
|         run: | |         run: | | ||||||
| @ -185,8 +183,6 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v2 | ||||||
|         with: |  | ||||||
|           python-version: '3.9' |  | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v2 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
| @ -195,14 +191,14 @@ jobs: | |||||||
|       - uses: testspace-com/setup-testspace@v1 |       - uses: testspace-com/setup-testspace@v1 | ||||||
|         with: |         with: | ||||||
|           domain: ${{github.repository_owner}} |           domain: ${{github.repository_owner}} | ||||||
|       - id: cache-pipenv |       - id: cache-poetry | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.local/share/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} |           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|         run: | |         run: | | ||||||
|           scripts/ci_prepare.sh |           scripts/ci_prepare.sh | ||||||
|           docker-compose -f tests/e2e/docker-compose.yml up -d |           docker-compose -f tests/e2e/docker-compose.yml up -d | ||||||
| @ -219,8 +215,8 @@ jobs: | |||||||
|           npm run build |           npm run build | ||||||
|       - name: run e2e |       - name: run e2e | ||||||
|         run: | |         run: | | ||||||
|           pipenv run make test-e2e-provider |           poetry run make test-e2e-provider | ||||||
|           pipenv run coverage xml |           poetry run coverage xml | ||||||
|       - name: run testspace |       - name: run testspace | ||||||
|         if: ${{ always() }} |         if: ${{ always() }} | ||||||
|         run: | |         run: | | ||||||
| @ -232,8 +228,6 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v2 | ||||||
|         with: |  | ||||||
|           python-version: '3.9' |  | ||||||
|       - uses: actions/setup-node@v2 |       - uses: actions/setup-node@v2 | ||||||
|         with: |         with: | ||||||
|           node-version: '16' |           node-version: '16' | ||||||
| @ -242,14 +236,14 @@ jobs: | |||||||
|       - uses: testspace-com/setup-testspace@v1 |       - uses: testspace-com/setup-testspace@v1 | ||||||
|         with: |         with: | ||||||
|           domain: ${{github.repository_owner}} |           domain: ${{github.repository_owner}} | ||||||
|       - id: cache-pipenv |       - id: cache-poetry | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.local/share/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} |           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|         run: | |         run: | | ||||||
|           scripts/ci_prepare.sh |           scripts/ci_prepare.sh | ||||||
|           docker-compose -f tests/e2e/docker-compose.yml up -d |           docker-compose -f tests/e2e/docker-compose.yml up -d | ||||||
| @ -266,8 +260,8 @@ jobs: | |||||||
|           npm run build |           npm run build | ||||||
|       - name: run e2e |       - name: run e2e | ||||||
|         run: | |         run: | | ||||||
|           pipenv run make test-e2e-rest |           poetry run make test-e2e-rest | ||||||
|           pipenv run coverage xml |           poetry run coverage xml | ||||||
|       - name: run testspace |       - name: run testspace | ||||||
|         if: ${{ always() }} |         if: ${{ always() }} | ||||||
|         run: | |         run: | | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -30,14 +30,14 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik:2021.12.3, |             beryju/authentik:2021.12.5, | ||||||
|             beryju/authentik:latest, |             beryju/authentik:latest, | ||||||
|             ghcr.io/goauthentik/server:2021.12.3, |             ghcr.io/goauthentik/server:2021.12.5, | ||||||
|             ghcr.io/goauthentik/server:latest |             ghcr.io/goauthentik/server:latest | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|           context: . |           context: . | ||||||
|       - name: Building Docker Image (stable) |       - name: Building Docker Image (stable) | ||||||
|         if: ${{ github.event_name == 'release' && !contains('2021.12.3', 'rc') }} |         if: ${{ github.event_name == 'release' && !contains('2021.12.5', 'rc') }} | ||||||
|         run: | |         run: | | ||||||
|           docker pull beryju/authentik:latest |           docker pull beryju/authentik:latest | ||||||
|           docker tag beryju/authentik:latest beryju/authentik:stable |           docker tag beryju/authentik:latest beryju/authentik:stable | ||||||
| @ -78,14 +78,14 @@ jobs: | |||||||
|         with: |         with: | ||||||
|           push: ${{ github.event_name == 'release' }} |           push: ${{ github.event_name == 'release' }} | ||||||
|           tags: | |           tags: | | ||||||
|             beryju/authentik-${{ matrix.type }}:2021.12.3, |             beryju/authentik-${{ matrix.type }}:2021.12.5, | ||||||
|             beryju/authentik-${{ matrix.type }}:latest, |             beryju/authentik-${{ matrix.type }}:latest, | ||||||
|             ghcr.io/goauthentik/${{ matrix.type }}:2021.12.3, |             ghcr.io/goauthentik/${{ matrix.type }}:2021.12.5, | ||||||
|             ghcr.io/goauthentik/${{ matrix.type }}:latest |             ghcr.io/goauthentik/${{ matrix.type }}:latest | ||||||
|           file: ${{ matrix.type }}.Dockerfile |           file: ${{ matrix.type }}.Dockerfile | ||||||
|           platforms: linux/amd64,linux/arm64 |           platforms: linux/amd64,linux/arm64 | ||||||
|       - name: Building Docker Image (stable) |       - name: Building Docker Image (stable) | ||||||
|         if: ${{ github.event_name == 'release' && !contains('2021.12.3', 'rc') }} |         if: ${{ github.event_name == 'release' && !contains('2021.12.5', 'rc') }} | ||||||
|         run: | |         run: | | ||||||
|           docker pull beryju/authentik-${{ matrix.type }}:latest |           docker pull beryju/authentik-${{ matrix.type }}:latest | ||||||
|           docker tag beryju/authentik-${{ matrix.type }}:latest beryju/authentik-${{ matrix.type }}:stable |           docker tag beryju/authentik-${{ matrix.type }}:latest beryju/authentik-${{ matrix.type }}:stable | ||||||
| @ -170,7 +170,7 @@ jobs: | |||||||
|           SENTRY_PROJECT: authentik |           SENTRY_PROJECT: authentik | ||||||
|           SENTRY_URL: https://sentry.beryju.org |           SENTRY_URL: https://sentry.beryju.org | ||||||
|         with: |         with: | ||||||
|           version: authentik@2021.12.3 |           version: authentik@2021.12.5 | ||||||
|           environment: beryjuorg-prod |           environment: beryjuorg-prod | ||||||
|           sourcemaps: './web/dist' |           sourcemaps: './web/dist' | ||||||
|           url_prefix: '~/static/dist' |           url_prefix: '~/static/dist' | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							| @ -22,22 +22,20 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v2 |       - uses: actions/checkout@v2 | ||||||
|       - uses: actions/setup-python@v2 |       - uses: actions/setup-python@v2 | ||||||
|         with: |       - id: cache-poetry | ||||||
|           python-version: '3.9' |  | ||||||
|       - id: cache-pipenv |  | ||||||
|         uses: actions/cache@v2.1.7 |         uses: actions/cache@v2.1.7 | ||||||
|         with: |         with: | ||||||
|           path: ~/.local/share/virtualenvs |           path: ~/.cache/pypoetry/virtualenvs | ||||||
|           key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} |           key: ${{ runner.os }}-poetry-cache-v3-${{ hashFiles('**/poetry.lock') }} | ||||||
|       - name: prepare |       - name: prepare | ||||||
|         env: |         env: | ||||||
|           INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} |           INSTALL: ${{ steps.cache-poetry.outputs.cache-hit }} | ||||||
|         run: | |         run: | | ||||||
|           sudo apt-get update |           sudo apt-get update | ||||||
|           sudo apt-get install -y gettext |           sudo apt-get install -y gettext | ||||||
|           scripts/ci_prepare.sh |           scripts/ci_prepare.sh | ||||||
|       - name: run compile |       - name: run compile | ||||||
|         run: pipenv run ./manage.py compilemessages |         run: poetry run ./manage.py compilemessages | ||||||
|       - name: Create Pull Request |       - name: Create Pull Request | ||||||
|         uses: peter-evans/create-pull-request@v3 |         uses: peter-evans/create-pull-request@v3 | ||||||
|         id: cpr |         id: cpr | ||||||
|  | |||||||
							
								
								
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -11,7 +11,8 @@ | |||||||
|         "saml", |         "saml", | ||||||
|         "totp", |         "totp", | ||||||
|         "webauthn", |         "webauthn", | ||||||
|         "traefik" |         "traefik", | ||||||
|  |         "passwordless" | ||||||
|     ], |     ], | ||||||
|     "python.linting.pylintEnabled": true, |     "python.linting.pylintEnabled": true, | ||||||
|     "todo-tree.tree.showCountsInTree": true, |     "todo-tree.tree.showCountsInTree": true, | ||||||
|  | |||||||
							
								
								
									
										37
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										37
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,16 +1,4 @@ | |||||||
| # Stage 1: Lock python dependencies | # Stage 1: Build website | ||||||
| FROM docker.io/python:3.10.1-slim-bullseye as locker |  | ||||||
|  |  | ||||||
| COPY ./Pipfile /app/ |  | ||||||
| COPY ./Pipfile.lock /app/ |  | ||||||
|  |  | ||||||
| WORKDIR /app/ |  | ||||||
|  |  | ||||||
| RUN pip install pipenv && \ |  | ||||||
|     pipenv lock -r > requirements.txt && \ |  | ||||||
|     pipenv lock -r --dev-only > requirements-dev.txt |  | ||||||
|  |  | ||||||
| # Stage 2: Build website |  | ||||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:16 as website-builder | FROM --platform=${BUILDPLATFORM} docker.io/node:16 as website-builder | ||||||
|  |  | ||||||
| COPY ./website /work/website/ | COPY ./website /work/website/ | ||||||
| @ -18,7 +6,7 @@ COPY ./website /work/website/ | |||||||
| ENV NODE_ENV=production | ENV NODE_ENV=production | ||||||
| RUN cd /work/website && npm i && npm run build-docs-only | RUN cd /work/website && npm i && npm run build-docs-only | ||||||
|  |  | ||||||
| # Stage 3: Build webui | # Stage 2: Build webui | ||||||
| FROM --platform=${BUILDPLATFORM} docker.io/node:16 as web-builder | FROM --platform=${BUILDPLATFORM} docker.io/node:16 as web-builder | ||||||
|  |  | ||||||
| COPY ./web /work/web/ | COPY ./web /work/web/ | ||||||
| @ -27,7 +15,7 @@ COPY ./website /work/website/ | |||||||
| ENV NODE_ENV=production | ENV NODE_ENV=production | ||||||
| RUN cd /work/web && npm i && npm run build | RUN cd /work/web && npm i && npm run build | ||||||
|  |  | ||||||
| # Stage 4: Build go proxy | # Stage 3: Build go proxy | ||||||
| FROM docker.io/golang:1.17.5-bullseye AS builder | FROM docker.io/golang:1.17.5-bullseye AS builder | ||||||
|  |  | ||||||
| WORKDIR /work | WORKDIR /work | ||||||
| @ -43,29 +31,38 @@ COPY ./go.sum /work/go.sum | |||||||
|  |  | ||||||
| RUN go build -o /work/authentik ./cmd/server/main.go | RUN go build -o /work/authentik ./cmd/server/main.go | ||||||
|  |  | ||||||
| # Stage 5: Run | # Stage 4: Run | ||||||
| FROM docker.io/python:3.10.1-slim-bullseye | FROM docker.io/python:3.10.1-slim-bullseye | ||||||
|  |  | ||||||
|  | LABEL org.opencontainers.image.url https://goauthentik.io | ||||||
|  | LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info. | ||||||
|  | LABEL org.opencontainers.image.source https://github.com/goauthentik/authentik | ||||||
|  |  | ||||||
| WORKDIR / | WORKDIR / | ||||||
| COPY --from=locker /app/requirements.txt / |  | ||||||
| COPY --from=locker /app/requirements-dev.txt / |  | ||||||
|  |  | ||||||
| ARG GIT_BUILD_HASH | ARG GIT_BUILD_HASH | ||||||
| ENV GIT_BUILD_HASH=$GIT_BUILD_HASH | ENV GIT_BUILD_HASH=$GIT_BUILD_HASH | ||||||
|  |  | ||||||
|  | COPY ./pyproject.toml / | ||||||
|  | COPY ./poetry.lock / | ||||||
|  |  | ||||||
| RUN apt-get update && \ | RUN apt-get update && \ | ||||||
|     apt-get install -y --no-install-recommends \ |     apt-get install -y --no-install-recommends \ | ||||||
|         curl ca-certificates gnupg git runit libpq-dev \ |         curl ca-certificates gnupg git runit libpq-dev \ | ||||||
|         postgresql-client build-essential libxmlsec1-dev \ |         postgresql-client build-essential libxmlsec1-dev \ | ||||||
|         pkg-config libmaxminddb0 && \ |         pkg-config libmaxminddb0 && \ | ||||||
|     pip install -r /requirements.txt --no-cache-dir && \ |     pip install poetry && \ | ||||||
|  |     poetry config virtualenvs.create false && \ | ||||||
|  |     poetry install --no-dev && \ | ||||||
|  |     rm -rf ~/.cache/pypoetry && \ | ||||||
|     apt-get remove --purge -y build-essential git && \ |     apt-get remove --purge -y build-essential git && \ | ||||||
|     apt-get autoremove --purge -y && \ |     apt-get autoremove --purge -y && \ | ||||||
|     apt-get clean && \ |     apt-get clean && \ | ||||||
|     rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \ |     rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \ | ||||||
|     adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \ |     adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \ | ||||||
|     mkdir -p /backups /certs /media && \ |     mkdir -p /backups /certs /media && \ | ||||||
|     chown authentik:authentik /backups /certs /media |     mkdir -p /authentik/.ssh && \ | ||||||
|  |     chown authentik:authentik /backups /certs /media /authentik/.ssh | ||||||
|  |  | ||||||
| COPY ./authentik/ /authentik | COPY ./authentik/ /authentik | ||||||
| COPY ./pyproject.toml / | COPY ./pyproject.toml / | ||||||
|  | |||||||
							
								
								
									
										17
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								Makefile
									
									
									
									
									
								
							| @ -35,6 +35,7 @@ lint-fix: | |||||||
| lint: | lint: | ||||||
| 	bandit -r authentik tests lifecycle -x node_modules | 	bandit -r authentik tests lifecycle -x node_modules | ||||||
| 	pylint authentik tests lifecycle | 	pylint authentik tests lifecycle | ||||||
|  | 	golangci-lint run -v | ||||||
|  |  | ||||||
| i18n-extract: i18n-extract-core web-extract | i18n-extract: i18n-extract-core web-extract | ||||||
|  |  | ||||||
| @ -105,20 +106,24 @@ web-extract: | |||||||
| # These targets are use by GitHub actions to allow usage of matrix | # These targets are use by GitHub actions to allow usage of matrix | ||||||
| # which makes the YAML File a lot smaller | # which makes the YAML File a lot smaller | ||||||
|  |  | ||||||
| ci-pylint: | ci--meta-debug: | ||||||
|  | 	python -V | ||||||
|  | 	node --version | ||||||
|  |  | ||||||
|  | ci-pylint: ci--meta-debug | ||||||
| 	pylint authentik tests lifecycle | 	pylint authentik tests lifecycle | ||||||
|  |  | ||||||
| ci-black: | ci-black: ci--meta-debug | ||||||
| 	black --check authentik tests lifecycle | 	black --check authentik tests lifecycle | ||||||
|  |  | ||||||
| ci-isort: | ci-isort: ci--meta-debug | ||||||
| 	isort --check authentik tests lifecycle | 	isort --check authentik tests lifecycle | ||||||
|  |  | ||||||
| ci-bandit: | ci-bandit: ci--meta-debug | ||||||
| 	bandit -r authentik tests lifecycle | 	bandit -r authentik tests lifecycle | ||||||
|  |  | ||||||
| ci-pyright: | ci-pyright: ci--meta-debug | ||||||
| 	pyright e2e lifecycle | 	pyright e2e lifecycle | ||||||
|  |  | ||||||
| ci-pending-migrations: | ci-pending-migrations: ci--meta-debug | ||||||
| 	./manage.py makemigrations --check | 	./manage.py makemigrations --check | ||||||
|  | |||||||
							
								
								
									
										68
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										68
									
								
								Pipfile
									
									
									
									
									
								
							| @ -1,68 +0,0 @@ | |||||||
| [[source]] |  | ||||||
| name = "pypi" |  | ||||||
| url = "https://pypi.org/simple" |  | ||||||
| verify_ssl = true |  | ||||||
|  |  | ||||||
| [packages] |  | ||||||
| boto3 = "*" |  | ||||||
| celery = "*" |  | ||||||
| channels = "*" |  | ||||||
| channels-redis = "*" |  | ||||||
| codespell = "*" |  | ||||||
| colorama = "*" |  | ||||||
| dacite = "*" |  | ||||||
| deepmerge = "*" |  | ||||||
| defusedxml = "*" |  | ||||||
| django = "*" |  | ||||||
| django-dbbackup = { git = 'https://github.com/django-dbbackup/django-dbbackup.git', ref = '9d1909c30a3271c8c9c8450add30d6e0b996e145' } |  | ||||||
| django-filter = "*" |  | ||||||
| django-guardian = "*" |  | ||||||
| django-model-utils = "*" |  | ||||||
| django-otp = "*" |  | ||||||
| django-prometheus = "*" |  | ||||||
| django-redis = "*" |  | ||||||
| django-storages = "*" |  | ||||||
| djangorestframework = "*" |  | ||||||
| djangorestframework-guardian = "*" |  | ||||||
| docker = "*" |  | ||||||
| drf-spectacular = "*" |  | ||||||
| duo-client = "*" |  | ||||||
| facebook-sdk = "*" |  | ||||||
| geoip2 = "*" |  | ||||||
| gunicorn = "*" |  | ||||||
| kubernetes = "==v19.15.0" |  | ||||||
| ldap3 = "*" |  | ||||||
| lxml = "*" |  | ||||||
| packaging = "*" |  | ||||||
| psycopg2-binary = "*" |  | ||||||
| pycryptodome = "*" |  | ||||||
| pyjwt = "*" |  | ||||||
| pyyaml = "*" |  | ||||||
| requests-oauthlib = "*" |  | ||||||
| sentry-sdk = { git = 'https://github.com/beryju/sentry-python.git', ref = '379aee28b15d3b87b381317746c4efd24b3d7bc3' } |  | ||||||
| service_identity = "*" |  | ||||||
| structlog = "*" |  | ||||||
| swagger-spec-validator = "*" |  | ||||||
| twisted = "==21.7.0" |  | ||||||
| ua-parser = "*" |  | ||||||
| urllib3 = {extras = ["secure"],version = "*"} |  | ||||||
| uvicorn = {extras = ["standard"],version = "*"} |  | ||||||
| webauthn = "*" |  | ||||||
| xmlsec = "*" |  | ||||||
| flower = "*" |  | ||||||
| wsproto = "*" |  | ||||||
|  |  | ||||||
| [dev-packages] |  | ||||||
| bandit = "*" |  | ||||||
| black = "==21.11b1" |  | ||||||
| bump2version = "*" |  | ||||||
| colorama = "*" |  | ||||||
| coverage = {extras = ["toml"],version = "*"} |  | ||||||
| pylint = "*" |  | ||||||
| pylint-django = "*" |  | ||||||
| pytest = "*" |  | ||||||
| pytest-django = "*" |  | ||||||
| pytest-randomly = "*" |  | ||||||
| requests-mock = "*" |  | ||||||
| selenium = "*" |  | ||||||
| importlib-metadata = "*" |  | ||||||
							
								
								
									
										2514
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2514
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -1,3 +1,3 @@ | |||||||
| """authentik""" | """authentik""" | ||||||
| __version__ = "2021.12.3" | __version__ = "2021.12.5" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  | |||||||
| @ -95,7 +95,7 @@ class TaskViewSet(ViewSet): | |||||||
|                 _("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}), |                 _("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}), | ||||||
|             ) |             ) | ||||||
|             return Response(status=204) |             return Response(status=204) | ||||||
|         except ImportError:  # pragma: no cover |         except (ImportError, AttributeError):  # pragma: no cover | ||||||
|             # if we get an import error, the module path has probably changed |             # if we get an import error, the module path has probably changed | ||||||
|             task.delete() |             task.delete() | ||||||
|             return Response(status=500) |             return Response(status=500) | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """API Authentication""" | """API Authentication""" | ||||||
| from base64 import b64decode | from base64 import b64decode | ||||||
| from binascii import Error | from binascii import Error | ||||||
| from typing import Any, Optional, Union | from typing import Any, Optional | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from rest_framework.authentication import BaseAuthentication, get_authorization_header | from rest_framework.authentication import BaseAuthentication, get_authorization_header | ||||||
| @ -69,7 +69,7 @@ def token_secret_key(value: str) -> Optional[User]: | |||||||
| class TokenAuthentication(BaseAuthentication): | class TokenAuthentication(BaseAuthentication): | ||||||
|     """Token-based authentication using HTTP Bearer authentication""" |     """Token-based authentication using HTTP Bearer authentication""" | ||||||
|  |  | ||||||
|     def authenticate(self, request: Request) -> Union[tuple[User, Any], None]: |     def authenticate(self, request: Request) -> tuple[User, Any] | None: | ||||||
|         """Token-based authentication using HTTP Bearer authentication""" |         """Token-based authentication using HTTP Bearer authentication""" | ||||||
|         auth = get_authorization_header(request) |         auth = get_authorization_header(request) | ||||||
|  |  | ||||||
|  | |||||||
| @ -46,11 +46,7 @@ from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet | |||||||
| from authentik.policies.expression.api import ExpressionPolicyViewSet | from authentik.policies.expression.api import ExpressionPolicyViewSet | ||||||
| from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet | from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet | ||||||
| from authentik.policies.password.api import PasswordPolicyViewSet | from authentik.policies.password.api import PasswordPolicyViewSet | ||||||
| from authentik.policies.reputation.api import ( | from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet | ||||||
|     IPReputationViewSet, |  | ||||||
|     ReputationPolicyViewSet, |  | ||||||
|     UserReputationViewSet, |  | ||||||
| ) |  | ||||||
| from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet | from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet | ||||||
| from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet | from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet | ||||||
| from authentik.providers.oauth2.api.scope import ScopeMappingViewSet | from authentik.providers.oauth2.api.scope import ScopeMappingViewSet | ||||||
| @ -151,8 +147,7 @@ router.register("policies/event_matcher", EventMatcherPolicyViewSet) | |||||||
| router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet) | router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet) | ||||||
| router.register("policies/password_expiry", PasswordExpiryPolicyViewSet) | router.register("policies/password_expiry", PasswordExpiryPolicyViewSet) | ||||||
| router.register("policies/password", PasswordPolicyViewSet) | router.register("policies/password", PasswordPolicyViewSet) | ||||||
| router.register("policies/reputation/users", UserReputationViewSet) | router.register("policies/reputation/scores", ReputationViewSet) | ||||||
| router.register("policies/reputation/ips", IPReputationViewSet) |  | ||||||
| router.register("policies/reputation", ReputationPolicyViewSet) | router.register("policies/reputation", ReputationPolicyViewSet) | ||||||
|  |  | ||||||
| router.register("providers/all", ProviderViewSet) | router.register("providers/all", ProviderViewSet) | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from datetime import timedelta | |||||||
| from json import loads | from json import loads | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
|  | from django.contrib.auth import update_session_auth_hash | ||||||
| from django.db.models.query import QuerySet | from django.db.models.query import QuerySet | ||||||
| from django.db.transaction import atomic | from django.db.transaction import atomic | ||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
| @ -46,6 +47,7 @@ from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | |||||||
| from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER | from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER | ||||||
| from authentik.core.models import ( | from authentik.core.models import ( | ||||||
|     USER_ATTRIBUTE_CHANGE_EMAIL, |     USER_ATTRIBUTE_CHANGE_EMAIL, | ||||||
|  |     USER_ATTRIBUTE_CHANGE_NAME, | ||||||
|     USER_ATTRIBUTE_CHANGE_USERNAME, |     USER_ATTRIBUTE_CHANGE_USERNAME, | ||||||
|     USER_ATTRIBUTE_SA, |     USER_ATTRIBUTE_SA, | ||||||
|     USER_ATTRIBUTE_TOKEN_EXPIRING, |     USER_ATTRIBUTE_TOKEN_EXPIRING, | ||||||
| @ -134,6 +136,16 @@ class UserSelfSerializer(ModelSerializer): | |||||||
|             raise ValidationError("Not allowed to change email.") |             raise ValidationError("Not allowed to change email.") | ||||||
|         return email |         return email | ||||||
|  |  | ||||||
|  |     def validate_name(self, name: str): | ||||||
|  |         """Check if the user is allowed to change their name""" | ||||||
|  |         if self.instance.group_attributes().get( | ||||||
|  |             USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True) | ||||||
|  |         ): | ||||||
|  |             return name | ||||||
|  |         if name != self.instance.name: | ||||||
|  |             raise ValidationError("Not allowed to change name.") | ||||||
|  |         return name | ||||||
|  |  | ||||||
|     def validate_username(self, username: str): |     def validate_username(self, username: str): | ||||||
|         """Check if the user is allowed to change their username""" |         """Check if the user is allowed to change their username""" | ||||||
|         if self.instance.group_attributes().get( |         if self.instance.group_attributes().get( | ||||||
| @ -144,6 +156,13 @@ class UserSelfSerializer(ModelSerializer): | |||||||
|             raise ValidationError("Not allowed to change username.") |             raise ValidationError("Not allowed to change username.") | ||||||
|         return username |         return username | ||||||
|  |  | ||||||
|  |     def save(self, **kwargs): | ||||||
|  |         if self.instance: | ||||||
|  |             attributes: dict = self.instance.attributes | ||||||
|  |             attributes.update(self.validated_data.get("attributes", {})) | ||||||
|  |             self.validated_data["attributes"] = attributes | ||||||
|  |         return super().save(**kwargs) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = User |         model = User | ||||||
| @ -359,6 +378,35 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|             ).data |             ).data | ||||||
|         return Response(serializer.initial_data) |         return Response(serializer.initial_data) | ||||||
|  |  | ||||||
|  |     @permission_required("authentik_core.reset_user_password") | ||||||
|  |     @extend_schema( | ||||||
|  |         request=inline_serializer( | ||||||
|  |             "UserPasswordSetSerializer", | ||||||
|  |             { | ||||||
|  |                 "password": CharField(required=True), | ||||||
|  |             }, | ||||||
|  |         ), | ||||||
|  |         responses={ | ||||||
|  |             204: "", | ||||||
|  |             400: "", | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     @action(detail=True, methods=["POST"]) | ||||||
|  |     # pylint: disable=invalid-name, unused-argument | ||||||
|  |     def set_password(self, request: Request, pk: int) -> Response: | ||||||
|  |         """Set password for user""" | ||||||
|  |         user: User = self.get_object() | ||||||
|  |         try: | ||||||
|  |             user.set_password(request.data.get("password")) | ||||||
|  |             user.save() | ||||||
|  |         except (ValidationError, IntegrityError) as exc: | ||||||
|  |             LOGGER.debug("Failed to set password", exc=exc) | ||||||
|  |             return Response(status=400) | ||||||
|  |         if user.pk == request.user.pk and SESSION_IMPERSONATE_USER not in self.request.session: | ||||||
|  |             LOGGER.debug("Updating session hash after password change") | ||||||
|  |             update_session_auth_hash(self.request, user) | ||||||
|  |         return Response(status=204) | ||||||
|  |  | ||||||
|     @extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)}) |     @extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)}) | ||||||
|     @action( |     @action( | ||||||
|         methods=["PUT"], |         methods=["PUT"], | ||||||
|  | |||||||
| @ -15,7 +15,6 @@ import authentik.lib.models | |||||||
|  |  | ||||||
|  |  | ||||||
| def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     db_alias = schema_editor.connection.alias |  | ||||||
|     from django.contrib.sessions.backends.cache import KEY_PREFIX |     from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||||
|     from django.core.cache import cache |     from django.core.cache import cache | ||||||
|  |  | ||||||
|  | |||||||
| @ -12,7 +12,6 @@ import authentik.core.models | |||||||
|  |  | ||||||
|  |  | ||||||
| def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     db_alias = schema_editor.connection.alias |  | ||||||
|     from django.contrib.sessions.backends.cache import KEY_PREFIX |     from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||||
|     from django.core.cache import cache |     from django.core.cache import cache | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,12 +1,13 @@ | |||||||
| """authentik core models""" | """authentik core models""" | ||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
| from hashlib import md5, sha256 | from hashlib import md5, sha256 | ||||||
| from typing import Any, Optional, Type | from typing import Any, Optional | ||||||
| from urllib.parse import urlencode | from urllib.parse import urlencode | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from deepmerge import always_merger | from deepmerge import always_merger | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
|  | from django.contrib.auth.hashers import check_password | ||||||
| from django.contrib.auth.models import AbstractUser | from django.contrib.auth.models import AbstractUser | ||||||
| from django.contrib.auth.models import UserManager as DjangoUserManager | from django.contrib.auth.models import UserManager as DjangoUserManager | ||||||
| from django.db import models | from django.db import models | ||||||
| @ -38,6 +39,7 @@ USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" | |||||||
| USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources" | USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources" | ||||||
| USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires"  # nosec | USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires"  # nosec | ||||||
| USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username" | USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username" | ||||||
|  | USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name" | ||||||
| USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email" | USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email" | ||||||
| USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" | USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" | ||||||
|  |  | ||||||
| @ -160,6 +162,22 @@ class User(GuardianUserMixin, AbstractUser): | |||||||
|         self.password_change_date = now() |         self.password_change_date = now() | ||||||
|         return super().set_password(password) |         return super().set_password(password) | ||||||
|  |  | ||||||
|  |     def check_password(self, raw_password: str) -> bool: | ||||||
|  |         """ | ||||||
|  |         Return a boolean of whether the raw_password was correct. Handles | ||||||
|  |         hashing formats behind the scenes. | ||||||
|  |  | ||||||
|  |         Slightly changed version which doesn't send a signal for such internal hash upgrades | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         def setter(raw_password): | ||||||
|  |             self.set_password(raw_password, signal=False) | ||||||
|  |             # Password hash upgrades shouldn't be considered password changes. | ||||||
|  |             self._password = None | ||||||
|  |             self.save(update_fields=["password"]) | ||||||
|  |  | ||||||
|  |         return check_password(raw_password, self.password, setter) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def uid(self) -> str: |     def uid(self) -> str: | ||||||
|         """Generate a globall unique UID, based on the user ID and the hashed secret key""" |         """Generate a globall unique UID, based on the user ID and the hashed secret key""" | ||||||
| @ -224,7 +242,7 @@ class Provider(SerializerModel): | |||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         """Get serializer for this model""" |         """Get serializer for this model""" | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
| @ -505,7 +523,7 @@ class PropertyMapping(SerializerModel, ManagedModel): | |||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         """Get serializer for this model""" |         """Get serializer for this model""" | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """authentik core signals""" | """authentik core signals""" | ||||||
| from typing import TYPE_CHECKING, Type | from typing import TYPE_CHECKING | ||||||
|  |  | ||||||
| from django.contrib.auth.signals import user_logged_in, user_logged_out | from django.contrib.auth.signals import user_logged_in, user_logged_out | ||||||
| from django.contrib.sessions.backends.cache import KEY_PREFIX | from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||||
| @ -62,7 +62,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: "User", **_): | |||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(pre_delete) | @receiver(pre_delete) | ||||||
| def authenticated_session_delete(sender: Type[Model], instance: "AuthenticatedSession", **_): | def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): | ||||||
|     """Delete session when authenticated session is deleted""" |     """Delete session when authenticated session is deleted""" | ||||||
|     from authentik.core.models import AuthenticatedSession |     from authentik.core.models import AuthenticatedSession | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| """Source decision helper""" | """Source decision helper""" | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from typing import Any, Optional, Type | from typing import Any, Optional | ||||||
|  |  | ||||||
| from django.contrib import messages | from django.contrib import messages | ||||||
| from django.db import IntegrityError | from django.db import IntegrityError | ||||||
| @ -14,6 +14,7 @@ from structlog.stdlib import get_logger | |||||||
| from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection | from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection | ||||||
| from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage | from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
|  | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
| from authentik.flows.models import Flow, Stage, in_memory_stage | from authentik.flows.models import Flow, Stage, in_memory_stage | ||||||
| from authentik.flows.planner import ( | from authentik.flows.planner import ( | ||||||
|     PLAN_CONTEXT_PENDING_USER, |     PLAN_CONTEXT_PENDING_USER, | ||||||
| @ -24,6 +25,8 @@ from authentik.flows.planner import ( | |||||||
| ) | ) | ||||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | ||||||
| from authentik.lib.utils.urls import redirect_with_qs | from authentik.lib.utils.urls import redirect_with_qs | ||||||
|  | from authentik.policies.denied import AccessDeniedResponse | ||||||
|  | from authentik.policies.types import PolicyResult | ||||||
| from authentik.policies.utils import delete_none_keys | from authentik.policies.utils import delete_none_keys | ||||||
| from authentik.stages.password import BACKEND_INBUILT | from authentik.stages.password import BACKEND_INBUILT | ||||||
| from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | ||||||
| @ -50,7 +53,10 @@ class SourceFlowManager: | |||||||
|  |  | ||||||
|     identifier: str |     identifier: str | ||||||
|  |  | ||||||
|     connection_type: Type[UserSourceConnection] = UserSourceConnection |     connection_type: type[UserSourceConnection] = UserSourceConnection | ||||||
|  |  | ||||||
|  |     enroll_info: dict[str, Any] | ||||||
|  |     policy_context: dict[str, Any] | ||||||
|  |  | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
| @ -64,6 +70,7 @@ class SourceFlowManager: | |||||||
|         self.identifier = identifier |         self.identifier = identifier | ||||||
|         self.enroll_info = enroll_info |         self.enroll_info = enroll_info | ||||||
|         self._logger = get_logger().bind(source=source, identifier=identifier) |         self._logger = get_logger().bind(source=source, identifier=identifier) | ||||||
|  |         self.policy_context = {} | ||||||
|  |  | ||||||
|     # pylint: disable=too-many-return-statements |     # pylint: disable=too-many-return-statements | ||||||
|     def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]: |     def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]: | ||||||
| @ -144,7 +151,8 @@ class SourceFlowManager: | |||||||
|         except IntegrityError as exc: |         except IntegrityError as exc: | ||||||
|             self._logger.warning("failed to get action", exc=exc) |             self._logger.warning("failed to get action", exc=exc) | ||||||
|             return redirect("/") |             return redirect("/") | ||||||
|         self._logger.debug("get_action() says", action=action, connection=connection) |         self._logger.debug("get_action", action=action, connection=connection) | ||||||
|  |         try: | ||||||
|             if connection: |             if connection: | ||||||
|                 if action == Action.LINK: |                 if action == Action.LINK: | ||||||
|                     self._logger.debug("Linking existing user") |                     self._logger.debug("Linking existing user") | ||||||
| @ -155,9 +163,11 @@ class SourceFlowManager: | |||||||
|                 if action == Action.ENROLL: |                 if action == Action.ENROLL: | ||||||
|                     self._logger.debug("Handling enrollment of new user") |                     self._logger.debug("Handling enrollment of new user") | ||||||
|                     return self.handle_enroll(connection) |                     return self.handle_enroll(connection) | ||||||
|  |         except FlowNonApplicableException as exc: | ||||||
|  |             self._logger.warning("Flow non applicable", exc=exc) | ||||||
|  |             return self.error_handler(exc, exc.policy_result) | ||||||
|         # Default case, assume deny |         # Default case, assume deny | ||||||
|         messages.error( |         error = ( | ||||||
|             self.request, |  | ||||||
|             _( |             _( | ||||||
|                 ( |                 ( | ||||||
|                     "Request to authenticate with %(source)s has been denied. Please authenticate " |                     "Request to authenticate with %(source)s has been denied. Please authenticate " | ||||||
| @ -166,7 +176,17 @@ class SourceFlowManager: | |||||||
|                 % {"source": self.source.name} |                 % {"source": self.source.name} | ||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|         return redirect(reverse("authentik_core:root-redirect")) |         return self.error_handler(error) | ||||||
|  |  | ||||||
|  |     def error_handler( | ||||||
|  |         self, error: Exception, policy_result: Optional[PolicyResult] = None | ||||||
|  |     ) -> HttpResponse: | ||||||
|  |         """Handle any errors by returning an access denied stage""" | ||||||
|  |         response = AccessDeniedResponse(self.request) | ||||||
|  |         response.error_message = str(error) | ||||||
|  |         if policy_result: | ||||||
|  |             response.policy_result = policy_result | ||||||
|  |         return response | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def get_stages_to_append(self, flow: Flow) -> list[Stage]: |     def get_stages_to_append(self, flow: Flow) -> list[Stage]: | ||||||
| @ -179,7 +199,9 @@ class SourceFlowManager: | |||||||
|             ] |             ] | ||||||
|         return [] |         return [] | ||||||
|  |  | ||||||
|     def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse: |     def _handle_login_flow( | ||||||
|  |         self, flow: Flow, connection: UserSourceConnection, **kwargs | ||||||
|  |     ) -> HttpResponse: | ||||||
|         """Prepare Authentication Plan, redirect user FlowExecutor""" |         """Prepare Authentication Plan, redirect user FlowExecutor""" | ||||||
|         # Ensure redirect is carried through when user was trying to |         # Ensure redirect is carried through when user was trying to | ||||||
|         # authorize application |         # authorize application | ||||||
| @ -193,8 +215,10 @@ class SourceFlowManager: | |||||||
|                 PLAN_CONTEXT_SSO: True, |                 PLAN_CONTEXT_SSO: True, | ||||||
|                 PLAN_CONTEXT_SOURCE: self.source, |                 PLAN_CONTEXT_SOURCE: self.source, | ||||||
|                 PLAN_CONTEXT_REDIRECT: final_redirect, |                 PLAN_CONTEXT_REDIRECT: final_redirect, | ||||||
|  |                 PLAN_CONTEXT_SOURCES_CONNECTION: connection, | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |         kwargs.update(self.policy_context) | ||||||
|         if not flow: |         if not flow: | ||||||
|             return HttpResponseBadRequest() |             return HttpResponseBadRequest() | ||||||
|         # We run the Flow planner here so we can pass the Pending user in the context |         # We run the Flow planner here so we can pass the Pending user in the context | ||||||
| @ -220,7 +244,7 @@ class SourceFlowManager: | |||||||
|             _("Successfully authenticated with %(source)s!" % {"source": self.source.name}), |             _("Successfully authenticated with %(source)s!" % {"source": self.source.name}), | ||||||
|         ) |         ) | ||||||
|         flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user} |         flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user} | ||||||
|         return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs) |         return self._handle_login_flow(self.source.authentication_flow, connection, **flow_kwargs) | ||||||
|  |  | ||||||
|     def handle_existing_user_link( |     def handle_existing_user_link( | ||||||
|         self, |         self, | ||||||
| @ -264,8 +288,8 @@ class SourceFlowManager: | |||||||
|             return HttpResponseBadRequest() |             return HttpResponseBadRequest() | ||||||
|         return self._handle_login_flow( |         return self._handle_login_flow( | ||||||
|             self.source.enrollment_flow, |             self.source.enrollment_flow, | ||||||
|  |             connection, | ||||||
|             **{ |             **{ | ||||||
|                 PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), |                 PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), | ||||||
|                 PLAN_CONTEXT_SOURCES_CONNECTION: connection, |  | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -6,7 +6,6 @@ from os import environ | |||||||
| from boto3.exceptions import Boto3Error | from boto3.exceptions import Boto3Error | ||||||
| from botocore.exceptions import BotoCoreError, ClientError | from botocore.exceptions import BotoCoreError, ClientError | ||||||
| from dbbackup.db.exceptions import CommandConnectorError | from dbbackup.db.exceptions import CommandConnectorError | ||||||
| from django.conf import settings |  | ||||||
| from django.contrib.humanize.templatetags.humanize import naturaltime | from django.contrib.humanize.templatetags.humanize import naturaltime | ||||||
| from django.contrib.sessions.backends.cache import KEY_PREFIX | from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||||
| from django.core import management | from django.core import management | ||||||
| @ -63,8 +62,6 @@ def should_backup() -> bool: | |||||||
|         return False |         return False | ||||||
|     if not CONFIG.y_bool("postgresql.backup.enabled"): |     if not CONFIG.y_bool("postgresql.backup.enabled"): | ||||||
|         return False |         return False | ||||||
|     if settings.DEBUG: |  | ||||||
|         return False |  | ||||||
|     return True |     return True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,6 +5,8 @@ | |||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% static 'dist/admin/AdminInterface.js' %}" type="module"></script> | <script src="{% static 'dist/admin/AdminInterface.js' %}" type="module"></script> | ||||||
|  | <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | ||||||
|  | <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
|  | |||||||
| @ -5,6 +5,8 @@ | |||||||
|  |  | ||||||
| {% block head %} | {% block head %} | ||||||
| <script src="{% static 'dist/user/UserInterface.js' %}" type="module"></script> | <script src="{% static 'dist/user/UserInterface.js' %}" type="module"></script> | ||||||
|  | <meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)"> | ||||||
|  | <meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)"> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """Test Applications API""" | """Test Applications API""" | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.encoding import force_str |  | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
| @ -32,7 +31,7 @@ class TestApplicationsAPI(APITestCase): | |||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual(force_str(response.content), {"messages": [], "passing": True}) |         self.assertJSONEqual(response.content.decode(), {"messages": [], "passing": True}) | ||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_api:application-check-access", |                 "authentik_api:application-check-access", | ||||||
| @ -40,14 +39,14 @@ class TestApplicationsAPI(APITestCase): | |||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual(force_str(response.content), {"messages": ["dummy"], "passing": False}) |         self.assertJSONEqual(response.content.decode(), {"messages": ["dummy"], "passing": False}) | ||||||
|  |  | ||||||
|     def test_list(self): |     def test_list(self): | ||||||
|         """Test list operation without superuser_full_list""" |         """Test list operation without superuser_full_list""" | ||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|         response = self.client.get(reverse("authentik_api:application-list")) |         response = self.client.get(reverse("authentik_api:application-list")) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             response.content.decode(), | ||||||
|             { |             { | ||||||
|                 "pagination": { |                 "pagination": { | ||||||
|                     "next": 0, |                     "next": 0, | ||||||
| @ -83,7 +82,7 @@ class TestApplicationsAPI(APITestCase): | |||||||
|             reverse("authentik_api:application-list") + "?superuser_full_list=true" |             reverse("authentik_api:application-list") + "?superuser_full_list=true" | ||||||
|         ) |         ) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             response.content.decode(), | ||||||
|             { |             { | ||||||
|                 "pagination": { |                 "pagination": { | ||||||
|                     "next": 0, |                     "next": 0, | ||||||
|  | |||||||
| @ -2,7 +2,6 @@ | |||||||
| from json import loads | from json import loads | ||||||
|  |  | ||||||
| from django.urls.base import reverse | from django.urls.base import reverse | ||||||
| from django.utils.encoding import force_str |  | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| @ -28,5 +27,5 @@ class TestAuthenticatedSessionsAPI(APITestCase): | |||||||
|         self.client.force_login(self.other_user) |         self.client.force_login(self.other_user) | ||||||
|         response = self.client.get(reverse("authentik_api:authenticatedsession-list")) |         response = self.client.get(reverse("authentik_api:authenticatedsession-list")) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         body = loads(force_str(response.content)) |         body = loads(response.content.decode()) | ||||||
|         self.assertEqual(body["pagination"]["count"], 1) |         self.assertEqual(body["pagination"]["count"], 1) | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| """authentik core models tests""" | """authentik core models tests""" | ||||||
| from time import sleep | from time import sleep | ||||||
| from typing import Callable, Type | from typing import Callable | ||||||
|  |  | ||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
| @ -27,7 +27,7 @@ class TestModels(TestCase): | |||||||
|         self.assertFalse(token.is_expired) |         self.assertFalse(token.is_expired) | ||||||
|  |  | ||||||
|  |  | ||||||
| def source_tester_factory(test_model: Type[Stage]) -> Callable: | def source_tester_factory(test_model: type[Stage]) -> Callable: | ||||||
|     """Test source""" |     """Test source""" | ||||||
|  |  | ||||||
|     factory = RequestFactory() |     factory = RequestFactory() | ||||||
| @ -47,7 +47,7 @@ def source_tester_factory(test_model: Type[Stage]) -> Callable: | |||||||
|     return tester |     return tester | ||||||
|  |  | ||||||
|  |  | ||||||
| def provider_tester_factory(test_model: Type[Stage]) -> Callable: | def provider_tester_factory(test_model: type[Stage]) -> Callable: | ||||||
|     """Test provider""" |     """Test provider""" | ||||||
|  |  | ||||||
|     def tester(self: TestModels): |     def tester(self: TestModels): | ||||||
|  | |||||||
| @ -6,8 +6,12 @@ from guardian.utils import get_anonymous_user | |||||||
|  |  | ||||||
| from authentik.core.models import SourceUserMatchingModes, User | from authentik.core.models import SourceUserMatchingModes, User | ||||||
| from authentik.core.sources.flow_manager import Action | from authentik.core.sources.flow_manager import Action | ||||||
|  | from authentik.flows.models import Flow, FlowDesignation | ||||||
| from authentik.lib.generators import generate_id | from authentik.lib.generators import generate_id | ||||||
| from authentik.lib.tests.utils import get_request | from authentik.lib.tests.utils import get_request | ||||||
|  | from authentik.policies.denied import AccessDeniedResponse | ||||||
|  | from authentik.policies.expression.models import ExpressionPolicy | ||||||
|  | from authentik.policies.models import PolicyBinding | ||||||
| from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection | from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection | ||||||
| from authentik.sources.oauth.views.callback import OAuthSourceFlowManager | from authentik.sources.oauth.views.callback import OAuthSourceFlowManager | ||||||
|  |  | ||||||
| @ -17,7 +21,7 @@ class TestSourceFlowManager(TestCase): | |||||||
|  |  | ||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
|         super().setUp() |         super().setUp() | ||||||
|         self.source = OAuthSource.objects.create(name="test") |         self.source: OAuthSource = OAuthSource.objects.create(name="test") | ||||||
|         self.factory = RequestFactory() |         self.factory = RequestFactory() | ||||||
|         self.identifier = generate_id() |         self.identifier = generate_id() | ||||||
|  |  | ||||||
| @ -143,3 +147,34 @@ class TestSourceFlowManager(TestCase): | |||||||
|         action, _ = flow_manager.get_action() |         action, _ = flow_manager.get_action() | ||||||
|         self.assertEqual(action, Action.ENROLL) |         self.assertEqual(action, Action.ENROLL) | ||||||
|         flow_manager.get_flow() |         flow_manager.get_flow() | ||||||
|  |  | ||||||
|  |     def test_error_non_applicable_flow(self): | ||||||
|  |         """Test error handling when a source selected flow is non-applicable due to a policy""" | ||||||
|  |         self.source.user_matching_mode = SourceUserMatchingModes.USERNAME_LINK | ||||||
|  |  | ||||||
|  |         flow = Flow.objects.create( | ||||||
|  |             name="test", slug="test", title="test", designation=FlowDesignation.ENROLLMENT | ||||||
|  |         ) | ||||||
|  |         policy = ExpressionPolicy.objects.create( | ||||||
|  |             name="false", expression="""ak_message("foo");return False""" | ||||||
|  |         ) | ||||||
|  |         PolicyBinding.objects.create( | ||||||
|  |             policy=policy, | ||||||
|  |             target=flow, | ||||||
|  |             order=0, | ||||||
|  |         ) | ||||||
|  |         self.source.enrollment_flow = flow | ||||||
|  |         self.source.save() | ||||||
|  |  | ||||||
|  |         flow_manager = OAuthSourceFlowManager( | ||||||
|  |             self.source, | ||||||
|  |             get_request("/", user=AnonymousUser()), | ||||||
|  |             self.identifier, | ||||||
|  |             {"username": "foo"}, | ||||||
|  |         ) | ||||||
|  |         action, _ = flow_manager.get_action() | ||||||
|  |         self.assertEqual(action, Action.ENROLL) | ||||||
|  |         response = flow_manager.get_flow() | ||||||
|  |         self.assertIsInstance(response, AccessDeniedResponse) | ||||||
|  |         # pylint: disable=no-member | ||||||
|  |         self.assertEqual(response.error_message, "foo") | ||||||
|  | |||||||
| @ -2,9 +2,15 @@ | |||||||
| from django.urls.base import reverse | from django.urls.base import reverse | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from authentik.core.models import USER_ATTRIBUTE_CHANGE_EMAIL, USER_ATTRIBUTE_CHANGE_USERNAME, User | from authentik.core.models import ( | ||||||
|  |     USER_ATTRIBUTE_CHANGE_EMAIL, | ||||||
|  |     USER_ATTRIBUTE_CHANGE_NAME, | ||||||
|  |     USER_ATTRIBUTE_CHANGE_USERNAME, | ||||||
|  |     User, | ||||||
|  | ) | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant | from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant | ||||||
| from authentik.flows.models import FlowDesignation | from authentik.flows.models import FlowDesignation | ||||||
|  | from authentik.lib.generators import generate_key | ||||||
| from authentik.stages.email.models import EmailStage | from authentik.stages.email.models import EmailStage | ||||||
| from authentik.tenants.models import Tenant | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
| @ -18,11 +24,28 @@ class TestUsersAPI(APITestCase): | |||||||
|  |  | ||||||
|     def test_update_self(self): |     def test_update_self(self): | ||||||
|         """Test update_self""" |         """Test update_self""" | ||||||
|  |         self.admin.attributes["foo"] = "bar" | ||||||
|  |         self.admin.save() | ||||||
|  |         self.admin.refresh_from_db() | ||||||
|         self.client.force_login(self.admin) |         self.client.force_login(self.admin) | ||||||
|         response = self.client.put( |         response = self.client.put( | ||||||
|             reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} |             reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} | ||||||
|         ) |         ) | ||||||
|  |         self.admin.refresh_from_db() | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|  |         self.assertEqual(self.admin.attributes["foo"], "bar") | ||||||
|  |         self.assertEqual(self.admin.username, "foo") | ||||||
|  |         self.assertEqual(self.admin.name, "foo") | ||||||
|  |  | ||||||
|  |     def test_update_self_name_denied(self): | ||||||
|  |         """Test update_self""" | ||||||
|  |         self.admin.attributes[USER_ATTRIBUTE_CHANGE_NAME] = False | ||||||
|  |         self.admin.save() | ||||||
|  |         self.client.force_login(self.admin) | ||||||
|  |         response = self.client.put( | ||||||
|  |             reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"} | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |  | ||||||
|     def test_update_self_username_denied(self): |     def test_update_self_username_denied(self): | ||||||
|         """Test update_self""" |         """Test update_self""" | ||||||
| @ -68,6 +91,18 @@ class TestUsersAPI(APITestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 404) |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|  |     def test_set_password(self): | ||||||
|  |         """Test Direct password set""" | ||||||
|  |         self.client.force_login(self.admin) | ||||||
|  |         new_pw = generate_key() | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}), | ||||||
|  |             data={"password": new_pw}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 204) | ||||||
|  |         self.admin.refresh_from_db() | ||||||
|  |         self.assertTrue(self.admin.check_password(new_pw)) | ||||||
|  |  | ||||||
|     def test_recovery(self): |     def test_recovery(self): | ||||||
|         """Test user recovery link (no recovery flow set)""" |         """Test user recovery link (no recovery flow set)""" | ||||||
|         flow = create_test_flow(FlowDesignation.RECOVERY) |         flow = create_test_flow(FlowDesignation.RECOVERY) | ||||||
|  | |||||||
| @ -29,3 +29,4 @@ class UserSettingSerializer(PassiveSerializer): | |||||||
|     component = CharField() |     component = CharField() | ||||||
|     title = CharField() |     title = CharField() | ||||||
|     configure_url = CharField(required=False) |     configure_url = CharField(required=False) | ||||||
|  |     icon_url = CharField() | ||||||
|  | |||||||
| @ -1,4 +1,6 @@ | |||||||
| """Crypto API Views""" | """Crypto API Views""" | ||||||
|  | from typing import Optional | ||||||
|  |  | ||||||
| from cryptography.hazmat.backends import default_backend | from cryptography.hazmat.backends import default_backend | ||||||
| from cryptography.hazmat.primitives.serialization import load_pem_private_key | from cryptography.hazmat.primitives.serialization import load_pem_private_key | ||||||
| from cryptography.x509 import load_pem_x509_certificate | from cryptography.x509 import load_pem_x509_certificate | ||||||
| @ -31,6 +33,7 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|     cert_expiry = DateTimeField(source="certificate.not_valid_after", read_only=True) |     cert_expiry = DateTimeField(source="certificate.not_valid_after", read_only=True) | ||||||
|     cert_subject = SerializerMethodField() |     cert_subject = SerializerMethodField() | ||||||
|     private_key_available = SerializerMethodField() |     private_key_available = SerializerMethodField() | ||||||
|  |     private_key_type = SerializerMethodField() | ||||||
|  |  | ||||||
|     certificate_download_url = SerializerMethodField() |     certificate_download_url = SerializerMethodField() | ||||||
|     private_key_download_url = SerializerMethodField() |     private_key_download_url = SerializerMethodField() | ||||||
| @ -43,6 +46,13 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|         """Show if this keypair has a private key configured or not""" |         """Show if this keypair has a private key configured or not""" | ||||||
|         return instance.key_data != "" and instance.key_data is not None |         return instance.key_data != "" and instance.key_data is not None | ||||||
|  |  | ||||||
|  |     def get_private_key_type(self, instance: CertificateKeyPair) -> Optional[str]: | ||||||
|  |         """Get the private key's type, if set""" | ||||||
|  |         key = instance.private_key | ||||||
|  |         if key: | ||||||
|  |             return key.__class__.__name__.replace("_", "").lower().replace("privatekey", "") | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     def get_certificate_download_url(self, instance: CertificateKeyPair) -> str: |     def get_certificate_download_url(self, instance: CertificateKeyPair) -> str: | ||||||
|         """Get URL to download certificate""" |         """Get URL to download certificate""" | ||||||
|         return ( |         return ( | ||||||
| @ -72,7 +82,7 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|         return value |         return value | ||||||
|  |  | ||||||
|     def validate_key_data(self, value: str) -> str: |     def validate_key_data(self, value: str) -> str: | ||||||
|         """Verify that input is a valid PEM RSA Key""" |         """Verify that input is a valid PEM Key""" | ||||||
|         # Since this field is optional, data can be empty. |         # Since this field is optional, data can be empty. | ||||||
|         if value != "": |         if value != "": | ||||||
|             try: |             try: | ||||||
| @ -98,6 +108,7 @@ class CertificateKeyPairSerializer(ModelSerializer): | |||||||
|             "cert_expiry", |             "cert_expiry", | ||||||
|             "cert_subject", |             "cert_subject", | ||||||
|             "private_key_available", |             "private_key_available", | ||||||
|  |             "private_key_type", | ||||||
|             "certificate_download_url", |             "certificate_download_url", | ||||||
|             "private_key_download_url", |             "private_key_download_url", | ||||||
|             "managed", |             "managed", | ||||||
|  | |||||||
| @ -44,7 +44,7 @@ class CertificateBuilder: | |||||||
|         """Build self-signed certificate""" |         """Build self-signed certificate""" | ||||||
|         one_day = datetime.timedelta(1, 0, 0) |         one_day = datetime.timedelta(1, 0, 0) | ||||||
|         self.__private_key = rsa.generate_private_key( |         self.__private_key = rsa.generate_private_key( | ||||||
|             public_exponent=65537, key_size=2048, backend=default_backend() |             public_exponent=65537, key_size=4096, backend=default_backend() | ||||||
|         ) |         ) | ||||||
|         self.__public_key = self.__private_key.public_key() |         self.__public_key = self.__private_key.public_key() | ||||||
|         alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []] |         alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []] | ||||||
|  | |||||||
| @ -6,6 +6,11 @@ from uuid import uuid4 | |||||||
|  |  | ||||||
| from cryptography.hazmat.backends import default_backend | from cryptography.hazmat.backends import default_backend | ||||||
| from cryptography.hazmat.primitives import hashes | from cryptography.hazmat.primitives import hashes | ||||||
|  | from cryptography.hazmat.primitives.asymmetric.ec import ( | ||||||
|  |     EllipticCurvePrivateKey, | ||||||
|  |     EllipticCurvePublicKey, | ||||||
|  | ) | ||||||
|  | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey | ||||||
| from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey | ||||||
| from cryptography.hazmat.primitives.serialization import load_pem_private_key | from cryptography.hazmat.primitives.serialization import load_pem_private_key | ||||||
| from cryptography.x509 import Certificate, load_pem_x509_certificate | from cryptography.x509 import Certificate, load_pem_x509_certificate | ||||||
| @ -36,8 +41,8 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     _cert: Optional[Certificate] = None |     _cert: Optional[Certificate] = None | ||||||
|     _private_key: Optional[RSAPrivateKey] = None |     _private_key: Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey] = None | ||||||
|     _public_key: Optional[RSAPublicKey] = None |     _public_key: Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey] = None | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def certificate(self) -> Certificate: |     def certificate(self) -> Certificate: | ||||||
| @ -49,14 +54,16 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel): | |||||||
|         return self._cert |         return self._cert | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def public_key(self) -> Optional[RSAPublicKey]: |     def public_key(self) -> Optional[RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey]: | ||||||
|         """Get public key of the private key""" |         """Get public key of the private key""" | ||||||
|         if not self._public_key: |         if not self._public_key: | ||||||
|             self._public_key = self.private_key.public_key() |             self._public_key = self.private_key.public_key() | ||||||
|         return self._public_key |         return self._public_key | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def private_key(self) -> Optional[RSAPrivateKey]: |     def private_key( | ||||||
|  |         self, | ||||||
|  |     ) -> Optional[RSAPrivateKey | EllipticCurvePrivateKey | Ed25519PrivateKey]: | ||||||
|         """Get python cryptography PrivateKey instance""" |         """Get python cryptography PrivateKey instance""" | ||||||
|         if not self._private_key and self.key_data != "": |         if not self._private_key and self.key_data != "": | ||||||
|             try: |             try: | ||||||
|  | |||||||
| @ -24,7 +24,7 @@ MANAGED_DISCOVERED = "goauthentik.io/crypto/discovered/%s" | |||||||
|  |  | ||||||
|  |  | ||||||
| def ensure_private_key_valid(body: str): | def ensure_private_key_valid(body: str): | ||||||
|     """Attempt loading of an RSA Private key without password""" |     """Attempt loading of a PEM Private key without password""" | ||||||
|     load_pem_private_key( |     load_pem_private_key( | ||||||
|         str.encode("\n".join([x.strip() for x in body.split("\n")])), |         str.encode("\n".join([x.strip() for x in body.split("\n")])), | ||||||
|         password=None, |         password=None, | ||||||
| @ -42,7 +42,7 @@ def ensure_certificate_valid(body: str): | |||||||
| @CELERY_APP.task(bind=True, base=MonitoredTask) | @CELERY_APP.task(bind=True, base=MonitoredTask) | ||||||
| @prefill_task | @prefill_task | ||||||
| def certificate_discovery(self: MonitoredTask): | def certificate_discovery(self: MonitoredTask): | ||||||
|     """Discover and update certificates form the filesystem""" |     """Discover, import and update certificates from the filesystem""" | ||||||
|     certs = {} |     certs = {} | ||||||
|     private_keys = {} |     private_keys = {} | ||||||
|     discovered = 0 |     discovered = 0 | ||||||
| @ -52,6 +52,9 @@ def certificate_discovery(self: MonitoredTask): | |||||||
|             continue |             continue | ||||||
|         if path.is_dir(): |         if path.is_dir(): | ||||||
|             continue |             continue | ||||||
|  |         # For certbot setups, we want to ignore archive. | ||||||
|  |         if "archive" in file: | ||||||
|  |             continue | ||||||
|         # Support certbot's directory structure |         # Support certbot's directory structure | ||||||
|         if path.name in ["fullchain.pem", "privkey.pem"]: |         if path.name in ["fullchain.pem", "privkey.pem"]: | ||||||
|             cert_name = path.parent.name |             cert_name = path.parent.name | ||||||
| @ -60,7 +63,7 @@ def certificate_discovery(self: MonitoredTask): | |||||||
|         try: |         try: | ||||||
|             with open(path, "r+", encoding="utf-8") as _file: |             with open(path, "r+", encoding="utf-8") as _file: | ||||||
|                 body = _file.read() |                 body = _file.read() | ||||||
|                 if "BEGIN RSA PRIVATE KEY" in body: |                 if "PRIVATE KEY" in body: | ||||||
|                     private_keys[cert_name] = ensure_private_key_valid(body) |                     private_keys[cert_name] = ensure_private_key_valid(body) | ||||||
|                 else: |                 else: | ||||||
|                     certs[cert_name] = ensure_certificate_valid(body) |                     certs[cert_name] = ensure_certificate_valid(body) | ||||||
|  | |||||||
| @ -146,7 +146,7 @@ class TestCrypto(APITestCase): | |||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://localhost", |             redirect_uris="http://localhost", | ||||||
|             rsa_key=keypair, |             signing_key=keypair, | ||||||
|         ) |         ) | ||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse( |             reverse( | ||||||
|  | |||||||
| @ -15,12 +15,14 @@ from authentik.api.decorators import permission_required | |||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.events.models import ( | from authentik.events.models import ( | ||||||
|  |     Event, | ||||||
|     Notification, |     Notification, | ||||||
|     NotificationSeverity, |     NotificationSeverity, | ||||||
|     NotificationTransport, |     NotificationTransport, | ||||||
|     NotificationTransportError, |     NotificationTransportError, | ||||||
|     TransportMode, |     TransportMode, | ||||||
| ) | ) | ||||||
|  | from authentik.events.utils import get_user | ||||||
|  |  | ||||||
|  |  | ||||||
| class NotificationTransportSerializer(ModelSerializer): | class NotificationTransportSerializer(ModelSerializer): | ||||||
| @ -86,6 +88,12 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet): | |||||||
|             severity=NotificationSeverity.NOTICE, |             severity=NotificationSeverity.NOTICE, | ||||||
|             body=f"Test Notification from transport {transport.name}", |             body=f"Test Notification from transport {transport.name}", | ||||||
|             user=request.user, |             user=request.user, | ||||||
|  |             event=Event( | ||||||
|  |                 action="Test", | ||||||
|  |                 user=get_user(request.user), | ||||||
|  |                 app=self.__class__.__module__, | ||||||
|  |                 context={"foo": "bar"}, | ||||||
|  |             ), | ||||||
|         ) |         ) | ||||||
|         try: |         try: | ||||||
|             response = NotificationTransportTestSerializer( |             response = NotificationTransportTestSerializer( | ||||||
|  | |||||||
| @ -35,12 +35,11 @@ class GeoIPReader: | |||||||
|  |  | ||||||
|     def __open(self): |     def __open(self): | ||||||
|         """Get GeoIP Reader, if configured, otherwise none""" |         """Get GeoIP Reader, if configured, otherwise none""" | ||||||
|         path = CONFIG.y("authentik.geoip") |         path = CONFIG.y("geoip") | ||||||
|         if path == "" or not path: |         if path == "" or not path: | ||||||
|             return |             return | ||||||
|         try: |         try: | ||||||
|             reader = Reader(path) |             self.__reader = Reader(path) | ||||||
|             self.__reader = reader |  | ||||||
|             self.__last_mtime = stat(path).st_mtime |             self.__last_mtime = stat(path).st_mtime | ||||||
|             LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime) |             LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime) | ||||||
|         except OSError as exc: |         except OSError as exc: | ||||||
|  | |||||||
| @ -19,7 +19,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|     Event = apps.get_model("authentik_events", "Event") |     Event = apps.get_model("authentik_events", "Event") | ||||||
|  |  | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|     for event in Event.objects.all(): |     for event in Event.objects.using(db_alias).all(): | ||||||
|         event.delete() |         event.delete() | ||||||
|         # Because event objects cannot be updated, we have to re-create them |         # Because event objects cannot be updated, we have to re-create them | ||||||
|         event.pk = None |         event.pk = None | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|     Event = apps.get_model("authentik_events", "Event") |     Event = apps.get_model("authentik_events", "Event") | ||||||
|  |  | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|     for event in Event.objects.all(): |     for event in Event.objects.using(db_alias).all(): | ||||||
|         event.delete() |         event.delete() | ||||||
|         # Because event objects cannot be updated, we have to re-create them |         # Because event objects cannot be updated, we have to re-create them | ||||||
|         event.pk = None |         event.pk = None | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ from collections import Counter | |||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
| from inspect import currentframe | from inspect import currentframe | ||||||
| from smtplib import SMTPException | from smtplib import SMTPException | ||||||
| from typing import TYPE_CHECKING, Optional, Type, Union | from typing import TYPE_CHECKING, Optional | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| @ -190,7 +190,7 @@ class Event(ExpiringModel): | |||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def new( |     def new( | ||||||
|         action: Union[str, EventAction], |         action: str | EventAction, | ||||||
|         app: Optional[str] = None, |         app: Optional[str] = None, | ||||||
|         **kwargs, |         **kwargs, | ||||||
|     ) -> "Event": |     ) -> "Event": | ||||||
| @ -517,7 +517,7 @@ class NotificationWebhookMapping(PropertyMapping): | |||||||
|         return "ak-property-mapping-notification-form" |         return "ak-property-mapping-notification-form" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type["Serializer"]: |     def serializer(self) -> type["Serializer"]: | ||||||
|         from authentik.events.api.notification_mapping import NotificationWebhookMappingSerializer |         from authentik.events.api.notification_mapping import NotificationWebhookMappingSerializer | ||||||
|  |  | ||||||
|         return NotificationWebhookMappingSerializer |         return NotificationWebhookMappingSerializer | ||||||
|  | |||||||
| @ -72,7 +72,7 @@ class WithUserInfoChallenge(Challenge): | |||||||
|     pending_user_avatar = CharField() |     pending_user_avatar = CharField() | ||||||
|  |  | ||||||
|  |  | ||||||
| class AccessDeniedChallenge(Challenge): | class AccessDeniedChallenge(WithUserInfoChallenge): | ||||||
|     """Challenge when a flow's active stage calls `stage_invalid()`.""" |     """Challenge when a flow's active stage calls `stage_invalid()`.""" | ||||||
|  |  | ||||||
|     error_message = CharField(required=False) |     error_message = CharField(required=False) | ||||||
|  | |||||||
| @ -1,11 +1,14 @@ | |||||||
| """flow exceptions""" | """flow exceptions""" | ||||||
|  |  | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
|  | from authentik.policies.types import PolicyResult | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowNonApplicableException(SentryIgnoredException): | class FlowNonApplicableException(SentryIgnoredException): | ||||||
|     """Flow does not apply to current user (denied by policy).""" |     """Flow does not apply to current user (denied by policy).""" | ||||||
|  |  | ||||||
|  |     policy_result: PolicyResult | ||||||
|  |  | ||||||
|  |  | ||||||
| class EmptyFlowException(SentryIgnoredException): | class EmptyFlowException(SentryIgnoredException): | ||||||
|     """Flow has no stages.""" |     """Flow has no stages.""" | ||||||
|  | |||||||
| @ -10,8 +10,8 @@ def add_title_for_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|         "default-invalidation-flow": "Default Invalidation Flow", |         "default-invalidation-flow": "Default Invalidation Flow", | ||||||
|         "default-source-enrollment": "Welcome to authentik! Please select a username.", |         "default-source-enrollment": "Welcome to authentik! Please select a username.", | ||||||
|         "default-source-authentication": "Welcome to authentik!", |         "default-source-authentication": "Welcome to authentik!", | ||||||
|         "default-provider-authorization-implicit-consent": "Default Provider Authorization Flow (implicit consent)", |         "default-provider-authorization-implicit-consent": "Redirecting to %(app)s", | ||||||
|         "default-provider-authorization-explicit-consent": "Default Provider Authorization Flow (explicit consent)", |         "default-provider-authorization-explicit-consent": "Redirecting to %(app)s", | ||||||
|         "default-password-change": "Change password", |         "default-password-change": "Change password", | ||||||
|     } |     } | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								authentik/flows/migrations/0021_auto_20211227_2103.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								authentik/flows/migrations/0021_auto_20211227_2103.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | |||||||
|  | # Generated by Django 4.0 on 2021-12-27 21:03 | ||||||
|  | from django.apps.registry import Apps | ||||||
|  | from django.db import migrations, models | ||||||
|  | from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def update_title_for_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  |     slug_title_map = { | ||||||
|  |         "default-provider-authorization-implicit-consent": "Redirecting to %(app)s", | ||||||
|  |         "default-provider-authorization-explicit-consent": "Redirecting to %(app)s", | ||||||
|  |     } | ||||||
|  |     db_alias = schema_editor.connection.alias | ||||||
|  |     Flow = apps.get_model("authentik_flows", "Flow") | ||||||
|  |     for flow in Flow.objects.using(db_alias).all(): | ||||||
|  |         if flow.slug not in slug_title_map: | ||||||
|  |             continue | ||||||
|  |         flow.title = slug_title_map[flow.slug] | ||||||
|  |         flow.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_flows", "0020_flowtoken"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [migrations.RunPython(update_title_for_defaults)] | ||||||
| @ -1,7 +1,7 @@ | |||||||
| """Flow models""" | """Flow models""" | ||||||
| from base64 import b64decode, b64encode | from base64 import b64decode, b64encode | ||||||
| from pickle import dumps, loads  # nosec | from pickle import dumps, loads  # nosec | ||||||
| from typing import TYPE_CHECKING, Optional, Type | from typing import TYPE_CHECKING, Optional | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| @ -63,7 +63,7 @@ class Stage(SerializerModel): | |||||||
|     objects = InheritanceManager() |     objects = InheritanceManager() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def type(self) -> Type["StageView"]: |     def type(self) -> type["StageView"]: | ||||||
|         """Return StageView class that implements logic for this stage""" |         """Return StageView class that implements logic for this stage""" | ||||||
|         # This is a bit of a workaround, since we can't set class methods with setattr |         # This is a bit of a workaround, since we can't set class methods with setattr | ||||||
|         if hasattr(self, "__in_memory_type"): |         if hasattr(self, "__in_memory_type"): | ||||||
| @ -86,7 +86,7 @@ class Stage(SerializerModel): | |||||||
|         return f"Stage {self.name}" |         return f"Stage {self.name}" | ||||||
|  |  | ||||||
|  |  | ||||||
| def in_memory_stage(view: Type["StageView"]) -> Stage: | def in_memory_stage(view: type["StageView"]) -> Stage: | ||||||
|     """Creates an in-memory stage instance, based on a `view` as view.""" |     """Creates an in-memory stage instance, based on a `view` as view.""" | ||||||
|     stage = Stage() |     stage = Stage() | ||||||
|     # Because we can't pickle a locally generated function, |     # Because we can't pickle a locally generated function, | ||||||
|  | |||||||
| @ -152,7 +152,9 @@ class FlowPlanner: | |||||||
|             engine.build() |             engine.build() | ||||||
|             result = engine.result |             result = engine.result | ||||||
|             if not result.passing: |             if not result.passing: | ||||||
|                 raise FlowNonApplicableException(",".join(result.messages)) |                 exc = FlowNonApplicableException(",".join(result.messages)) | ||||||
|  |                 exc.policy_result = result | ||||||
|  |                 raise exc | ||||||
|             # User is passing so far, check if we have a cached plan |             # User is passing so far, check if we have a cached plan | ||||||
|             cached_plan_key = cache_key(self.flow, user) |             cached_plan_key = cache_key(self.flow, user) | ||||||
|             cached_plan = cache.get(cached_plan_key, None) |             cached_plan = cache.get(cached_plan_key, None) | ||||||
|  | |||||||
| @ -1,4 +1,6 @@ | |||||||
| """authentik stage Base view""" | """authentik stage Base view""" | ||||||
|  | from typing import TYPE_CHECKING, Optional | ||||||
|  |  | ||||||
| from django.contrib.auth.models import AnonymousUser | from django.contrib.auth.models import AnonymousUser | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| @ -11,14 +13,18 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.core.models import DEFAULT_AVATAR, User | from authentik.core.models import DEFAULT_AVATAR, User | ||||||
| from authentik.flows.challenge import ( | from authentik.flows.challenge import ( | ||||||
|  |     AccessDeniedChallenge, | ||||||
|     Challenge, |     Challenge, | ||||||
|     ChallengeResponse, |     ChallengeResponse, | ||||||
|  |     ChallengeTypes, | ||||||
|     ContextualFlowInfo, |     ContextualFlowInfo, | ||||||
|     HttpChallengeResponse, |     HttpChallengeResponse, | ||||||
|     WithUserInfoChallenge, |     WithUserInfoChallenge, | ||||||
| ) | ) | ||||||
| from authentik.flows.models import InvalidResponseAction | from authentik.flows.models import InvalidResponseAction | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER | from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER | ||||||
|  |  | ||||||
|  | if TYPE_CHECKING: | ||||||
|     from authentik.flows.views.executor import FlowExecutorView |     from authentik.flows.views.executor import FlowExecutorView | ||||||
|  |  | ||||||
| PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" | PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" | ||||||
| @ -28,11 +34,11 @@ LOGGER = get_logger() | |||||||
| class StageView(View): | class StageView(View): | ||||||
|     """Abstract Stage, inherits TemplateView but can be combined with FormView""" |     """Abstract Stage, inherits TemplateView but can be combined with FormView""" | ||||||
|  |  | ||||||
|     executor: FlowExecutorView |     executor: "FlowExecutorView" | ||||||
|  |  | ||||||
|     request: HttpRequest = None |     request: HttpRequest = None | ||||||
|  |  | ||||||
|     def __init__(self, executor: FlowExecutorView, **kwargs): |     def __init__(self, executor: "FlowExecutorView", **kwargs): | ||||||
|         self.executor = executor |         self.executor = executor | ||||||
|         super().__init__(**kwargs) |         super().__init__(**kwargs) | ||||||
|  |  | ||||||
| @ -43,6 +49,8 @@ class StageView(View): | |||||||
|         other things besides the form display. |         other things besides the form display. | ||||||
|  |  | ||||||
|         If no user is pending, returns request.user""" |         If no user is pending, returns request.user""" | ||||||
|  |         if not self.executor.plan: | ||||||
|  |             return self.request.user | ||||||
|         if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context and for_display: |         if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context and for_display: | ||||||
|             return User( |             return User( | ||||||
|                 username=self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER_IDENTIFIER), |                 username=self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER_IDENTIFIER), | ||||||
| @ -108,6 +116,8 @@ class ChallengeStageView(StageView): | |||||||
|  |  | ||||||
|     def format_title(self) -> str: |     def format_title(self) -> str: | ||||||
|         """Allow usage of placeholder in flow title.""" |         """Allow usage of placeholder in flow title.""" | ||||||
|  |         if not self.executor.plan: | ||||||
|  |             return self.executor.flow.title | ||||||
|         return self.executor.flow.title % { |         return self.executor.flow.title % { | ||||||
|             "app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "") |             "app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "") | ||||||
|         } |         } | ||||||
| @ -169,3 +179,27 @@ class ChallengeStageView(StageView): | |||||||
|                 stage_view=self, |                 stage_view=self, | ||||||
|             ) |             ) | ||||||
|         return HttpChallengeResponse(challenge_response) |         return HttpChallengeResponse(challenge_response) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AccessDeniedChallengeView(ChallengeStageView): | ||||||
|  |     """Used internally by FlowExecutor's stage_invalid()""" | ||||||
|  |  | ||||||
|  |     error_message: Optional[str] | ||||||
|  |  | ||||||
|  |     def __init__(self, executor: "FlowExecutorView", error_message: Optional[str] = None, **kwargs): | ||||||
|  |         super().__init__(executor, **kwargs) | ||||||
|  |         self.error_message = error_message | ||||||
|  |  | ||||||
|  |     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||||
|  |         return AccessDeniedChallenge( | ||||||
|  |             data={ | ||||||
|  |                 "error_message": self.error_message or "Unknown error", | ||||||
|  |                 "type": ChallengeTypes.NATIVE.value, | ||||||
|  |                 "component": "ak-stage-access-denied", | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     # This can never be reached since this challenge is created on demand and only the | ||||||
|  |     # .get() method is called | ||||||
|  |     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:  # pragma: no cover | ||||||
|  |         return self.executor.cancel() | ||||||
|  | |||||||
| @ -0,0 +1,51 @@ | |||||||
|  | """Test helpers""" | ||||||
|  | from json import loads | ||||||
|  | from typing import Any, Optional | ||||||
|  |  | ||||||
|  | from django.http.response import HttpResponse | ||||||
|  | from django.urls.base import reverse | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import User | ||||||
|  | from authentik.flows.challenge import ChallengeTypes | ||||||
|  | from authentik.flows.models import Flow | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FlowTestCase(APITestCase): | ||||||
|  |     """Helpers for testing flows and stages.""" | ||||||
|  |  | ||||||
|  |     # pylint: disable=invalid-name | ||||||
|  |     def assertStageResponse( | ||||||
|  |         self, | ||||||
|  |         response: HttpResponse, | ||||||
|  |         flow: Optional[Flow] = None, | ||||||
|  |         user: Optional[User] = None, | ||||||
|  |         **kwargs, | ||||||
|  |     ) -> dict[str, Any]: | ||||||
|  |         """Assert various attributes of a stage response""" | ||||||
|  |         raw_response = loads(response.content.decode()) | ||||||
|  |         self.assertIsNotNone(raw_response["component"]) | ||||||
|  |         self.assertIsNotNone(raw_response["type"]) | ||||||
|  |         if flow: | ||||||
|  |             self.assertIn("flow_info", raw_response) | ||||||
|  |             self.assertEqual(raw_response["flow_info"]["background"], flow.background_url) | ||||||
|  |             self.assertEqual( | ||||||
|  |                 raw_response["flow_info"]["cancel_url"], reverse("authentik_flows:cancel") | ||||||
|  |             ) | ||||||
|  |             # We don't check the flow title since it will most likely go | ||||||
|  |             # through ChallengeStageView.format_title() so might not match 1:1 | ||||||
|  |             # self.assertEqual(raw_response["flow_info"]["title"], flow.title) | ||||||
|  |             self.assertIsNotNone(raw_response["flow_info"]["title"]) | ||||||
|  |         if user: | ||||||
|  |             self.assertEqual(raw_response["pending_user"], user.username) | ||||||
|  |             self.assertEqual(raw_response["pending_user_avatar"], user.avatar) | ||||||
|  |         for key, expected in kwargs.items(): | ||||||
|  |             self.assertEqual(raw_response[key], expected) | ||||||
|  |         return raw_response | ||||||
|  |  | ||||||
|  |     # pylint: disable=invalid-name | ||||||
|  |     def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]: | ||||||
|  |         """Wrapper around assertStageResponse that checks for a redirect""" | ||||||
|  |         return self.assertStageResponse( | ||||||
|  |             response, component="xak-flow-redirect", to=to, type=ChallengeTypes.REDIRECT.value | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -4,16 +4,14 @@ from unittest.mock import MagicMock, PropertyMock, patch | |||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
| from django.test.client import RequestFactory | from django.test.client import RequestFactory | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.encoding import force_str |  | ||||||
| from rest_framework.test import APITestCase |  | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.flows.challenge import ChallengeTypes |  | ||||||
| from authentik.flows.exceptions import FlowNonApplicableException | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||||
| from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction | from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction | ||||||
| from authentik.flows.planner import FlowPlan, FlowPlanner | from authentik.flows.planner import FlowPlan, FlowPlanner | ||||||
| from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView | from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView | ||||||
|  | from authentik.flows.tests import FlowTestCase | ||||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView | from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.policies.dummy.models import DummyPolicy | from authentik.policies.dummy.models import DummyPolicy | ||||||
| @ -37,7 +35,7 @@ def to_stage_response(request: HttpRequest, source: HttpResponse): | |||||||
| TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response) | TO_STAGE_RESPONSE_MOCK = MagicMock(side_effect=to_stage_response) | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestFlowExecutor(APITestCase): | class TestFlowExecutor(FlowTestCase): | ||||||
|     """Test executor""" |     """Test executor""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
| @ -90,18 +88,11 @@ class TestFlowExecutor(APITestCase): | |||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertStageResponse( | ||||||
|             force_str(response.content), |             response, | ||||||
|             { |             flow=flow, | ||||||
|                 "component": "ak-stage-access-denied", |             error_message=FlowNonApplicableException.__doc__, | ||||||
|                 "error_message": FlowNonApplicableException.__doc__, |             component="ak-stage-access-denied", | ||||||
|                 "flow_info": { |  | ||||||
|                     "background": flow.background_url, |  | ||||||
|                     "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                     "title": "", |  | ||||||
|                 }, |  | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |  | ||||||
|             }, |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @patch( |     @patch( | ||||||
| @ -283,14 +274,7 @@ class TestFlowExecutor(APITestCase): | |||||||
|         # We do this request without the patch, so the policy results in false |         # We do this request without the patch, so the policy results in false | ||||||
|         response = self.client.post(exec_url) |         response = self.client.post(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) | ||||||
|             force_str(response.content), |  | ||||||
|             { |  | ||||||
|                 "component": "xak-flow-redirect", |  | ||||||
|                 "to": reverse("authentik_core:root-redirect"), |  | ||||||
|                 "type": ChallengeTypes.REDIRECT.value, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_reevaluate_keep(self): |     def test_reevaluate_keep(self): | ||||||
|         """Test planner with re-evaluate (everything is kept)""" |         """Test planner with re-evaluate (everything is kept)""" | ||||||
| @ -360,14 +344,7 @@ class TestFlowExecutor(APITestCase): | |||||||
|         # We do this request without the patch, so the policy results in false |         # We do this request without the patch, so the policy results in false | ||||||
|         response = self.client.post(exec_url) |         response = self.client.post(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) | ||||||
|             force_str(response.content), |  | ||||||
|             { |  | ||||||
|                 "component": "xak-flow-redirect", |  | ||||||
|                 "to": reverse("authentik_core:root-redirect"), |  | ||||||
|                 "type": ChallengeTypes.REDIRECT.value, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_reevaluate_remove_consecutive(self): |     def test_reevaluate_remove_consecutive(self): | ||||||
|         """Test planner with re-evaluate (consecutive stages are removed)""" |         """Test planner with re-evaluate (consecutive stages are removed)""" | ||||||
| @ -407,18 +384,7 @@ class TestFlowExecutor(APITestCase): | |||||||
|             # First request, run the planner |             # First request, run the planner | ||||||
|             response = self.client.get(exec_url) |             response = self.client.get(exec_url) | ||||||
|             self.assertEqual(response.status_code, 200) |             self.assertEqual(response.status_code, 200) | ||||||
|             self.assertJSONEqual( |             self.assertStageResponse(response, flow, component="ak-stage-dummy") | ||||||
|                 force_str(response.content), |  | ||||||
|                 { |  | ||||||
|                     "type": ChallengeTypes.NATIVE.value, |  | ||||||
|                     "component": "ak-stage-dummy", |  | ||||||
|                     "flow_info": { |  | ||||||
|                         "background": flow.background_url, |  | ||||||
|                         "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                         "title": "", |  | ||||||
|                     }, |  | ||||||
|                 }, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] |             plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||||
|  |  | ||||||
| @ -441,31 +407,13 @@ class TestFlowExecutor(APITestCase): | |||||||
|         # but it won't save it, hence we can't check the plan |         # but it won't save it, hence we can't check the plan | ||||||
|         response = self.client.get(exec_url) |         response = self.client.get(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertStageResponse(response, flow, component="ak-stage-dummy") | ||||||
|             force_str(response.content), |  | ||||||
|             { |  | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |  | ||||||
|                 "component": "ak-stage-dummy", |  | ||||||
|                 "flow_info": { |  | ||||||
|                     "background": flow.background_url, |  | ||||||
|                     "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                     "title": "", |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         # fourth request, this confirms the last stage (dummy4) |         # fourth request, this confirms the last stage (dummy4) | ||||||
|         # We do this request without the patch, so the policy results in false |         # We do this request without the patch, so the policy results in false | ||||||
|         response = self.client.post(exec_url) |         response = self.client.post(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) | ||||||
|             force_str(response.content), |  | ||||||
|             { |  | ||||||
|                 "component": "xak-flow-redirect", |  | ||||||
|                 "to": reverse("authentik_core:root-redirect"), |  | ||||||
|                 "type": ChallengeTypes.REDIRECT.value, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def test_stageview_user_identifier(self): |     def test_stageview_user_identifier(self): | ||||||
|         """Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER""" |         """Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER""" | ||||||
| @ -532,35 +480,16 @@ class TestFlowExecutor(APITestCase): | |||||||
|         # First request, run the planner |         # First request, run the planner | ||||||
|         response = self.client.get(exec_url) |         response = self.client.get(exec_url) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertStageResponse( | ||||||
|             force_str(response.content), |             response, | ||||||
|             { |             flow, | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |             component="ak-stage-identification", | ||||||
|                 "component": "ak-stage-identification", |             password_fields=False, | ||||||
|                 "flow_info": { |             primary_action="Log in", | ||||||
|                     "background": flow.background_url, |             sources=[], | ||||||
|                     "cancel_url": reverse("authentik_flows:cancel"), |             show_source_labels=False, | ||||||
|                     "title": "", |             user_fields=[UserFields.E_MAIL], | ||||||
|                 }, |  | ||||||
|                 "password_fields": False, |  | ||||||
|                 "primary_action": "Log in", |  | ||||||
|                 "sources": [], |  | ||||||
|                 "show_source_labels": False, |  | ||||||
|                 "user_fields": [UserFields.E_MAIL], |  | ||||||
|             }, |  | ||||||
|         ) |         ) | ||||||
|         response = self.client.post(exec_url, {"uid_field": "invalid-string"}, follow=True) |         response = self.client.post(exec_url, {"uid_field": "invalid-string"}, follow=True) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertStageResponse(response, flow, component="ak-stage-access-denied") | ||||||
|             force_str(response.content), |  | ||||||
|             { |  | ||||||
|                 "component": "ak-stage-access-denied", |  | ||||||
|                 "error_message": None, |  | ||||||
|                 "flow_info": { |  | ||||||
|                     "background": flow.background_url, |  | ||||||
|                     "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                     "title": "", |  | ||||||
|                 }, |  | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """base model tests""" | """base model tests""" | ||||||
| from typing import Callable, Type | from typing import Callable | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
|  |  | ||||||
| @ -12,7 +12,7 @@ class TestModels(TestCase): | |||||||
|     """Generic model properties tests""" |     """Generic model properties tests""" | ||||||
|  |  | ||||||
|  |  | ||||||
| def model_tester_factory(test_model: Type[Stage]) -> Callable: | def model_tester_factory(test_model: type[Stage]) -> Callable: | ||||||
|     """Test a form""" |     """Test a form""" | ||||||
|  |  | ||||||
|     def tester(self: TestModels): |     def tester(self: TestModels): | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """stage view tests""" | """stage view tests""" | ||||||
| from typing import Callable, Type | from typing import Callable | ||||||
|  |  | ||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
|  |  | ||||||
| @ -16,7 +16,7 @@ class TestViews(TestCase): | |||||||
|         self.exec = FlowExecutorView(request=self.factory.get("/")) |         self.exec = FlowExecutorView(request=self.factory.get("/")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def view_tester_factory(view_class: Type[StageView]) -> Callable: | def view_tester_factory(view_class: type[StageView]) -> Callable: | ||||||
|     """Test a form""" |     """Test a form""" | ||||||
|  |  | ||||||
|     def tester(self: TestViews): |     def tester(self: TestViews): | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from contextlib import contextmanager | from contextlib import contextmanager | ||||||
| from copy import deepcopy | from copy import deepcopy | ||||||
| from json import loads | from json import loads | ||||||
| from typing import Any, Type | from typing import Any | ||||||
|  |  | ||||||
| from dacite import from_dict | from dacite import from_dict | ||||||
| from dacite.exceptions import DaciteError | from dacite.exceptions import DaciteError | ||||||
| @ -87,7 +87,7 @@ class FlowImporter: | |||||||
|     def _validate_single(self, entry: FlowBundleEntry) -> BaseSerializer: |     def _validate_single(self, entry: FlowBundleEntry) -> BaseSerializer: | ||||||
|         """Validate a single entry""" |         """Validate a single entry""" | ||||||
|         model_app_label, model_name = entry.model.split(".") |         model_app_label, model_name = entry.model.split(".") | ||||||
|         model: Type[SerializerModel] = apps.get_model(model_app_label, model_name) |         model: type[SerializerModel] = apps.get_model(model_app_label, model_name) | ||||||
|         if not isinstance(model(), ALLOWED_MODELS): |         if not isinstance(model(), ALLOWED_MODELS): | ||||||
|             raise EntryInvalidError(f"Model {model} not allowed") |             raise EntryInvalidError(f"Model {model} not allowed") | ||||||
|  |  | ||||||
|  | |||||||
| @ -10,7 +10,6 @@ from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect | |||||||
| from django.http.request import QueryDict | from django.http.request import QueryDict | ||||||
| from django.shortcuts import get_object_or_404, redirect | from django.shortcuts import get_object_or_404, redirect | ||||||
| from django.template.response import TemplateResponse | from django.template.response import TemplateResponse | ||||||
| from django.urls.base import reverse |  | ||||||
| from django.utils.decorators import method_decorator | from django.utils.decorators import method_decorator | ||||||
| from django.views.decorators.clickjacking import xframe_options_sameorigin | from django.views.decorators.clickjacking import xframe_options_sameorigin | ||||||
| from django.views.generic import View | from django.views.generic import View | ||||||
| @ -26,7 +25,6 @@ from structlog.stdlib import BoundLogger, get_logger | |||||||
| from authentik.core.models import USER_ATTRIBUTE_DEBUG | from authentik.core.models import USER_ATTRIBUTE_DEBUG | ||||||
| from authentik.events.models import Event, EventAction, cleanse_dict | from authentik.events.models import Event, EventAction, cleanse_dict | ||||||
| from authentik.flows.challenge import ( | from authentik.flows.challenge import ( | ||||||
|     AccessDeniedChallenge, |  | ||||||
|     Challenge, |     Challenge, | ||||||
|     ChallengeResponse, |     ChallengeResponse, | ||||||
|     ChallengeTypes, |     ChallengeTypes, | ||||||
| @ -51,6 +49,7 @@ from authentik.flows.planner import ( | |||||||
|     FlowPlan, |     FlowPlan, | ||||||
|     FlowPlanner, |     FlowPlanner, | ||||||
| ) | ) | ||||||
|  | from authentik.flows.stage import AccessDeniedChallengeView | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.lib.utils.errors import exception_to_string | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.lib.utils.reflection import all_subclasses, class_to_path | from authentik.lib.utils.reflection import all_subclasses, class_to_path | ||||||
| @ -371,12 +370,6 @@ class FlowExecutorView(APIView): | |||||||
|             NEXT_ARG_NAME, "authentik_core:root-redirect" |             NEXT_ARG_NAME, "authentik_core:root-redirect" | ||||||
|         ) |         ) | ||||||
|         self.cancel() |         self.cancel() | ||||||
|         Event.new( |  | ||||||
|             action=EventAction.FLOW_EXECUTION, |  | ||||||
|             flow=self.flow, |  | ||||||
|             designation=self.flow.designation, |  | ||||||
|             successful=True, |  | ||||||
|         ).from_http(self.request) |  | ||||||
|         return to_stage_response(self.request, redirect_with_qs(next_param)) |         return to_stage_response(self.request, redirect_with_qs(next_param)) | ||||||
|  |  | ||||||
|     def stage_ok(self) -> HttpResponse: |     def stage_ok(self) -> HttpResponse: | ||||||
| @ -412,21 +405,9 @@ class FlowExecutorView(APIView): | |||||||
|         is a superuser.""" |         is a superuser.""" | ||||||
|         self._logger.debug("f(exec): Stage invalid") |         self._logger.debug("f(exec): Stage invalid") | ||||||
|         self.cancel() |         self.cancel() | ||||||
|         response = HttpChallengeResponse( |         challenge_view = AccessDeniedChallengeView(self, error_message) | ||||||
|             AccessDeniedChallenge( |         challenge_view.request = self.request | ||||||
|                 { |         return to_stage_response(self.request, challenge_view.get(self.request)) | ||||||
|                     "error_message": error_message, |  | ||||||
|                     "type": ChallengeTypes.NATIVE.value, |  | ||||||
|                     "component": "ak-stage-access-denied", |  | ||||||
|                     "flow_info": { |  | ||||||
|                         "title": self.flow.title, |  | ||||||
|                         "background": self.flow.background_url, |  | ||||||
|                         "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                     }, |  | ||||||
|                 } |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|         return to_stage_response(self.request, response) |  | ||||||
|  |  | ||||||
|     def cancel(self): |     def cancel(self): | ||||||
|         """Cancel current execution and return a redirect""" |         """Cancel current execution and return a redirect""" | ||||||
|  | |||||||
| @ -78,6 +78,7 @@ footer_links: | |||||||
|   - name: authentik Website |   - name: authentik Website | ||||||
|     href: https://goauthentik.io/?utm_source=authentik |     href: https://goauthentik.io/?utm_source=authentik | ||||||
|  |  | ||||||
|  | default_user_change_name: true | ||||||
| default_user_change_email: true | default_user_change_email: true | ||||||
| default_user_change_username: true | default_user_change_username: true | ||||||
|  |  | ||||||
|  | |||||||
| @ -97,7 +97,7 @@ def before_send(event: dict, hint: dict) -> Optional[dict]: | |||||||
|     if "exc_info" in hint: |     if "exc_info" in hint: | ||||||
|         _, exc_value, _ = hint["exc_info"] |         _, exc_value, _ = hint["exc_info"] | ||||||
|         if isinstance(exc_value, ignored_classes): |         if isinstance(exc_value, ignored_classes): | ||||||
|             LOGGER.debug("dropping exception", exception=exc_value) |             LOGGER.debug("dropping exception", exc=exc_value) | ||||||
|             return None |             return None | ||||||
|     if "logger" in event: |     if "logger" in event: | ||||||
|         if event["logger"] in [ |         if event["logger"] in [ | ||||||
| @ -114,6 +114,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]: | |||||||
|         ]: |         ]: | ||||||
|             return None |             return None | ||||||
|     LOGGER.debug("sending event to sentry", exc=exc_value, source_logger=event.get("logger", None)) |     LOGGER.debug("sending event to sentry", exc=exc_value, source_logger=event.get("logger", None)) | ||||||
|     if settings.DEBUG: |     if settings.DEBUG or settings.TEST: | ||||||
|         return None |         return None | ||||||
|     return event |     return event | ||||||
|  | |||||||
| @ -13,4 +13,4 @@ class TestSentry(TestCase): | |||||||
|  |  | ||||||
|     def test_error_sent(self): |     def test_error_sent(self): | ||||||
|         """Test error sent""" |         """Test error sent""" | ||||||
|         self.assertEqual({}, before_send({}, {"exc_info": (0, ValueError(), 0)})) |         self.assertEqual(None, before_send({}, {"exc_info": (0, ValueError(), 0)})) | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """base model tests""" | """base model tests""" | ||||||
| from typing import Callable, Type | from typing import Callable | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from rest_framework.serializers import BaseSerializer | from rest_framework.serializers import BaseSerializer | ||||||
| @ -13,7 +13,7 @@ class TestModels(TestCase): | |||||||
|     """Generic model properties tests""" |     """Generic model properties tests""" | ||||||
|  |  | ||||||
|  |  | ||||||
| def model_tester_factory(test_model: Type[Stage]) -> Callable: | def model_tester_factory(test_model: type[Stage]) -> Callable: | ||||||
|     """Test a form""" |     """Test a form""" | ||||||
|  |  | ||||||
|     def tester(self: TestModels): |     def tester(self: TestModels): | ||||||
|  | |||||||
| @ -2,7 +2,6 @@ | |||||||
| import os | import os | ||||||
| from importlib import import_module | from importlib import import_module | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from typing import Union |  | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME | from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME | ||||||
| @ -30,7 +29,7 @@ def class_to_path(cls: type) -> str: | |||||||
|     return f"{cls.__module__}.{cls.__name__}" |     return f"{cls.__module__}.{cls.__name__}" | ||||||
|  |  | ||||||
|  |  | ||||||
| def path_to_class(path: Union[str, None]) -> Union[type, None]: | def path_to_class(path: str | None) -> type | None: | ||||||
|     """Import module and return class""" |     """Import module and return class""" | ||||||
|     if not path: |     if not path: | ||||||
|         return None |         return None | ||||||
|  | |||||||
| @ -34,7 +34,7 @@ def timedelta_from_string(expr: str) -> datetime.timedelta: | |||||||
|         key, value = duration_pair.split("=") |         key, value = duration_pair.split("=") | ||||||
|         if key.lower() not in ALLOWED_KEYS: |         if key.lower() not in ALLOWED_KEYS: | ||||||
|             continue |             continue | ||||||
|         kwargs[key.lower()] = float(value) |         kwargs[key.lower()] = float(value.strip()) | ||||||
|     if len(kwargs) < 1: |     if len(kwargs) < 1: | ||||||
|         raise ValueError("No valid keys to pass to timedelta") |         raise ValueError("No valid keys to pass to timedelta") | ||||||
|     return datetime.timedelta(**kwargs) |     return datetime.timedelta(**kwargs) | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """Managed objects manager""" | """Managed objects manager""" | ||||||
| from typing import Callable, Optional, Type | from typing import Callable, Optional | ||||||
|  |  | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| @ -11,11 +11,11 @@ LOGGER = get_logger() | |||||||
| class EnsureOp: | class EnsureOp: | ||||||
|     """Ensure operation, executed as part of an ObjectManager run""" |     """Ensure operation, executed as part of an ObjectManager run""" | ||||||
|  |  | ||||||
|     _obj: Type[ManagedModel] |     _obj: type[ManagedModel] | ||||||
|     _managed_uid: str |     _managed_uid: str | ||||||
|     _kwargs: dict |     _kwargs: dict | ||||||
|  |  | ||||||
|     def __init__(self, obj: Type[ManagedModel], managed_uid: str, **kwargs) -> None: |     def __init__(self, obj: type[ManagedModel], managed_uid: str, **kwargs) -> None: | ||||||
|         self._obj = obj |         self._obj = obj | ||||||
|         self._managed_uid = managed_uid |         self._managed_uid = managed_uid | ||||||
|         self._kwargs = kwargs |         self._kwargs = kwargs | ||||||
| @ -32,7 +32,7 @@ class EnsureExists(EnsureOp): | |||||||
|  |  | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|         obj: Type[ManagedModel], |         obj: type[ManagedModel], | ||||||
|         managed_uid: str, |         managed_uid: str, | ||||||
|         created_callback: Optional[Callable] = None, |         created_callback: Optional[Callable] = None, | ||||||
|         **kwargs, |         **kwargs, | ||||||
|  | |||||||
| @ -1,4 +1,6 @@ | |||||||
| """Outpost API Views""" | """Outpost API Views""" | ||||||
|  | from os import environ | ||||||
|  |  | ||||||
| from dacite.core import from_dict | from dacite.core import from_dict | ||||||
| from dacite.exceptions import DaciteError | from dacite.exceptions import DaciteError | ||||||
| from django_filters.filters import ModelMultipleChoiceFilter | from django_filters.filters import ModelMultipleChoiceFilter | ||||||
| @ -12,6 +14,7 @@ from rest_framework.response import Response | |||||||
| from rest_framework.serializers import JSONField, ModelSerializer, ValidationError | from rest_framework.serializers import JSONField, ModelSerializer, ValidationError | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
|  | from authentik import ENV_GIT_HASH_KEY | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer, is_dict | from authentik.core.api.utils import PassiveSerializer, is_dict | ||||||
| @ -98,8 +101,12 @@ class OutpostHealthSerializer(PassiveSerializer): | |||||||
|     last_seen = DateTimeField(read_only=True) |     last_seen = DateTimeField(read_only=True) | ||||||
|     version = CharField(read_only=True) |     version = CharField(read_only=True) | ||||||
|     version_should = CharField(read_only=True) |     version_should = CharField(read_only=True) | ||||||
|  |  | ||||||
|     version_outdated = BooleanField(read_only=True) |     version_outdated = BooleanField(read_only=True) | ||||||
|  |  | ||||||
|  |     build_hash = CharField(read_only=True, required=False) | ||||||
|  |     build_hash_should = CharField(read_only=True, required=False) | ||||||
|  |  | ||||||
|  |  | ||||||
| class OutpostFilter(FilterSet): | class OutpostFilter(FilterSet): | ||||||
|     """Filter for Outposts""" |     """Filter for Outposts""" | ||||||
| @ -146,6 +153,8 @@ class OutpostViewSet(UsedByMixin, ModelViewSet): | |||||||
|                     "version": state.version, |                     "version": state.version, | ||||||
|                     "version_should": state.version_should, |                     "version_should": state.version_should, | ||||||
|                     "version_outdated": state.version_outdated, |                     "version_outdated": state.version_outdated, | ||||||
|  |                     "build_hash": state.build_hash, | ||||||
|  |                     "build_hash_should": environ.get(ENV_GIT_HASH_KEY, ""), | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|         return Response(OutpostHealthSerializer(states, many=True).data) |         return Response(OutpostHealthSerializer(states, many=True).data) | ||||||
|  | |||||||
| @ -9,7 +9,11 @@ from structlog.testing import capture_logs | |||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import ENV_GIT_HASH_KEY, __version__ | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.sentry import SentryIgnoredException | from authentik.lib.sentry import SentryIgnoredException | ||||||
| from authentik.outposts.models import Outpost, OutpostServiceConnection | from authentik.outposts.models import ( | ||||||
|  |     Outpost, | ||||||
|  |     OutpostServiceConnection, | ||||||
|  |     OutpostServiceConnectionState, | ||||||
|  | ) | ||||||
|  |  | ||||||
| FIELD_MANAGER = "goauthentik.io" | FIELD_MANAGER = "goauthentik.io" | ||||||
|  |  | ||||||
| @ -28,11 +32,25 @@ class DeploymentPort: | |||||||
|     inner_port: Optional[int] = None |     inner_port: Optional[int] = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BaseClient: | ||||||
|  |     """Base class for custom clients""" | ||||||
|  |  | ||||||
|  |     def fetch_state(self) -> OutpostServiceConnectionState: | ||||||
|  |         """Get state, version info""" | ||||||
|  |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |     def __enter__(self): | ||||||
|  |         return self | ||||||
|  |  | ||||||
|  |     def __exit__(self, exc_type, exc_value, traceback): | ||||||
|  |         """Cleanup after usage""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseController: | class BaseController: | ||||||
|     """Base Outpost deployment controller""" |     """Base Outpost deployment controller""" | ||||||
|  |  | ||||||
|     deployment_ports: list[DeploymentPort] |     deployment_ports: list[DeploymentPort] | ||||||
|  |     client: BaseClient | ||||||
|     outpost: Outpost |     outpost: Outpost | ||||||
|     connection: OutpostServiceConnection |     connection: OutpostServiceConnection | ||||||
|  |  | ||||||
| @ -63,6 +81,14 @@ class BaseController: | |||||||
|             self.down() |             self.down() | ||||||
|         return [x["event"] for x in logs] |         return [x["event"] for x in logs] | ||||||
|  |  | ||||||
|  |     def __enter__(self): | ||||||
|  |         return self | ||||||
|  |  | ||||||
|  |     def __exit__(self, exc_type, exc_value, traceback): | ||||||
|  |         """Cleanup after usage""" | ||||||
|  |         if hasattr(self, "client"): | ||||||
|  |             self.client.__exit__(exc_type, exc_value, traceback) | ||||||
|  |  | ||||||
|     def get_static_deployment(self) -> str: |     def get_static_deployment(self) -> str: | ||||||
|         """Return a static deployment configuration""" |         """Return a static deployment configuration""" | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  | |||||||
| @ -1,17 +1,75 @@ | |||||||
| """Docker controller""" | """Docker controller""" | ||||||
| from time import sleep | from time import sleep | ||||||
|  | from typing import Optional | ||||||
|  | from urllib.parse import urlparse | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.utils.text import slugify | from django.utils.text import slugify | ||||||
| from docker import DockerClient | from docker import DockerClient as UpstreamDockerClient | ||||||
| from docker.errors import DockerException, NotFound | from docker.errors import DockerException, NotFound | ||||||
| from docker.models.containers import Container | from docker.models.containers import Container | ||||||
|  | from docker.utils.utils import kwargs_from_env | ||||||
|  | from structlog.stdlib import get_logger | ||||||
| from yaml import safe_dump | from yaml import safe_dump | ||||||
|  |  | ||||||
| from authentik import __version__ | from authentik import __version__ | ||||||
| from authentik.outposts.controllers.base import BaseController, ControllerException | from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException | ||||||
|  | from authentik.outposts.docker_ssh import DockerInlineSSH | ||||||
|  | from authentik.outposts.docker_tls import DockerInlineTLS | ||||||
| from authentik.outposts.managed import MANAGED_OUTPOST | from authentik.outposts.managed import MANAGED_OUTPOST | ||||||
| from authentik.outposts.models import DockerServiceConnection, Outpost, ServiceConnectionInvalid | from authentik.outposts.models import ( | ||||||
|  |     DockerServiceConnection, | ||||||
|  |     Outpost, | ||||||
|  |     OutpostServiceConnectionState, | ||||||
|  |     ServiceConnectionInvalid, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DockerClient(UpstreamDockerClient, BaseClient): | ||||||
|  |     """Custom docker client, which can handle TLS and SSH from a database.""" | ||||||
|  |  | ||||||
|  |     tls: Optional[DockerInlineTLS] | ||||||
|  |     ssh: Optional[DockerInlineSSH] | ||||||
|  |  | ||||||
|  |     def __init__(self, connection: DockerServiceConnection): | ||||||
|  |         self.tls = None | ||||||
|  |         self.ssh = None | ||||||
|  |         if connection.local: | ||||||
|  |             # Same result as DockerClient.from_env | ||||||
|  |             super().__init__(**kwargs_from_env()) | ||||||
|  |         else: | ||||||
|  |             parsed_url = urlparse(connection.url) | ||||||
|  |             tls_config = False | ||||||
|  |             if parsed_url.scheme == "ssh": | ||||||
|  |                 self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication) | ||||||
|  |                 self.ssh.write() | ||||||
|  |             else: | ||||||
|  |                 self.tls = DockerInlineTLS( | ||||||
|  |                     verification_kp=connection.tls_verification, | ||||||
|  |                     authentication_kp=connection.tls_authentication, | ||||||
|  |                 ) | ||||||
|  |                 tls_config = self.tls.write() | ||||||
|  |             super().__init__( | ||||||
|  |                 base_url=connection.url, | ||||||
|  |                 tls=tls_config, | ||||||
|  |             ) | ||||||
|  |         self.logger = get_logger() | ||||||
|  |         # Ensure the client actually works | ||||||
|  |         self.containers.list() | ||||||
|  |  | ||||||
|  |     def fetch_state(self) -> OutpostServiceConnectionState: | ||||||
|  |         try: | ||||||
|  |             return OutpostServiceConnectionState(version=self.info()["ServerVersion"], healthy=True) | ||||||
|  |         except (ServiceConnectionInvalid, DockerException): | ||||||
|  |             return OutpostServiceConnectionState(version="", healthy=False) | ||||||
|  |  | ||||||
|  |     def __exit__(self, exc_type, exc_value, traceback): | ||||||
|  |         if self.tls: | ||||||
|  |             self.logger.debug("Cleaning up TLS") | ||||||
|  |             self.tls.cleanup() | ||||||
|  |         if self.ssh: | ||||||
|  |             self.logger.debug("Cleaning up SSH") | ||||||
|  |             self.ssh.cleanup() | ||||||
|  |  | ||||||
|  |  | ||||||
| class DockerController(BaseController): | class DockerController(BaseController): | ||||||
| @ -27,8 +85,9 @@ class DockerController(BaseController): | |||||||
|         if outpost.managed == MANAGED_OUTPOST: |         if outpost.managed == MANAGED_OUTPOST: | ||||||
|             return |             return | ||||||
|         try: |         try: | ||||||
|             self.client = connection.client() |             self.client = DockerClient(connection) | ||||||
|         except ServiceConnectionInvalid as exc: |         except DockerException as exc: | ||||||
|  |             self.logger.warning(exc) | ||||||
|             raise ControllerException from exc |             raise ControllerException from exc | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|  | |||||||
| @ -1,34 +1,67 @@ | |||||||
| """Kubernetes deployment controller""" | """Kubernetes deployment controller""" | ||||||
| from io import StringIO | from io import StringIO | ||||||
| from typing import Type |  | ||||||
|  |  | ||||||
|  | from kubernetes.client import VersionApi, VersionInfo | ||||||
| from kubernetes.client.api_client import ApiClient | from kubernetes.client.api_client import ApiClient | ||||||
|  | from kubernetes.client.configuration import Configuration | ||||||
| from kubernetes.client.exceptions import OpenApiException | from kubernetes.client.exceptions import OpenApiException | ||||||
|  | from kubernetes.config.config_exception import ConfigException | ||||||
|  | from kubernetes.config.incluster_config import load_incluster_config | ||||||
|  | from kubernetes.config.kube_config import load_kube_config_from_dict | ||||||
| from structlog.testing import capture_logs | from structlog.testing import capture_logs | ||||||
| from urllib3.exceptions import HTTPError | from urllib3.exceptions import HTTPError | ||||||
| from yaml import dump_all | from yaml import dump_all | ||||||
|  |  | ||||||
| from authentik.outposts.controllers.base import BaseController, ControllerException | from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException | ||||||
| from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | ||||||
| from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler | from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler | ||||||
| from authentik.outposts.controllers.k8s.secret import SecretReconciler | from authentik.outposts.controllers.k8s.secret import SecretReconciler | ||||||
| from authentik.outposts.controllers.k8s.service import ServiceReconciler | from authentik.outposts.controllers.k8s.service import ServiceReconciler | ||||||
| from authentik.outposts.controllers.k8s.service_monitor import PrometheusServiceMonitorReconciler | from authentik.outposts.controllers.k8s.service_monitor import PrometheusServiceMonitorReconciler | ||||||
| from authentik.outposts.models import KubernetesServiceConnection, Outpost, ServiceConnectionInvalid | from authentik.outposts.models import ( | ||||||
|  |     KubernetesServiceConnection, | ||||||
|  |     Outpost, | ||||||
|  |     OutpostServiceConnectionState, | ||||||
|  |     ServiceConnectionInvalid, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class KubernetesClient(ApiClient, BaseClient): | ||||||
|  |     """Custom kubernetes client based on service connection""" | ||||||
|  |  | ||||||
|  |     def __init__(self, connection: KubernetesServiceConnection): | ||||||
|  |         config = Configuration() | ||||||
|  |         try: | ||||||
|  |             if connection.local: | ||||||
|  |                 load_incluster_config(client_configuration=config) | ||||||
|  |             else: | ||||||
|  |                 load_kube_config_from_dict(connection.kubeconfig, client_configuration=config) | ||||||
|  |             super().__init__(config) | ||||||
|  |         except ConfigException as exc: | ||||||
|  |             raise ServiceConnectionInvalid from exc | ||||||
|  |  | ||||||
|  |     def fetch_state(self) -> OutpostServiceConnectionState: | ||||||
|  |         """Get version info""" | ||||||
|  |         try: | ||||||
|  |             api_instance = VersionApi(self) | ||||||
|  |             version: VersionInfo = api_instance.get_code() | ||||||
|  |             return OutpostServiceConnectionState(version=version.git_version, healthy=True) | ||||||
|  |         except (OpenApiException, HTTPError, ServiceConnectionInvalid): | ||||||
|  |             return OutpostServiceConnectionState(version="", healthy=False) | ||||||
|  |  | ||||||
|  |  | ||||||
| class KubernetesController(BaseController): | class KubernetesController(BaseController): | ||||||
|     """Manage deployment of outpost in kubernetes""" |     """Manage deployment of outpost in kubernetes""" | ||||||
|  |  | ||||||
|     reconcilers: dict[str, Type[KubernetesObjectReconciler]] |     reconcilers: dict[str, type[KubernetesObjectReconciler]] | ||||||
|     reconcile_order: list[str] |     reconcile_order: list[str] | ||||||
|  |  | ||||||
|     client: ApiClient |     client: KubernetesClient | ||||||
|     connection: KubernetesServiceConnection |     connection: KubernetesServiceConnection | ||||||
|  |  | ||||||
|     def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection) -> None: |     def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection) -> None: | ||||||
|         super().__init__(outpost, connection) |         super().__init__(outpost, connection) | ||||||
|         self.client = connection.client() |         self.client = KubernetesClient(connection) | ||||||
|         self.reconcilers = { |         self.reconcilers = { | ||||||
|             "secret": SecretReconciler, |             "secret": SecretReconciler, | ||||||
|             "deployment": DeploymentReconciler, |             "deployment": DeploymentReconciler, | ||||||
|  | |||||||
							
								
								
									
										82
									
								
								authentik/outposts/docker_ssh.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								authentik/outposts/docker_ssh.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,82 @@ | |||||||
|  | """Docker SSH helper""" | ||||||
|  | import os | ||||||
|  | from pathlib import Path | ||||||
|  | from tempfile import gettempdir | ||||||
|  |  | ||||||
|  | from authentik.crypto.models import CertificateKeyPair | ||||||
|  |  | ||||||
|  | HEADER = "### Managed by authentik" | ||||||
|  | FOOTER = "### End Managed by authentik" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def opener(path, flags): | ||||||
|  |     """File opener to create files as 700 perms""" | ||||||
|  |     return os.open(path, flags, 0o700) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DockerInlineSSH: | ||||||
|  |     """Create paramiko ssh config from CertificateKeyPair""" | ||||||
|  |  | ||||||
|  |     host: str | ||||||
|  |     keypair: CertificateKeyPair | ||||||
|  |  | ||||||
|  |     key_path: str | ||||||
|  |     config_path: Path | ||||||
|  |     header: str | ||||||
|  |  | ||||||
|  |     def __init__(self, host: str, keypair: CertificateKeyPair) -> None: | ||||||
|  |         self.host = host | ||||||
|  |         self.keypair = keypair | ||||||
|  |         self.config_path = Path("~/.ssh/config").expanduser() | ||||||
|  |         self.header = f"{HEADER} - {self.host}\n" | ||||||
|  |  | ||||||
|  |     def write_config(self, key_path: str) -> bool: | ||||||
|  |         """Update the local user's ssh config file""" | ||||||
|  |         with open(self.config_path, "a+", encoding="utf-8") as ssh_config: | ||||||
|  |             if self.header in ssh_config.readlines(): | ||||||
|  |                 return False | ||||||
|  |             ssh_config.writelines( | ||||||
|  |                 [ | ||||||
|  |                     self.header, | ||||||
|  |                     f"Host {self.host}\n", | ||||||
|  |                     f"    IdentityFile {key_path}\n", | ||||||
|  |                     f"{FOOTER}\n", | ||||||
|  |                     "\n", | ||||||
|  |                 ] | ||||||
|  |             ) | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def write_key(self): | ||||||
|  |         """Write keypair's private key to a temporary file""" | ||||||
|  |         path = Path(gettempdir(), f"{self.keypair.pk}_private.pem") | ||||||
|  |         with open(path, "w", encoding="utf8", opener=opener) as _file: | ||||||
|  |             _file.write(self.keypair.key_data) | ||||||
|  |         return str(path) | ||||||
|  |  | ||||||
|  |     def write(self): | ||||||
|  |         """Write keyfile and update ssh config""" | ||||||
|  |         self.key_path = self.write_key() | ||||||
|  |         was_written = self.write_config(self.key_path) | ||||||
|  |         if not was_written: | ||||||
|  |             self.cleanup() | ||||||
|  |  | ||||||
|  |     def cleanup(self): | ||||||
|  |         """Cleanup when we're done""" | ||||||
|  |         try: | ||||||
|  |             os.unlink(self.key_path) | ||||||
|  |             with open(self.config_path, "r+", encoding="utf-8") as ssh_config: | ||||||
|  |                 start = 0 | ||||||
|  |                 end = 0 | ||||||
|  |                 lines = ssh_config.readlines() | ||||||
|  |                 for idx, line in enumerate(lines): | ||||||
|  |                     if line == self.header: | ||||||
|  |                         start = idx | ||||||
|  |                     if start != 0 and line == f"{FOOTER}\n": | ||||||
|  |                         end = idx | ||||||
|  |             with open(self.config_path, "w+", encoding="utf-8") as ssh_config: | ||||||
|  |                 lines = lines[:start] + lines[end + 2 :] | ||||||
|  |                 ssh_config.writelines(lines) | ||||||
|  |         except OSError: | ||||||
|  |             # If we fail deleting a file it doesn't matter that much | ||||||
|  |             # since we're just in a container | ||||||
|  |             pass | ||||||
| @ -1,4 +1,5 @@ | |||||||
| """Create Docker TLSConfig from CertificateKeyPair""" | """Create Docker TLSConfig from CertificateKeyPair""" | ||||||
|  | from os import unlink | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from tempfile import gettempdir | from tempfile import gettempdir | ||||||
| from typing import Optional | from typing import Optional | ||||||
| @ -14,6 +15,8 @@ class DockerInlineTLS: | |||||||
|     verification_kp: Optional[CertificateKeyPair] |     verification_kp: Optional[CertificateKeyPair] | ||||||
|     authentication_kp: Optional[CertificateKeyPair] |     authentication_kp: Optional[CertificateKeyPair] | ||||||
|  |  | ||||||
|  |     _paths: list[str] | ||||||
|  |  | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|         verification_kp: Optional[CertificateKeyPair], |         verification_kp: Optional[CertificateKeyPair], | ||||||
| @ -21,14 +24,21 @@ class DockerInlineTLS: | |||||||
|     ) -> None: |     ) -> None: | ||||||
|         self.verification_kp = verification_kp |         self.verification_kp = verification_kp | ||||||
|         self.authentication_kp = authentication_kp |         self.authentication_kp = authentication_kp | ||||||
|  |         self._paths = [] | ||||||
|  |  | ||||||
|     def write_file(self, name: str, contents: str) -> str: |     def write_file(self, name: str, contents: str) -> str: | ||||||
|         """Wrapper for mkstemp that uses fdopen""" |         """Wrapper for mkstemp that uses fdopen""" | ||||||
|         path = Path(gettempdir(), name) |         path = Path(gettempdir(), name) | ||||||
|         with open(path, "w", encoding="utf8") as _file: |         with open(path, "w", encoding="utf8") as _file: | ||||||
|             _file.write(contents) |             _file.write(contents) | ||||||
|  |         self._paths.append(str(path)) | ||||||
|         return str(path) |         return str(path) | ||||||
|  |  | ||||||
|  |     def cleanup(self): | ||||||
|  |         """Clean up certificates when we're done""" | ||||||
|  |         for path in self._paths: | ||||||
|  |             unlink(path) | ||||||
|  |  | ||||||
|     def write(self) -> TLSConfig: |     def write(self) -> TLSConfig: | ||||||
|         """Create TLSConfig with Certificate Key pairs""" |         """Create TLSConfig with Certificate Key pairs""" | ||||||
|         # So yes, this is quite ugly. But sadly, there is no clean way to pass |         # So yes, this is quite ugly. But sadly, there is no clean way to pass | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from dataclasses import asdict, dataclass, field | from dataclasses import asdict, dataclass, field | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from os import environ | from os import environ | ||||||
| from typing import Iterable, Optional, Union | from typing import Iterable, Optional | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from dacite import from_dict | from dacite import from_dict | ||||||
| @ -11,21 +11,11 @@ from django.core.cache import cache | |||||||
| from django.db import IntegrityError, models, transaction | from django.db import IntegrityError, models, transaction | ||||||
| from django.db.models.base import Model | from django.db.models.base import Model | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from docker.client import DockerClient |  | ||||||
| from docker.errors import DockerException |  | ||||||
| from guardian.models import UserObjectPermission | from guardian.models import UserObjectPermission | ||||||
| from guardian.shortcuts import assign_perm | from guardian.shortcuts import assign_perm | ||||||
| from kubernetes.client import VersionApi, VersionInfo |  | ||||||
| from kubernetes.client.api_client import ApiClient |  | ||||||
| from kubernetes.client.configuration import Configuration |  | ||||||
| from kubernetes.client.exceptions import OpenApiException |  | ||||||
| from kubernetes.config.config_exception import ConfigException |  | ||||||
| from kubernetes.config.incluster_config import load_incluster_config |  | ||||||
| from kubernetes.config.kube_config import load_kube_config_from_dict |  | ||||||
| from model_utils.managers import InheritanceManager | from model_utils.managers import InheritanceManager | ||||||
| from packaging.version import LegacyVersion, Version, parse | from packaging.version import LegacyVersion, Version, parse | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
| from urllib3.exceptions import HTTPError |  | ||||||
|  |  | ||||||
| from authentik import ENV_GIT_HASH_KEY, __version__ | from authentik import ENV_GIT_HASH_KEY, __version__ | ||||||
| from authentik.core.models import ( | from authentik.core.models import ( | ||||||
| @ -44,7 +34,7 @@ from authentik.lib.sentry import SentryIgnoredException | |||||||
| from authentik.lib.utils.errors import exception_to_string | from authentik.lib.utils.errors import exception_to_string | ||||||
| from authentik.managed.models import ManagedModel | from authentik.managed.models import ManagedModel | ||||||
| from authentik.outposts.controllers.k8s.utils import get_namespace | from authentik.outposts.controllers.k8s.utils import get_namespace | ||||||
| from authentik.outposts.docker_tls import DockerInlineTLS | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
| OUR_VERSION = parse(__version__) | OUR_VERSION = parse(__version__) | ||||||
| OUTPOST_HELLO_INTERVAL = 10 | OUTPOST_HELLO_INTERVAL = 10 | ||||||
| @ -86,7 +76,7 @@ class OutpostConfig: | |||||||
| class OutpostModel(Model): | class OutpostModel(Model): | ||||||
|     """Base model for providers that need more objects than just themselves""" |     """Base model for providers that need more objects than just themselves""" | ||||||
|  |  | ||||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: |     def get_required_objects(self) -> Iterable[models.Model | str]: | ||||||
|         """Return a list of all required objects""" |         """Return a list of all required objects""" | ||||||
|         return [self] |         return [self] | ||||||
|  |  | ||||||
| @ -149,10 +139,6 @@ class OutpostServiceConnection(models.Model): | |||||||
|             return OutpostServiceConnectionState("", False) |             return OutpostServiceConnectionState("", False) | ||||||
|         return state |         return state | ||||||
|  |  | ||||||
|     def fetch_state(self) -> OutpostServiceConnectionState: |  | ||||||
|         """Fetch current Service Connection state""" |  | ||||||
|         raise NotImplementedError |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def component(self) -> str: |     def component(self) -> str: | ||||||
|         """Return component used to edit this object""" |         """Return component used to edit this object""" | ||||||
| @ -210,35 +196,6 @@ class DockerServiceConnection(OutpostServiceConnection): | |||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"Docker Service-Connection {self.name}" |         return f"Docker Service-Connection {self.name}" | ||||||
|  |  | ||||||
|     def client(self) -> DockerClient: |  | ||||||
|         """Get DockerClient""" |  | ||||||
|         try: |  | ||||||
|             client = None |  | ||||||
|             if self.local: |  | ||||||
|                 client = DockerClient.from_env() |  | ||||||
|             else: |  | ||||||
|                 client = DockerClient( |  | ||||||
|                     base_url=self.url, |  | ||||||
|                     tls=DockerInlineTLS( |  | ||||||
|                         verification_kp=self.tls_verification, |  | ||||||
|                         authentication_kp=self.tls_authentication, |  | ||||||
|                     ).write(), |  | ||||||
|                 ) |  | ||||||
|             client.containers.list() |  | ||||||
|         except DockerException as exc: |  | ||||||
|             LOGGER.warning(exc) |  | ||||||
|             raise ServiceConnectionInvalid from exc |  | ||||||
|         return client |  | ||||||
|  |  | ||||||
|     def fetch_state(self) -> OutpostServiceConnectionState: |  | ||||||
|         try: |  | ||||||
|             client = self.client() |  | ||||||
|             return OutpostServiceConnectionState( |  | ||||||
|                 version=client.info()["ServerVersion"], healthy=True |  | ||||||
|             ) |  | ||||||
|         except ServiceConnectionInvalid: |  | ||||||
|             return OutpostServiceConnectionState(version="", healthy=False) |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         verbose_name = _("Docker Service-Connection") |         verbose_name = _("Docker Service-Connection") | ||||||
| @ -265,27 +222,6 @@ class KubernetesServiceConnection(OutpostServiceConnection): | |||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"Kubernetes Service-Connection {self.name}" |         return f"Kubernetes Service-Connection {self.name}" | ||||||
|  |  | ||||||
|     def fetch_state(self) -> OutpostServiceConnectionState: |  | ||||||
|         try: |  | ||||||
|             client = self.client() |  | ||||||
|             api_instance = VersionApi(client) |  | ||||||
|             version: VersionInfo = api_instance.get_code() |  | ||||||
|             return OutpostServiceConnectionState(version=version.git_version, healthy=True) |  | ||||||
|         except (OpenApiException, HTTPError, ServiceConnectionInvalid): |  | ||||||
|             return OutpostServiceConnectionState(version="", healthy=False) |  | ||||||
|  |  | ||||||
|     def client(self) -> ApiClient: |  | ||||||
|         """Get Kubernetes client configured from kubeconfig""" |  | ||||||
|         config = Configuration() |  | ||||||
|         try: |  | ||||||
|             if self.local: |  | ||||||
|                 load_incluster_config(client_configuration=config) |  | ||||||
|             else: |  | ||||||
|                 load_kube_config_from_dict(self.kubeconfig, client_configuration=config) |  | ||||||
|             return ApiClient(config) |  | ||||||
|         except ConfigException as exc: |  | ||||||
|             raise ServiceConnectionInvalid from exc |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         verbose_name = _("Kubernetes Service-Connection") |         verbose_name = _("Kubernetes Service-Connection") | ||||||
| @ -385,7 +321,8 @@ class Outpost(ManagedModel): | |||||||
|                     user.user_permissions.add(permission.first()) |                     user.user_permissions.add(permission.first()) | ||||||
|         LOGGER.debug( |         LOGGER.debug( | ||||||
|             "Updated service account's permissions", |             "Updated service account's permissions", | ||||||
|             perms=UserObjectPermission.objects.filter(user=user), |             obj_perms=UserObjectPermission.objects.filter(user=user), | ||||||
|  |             perms=user.user_permissions.all(), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @ -438,9 +375,9 @@ class Outpost(ManagedModel): | |||||||
|             Token.objects.filter(identifier=self.token_identifier).delete() |             Token.objects.filter(identifier=self.token_identifier).delete() | ||||||
|             return self.token |             return self.token | ||||||
|  |  | ||||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: |     def get_required_objects(self) -> Iterable[models.Model | str]: | ||||||
|         """Get an iterator of all objects the user needs read access to""" |         """Get an iterator of all objects the user needs read access to""" | ||||||
|         objects: list[Union[models.Model, str]] = [ |         objects: list[models.Model | str] = [ | ||||||
|             self, |             self, | ||||||
|             "authentik_events.add_event", |             "authentik_events.add_event", | ||||||
|         ] |         ] | ||||||
| @ -449,6 +386,10 @@ class Outpost(ManagedModel): | |||||||
|                 objects.extend(provider.get_required_objects()) |                 objects.extend(provider.get_required_objects()) | ||||||
|             else: |             else: | ||||||
|                 objects.append(provider) |                 objects.append(provider) | ||||||
|  |         if self.managed: | ||||||
|  |             for tenant in Tenant.objects.filter(web_certificate__isnull=False): | ||||||
|  |                 objects.append(tenant) | ||||||
|  |                 objects.append(tenant.web_certificate) | ||||||
|         return objects |         return objects | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
| @ -463,7 +404,7 @@ class OutpostState: | |||||||
|     channel_ids: list[str] = field(default_factory=list) |     channel_ids: list[str] = field(default_factory=list) | ||||||
|     last_seen: Optional[datetime] = field(default=None) |     last_seen: Optional[datetime] = field(default=None) | ||||||
|     version: Optional[str] = field(default=None) |     version: Optional[str] = field(default=None) | ||||||
|     version_should: Union[Version, LegacyVersion] = field(default=OUR_VERSION) |     version_should: Version | LegacyVersion = field(default=OUR_VERSION) | ||||||
|     build_hash: str = field(default="") |     build_hash: str = field(default="") | ||||||
|  |  | ||||||
|     _outpost: Optional[Outpost] = field(default=None) |     _outpost: Optional[Outpost] = field(default=None) | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ from authentik.crypto.models import CertificateKeyPair | |||||||
| from authentik.lib.utils.reflection import class_to_path | from authentik.lib.utils.reflection import class_to_path | ||||||
| from authentik.outposts.models import Outpost, OutpostServiceConnection | from authentik.outposts.models import Outpost, OutpostServiceConnection | ||||||
| from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save | from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save | ||||||
|  | from authentik.tenants.models import Tenant | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| UPDATE_TRIGGERING_MODELS = ( | UPDATE_TRIGGERING_MODELS = ( | ||||||
| @ -17,6 +18,7 @@ UPDATE_TRIGGERING_MODELS = ( | |||||||
|     OutpostServiceConnection, |     OutpostServiceConnection, | ||||||
|     Provider, |     Provider, | ||||||
|     CertificateKeyPair, |     CertificateKeyPair, | ||||||
|  |     Tenant, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -25,6 +25,8 @@ from authentik.events.monitored_tasks import ( | |||||||
| ) | ) | ||||||
| from authentik.lib.utils.reflection import path_to_class | from authentik.lib.utils.reflection import path_to_class | ||||||
| from authentik.outposts.controllers.base import BaseController, ControllerException | from authentik.outposts.controllers.base import BaseController, ControllerException | ||||||
|  | from authentik.outposts.controllers.docker import DockerClient | ||||||
|  | from authentik.outposts.controllers.kubernetes import KubernetesClient | ||||||
| from authentik.outposts.models import ( | from authentik.outposts.models import ( | ||||||
|     DockerServiceConnection, |     DockerServiceConnection, | ||||||
|     KubernetesServiceConnection, |     KubernetesServiceConnection, | ||||||
| @ -45,21 +47,21 @@ LOGGER = get_logger() | |||||||
| CACHE_KEY_OUTPOST_DOWN = "outpost_teardown_%s" | CACHE_KEY_OUTPOST_DOWN = "outpost_teardown_%s" | ||||||
|  |  | ||||||
|  |  | ||||||
| def controller_for_outpost(outpost: Outpost) -> Optional[BaseController]: | def controller_for_outpost(outpost: Outpost) -> Optional[type[BaseController]]: | ||||||
|     """Get a controller for the outpost, when a service connection is defined""" |     """Get a controller for the outpost, when a service connection is defined""" | ||||||
|     if not outpost.service_connection: |     if not outpost.service_connection: | ||||||
|         return None |         return None | ||||||
|     service_connection = outpost.service_connection |     service_connection = outpost.service_connection | ||||||
|     if outpost.type == OutpostType.PROXY: |     if outpost.type == OutpostType.PROXY: | ||||||
|         if isinstance(service_connection, DockerServiceConnection): |         if isinstance(service_connection, DockerServiceConnection): | ||||||
|             return ProxyDockerController(outpost, service_connection) |             return ProxyDockerController | ||||||
|         if isinstance(service_connection, KubernetesServiceConnection): |         if isinstance(service_connection, KubernetesServiceConnection): | ||||||
|             return ProxyKubernetesController(outpost, service_connection) |             return ProxyKubernetesController | ||||||
|     if outpost.type == OutpostType.LDAP: |     if outpost.type == OutpostType.LDAP: | ||||||
|         if isinstance(service_connection, DockerServiceConnection): |         if isinstance(service_connection, DockerServiceConnection): | ||||||
|             return LDAPDockerController(outpost, service_connection) |             return LDAPDockerController | ||||||
|         if isinstance(service_connection, KubernetesServiceConnection): |         if isinstance(service_connection, KubernetesServiceConnection): | ||||||
|             return LDAPKubernetesController(outpost, service_connection) |             return LDAPKubernetesController | ||||||
|     return None |     return None | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -71,7 +73,12 @@ def outpost_service_connection_state(connection_pk: Any): | |||||||
|     ) |     ) | ||||||
|     if not connection: |     if not connection: | ||||||
|         return |         return | ||||||
|     state = connection.fetch_state() |     if isinstance(connection, DockerServiceConnection): | ||||||
|  |         cls = DockerClient | ||||||
|  |     if isinstance(connection, KubernetesServiceConnection): | ||||||
|  |         cls = KubernetesClient | ||||||
|  |     with cls(connection) as client: | ||||||
|  |         state = client.fetch_state() | ||||||
|     cache.set(connection.state_key, state, timeout=None) |     cache.set(connection.state_key, state, timeout=None) | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -114,9 +121,10 @@ def outpost_controller( | |||||||
|         return |         return | ||||||
|     self.set_uid(slugify(outpost.name)) |     self.set_uid(slugify(outpost.name)) | ||||||
|     try: |     try: | ||||||
|         controller = controller_for_outpost(outpost) |         controller_type = controller_for_outpost(outpost) | ||||||
|         if not controller: |         if not controller_type: | ||||||
|             return |             return | ||||||
|  |         with controller_type(outpost, outpost.service_connection) as controller: | ||||||
|             logs = getattr(controller, f"{action}_with_logs")() |             logs = getattr(controller, f"{action}_with_logs")() | ||||||
|             LOGGER.debug("---------------Outpost Controller logs starting----------------") |             LOGGER.debug("---------------Outpost Controller logs starting----------------") | ||||||
|             for log in logs: |             for log in logs: | ||||||
|  | |||||||
| @ -1,16 +1,14 @@ | |||||||
| """Password flow tests""" | """Password flow tests""" | ||||||
| from django.urls.base import reverse | from django.urls.base import reverse | ||||||
| from django.utils.encoding import force_str |  | ||||||
| from rest_framework.test import APITestCase |  | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.flows.challenge import ChallengeTypes |  | ||||||
| from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding | from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding | ||||||
|  | from authentik.flows.tests import FlowTestCase | ||||||
| from authentik.policies.password.models import PasswordPolicy | from authentik.policies.password.models import PasswordPolicy | ||||||
| from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage | from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestPasswordPolicyFlow(APITestCase): | class TestPasswordPolicyFlow(FlowTestCase): | ||||||
|     """Test Password Policy""" |     """Test Password Policy""" | ||||||
|  |  | ||||||
|     def setUp(self) -> None: |     def setUp(self) -> None: | ||||||
| @ -53,11 +51,11 @@ class TestPasswordPolicyFlow(APITestCase): | |||||||
|             {"password": "akadmin"}, |             {"password": "akadmin"}, | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(response.status_code, 200) |         self.assertEqual(response.status_code, 200) | ||||||
|         self.assertJSONEqual( |         self.assertStageResponse( | ||||||
|             force_str(response.content), |             response, | ||||||
|             { |             self.flow, | ||||||
|                 "component": "ak-stage-prompt", |             component="ak-stage-prompt", | ||||||
|                 "fields": [ |             fields=[ | ||||||
|                 { |                 { | ||||||
|                     "field_key": "password", |                     "field_key": "password", | ||||||
|                     "label": "PASSWORD_LABEL", |                     "label": "PASSWORD_LABEL", | ||||||
| @ -68,14 +66,7 @@ class TestPasswordPolicyFlow(APITestCase): | |||||||
|                     "sub_text": "", |                     "sub_text": "", | ||||||
|                 } |                 } | ||||||
|             ], |             ], | ||||||
|                 "flow_info": { |             response_errors={ | ||||||
|                     "background": self.flow.background_url, |  | ||||||
|                     "cancel_url": reverse("authentik_flows:cancel"), |  | ||||||
|                     "title": "", |  | ||||||
|                 }, |  | ||||||
|                 "response_errors": { |  | ||||||
|                 "non_field_errors": [{"code": "invalid", "string": self.policy.error_message}] |                 "non_field_errors": [{"code": "invalid", "string": self.policy.error_message}] | ||||||
|             }, |             }, | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |  | ||||||
|             }, |  | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -1,11 +1,11 @@ | |||||||
| """Source API Views""" | """Reputation policy API Views""" | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.serializers import ModelSerializer | from rest_framework.serializers import ModelSerializer | ||||||
| from rest_framework.viewsets import GenericViewSet, ModelViewSet | from rest_framework.viewsets import GenericViewSet, ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.policies.api.policies import PolicySerializer | from authentik.policies.api.policies import PolicySerializer | ||||||
| from authentik.policies.reputation.models import IPReputation, ReputationPolicy, UserReputation | from authentik.policies.reputation.models import Reputation, ReputationPolicy | ||||||
|  |  | ||||||
|  |  | ||||||
| class ReputationPolicySerializer(PolicySerializer): | class ReputationPolicySerializer(PolicySerializer): | ||||||
| @ -29,59 +29,32 @@ class ReputationPolicyViewSet(UsedByMixin, ModelViewSet): | |||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class IPReputationSerializer(ModelSerializer): | class ReputationSerializer(ModelSerializer): | ||||||
|     """IPReputation Serializer""" |     """Reputation Serializer""" | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = IPReputation |         model = Reputation | ||||||
|         fields = [ |         fields = [ | ||||||
|             "pk", |             "pk", | ||||||
|  |             "identifier", | ||||||
|             "ip", |             "ip", | ||||||
|  |             "ip_geo_data", | ||||||
|             "score", |             "score", | ||||||
|             "updated", |             "updated", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class IPReputationViewSet( | class ReputationViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.DestroyModelMixin, |     mixins.DestroyModelMixin, | ||||||
|     UsedByMixin, |     UsedByMixin, | ||||||
|     mixins.ListModelMixin, |     mixins.ListModelMixin, | ||||||
|     GenericViewSet, |     GenericViewSet, | ||||||
| ): | ): | ||||||
|     """IPReputation Viewset""" |     """Reputation Viewset""" | ||||||
|  |  | ||||||
|     queryset = IPReputation.objects.all() |     queryset = Reputation.objects.all() | ||||||
|     serializer_class = IPReputationSerializer |     serializer_class = ReputationSerializer | ||||||
|     search_fields = ["ip", "score"] |     search_fields = ["identifier", "ip", "score"] | ||||||
|     filterset_fields = ["ip", "score"] |     filterset_fields = ["identifier", "ip", "score"] | ||||||
|     ordering = ["ip"] |     ordering = ["ip"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserReputationSerializer(ModelSerializer): |  | ||||||
|     """UserReputation Serializer""" |  | ||||||
|  |  | ||||||
|     class Meta: |  | ||||||
|         model = UserReputation |  | ||||||
|         fields = [ |  | ||||||
|             "pk", |  | ||||||
|             "username", |  | ||||||
|             "score", |  | ||||||
|             "updated", |  | ||||||
|         ] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserReputationViewSet( |  | ||||||
|     mixins.RetrieveModelMixin, |  | ||||||
|     mixins.DestroyModelMixin, |  | ||||||
|     UsedByMixin, |  | ||||||
|     mixins.ListModelMixin, |  | ||||||
|     GenericViewSet, |  | ||||||
| ): |  | ||||||
|     """UserReputation Viewset""" |  | ||||||
|  |  | ||||||
|     queryset = UserReputation.objects.all() |  | ||||||
|     serializer_class = UserReputationSerializer |  | ||||||
|     search_fields = ["username", "score"] |  | ||||||
|     filterset_fields = ["username", "score"] |  | ||||||
|     ordering = ["username"] |  | ||||||
|  | |||||||
| @ -13,3 +13,4 @@ class AuthentikPolicyReputationConfig(AppConfig): | |||||||
|  |  | ||||||
|     def ready(self): |     def ready(self): | ||||||
|         import_module("authentik.policies.reputation.signals") |         import_module("authentik.policies.reputation.signals") | ||||||
|  |         import_module("authentik.policies.reputation.tasks") | ||||||
|  | |||||||
| @ -0,0 +1,40 @@ | |||||||
|  | # Generated by Django 4.0.1 on 2022-01-05 18:56 | ||||||
|  |  | ||||||
|  | import uuid | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_policies_reputation", "0002_auto_20210529_2046"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.CreateModel( | ||||||
|  |             name="Reputation", | ||||||
|  |             fields=[ | ||||||
|  |                 ( | ||||||
|  |                     "reputation_uuid", | ||||||
|  |                     models.UUIDField( | ||||||
|  |                         default=uuid.uuid4, primary_key=True, serialize=False, unique=True | ||||||
|  |                     ), | ||||||
|  |                 ), | ||||||
|  |                 ("identifier", models.TextField()), | ||||||
|  |                 ("ip", models.GenericIPAddressField()), | ||||||
|  |                 ("ip_geo_data", models.JSONField(default=dict)), | ||||||
|  |                 ("score", models.BigIntegerField(default=0)), | ||||||
|  |                 ("updated", models.DateTimeField(auto_now_add=True)), | ||||||
|  |             ], | ||||||
|  |             options={ | ||||||
|  |                 "unique_together": {("identifier", "ip")}, | ||||||
|  |             }, | ||||||
|  |         ), | ||||||
|  |         migrations.DeleteModel( | ||||||
|  |             name="IPReputation", | ||||||
|  |         ), | ||||||
|  |         migrations.DeleteModel( | ||||||
|  |             name="UserReputation", | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -1,17 +1,20 @@ | |||||||
| """authentik reputation request policy""" | """authentik reputation request policy""" | ||||||
| from django.core.cache import cache | from uuid import uuid4 | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
|  | from django.db.models import Sum | ||||||
|  | from django.db.models.query_utils import Q | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from rest_framework.serializers import BaseSerializer | from rest_framework.serializers import BaseSerializer | ||||||
| from structlog import get_logger | from structlog import get_logger | ||||||
|  |  | ||||||
|  | from authentik.lib.models import SerializerModel | ||||||
| from authentik.lib.utils.http import get_client_ip | from authentik.lib.utils.http import get_client_ip | ||||||
| from authentik.policies.models import Policy | from authentik.policies.models import Policy | ||||||
| from authentik.policies.types import PolicyRequest, PolicyResult | from authentik.policies.types import PolicyRequest, PolicyResult | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| CACHE_KEY_IP_PREFIX = "authentik_reputation_ip_" | CACHE_KEY_PREFIX = "goauthentik.io/policies/reputation/scores/" | ||||||
| CACHE_KEY_USER_PREFIX = "authentik_reputation_user_" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ReputationPolicy(Policy): | class ReputationPolicy(Policy): | ||||||
| @ -33,17 +36,19 @@ class ReputationPolicy(Policy): | |||||||
|  |  | ||||||
|     def passes(self, request: PolicyRequest) -> PolicyResult: |     def passes(self, request: PolicyRequest) -> PolicyResult: | ||||||
|         remote_ip = get_client_ip(request.http_request) |         remote_ip = get_client_ip(request.http_request) | ||||||
|         passing = False |         query = Q() | ||||||
|         if self.check_ip: |         if self.check_ip: | ||||||
|             score = cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0) |             query |= Q(ip=remote_ip) | ||||||
|             passing += passing or score <= self.threshold |  | ||||||
|             LOGGER.debug("Score for IP", ip=remote_ip, score=score, passing=passing) |  | ||||||
|         if self.check_username: |         if self.check_username: | ||||||
|             score = cache.get_or_set(CACHE_KEY_USER_PREFIX + request.user.username, 0) |             query |= Q(identifier=request.user.username) | ||||||
|             passing += passing or score <= self.threshold |         score = ( | ||||||
|  |             Reputation.objects.filter(query).aggregate(total_score=Sum("score"))["total_score"] or 0 | ||||||
|  |         ) | ||||||
|  |         passing = score <= self.threshold | ||||||
|         LOGGER.debug( |         LOGGER.debug( | ||||||
|                 "Score for Username", |             "Score for user", | ||||||
|             username=request.user.username, |             username=request.user.username, | ||||||
|  |             remote_ip=remote_ip, | ||||||
|             score=score, |             score=score, | ||||||
|             passing=passing, |             passing=passing, | ||||||
|         ) |         ) | ||||||
| @ -55,23 +60,27 @@ class ReputationPolicy(Policy): | |||||||
|         verbose_name_plural = _("Reputation Policies") |         verbose_name_plural = _("Reputation Policies") | ||||||
|  |  | ||||||
|  |  | ||||||
| class IPReputation(models.Model): | class Reputation(SerializerModel): | ||||||
|     """Store score coming from the same IP""" |     """Reputation for user and or IP.""" | ||||||
|  |  | ||||||
|     ip = models.GenericIPAddressField(unique=True) |     reputation_uuid = models.UUIDField(primary_key=True, unique=True, default=uuid4) | ||||||
|     score = models.IntegerField(default=0) |  | ||||||
|     updated = models.DateTimeField(auto_now=True) |  | ||||||
|  |  | ||||||
|     def __str__(self): |     identifier = models.TextField() | ||||||
|         return f"IPReputation for {self.ip} @ {self.score}" |     ip = models.GenericIPAddressField() | ||||||
|  |     ip_geo_data = models.JSONField(default=dict) | ||||||
|  |     score = models.BigIntegerField(default=0) | ||||||
|  |  | ||||||
|  |     updated = models.DateTimeField(auto_now_add=True) | ||||||
|  |  | ||||||
| class UserReputation(models.Model): |     @property | ||||||
|     """Store score attempting to log in as the same username""" |     def serializer(self) -> BaseSerializer: | ||||||
|  |         from authentik.policies.reputation.api import ReputationSerializer | ||||||
|  |  | ||||||
|     username = models.TextField() |         return ReputationSerializer | ||||||
|     score = models.IntegerField(default=0) |  | ||||||
|     updated = models.DateTimeField(auto_now=True) |  | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self) -> str: | ||||||
|         return f"UserReputation for {self.username} @ {self.score}" |         return f"Reputation {self.identifier}/{self.ip} @ {self.score}" | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |  | ||||||
|  |         unique_together = ("identifier", "ip") | ||||||
|  | |||||||
| @ -2,13 +2,8 @@ | |||||||
| from celery.schedules import crontab | from celery.schedules import crontab | ||||||
|  |  | ||||||
| CELERY_BEAT_SCHEDULE = { | CELERY_BEAT_SCHEDULE = { | ||||||
|     "policies_reputation_ip_save": { |     "policies_reputation_save": { | ||||||
|         "task": "authentik.policies.reputation.tasks.save_ip_reputation", |         "task": "authentik.policies.reputation.tasks.save_reputation", | ||||||
|         "schedule": crontab(minute="*/5"), |  | ||||||
|         "options": {"queue": "authentik_scheduled"}, |  | ||||||
|     }, |  | ||||||
|     "policies_reputation_user_save": { |  | ||||||
|         "task": "authentik.policies.reputation.tasks.save_user_reputation", |  | ||||||
|         "schedule": crontab(minute="*/5"), |         "schedule": crontab(minute="*/5"), | ||||||
|         "options": {"queue": "authentik_scheduled"}, |         "options": {"queue": "authentik_scheduled"}, | ||||||
|     }, |     }, | ||||||
|  | |||||||
| @ -7,28 +7,32 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.lib.config import CONFIG | from authentik.lib.config import CONFIG | ||||||
| from authentik.lib.utils.http import get_client_ip | from authentik.lib.utils.http import get_client_ip | ||||||
| from authentik.policies.reputation.models import CACHE_KEY_IP_PREFIX, CACHE_KEY_USER_PREFIX | from authentik.policies.reputation.models import CACHE_KEY_PREFIX | ||||||
|  | from authentik.policies.reputation.tasks import save_reputation | ||||||
| from authentik.stages.identification.signals import identification_failed | from authentik.stages.identification.signals import identification_failed | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_reputation")) | CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_reputation")) | ||||||
|  |  | ||||||
|  |  | ||||||
| def update_score(request: HttpRequest, username: str, amount: int): | def update_score(request: HttpRequest, identifier: str, amount: int): | ||||||
|     """Update score for IP and User""" |     """Update score for IP and User""" | ||||||
|     remote_ip = get_client_ip(request) |     remote_ip = get_client_ip(request) | ||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         # We only update the cache here, as its faster than writing to the DB |         # We only update the cache here, as its faster than writing to the DB | ||||||
|         cache.get_or_set(CACHE_KEY_IP_PREFIX + remote_ip, 0, CACHE_TIMEOUT) |         score = cache.get_or_set( | ||||||
|         cache.incr(CACHE_KEY_IP_PREFIX + remote_ip, amount) |             CACHE_KEY_PREFIX + remote_ip + identifier, | ||||||
|  |             {"ip": remote_ip, "identifier": identifier, "score": 0}, | ||||||
|         cache.get_or_set(CACHE_KEY_USER_PREFIX + username, 0, CACHE_TIMEOUT) |             CACHE_TIMEOUT, | ||||||
|         cache.incr(CACHE_KEY_USER_PREFIX + username, amount) |         ) | ||||||
|  |         score["score"] += amount | ||||||
|  |         cache.set(CACHE_KEY_PREFIX + remote_ip + identifier, score) | ||||||
|     except ValueError as exc: |     except ValueError as exc: | ||||||
|         LOGGER.warning("failed to set reputation", exc=exc) |         LOGGER.warning("failed to set reputation", exc=exc) | ||||||
|  |  | ||||||
|     LOGGER.debug("Updated score", amount=amount, for_user=username, for_ip=remote_ip) |     LOGGER.debug("Updated score", amount=amount, for_user=identifier, for_ip=remote_ip) | ||||||
|  |     save_reputation.delay() | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(user_login_failed) | @receiver(user_login_failed) | ||||||
|  | |||||||
| @ -2,14 +2,15 @@ | |||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
|  | from authentik.events.geo import GEOIP_READER | ||||||
| from authentik.events.monitored_tasks import ( | from authentik.events.monitored_tasks import ( | ||||||
|     MonitoredTask, |     MonitoredTask, | ||||||
|     TaskResult, |     TaskResult, | ||||||
|     TaskResultStatus, |     TaskResultStatus, | ||||||
|     prefill_task, |     prefill_task, | ||||||
| ) | ) | ||||||
| from authentik.policies.reputation.models import IPReputation, UserReputation | from authentik.policies.reputation.models import Reputation | ||||||
| from authentik.policies.reputation.signals import CACHE_KEY_IP_PREFIX, CACHE_KEY_USER_PREFIX | from authentik.policies.reputation.signals import CACHE_KEY_PREFIX | ||||||
| from authentik.root.celery import CELERY_APP | from authentik.root.celery import CELERY_APP | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| @ -17,29 +18,16 @@ LOGGER = get_logger() | |||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True, base=MonitoredTask) | @CELERY_APP.task(bind=True, base=MonitoredTask) | ||||||
| @prefill_task | @prefill_task | ||||||
| def save_ip_reputation(self: MonitoredTask): | def save_reputation(self: MonitoredTask): | ||||||
|     """Save currently cached reputation to database""" |     """Save currently cached reputation to database""" | ||||||
|     objects_to_update = [] |     objects_to_update = [] | ||||||
|     for key, score in cache.get_many(cache.keys(CACHE_KEY_IP_PREFIX + "*")).items(): |     for _, score in cache.get_many(cache.keys(CACHE_KEY_PREFIX + "*")).items(): | ||||||
|         remote_ip = key.replace(CACHE_KEY_IP_PREFIX, "") |         rep, _ = Reputation.objects.get_or_create( | ||||||
|         rep, _ = IPReputation.objects.get_or_create(ip=remote_ip) |             ip=score["ip"], | ||||||
|         rep.score = score |             identifier=score["identifier"], | ||||||
|         objects_to_update.append(rep) |  | ||||||
|     IPReputation.objects.bulk_update(objects_to_update, ["score"]) |  | ||||||
|     self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated IP Reputation"])) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @CELERY_APP.task(bind=True, base=MonitoredTask) |  | ||||||
| @prefill_task |  | ||||||
| def save_user_reputation(self: MonitoredTask): |  | ||||||
|     """Save currently cached reputation to database""" |  | ||||||
|     objects_to_update = [] |  | ||||||
|     for key, score in cache.get_many(cache.keys(CACHE_KEY_USER_PREFIX + "*")).items(): |  | ||||||
|         username = key.replace(CACHE_KEY_USER_PREFIX, "") |  | ||||||
|         rep, _ = UserReputation.objects.get_or_create(username=username) |  | ||||||
|         rep.score = score |  | ||||||
|         objects_to_update.append(rep) |  | ||||||
|     UserReputation.objects.bulk_update(objects_to_update, ["score"]) |  | ||||||
|     self.set_status( |  | ||||||
|         TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated User Reputation"]) |  | ||||||
|         ) |         ) | ||||||
|  |         rep.ip_geo_data = GEOIP_READER.city_dict(score["ip"]) or {} | ||||||
|  |         rep.score = score["score"] | ||||||
|  |         objects_to_update.append(rep) | ||||||
|  |     Reputation.objects.bulk_update(objects_to_update, ["score", "ip_geo_data"]) | ||||||
|  |     self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated Reputation"])) | ||||||
|  | |||||||
| @ -4,15 +4,8 @@ from django.core.cache import cache | |||||||
| from django.test import RequestFactory, TestCase | from django.test import RequestFactory, TestCase | ||||||
|  |  | ||||||
| from authentik.core.models import User | from authentik.core.models import User | ||||||
| from authentik.lib.utils.http import DEFAULT_IP | from authentik.policies.reputation.models import CACHE_KEY_PREFIX, Reputation, ReputationPolicy | ||||||
| from authentik.policies.reputation.models import ( | from authentik.policies.reputation.tasks import save_reputation | ||||||
|     CACHE_KEY_IP_PREFIX, |  | ||||||
|     CACHE_KEY_USER_PREFIX, |  | ||||||
|     IPReputation, |  | ||||||
|     ReputationPolicy, |  | ||||||
|     UserReputation, |  | ||||||
| ) |  | ||||||
| from authentik.policies.reputation.tasks import save_ip_reputation, save_user_reputation |  | ||||||
| from authentik.policies.types import PolicyRequest | from authentik.policies.types import PolicyRequest | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -24,9 +17,8 @@ class TestReputationPolicy(TestCase): | |||||||
|         self.request = self.request_factory.get("/") |         self.request = self.request_factory.get("/") | ||||||
|         self.test_ip = "127.0.0.1" |         self.test_ip = "127.0.0.1" | ||||||
|         self.test_username = "test" |         self.test_username = "test" | ||||||
|         cache.delete(CACHE_KEY_IP_PREFIX + self.test_ip) |         keys = cache.keys(CACHE_KEY_PREFIX + "*") | ||||||
|         cache.delete(CACHE_KEY_IP_PREFIX + DEFAULT_IP) |         cache.delete_many(keys) | ||||||
|         cache.delete(CACHE_KEY_USER_PREFIX + self.test_username) |  | ||||||
|         # We need a user for the one-to-one in userreputation |         # We need a user for the one-to-one in userreputation | ||||||
|         self.user = User.objects.create(username=self.test_username) |         self.user = User.objects.create(username=self.test_username) | ||||||
|  |  | ||||||
| @ -35,20 +27,26 @@ class TestReputationPolicy(TestCase): | |||||||
|         # Trigger negative reputation |         # Trigger negative reputation | ||||||
|         authenticate(self.request, username=self.test_username, password=self.test_username) |         authenticate(self.request, username=self.test_username, password=self.test_username) | ||||||
|         # Test value in cache |         # Test value in cache | ||||||
|         self.assertEqual(cache.get(CACHE_KEY_IP_PREFIX + self.test_ip), -1) |         self.assertEqual( | ||||||
|  |             cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username), | ||||||
|  |             {"ip": "127.0.0.1", "identifier": "test", "score": -1}, | ||||||
|  |         ) | ||||||
|         # Save cache and check db values |         # Save cache and check db values | ||||||
|         save_ip_reputation.delay().get() |         save_reputation.delay().get() | ||||||
|         self.assertEqual(IPReputation.objects.get(ip=self.test_ip).score, -1) |         self.assertEqual(Reputation.objects.get(ip=self.test_ip).score, -1) | ||||||
|  |  | ||||||
|     def test_user_reputation(self): |     def test_user_reputation(self): | ||||||
|         """test User reputation""" |         """test User reputation""" | ||||||
|         # Trigger negative reputation |         # Trigger negative reputation | ||||||
|         authenticate(self.request, username=self.test_username, password=self.test_username) |         authenticate(self.request, username=self.test_username, password=self.test_username) | ||||||
|         # Test value in cache |         # Test value in cache | ||||||
|         self.assertEqual(cache.get(CACHE_KEY_USER_PREFIX + self.test_username), -1) |         self.assertEqual( | ||||||
|  |             cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username), | ||||||
|  |             {"ip": "127.0.0.1", "identifier": "test", "score": -1}, | ||||||
|  |         ) | ||||||
|         # Save cache and check db values |         # Save cache and check db values | ||||||
|         save_user_reputation.delay().get() |         save_reputation.delay().get() | ||||||
|         self.assertEqual(UserReputation.objects.get(username=self.test_username).score, -1) |         self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, -1) | ||||||
|  |  | ||||||
|     def test_policy(self): |     def test_policy(self): | ||||||
|         """Test Policy""" |         """Test Policy""" | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """LDAP Provider""" | """LDAP Provider""" | ||||||
| from typing import Iterable, Optional, Type, Union | from typing import Iterable, Optional | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| @ -78,7 +78,7 @@ class LDAPProvider(OutpostModel, Provider): | |||||||
|         return "ak-provider-ldap-form" |         return "ak-provider-ldap-form" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.providers.ldap.api import LDAPProviderSerializer |         from authentik.providers.ldap.api import LDAPProviderSerializer | ||||||
|  |  | ||||||
|         return LDAPProviderSerializer |         return LDAPProviderSerializer | ||||||
| @ -86,7 +86,7 @@ class LDAPProvider(OutpostModel, Provider): | |||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return f"LDAP Provider {self.name}" |         return f"LDAP Provider {self.name}" | ||||||
|  |  | ||||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: |     def get_required_objects(self) -> Iterable[models.Model | str]: | ||||||
|         required_models = [self, "authentik_core.view_user", "authentik_core.view_group"] |         required_models = [self, "authentik_core.view_user", "authentik_core.view_group"] | ||||||
|         if self.certificate is not None: |         if self.certificate is not None: | ||||||
|             required_models.append(self.certificate) |             required_models.append(self.certificate) | ||||||
|  | |||||||
| @ -1,31 +1,23 @@ | |||||||
| """OAuth2Provider API Views""" | """OAuth2Provider API Views""" | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.translation import gettext_lazy as _ |  | ||||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.fields import CharField | from rest_framework.fields import CharField | ||||||
| from rest_framework.generics import get_object_or_404 | from rest_framework.generics import get_object_or_404 | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.serializers import ValidationError |  | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  |  | ||||||
| from authentik.core.api.providers import ProviderSerializer | from authentik.core.api.providers import ProviderSerializer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
| from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider | from authentik.providers.oauth2.models import OAuth2Provider | ||||||
|  |  | ||||||
|  |  | ||||||
| class OAuth2ProviderSerializer(ProviderSerializer): | class OAuth2ProviderSerializer(ProviderSerializer): | ||||||
|     """OAuth2Provider Serializer""" |     """OAuth2Provider Serializer""" | ||||||
|  |  | ||||||
|     def validate_jwt_alg(self, value): |  | ||||||
|         """Ensure that when RS256 is selected, a certificate-key-pair is selected""" |  | ||||||
|         if self.initial_data.get("rsa_key", None) is None and value == JWTAlgorithms.RS256: |  | ||||||
|             raise ValidationError(_("RS256 requires a Certificate-Key-Pair to be selected.")) |  | ||||||
|         return value |  | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = OAuth2Provider |         model = OAuth2Provider | ||||||
| @ -37,8 +29,7 @@ class OAuth2ProviderSerializer(ProviderSerializer): | |||||||
|             "access_code_validity", |             "access_code_validity", | ||||||
|             "token_validity", |             "token_validity", | ||||||
|             "include_claims_in_id_token", |             "include_claims_in_id_token", | ||||||
|             "jwt_alg", |             "signing_key", | ||||||
|             "rsa_key", |  | ||||||
|             "redirect_uris", |             "redirect_uris", | ||||||
|             "sub_mode", |             "sub_mode", | ||||||
|             "property_mappings", |             "property_mappings", | ||||||
| @ -73,8 +64,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet): | |||||||
|         "access_code_validity", |         "access_code_validity", | ||||||
|         "token_validity", |         "token_validity", | ||||||
|         "include_claims_in_id_token", |         "include_claims_in_id_token", | ||||||
|         "jwt_alg", |         "signing_key", | ||||||
|         "rsa_key", |  | ||||||
|         "redirect_uris", |         "redirect_uris", | ||||||
|         "sub_mode", |         "sub_mode", | ||||||
|         "property_mappings", |         "property_mappings", | ||||||
|  | |||||||
| @ -0,0 +1,25 @@ | |||||||
|  | # Generated by Django 4.0 on 2021-12-22 21:04 | ||||||
|  |  | ||||||
|  | from django.db import migrations | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ( | ||||||
|  |             "authentik_providers_oauth2", | ||||||
|  |             "0007_auto_20201016_1107_squashed_0017_alter_oauth2provider_token_validity", | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.RenameField( | ||||||
|  |             model_name="oauth2provider", | ||||||
|  |             old_name="rsa_key", | ||||||
|  |             new_name="signing_key", | ||||||
|  |         ), | ||||||
|  |         migrations.RemoveField( | ||||||
|  |             model_name="oauth2provider", | ||||||
|  |             name="jwt_alg", | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -6,9 +6,11 @@ import time | |||||||
| from dataclasses import asdict, dataclass, field | from dataclasses import asdict, dataclass, field | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from hashlib import sha256 | from hashlib import sha256 | ||||||
| from typing import Any, Optional, Type | from typing import Any, Optional | ||||||
| from urllib.parse import urlparse | from urllib.parse import urlparse | ||||||
|  |  | ||||||
|  | from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey | ||||||
|  | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey | ||||||
| from dacite import from_dict | from dacite import from_dict | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| @ -88,6 +90,7 @@ class JWTAlgorithms(models.TextChoices): | |||||||
|  |  | ||||||
|     HS256 = "HS256", _("HS256 (Symmetric Encryption)") |     HS256 = "HS256", _("HS256 (Symmetric Encryption)") | ||||||
|     RS256 = "RS256", _("RS256 (Asymmetric Encryption)") |     RS256 = "RS256", _("RS256 (Asymmetric Encryption)") | ||||||
|  |     EC256 = "EC256", _("EC256 (Asymmetric Encryption)") | ||||||
|  |  | ||||||
|  |  | ||||||
| class ScopeMapping(PropertyMapping): | class ScopeMapping(PropertyMapping): | ||||||
| @ -109,7 +112,7 @@ class ScopeMapping(PropertyMapping): | |||||||
|         return "ak-property-mapping-scope-form" |         return "ak-property-mapping-scope-form" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.providers.oauth2.api.scope import ScopeMappingSerializer |         from authentik.providers.oauth2.api.scope import ScopeMappingSerializer | ||||||
|  |  | ||||||
|         return ScopeMappingSerializer |         return ScopeMappingSerializer | ||||||
| @ -145,13 +148,6 @@ class OAuth2Provider(Provider): | |||||||
|         verbose_name=_("Client Secret"), |         verbose_name=_("Client Secret"), | ||||||
|         default=generate_key, |         default=generate_key, | ||||||
|     ) |     ) | ||||||
|     jwt_alg = models.CharField( |  | ||||||
|         max_length=10, |  | ||||||
|         choices=JWTAlgorithms.choices, |  | ||||||
|         default=JWTAlgorithms.RS256, |  | ||||||
|         verbose_name=_("JWT Algorithm"), |  | ||||||
|         help_text=_(JWTAlgorithms.__doc__), |  | ||||||
|     ) |  | ||||||
|     redirect_uris = models.TextField( |     redirect_uris = models.TextField( | ||||||
|         default="", |         default="", | ||||||
|         blank=True, |         blank=True, | ||||||
| @ -207,7 +203,7 @@ class OAuth2Provider(Provider): | |||||||
|         help_text=_(("Configure how the issuer field of the ID Token should be filled.")), |         help_text=_(("Configure how the issuer field of the ID Token should be filled.")), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     rsa_key = models.ForeignKey( |     signing_key = models.ForeignKey( | ||||||
|         CertificateKeyPair, |         CertificateKeyPair, | ||||||
|         verbose_name=_("RSA Key"), |         verbose_name=_("RSA Key"), | ||||||
|         on_delete=models.SET_NULL, |         on_delete=models.SET_NULL, | ||||||
| @ -231,29 +227,18 @@ class OAuth2Provider(Provider): | |||||||
|         token.access_token = token.create_access_token(user, request) |         token.access_token = token.create_access_token(user, request) | ||||||
|         return token |         return token | ||||||
|  |  | ||||||
|     def get_jwt_key(self) -> str: |     def get_jwt_key(self) -> tuple[str, str]: | ||||||
|         """ |         """Get either the configured certificate or the client secret""" | ||||||
|         Takes a provider and returns the set of keys associated with it. |         if not self.signing_key: | ||||||
|         Returns a list of keys. |             # No Certificate at all, assume HS256 | ||||||
|         """ |             return self.client_secret, JWTAlgorithms.HS256 | ||||||
|         if self.jwt_alg == JWTAlgorithms.RS256: |         key: CertificateKeyPair = self.signing_key | ||||||
|             # if the user selected RS256 but didn't select a |         private_key = key.private_key | ||||||
|             # CertificateKeyPair, we fall back to HS256 |         if isinstance(private_key, RSAPrivateKey): | ||||||
|             if not self.rsa_key: |             return key.key_data, JWTAlgorithms.RS256 | ||||||
|                 Event.new( |         if isinstance(private_key, EllipticCurvePrivateKey): | ||||||
|                     EventAction.CONFIGURATION_ERROR, |             return key.key_data, JWTAlgorithms.EC256 | ||||||
|                     provider=self, |         raise Exception(f"Invalid private key type: {type(private_key)}") | ||||||
|                     message="Provider was configured for RS256, but no key was selected.", |  | ||||||
|                 ).save() |  | ||||||
|                 self.jwt_alg = JWTAlgorithms.HS256 |  | ||||||
|                 self.save() |  | ||||||
|             else: |  | ||||||
|                 return self.rsa_key.key_data |  | ||||||
|  |  | ||||||
|         if self.jwt_alg == JWTAlgorithms.HS256: |  | ||||||
|             return self.client_secret |  | ||||||
|  |  | ||||||
|         raise Exception("Unsupported key algorithm.") |  | ||||||
|  |  | ||||||
|     def get_issuer(self, request: HttpRequest) -> Optional[str]: |     def get_issuer(self, request: HttpRequest) -> Optional[str]: | ||||||
|         """Get issuer, based on request""" |         """Get issuer, based on request""" | ||||||
| @ -282,7 +267,7 @@ class OAuth2Provider(Provider): | |||||||
|         return "ak-provider-oauth2-form" |         return "ak-provider-oauth2-form" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.providers.oauth2.api.provider import OAuth2ProviderSerializer |         from authentik.providers.oauth2.api.provider import OAuth2ProviderSerializer | ||||||
|  |  | ||||||
|         return OAuth2ProviderSerializer |         return OAuth2ProviderSerializer | ||||||
| @ -293,13 +278,13 @@ class OAuth2Provider(Provider): | |||||||
|     def encode(self, payload: dict[str, Any]) -> str: |     def encode(self, payload: dict[str, Any]) -> str: | ||||||
|         """Represent the ID Token as a JSON Web Token (JWT).""" |         """Represent the ID Token as a JSON Web Token (JWT).""" | ||||||
|         headers = {} |         headers = {} | ||||||
|         if self.rsa_key: |         if self.signing_key: | ||||||
|             headers["kid"] = self.rsa_key.kid |             headers["kid"] = self.signing_key.kid | ||||||
|         key = self.get_jwt_key() |         key, alg = self.get_jwt_key() | ||||||
|         # If the provider does not have an RSA Key assigned, it was switched to Symmetric |         # If the provider does not have an RSA Key assigned, it was switched to Symmetric | ||||||
|         self.refresh_from_db() |         self.refresh_from_db() | ||||||
|         # pyright: reportGeneralTypeIssues=false |         # pyright: reportGeneralTypeIssues=false | ||||||
|         return encode(payload, key, algorithm=self.jwt_alg, headers=headers) |         return encode(payload, key, algorithm=alg, headers=headers) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,32 +0,0 @@ | |||||||
| """Test oauth2 provider API""" |  | ||||||
| from django.urls import reverse |  | ||||||
| from rest_framework.test import APITestCase |  | ||||||
|  |  | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow |  | ||||||
| from authentik.providers.oauth2.models import JWTAlgorithms |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestOAuth2ProviderAPI(APITestCase): |  | ||||||
|     """Test oauth2 provider API""" |  | ||||||
|  |  | ||||||
|     def setUp(self) -> None: |  | ||||||
|         super().setUp() |  | ||||||
|         self.user = create_test_admin_user() |  | ||||||
|         self.client.force_login(self.user) |  | ||||||
|  |  | ||||||
|     def test_validate(self): |  | ||||||
|         """Test OAuth2 Provider validation""" |  | ||||||
|         response = self.client.post( |  | ||||||
|             reverse( |  | ||||||
|                 "authentik_api:oauth2provider-list", |  | ||||||
|             ), |  | ||||||
|             data={ |  | ||||||
|                 "name": "test", |  | ||||||
|                 "jwt_alg": str(JWTAlgorithms.RS256), |  | ||||||
|                 "authorization_flow": create_test_flow().pk, |  | ||||||
|             }, |  | ||||||
|         ) |  | ||||||
|         self.assertJSONEqual( |  | ||||||
|             response.content.decode(), |  | ||||||
|             {"jwt_alg": ["RS256 requires a Certificate-Key-Pair to be selected."]}, |  | ||||||
|         ) |  | ||||||
| @ -1,7 +1,6 @@ | |||||||
| """Test authorize view""" | """Test authorize view""" | ||||||
| from django.test import RequestFactory | from django.test import RequestFactory | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.encoding import force_str |  | ||||||
|  |  | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | ||||||
| @ -201,7 +200,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|         ) |         ) | ||||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() |         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             response.content.decode(), | ||||||
|             { |             { | ||||||
|                 "component": "xak-flow-redirect", |                 "component": "xak-flow-redirect", | ||||||
|                 "type": ChallengeTypes.REDIRECT.value, |                 "type": ChallengeTypes.REDIRECT.value, | ||||||
| @ -218,7 +217,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=flow, |             authorization_flow=flow, | ||||||
|             redirect_uris="http://localhost", |             redirect_uris="http://localhost", | ||||||
|             rsa_key=create_test_cert(), |             signing_key=create_test_cert(), | ||||||
|         ) |         ) | ||||||
|         Application.objects.create(name="app", slug="app", provider=provider) |         Application.objects.create(name="app", slug="app", provider=provider) | ||||||
|         state = generate_id() |         state = generate_id() | ||||||
| @ -240,7 +239,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|         ) |         ) | ||||||
|         token: RefreshToken = RefreshToken.objects.filter(user=user).first() |         token: RefreshToken = RefreshToken.objects.filter(user=user).first() | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             response.content.decode(), | ||||||
|             { |             { | ||||||
|                 "component": "xak-flow-redirect", |                 "component": "xak-flow-redirect", | ||||||
|                 "type": ChallengeTypes.REDIRECT.value, |                 "type": ChallengeTypes.REDIRECT.value, | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ import json | |||||||
|  |  | ||||||
| from django.test import RequestFactory | from django.test import RequestFactory | ||||||
| from django.urls.base import reverse | from django.urls.base import reverse | ||||||
| from django.utils.encoding import force_str |  | ||||||
|  |  | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
| from authentik.core.tests.utils import create_test_cert, create_test_flow | from authentik.core.tests.utils import create_test_cert, create_test_flow | ||||||
| @ -25,13 +24,13 @@ class TestJWKS(OAuthTestCase): | |||||||
|             client_id="test", |             client_id="test", | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid", | ||||||
|             rsa_key=create_test_cert(), |             signing_key=create_test_cert(), | ||||||
|         ) |         ) | ||||||
|         app = Application.objects.create(name="test", slug="test", provider=provider) |         app = Application.objects.create(name="test", slug="test", provider=provider) | ||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug}) |             reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug}) | ||||||
|         ) |         ) | ||||||
|         body = json.loads(force_str(response.content)) |         body = json.loads(response.content.decode()) | ||||||
|         self.assertEqual(len(body["keys"]), 1) |         self.assertEqual(len(body["keys"]), 1) | ||||||
|  |  | ||||||
|     def test_hs256(self): |     def test_hs256(self): | ||||||
| @ -46,4 +45,4 @@ class TestJWKS(OAuthTestCase): | |||||||
|         response = self.client.get( |         response = self.client.get( | ||||||
|             reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug}) |             reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug}) | ||||||
|         ) |         ) | ||||||
|         self.assertJSONEqual(force_str(response.content), {}) |         self.assertJSONEqual(response.content.decode(), {}) | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ from base64 import b64encode | |||||||
|  |  | ||||||
| from django.test import RequestFactory | from django.test import RequestFactory | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.encoding import force_str |  | ||||||
|  |  | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | ||||||
| @ -35,7 +34,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://testserver", |             redirect_uris="http://testserver", | ||||||
|             rsa_key=create_test_cert(), |             signing_key=create_test_cert(), | ||||||
|         ) |         ) | ||||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() |         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||||
|         user = create_test_admin_user() |         user = create_test_admin_user() | ||||||
| @ -62,7 +61,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://testserver", |             redirect_uris="http://testserver", | ||||||
|             rsa_key=create_test_cert(), |             signing_key=create_test_cert(), | ||||||
|         ) |         ) | ||||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() |         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||||
|         request = self.factory.post( |         request = self.factory.post( | ||||||
| @ -85,7 +84,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid", | ||||||
|             rsa_key=create_test_cert(), |             signing_key=create_test_cert(), | ||||||
|         ) |         ) | ||||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() |         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||||
|         user = create_test_admin_user() |         user = create_test_admin_user() | ||||||
| @ -114,7 +113,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid", | ||||||
|             rsa_key=create_test_cert(), |             signing_key=create_test_cert(), | ||||||
|         ) |         ) | ||||||
|         # Needs to be assigned to an application for iss to be set |         # Needs to be assigned to an application for iss to be set | ||||||
|         self.app.provider = provider |         self.app.provider = provider | ||||||
| @ -135,7 +134,7 @@ class TestToken(OAuthTestCase): | |||||||
|         ) |         ) | ||||||
|         new_token: RefreshToken = RefreshToken.objects.filter(user=user).first() |         new_token: RefreshToken = RefreshToken.objects.filter(user=user).first() | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             response.content.decode(), | ||||||
|             { |             { | ||||||
|                 "access_token": new_token.access_token, |                 "access_token": new_token.access_token, | ||||||
|                 "refresh_token": new_token.refresh_token, |                 "refresh_token": new_token.refresh_token, | ||||||
| @ -156,7 +155,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid", | ||||||
|             rsa_key=create_test_cert(), |             signing_key=create_test_cert(), | ||||||
|         ) |         ) | ||||||
|         # Needs to be assigned to an application for iss to be set |         # Needs to be assigned to an application for iss to be set | ||||||
|         self.app.provider = provider |         self.app.provider = provider | ||||||
| @ -184,7 +183,7 @@ class TestToken(OAuthTestCase): | |||||||
|         self.assertEqual(response["Access-Control-Allow-Credentials"], "true") |         self.assertEqual(response["Access-Control-Allow-Credentials"], "true") | ||||||
|         self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid") |         self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid") | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             response.content.decode(), | ||||||
|             { |             { | ||||||
|                 "access_token": new_token.access_token, |                 "access_token": new_token.access_token, | ||||||
|                 "refresh_token": new_token.refresh_token, |                 "refresh_token": new_token.refresh_token, | ||||||
| @ -205,7 +204,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://local.invalid", |             redirect_uris="http://local.invalid", | ||||||
|             rsa_key=create_test_cert(), |             signing_key=create_test_cert(), | ||||||
|         ) |         ) | ||||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() |         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||||
|         user = create_test_admin_user() |         user = create_test_admin_user() | ||||||
| @ -230,7 +229,7 @@ class TestToken(OAuthTestCase): | |||||||
|         self.assertNotIn("Access-Control-Allow-Credentials", response) |         self.assertNotIn("Access-Control-Allow-Credentials", response) | ||||||
|         self.assertNotIn("Access-Control-Allow-Origin", response) |         self.assertNotIn("Access-Control-Allow-Origin", response) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(response.content), |             response.content.decode(), | ||||||
|             { |             { | ||||||
|                 "access_token": new_token.access_token, |                 "access_token": new_token.access_token, | ||||||
|                 "refresh_token": new_token.refresh_token, |                 "refresh_token": new_token.refresh_token, | ||||||
| @ -250,7 +249,7 @@ class TestToken(OAuthTestCase): | |||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="http://testserver", |             redirect_uris="http://testserver", | ||||||
|             rsa_key=create_test_cert(), |             signing_key=create_test_cert(), | ||||||
|         ) |         ) | ||||||
|         # Needs to be assigned to an application for iss to be set |         # Needs to be assigned to an application for iss to be set | ||||||
|         self.app.provider = provider |         self.app.provider = provider | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ import json | |||||||
| from dataclasses import asdict | from dataclasses import asdict | ||||||
|  |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.encoding import force_str |  | ||||||
|  |  | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | ||||||
| @ -27,7 +26,7 @@ class TestUserinfo(OAuthTestCase): | |||||||
|             client_secret=generate_key(), |             client_secret=generate_key(), | ||||||
|             authorization_flow=create_test_flow(), |             authorization_flow=create_test_flow(), | ||||||
|             redirect_uris="", |             redirect_uris="", | ||||||
|             rsa_key=create_test_cert(), |             signing_key=create_test_cert(), | ||||||
|         ) |         ) | ||||||
|         self.provider.property_mappings.set(ScopeMapping.objects.all()) |         self.provider.property_mappings.set(ScopeMapping.objects.all()) | ||||||
|         # Needs to be assigned to an application for iss to be set |         # Needs to be assigned to an application for iss to be set | ||||||
| @ -54,7 +53,7 @@ class TestUserinfo(OAuthTestCase): | |||||||
|             HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}", |             HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}", | ||||||
|         ) |         ) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(res.content), |             res.content.decode(), | ||||||
|             { |             { | ||||||
|                 "name": self.user.name, |                 "name": self.user.name, | ||||||
|                 "given_name": self.user.name, |                 "given_name": self.user.name, | ||||||
| @ -77,7 +76,7 @@ class TestUserinfo(OAuthTestCase): | |||||||
|             HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}", |             HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}", | ||||||
|         ) |         ) | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             force_str(res.content), |             res.content.decode(), | ||||||
|             { |             { | ||||||
|                 "name": self.user.name, |                 "name": self.user.name, | ||||||
|                 "given_name": self.user.name, |                 "given_name": self.user.name, | ||||||
|  | |||||||
| @ -19,13 +19,13 @@ class OAuthTestCase(TestCase): | |||||||
|  |  | ||||||
|     def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider): |     def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider): | ||||||
|         """Validate that all required fields are set""" |         """Validate that all required fields are set""" | ||||||
|         key = provider.client_secret |         key, alg = provider.get_jwt_key() | ||||||
|         if provider.jwt_alg == JWTAlgorithms.RS256: |         if alg != JWTAlgorithms.HS256: | ||||||
|             key = provider.rsa_key.public_key |             key = provider.signing_key.public_key | ||||||
|         jwt = decode( |         jwt = decode( | ||||||
|             token.access_token, |             token.access_token, | ||||||
|             key, |             key, | ||||||
|             algorithms=[provider.jwt_alg], |             algorithms=[alg], | ||||||
|             audience=provider.client_id, |             audience=provider.client_id, | ||||||
|         ) |         ) | ||||||
|         id_token = token.id_token.to_dict() |         id_token = token.id_token.to_dict() | ||||||
|  | |||||||
| @ -1,12 +1,17 @@ | |||||||
| """authentik OAuth2 JWKS Views""" | """authentik OAuth2 JWKS Views""" | ||||||
| from base64 import urlsafe_b64encode | from base64 import urlsafe_b64encode | ||||||
|  |  | ||||||
| from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey | from cryptography.hazmat.primitives.asymmetric.ec import ( | ||||||
|  |     EllipticCurvePrivateKey, | ||||||
|  |     EllipticCurvePublicKey, | ||||||
|  | ) | ||||||
|  | from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey | ||||||
| from django.http import HttpRequest, HttpResponse, JsonResponse | from django.http import HttpRequest, HttpResponse, JsonResponse | ||||||
| from django.shortcuts import get_object_or_404 | from django.shortcuts import get_object_or_404 | ||||||
| from django.views import View | from django.views import View | ||||||
|  |  | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
|  | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider | from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -25,18 +30,35 @@ class JWKSView(View): | |||||||
|         """Show RSA Key data for Provider""" |         """Show RSA Key data for Provider""" | ||||||
|         application = get_object_or_404(Application, slug=application_slug) |         application = get_object_or_404(Application, slug=application_slug) | ||||||
|         provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id) |         provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id) | ||||||
|  |         signing_key: CertificateKeyPair = provider.signing_key | ||||||
|  |  | ||||||
|         response_data = {} |         response_data = {} | ||||||
|  |  | ||||||
|         if provider.jwt_alg == JWTAlgorithms.RS256 and provider.rsa_key: |         if signing_key: | ||||||
|             public_key: RSAPublicKey = provider.rsa_key.private_key.public_key() |             private_key = signing_key.private_key | ||||||
|  |             print(type(private_key)) | ||||||
|  |             if isinstance(private_key, RSAPrivateKey): | ||||||
|  |                 public_key: RSAPublicKey = private_key.public_key() | ||||||
|                 public_numbers = public_key.public_numbers() |                 public_numbers = public_key.public_numbers() | ||||||
|                 response_data["keys"] = [ |                 response_data["keys"] = [ | ||||||
|                     { |                     { | ||||||
|                         "kty": "RSA", |                         "kty": "RSA", | ||||||
|                     "alg": "RS256", |                         "alg": JWTAlgorithms.RS256, | ||||||
|                         "use": "sig", |                         "use": "sig", | ||||||
|                     "kid": provider.rsa_key.kid, |                         "kid": signing_key.kid, | ||||||
|  |                         "n": b64_enc(public_numbers.n), | ||||||
|  |                         "e": b64_enc(public_numbers.e), | ||||||
|  |                     } | ||||||
|  |                 ] | ||||||
|  |             elif isinstance(private_key, EllipticCurvePrivateKey): | ||||||
|  |                 public_key: EllipticCurvePublicKey = private_key.public_key() | ||||||
|  |                 public_numbers = public_key.public_numbers() | ||||||
|  |                 response_data["keys"] = [ | ||||||
|  |                     { | ||||||
|  |                         "kty": "EC", | ||||||
|  |                         "alg": JWTAlgorithms.EC256, | ||||||
|  |                         "use": "sig", | ||||||
|  |                         "kid": signing_key.kid, | ||||||
|                         "n": b64_enc(public_numbers.n), |                         "n": b64_enc(public_numbers.n), | ||||||
|                         "e": b64_enc(public_numbers.e), |                         "e": b64_enc(public_numbers.e), | ||||||
|                     } |                     } | ||||||
|  | |||||||
| @ -39,6 +39,7 @@ class ProviderInfoView(View): | |||||||
|         ) |         ) | ||||||
|         if SCOPE_OPENID not in scopes: |         if SCOPE_OPENID not in scopes: | ||||||
|             scopes.append(SCOPE_OPENID) |             scopes.append(SCOPE_OPENID) | ||||||
|  |         _, supported_alg = provider.get_jwt_key() | ||||||
|         return { |         return { | ||||||
|             "issuer": provider.get_issuer(self.request), |             "issuer": provider.get_issuer(self.request), | ||||||
|             "authorization_endpoint": self.request.build_absolute_uri( |             "authorization_endpoint": self.request.build_absolute_uri( | ||||||
| @ -78,7 +79,7 @@ class ProviderInfoView(View): | |||||||
|                 GRANT_TYPE_REFRESH_TOKEN, |                 GRANT_TYPE_REFRESH_TOKEN, | ||||||
|                 GrantTypes.IMPLICIT, |                 GrantTypes.IMPLICIT, | ||||||
|             ], |             ], | ||||||
|             "id_token_signing_alg_values_supported": [provider.jwt_alg], |             "id_token_signing_alg_values_supported": [supported_alg], | ||||||
|             # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes |             # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes | ||||||
|             "subject_types_supported": ["public"], |             "subject_types_supported": ["public"], | ||||||
|             "token_endpoint_auth_methods_supported": [ |             "token_endpoint_auth_methods_supported": [ | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ | |||||||
|  |  | ||||||
| import django.db.models.deletion | import django.db.models.deletion | ||||||
| from django.apps.registry import Apps | from django.apps.registry import Apps | ||||||
|  | from django.core.exceptions import FieldError | ||||||
| from django.db import migrations, models | from django.db import migrations, models | ||||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor | from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||||
|  |  | ||||||
| @ -10,12 +11,17 @@ import authentik.providers.proxy.models | |||||||
|  |  | ||||||
|  |  | ||||||
| def migrate_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def migrate_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     from authentik.providers.proxy.models import JWTAlgorithms, ProxyProvider |     from authentik.providers.oauth2.models import JWTAlgorithms | ||||||
|  |     from authentik.providers.proxy.models import ProxyProvider | ||||||
|  |  | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|  |     try: | ||||||
|         for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256): |         for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256): | ||||||
|             provider.set_oauth_defaults() |             provider.set_oauth_defaults() | ||||||
|             provider.save() |             provider.save() | ||||||
|  |     except FieldError: | ||||||
|  |         # If the jwt_alg field doesn't exist, just ignore this migration | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |  | ||||||
| def migrate_mode(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def migrate_mode(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  | |||||||
| @ -1,17 +1,23 @@ | |||||||
| # Generated by Django 3.2.6 on 2021-09-09 11:24 | # Generated by Django 3.2.6 on 2021-09-09 11:24 | ||||||
|  |  | ||||||
| from django.apps.registry import Apps | from django.apps.registry import Apps | ||||||
|  | from django.core.exceptions import FieldError | ||||||
| from django.db import migrations | from django.db import migrations | ||||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor | from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||||
|  |  | ||||||
|  |  | ||||||
| def migrate_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def migrate_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     from authentik.providers.proxy.models import JWTAlgorithms, ProxyProvider |     from authentik.providers.oauth2.models import JWTAlgorithms | ||||||
|  |     from authentik.providers.proxy.models import ProxyProvider | ||||||
|  |  | ||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|  |     try: | ||||||
|         for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256): |         for provider in ProxyProvider.objects.using(db_alias).filter(jwt_alg=JWTAlgorithms.RS256): | ||||||
|             provider.set_oauth_defaults() |             provider.set_oauth_defaults() | ||||||
|             provider.save() |             provider.save() | ||||||
|  |     except FieldError: | ||||||
|  |         # If the jwt_alg field doesn't exist, just ignore this migration | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """authentik proxy models""" | """authentik proxy models""" | ||||||
| import string | import string | ||||||
| from random import SystemRandom | from random import SystemRandom | ||||||
| from typing import Iterable, Optional, Type, Union | from typing import Iterable, Optional | ||||||
| from urllib.parse import urljoin | from urllib.parse import urljoin | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| @ -16,12 +16,7 @@ from authentik.providers.oauth2.constants import ( | |||||||
|     SCOPE_OPENID_EMAIL, |     SCOPE_OPENID_EMAIL, | ||||||
|     SCOPE_OPENID_PROFILE, |     SCOPE_OPENID_PROFILE, | ||||||
| ) | ) | ||||||
| from authentik.providers.oauth2.models import ( | from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping | ||||||
|     ClientTypes, |  | ||||||
|     JWTAlgorithms, |  | ||||||
|     OAuth2Provider, |  | ||||||
|     ScopeMapping, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| SCOPE_AK_PROXY = "ak_proxy" | SCOPE_AK_PROXY = "ak_proxy" | ||||||
|  |  | ||||||
| @ -115,7 +110,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | |||||||
|         return "ak-provider-proxy-form" |         return "ak-provider-proxy-form" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.providers.proxy.api import ProxyProviderSerializer |         from authentik.providers.proxy.api import ProxyProviderSerializer | ||||||
|  |  | ||||||
|         return ProxyProviderSerializer |         return ProxyProviderSerializer | ||||||
| @ -128,8 +123,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | |||||||
|     def set_oauth_defaults(self): |     def set_oauth_defaults(self): | ||||||
|         """Ensure all OAuth2-related settings are correct""" |         """Ensure all OAuth2-related settings are correct""" | ||||||
|         self.client_type = ClientTypes.CONFIDENTIAL |         self.client_type = ClientTypes.CONFIDENTIAL | ||||||
|         self.jwt_alg = JWTAlgorithms.HS256 |         self.signing_key = None | ||||||
|         self.rsa_key = None |  | ||||||
|         scopes = ScopeMapping.objects.filter( |         scopes = ScopeMapping.objects.filter( | ||||||
|             scope_name__in=[ |             scope_name__in=[ | ||||||
|                 SCOPE_OPENID, |                 SCOPE_OPENID, | ||||||
| @ -144,7 +138,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | |||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return f"Proxy Provider {self.name}" |         return f"Proxy Provider {self.name}" | ||||||
|  |  | ||||||
|     def get_required_objects(self) -> Iterable[Union[models.Model, str]]: |     def get_required_objects(self) -> Iterable[models.Model | str]: | ||||||
|         required_models = [self] |         required_models = [self] | ||||||
|         if self.certificate is not None: |         if self.certificate is not None: | ||||||
|             required_models.append(self.certificate) |             required_models.append(self.certificate) | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| """authentik saml_idp Models""" | """authentik saml_idp Models""" | ||||||
| from typing import Optional, Type | from typing import Optional | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| @ -163,7 +163,7 @@ class SAMLProvider(Provider): | |||||||
|             return None |             return None | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.providers.saml.api import SAMLProviderSerializer |         from authentik.providers.saml.api import SAMLProviderSerializer | ||||||
|  |  | ||||||
|         return SAMLProviderSerializer |         return SAMLProviderSerializer | ||||||
| @ -192,7 +192,7 @@ class SAMLPropertyMapping(PropertyMapping): | |||||||
|         return "ak-property-mapping-saml-form" |         return "ak-property-mapping-saml-form" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.providers.saml.api import SAMLPropertyMappingSerializer |         from authentik.providers.saml.api import SAMLPropertyMappingSerializer | ||||||
|  |  | ||||||
|         return SAMLPropertyMappingSerializer |         return SAMLPropertyMappingSerializer | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| """SAML AuthNRequest Parser and dataclass""" | """SAML AuthNRequest Parser and dataclass""" | ||||||
| from base64 import b64decode | from base64 import b64decode | ||||||
| from dataclasses import dataclass | from dataclasses import dataclass | ||||||
| from typing import Optional, Union | from typing import Optional | ||||||
| from urllib.parse import quote_plus | from urllib.parse import quote_plus | ||||||
|  |  | ||||||
| import xmlsec | import xmlsec | ||||||
| @ -54,9 +54,7 @@ class AuthNRequestParser: | |||||||
|     def __init__(self, provider: SAMLProvider): |     def __init__(self, provider: SAMLProvider): | ||||||
|         self.provider = provider |         self.provider = provider | ||||||
|  |  | ||||||
|     def _parse_xml( |     def _parse_xml(self, decoded_xml: str | bytes, relay_state: Optional[str]) -> AuthNRequest: | ||||||
|         self, decoded_xml: Union[str, bytes], relay_state: Optional[str] |  | ||||||
|     ) -> AuthNRequest: |  | ||||||
|         root = ElementTree.fromstring(decoded_xml) |         root = ElementTree.fromstring(decoded_xml) | ||||||
|  |  | ||||||
|         # http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf |         # http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf | ||||||
|  | |||||||
| @ -69,7 +69,7 @@ def task_error_hook(task_id, exception: Exception, traceback, *args, **kwargs): | |||||||
|     """Create system event for failed task""" |     """Create system event for failed task""" | ||||||
|     from authentik.events.models import Event, EventAction |     from authentik.events.models import Event, EventAction | ||||||
|  |  | ||||||
|     LOGGER.warning("Task failure", exception=exception) |     LOGGER.warning("Task failure", exc=exception) | ||||||
|     if before_send({}, {"exc_info": (None, exception, None)}) is not None: |     if before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||||
|         Event.new(EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception)).save() |         Event.new(EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception)).save() | ||||||
|  |  | ||||||
|  | |||||||
| @ -357,7 +357,7 @@ CELERY_BEAT_SCHEDULE = { | |||||||
|     }, |     }, | ||||||
|     "db_backup": { |     "db_backup": { | ||||||
|         "task": "authentik.core.tasks.backup_database", |         "task": "authentik.core.tasks.backup_database", | ||||||
|         "schedule": crontab(minute=0, hour=0), |         "schedule": crontab(hour="*/24"), | ||||||
|         "options": {"queue": "authentik_scheduled"}, |         "options": {"queue": "authentik_scheduled"}, | ||||||
|     }, |     }, | ||||||
| } | } | ||||||
| @ -425,6 +425,7 @@ if _ERROR_REPORTING: | |||||||
|     set_tag("authentik.build_hash", build_hash) |     set_tag("authentik.build_hash", build_hash) | ||||||
|     set_tag("authentik.env", env) |     set_tag("authentik.env", env) | ||||||
|     set_tag("authentik.component", "backend") |     set_tag("authentik.component", "backend") | ||||||
|  |     set_tag("authentik.uuid", sha512(SECRET_KEY.encode("ascii")).hexdigest()[:16]) | ||||||
|     j_print( |     j_print( | ||||||
|         "Error reporting is enabled", |         "Error reporting is enabled", | ||||||
|         env=CONFIG.y("error_reporting.environment", "customer"), |         env=CONFIG.y("error_reporting.environment", "customer"), | ||||||
|  | |||||||
| @ -26,8 +26,8 @@ class PytestTestRunner:  # pragma: no cover | |||||||
|  |  | ||||||
|         settings.TEST = True |         settings.TEST = True | ||||||
|         settings.CELERY_TASK_ALWAYS_EAGER = True |         settings.CELERY_TASK_ALWAYS_EAGER = True | ||||||
|         CONFIG.y_set("authentik.avatars", "none") |         CONFIG.y_set("avatars", "none") | ||||||
|         CONFIG.y_set("authentik.geoip", "tests/GeoLite2-City-Test.mmdb") |         CONFIG.y_set("geoip", "tests/GeoLite2-City-Test.mmdb") | ||||||
|         CONFIG.y_set( |         CONFIG.y_set( | ||||||
|             "outposts.container_image_base", |             "outposts.container_image_base", | ||||||
|             f"ghcr.io/goauthentik/dev-%(type)s:{get_docker_tag()}", |             f"ghcr.io/goauthentik/dev-%(type)s:{get_docker_tag()}", | ||||||
|  | |||||||
| @ -1,6 +1,5 @@ | |||||||
| """authentik LDAP Models""" | """authentik LDAP Models""" | ||||||
| from ssl import CERT_REQUIRED | from ssl import CERT_REQUIRED | ||||||
| from typing import Type |  | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| @ -101,7 +100,7 @@ class LDAPSource(Source): | |||||||
|         return "ak-source-ldap-form" |         return "ak-source-ldap-form" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.sources.ldap.api import LDAPSourceSerializer |         from authentik.sources.ldap.api import LDAPSourceSerializer | ||||||
|  |  | ||||||
|         return LDAPSourceSerializer |         return LDAPSourceSerializer | ||||||
| @ -157,7 +156,7 @@ class LDAPPropertyMapping(PropertyMapping): | |||||||
|         return "ak-property-mapping-ldap-form" |         return "ak-property-mapping-ldap-form" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Type[Serializer]: |     def serializer(self) -> type[Serializer]: | ||||||
|         from authentik.sources.ldap.api import LDAPPropertyMappingSerializer |         from authentik.sources.ldap.api import LDAPPropertyMappingSerializer | ||||||
|  |  | ||||||
|         return LDAPPropertyMappingSerializer |         return LDAPPropertyMappingSerializer | ||||||
|  | |||||||
| @ -74,6 +74,7 @@ class OAuthSourceSerializer(SourceSerializer): | |||||||
|             "consumer_key", |             "consumer_key", | ||||||
|             "consumer_secret", |             "consumer_secret", | ||||||
|             "callback_url", |             "callback_url", | ||||||
|  |             "additional_scopes", | ||||||
|             "type", |             "type", | ||||||
|         ] |         ] | ||||||
|         extra_kwargs = {"consumer_secret": {"write_only": True}} |         extra_kwargs = {"consumer_secret": {"write_only": True}} | ||||||
| @ -99,6 +100,7 @@ class OAuthSourceViewSet(UsedByMixin, ModelViewSet): | |||||||
|         "access_token_url", |         "access_token_url", | ||||||
|         "profile_url", |         "profile_url", | ||||||
|         "consumer_key", |         "consumer_key", | ||||||
|  |         "additional_scopes", | ||||||
|     ] |     ] | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|  | |||||||
| @ -58,6 +58,9 @@ class BaseOAuthClient: | |||||||
|         args = self.get_redirect_args() |         args = self.get_redirect_args() | ||||||
|         additional = parameters or {} |         additional = parameters or {} | ||||||
|         args.update(additional) |         args.update(additional) | ||||||
|  |         # Special handling for scope, since it's set as array | ||||||
|  |         # to make additional scopes easier | ||||||
|  |         args["scope"] = " ".join(sorted(set(args["scope"]))) | ||||||
|         params = urlencode(args, quote_via=quote) |         params = urlencode(args, quote_via=quote) | ||||||
|         LOGGER.info("redirect args", **args) |         LOGGER.info("redirect args", **args) | ||||||
|         authorization_url = self.source.type.authorization_url or "" |         authorization_url = self.source.type.authorization_url or "" | ||||||
|  | |||||||
| @ -0,0 +1,18 @@ | |||||||
|  | # Generated by Django 4.0 on 2022-01-03 14:48 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_sources_oauth", "0005_update_provider_type_names"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="oauthsource", | ||||||
|  |             name="additional_scopes", | ||||||
|  |             field=models.TextField(default="", blank=True, verbose_name="Additional Scopes"), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	