Compare commits
	
		
			192 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 3a13d19695 | |||
| ed7bef9dbf | |||
| 4a17795df9 | |||
| 07b1aea767 | |||
| ab0f8d027d | |||
| b9fdb63a57 | |||
| 94833dd1e7 | |||
| 5262d89505 | |||
| ab3d47c437 | |||
| 14cd52686d | |||
| 1a39754fe9 | |||
| 8599eba863 | |||
| 4c6d21820e | |||
| ddee1c9a8c | |||
| 84678c41a8 | |||
| 7e1059dd43 | |||
| bc56ea6822 | |||
| 768dc55a71 | |||
| a0719ca65e | |||
| 38c8555f36 | |||
| 5b8223808e | |||
| 14f341f504 | |||
| c30aa90888 | |||
| 20c1770ec4 | |||
| 1efc0c1242 | |||
| 4467546464 | |||
| a2e512c36c | |||
| 91897b0ac6 | |||
| 3c2da8138d | |||
| e80df03819 | |||
| 426f0bc9dd | |||
| 2e2a4aaa78 | |||
| 90c2d94e69 | |||
| d5c463947e | |||
| e4bd4e23e5 | |||
| b9ad02781c | |||
| 29ab5b4000 | |||
| 71d144a67e | |||
| cc3ab141e5 | |||
| c158ef80db | |||
| d785edbbe3 | |||
| 3f30ef624e | |||
| ca1ee3e3f7 | |||
| ab021b4b7e | |||
| 11383d76a2 | |||
| 53baa806d9 | |||
| 828895195e | |||
| 78fefc166a | |||
| 7c6bcd0d5c | |||
| d0aebc8183 | |||
| 9f5fb692ba | |||
| 1e15d1f538 | |||
| 0173e4b882 | |||
| c0b75ebb79 | |||
| 0f4b18b792 | |||
| c15c4dd868 | |||
| e0b7e9f724 | |||
| d67ec1b62f | |||
| e5241ac574 | |||
| 55ddfc0014 | |||
| f8d989e4bc | |||
| fe2d53bfe4 | |||
| 18b101fbb5 | |||
| 276af8457d | |||
| a9111bd3fd | |||
| 18c1226762 | |||
| e15d45c0f9 | |||
| f0e00c3543 | |||
| d45fff499b | |||
| 9c7198609b | |||
| 55aa1897af | |||
| 9f269faf53 | |||
| 9bde7ef59e | |||
| 3abf3de596 | |||
| 195f8d58f0 | |||
| 901d98caf7 | |||
| 88594075b2 | |||
| 40844c975f | |||
| ffe6f65af5 | |||
| d2bbcc0e1e | |||
| 4095c422df | |||
| ee6dc45a30 | |||
| 5d8dd9cf3f | |||
| 2a47b7f474 | |||
| 29057626ea | |||
| 066229c279 | |||
| 8fcf033be8 | |||
| f617b3cc67 | |||
| 2d89fbafbe | |||
| 3306003f0e | |||
| bdf50a35cd | |||
| 252f631980 | |||
| a01e3dc0e1 | |||
| 1601a53a50 | |||
| 85c790728f | |||
| a2a4dbe266 | |||
| 64bc1e8884 | |||
| 9b0c9b8d5c | |||
| b2359e1f68 | |||
| 7204570580 | |||
| 5a6c36ab0b | |||
| b09f9ddb81 | |||
| d56f536dca | |||
| 75088cfb65 | |||
| abf8e90d22 | |||
| 6225f3cd8b | |||
| e1c0af92d0 | |||
| 4fc40db994 | |||
| cf66ceb961 | |||
| baf3756a97 | |||
| 541f463584 | |||
| 5ad6fb4c32 | |||
| efd05d5b0b | |||
| d0fe88063e | |||
| ac2e85c003 | |||
| 9cad24b180 | |||
| 57374586ac | |||
| f61786cd72 | |||
| cf97821d44 | |||
| c81ab88bbc | |||
| 47132faffb | |||
| 5391fd8def | |||
| 45da3795b6 | |||
| 77879e6a41 | |||
| 584adc246a | |||
| 7a2cc1db77 | |||
| 264cf8b6d8 | |||
| f1193a2b7d | |||
| 5e93ea6134 | |||
| 3a5dff6669 | |||
| c157030905 | |||
| 2b9915ea7c | |||
| 85952cd5a8 | |||
| 194600ef42 | |||
| e5c11107cf | |||
| ed527b5ab8 | |||
| 9e36423ad0 | |||
| 5f90550c52 | |||
| 88153cd490 | |||
| cd0d898a4b | |||
| 97b5ea2365 | |||
| c68768d086 | |||
| 154c4131e9 | |||
| 4ed3ecf9a2 | |||
| fc568112db | |||
| 08a917f498 | |||
| 400751ed3c | |||
| f3a72761c0 | |||
| 77a67dcbc1 | |||
| 8d7ce49101 | |||
| 841c13ed77 | |||
| 30d708dd1f | |||
| 8a50279142 | |||
| f1e1911788 | |||
| 0b712d22a8 | |||
| 9d0a7578ec | |||
| f8fab14e1e | |||
| 9b6e07de17 | |||
| 4e2ba8c916 | |||
| 6b35d0c70b | |||
| dd65862bf2 | |||
| 2206b71f6f | |||
| 24e02c82dc | |||
| 2b6213c3ce | |||
| d51d14fd32 | |||
| 35679f5abb | |||
| 98666cc5e9 | |||
| dbaad90c3e | |||
| 63b5656cca | |||
| 96713a82dd | |||
| 2b20b89c80 | |||
| cbb24dfddd | |||
| 056ff5ff59 | |||
| 4da2f44f8e | |||
| 3da7fcfc1d | |||
| 6ea57921f2 | |||
| c7ea4b5a7f | |||
| c2933f0681 | |||
| 27636cc49f | |||
| 42196f554e | |||
| ad5fc139eb | |||
| 3a68de0d38 | |||
| 93984b35b3 | |||
| d25d547486 | |||
| b84bc418af | |||
| ea94750ea8 | |||
| a3aa7a8d4f | |||
| 7004cb1c91 | |||
| e67464b8a0 | |||
| b0d4f035f1 | |||
| 661d2ec701 | |||
| 3f570bb96d | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2022.10.0 | current_version = 2022.11.1 | ||||||
| tag = True | tag = True | ||||||
| commit = True | commit = True | ||||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+) | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+) | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							| @ -13,7 +13,7 @@ runs: | |||||||
|     - name: Setup python and restore poetry |     - name: Setup python and restore poetry | ||||||
|       uses: actions/setup-python@v3 |       uses: actions/setup-python@v3 | ||||||
|       with: |       with: | ||||||
|         python-version: '3.10' |         python-version: '3.11' | ||||||
|         cache: 'poetry' |         cache: 'poetry' | ||||||
|     - name: Setup node |     - name: Setup node | ||||||
|       uses: actions/setup-node@v3.1.0 |       uses: actions/setup-node@v3.1.0 | ||||||
| @ -25,7 +25,7 @@ runs: | |||||||
|       shell: bash |       shell: bash | ||||||
|       run: | |       run: | | ||||||
|         docker-compose -f .github/actions/setup/docker-compose.yml up -d |         docker-compose -f .github/actions/setup/docker-compose.yml up -d | ||||||
|         poetry env use python3.10 |         poetry env use python3.11 | ||||||
|         poetry install |         poetry install | ||||||
|         cd web && npm ci |         cd web && npm ci | ||||||
|     - name: Generate config |     - name: Generate config | ||||||
|  | |||||||
							
								
								
									
										29
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										29
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -46,6 +46,7 @@ jobs: | |||||||
|         run: poetry 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 | ||||||
|  |     continue-on-error: true | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|         with: |         with: | ||||||
| @ -83,17 +84,10 @@ jobs: | |||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - name: Setup authentik env |       - name: Setup authentik env | ||||||
|         uses: ./.github/actions/setup |         uses: ./.github/actions/setup | ||||||
|       - uses: testspace-com/setup-testspace@v1 |  | ||||||
|         with: |  | ||||||
|           domain: ${{github.repository_owner}} |  | ||||||
|       - name: run unittest |       - name: run unittest | ||||||
|         run: | |         run: | | ||||||
|           poetry run make test |           poetry run make test | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - name: run testspace |  | ||||||
|         if: ${{ always() }} |  | ||||||
|         run: | |  | ||||||
|           testspace [unittest]unittest.xml --link=codecov |  | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v3 |         uses: codecov/codecov-action@v3 | ||||||
|         with: |         with: | ||||||
| @ -104,19 +98,12 @@ jobs: | |||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - name: Setup authentik env |       - name: Setup authentik env | ||||||
|         uses: ./.github/actions/setup |         uses: ./.github/actions/setup | ||||||
|       - uses: testspace-com/setup-testspace@v1 |  | ||||||
|         with: |  | ||||||
|           domain: ${{github.repository_owner}} |  | ||||||
|       - name: Create k8s Kind Cluster |       - name: Create k8s Kind Cluster | ||||||
|         uses: helm/kind-action@v1.4.0 |         uses: helm/kind-action@v1.4.0 | ||||||
|       - name: run integration |       - name: run integration | ||||||
|         run: | |         run: | | ||||||
|           poetry run make test-integration |           poetry run make test-integration | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - name: run testspace |  | ||||||
|         if: ${{ always() }} |  | ||||||
|         run: | |  | ||||||
|           testspace [integration]unittest.xml --link=codecov |  | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v3 |         uses: codecov/codecov-action@v3 | ||||||
|         with: |         with: | ||||||
| @ -127,9 +114,6 @@ jobs: | |||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - name: Setup authentik env |       - name: Setup authentik env | ||||||
|         uses: ./.github/actions/setup |         uses: ./.github/actions/setup | ||||||
|       - uses: testspace-com/setup-testspace@v1 |  | ||||||
|         with: |  | ||||||
|           domain: ${{github.repository_owner}} |  | ||||||
|       - name: Setup e2e env (chrome, etc) |       - name: Setup e2e env (chrome, etc) | ||||||
|         run: | |         run: | | ||||||
|           docker-compose -f tests/e2e/docker-compose.yml up -d |           docker-compose -f tests/e2e/docker-compose.yml up -d | ||||||
| @ -149,10 +133,6 @@ jobs: | |||||||
|         run: | |         run: | | ||||||
|           poetry run make test-e2e-provider |           poetry run make test-e2e-provider | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - name: run testspace |  | ||||||
|         if: ${{ always() }} |  | ||||||
|         run: | |  | ||||||
|           testspace [e2e-provider]unittest.xml --link=codecov |  | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v3 |         uses: codecov/codecov-action@v3 | ||||||
|         with: |         with: | ||||||
| @ -163,9 +143,6 @@ jobs: | |||||||
|       - uses: actions/checkout@v3 |       - uses: actions/checkout@v3 | ||||||
|       - name: Setup authentik env |       - name: Setup authentik env | ||||||
|         uses: ./.github/actions/setup |         uses: ./.github/actions/setup | ||||||
|       - uses: testspace-com/setup-testspace@v1 |  | ||||||
|         with: |  | ||||||
|           domain: ${{github.repository_owner}} |  | ||||||
|       - name: Setup e2e env (chrome, etc) |       - name: Setup e2e env (chrome, etc) | ||||||
|         run: | |         run: | | ||||||
|           docker-compose -f tests/e2e/docker-compose.yml up -d |           docker-compose -f tests/e2e/docker-compose.yml up -d | ||||||
| @ -185,10 +162,6 @@ jobs: | |||||||
|         run: | |         run: | | ||||||
|           poetry run make test-e2e-rest |           poetry run make test-e2e-rest | ||||||
|           poetry run coverage xml |           poetry run coverage xml | ||||||
|       - name: run testspace |  | ||||||
|         if: ${{ always() }} |  | ||||||
|         run: | |  | ||||||
|           testspace [e2e-rest]unittest.xml --link=codecov |  | ||||||
|       - if: ${{ always() }} |       - if: ${{ always() }} | ||||||
|         uses: codecov/codecov-action@v3 |         uses: codecov/codecov-action@v3 | ||||||
|         with: |         with: | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -22,7 +22,9 @@ | |||||||
|     "python.formatting.provider": "black", |     "python.formatting.provider": "black", | ||||||
|     "yaml.customTags": [ |     "yaml.customTags": [ | ||||||
|         "!Find sequence", |         "!Find sequence", | ||||||
|         "!KeyOf scalar" |         "!KeyOf scalar", | ||||||
|  |         "!Context scalar", | ||||||
|  |         "!Format sequence" | ||||||
|     ], |     ], | ||||||
|     "typescript.preferences.importModuleSpecifier": "non-relative", |     "typescript.preferences.importModuleSpecifier": "non-relative", | ||||||
|     "typescript.preferences.importModuleSpecifierEnding": "index", |     "typescript.preferences.importModuleSpecifierEnding": "index", | ||||||
| @ -30,5 +32,13 @@ | |||||||
|     "typescript.enablePromptUseWorkspaceTsdk": true, |     "typescript.enablePromptUseWorkspaceTsdk": true, | ||||||
|     "yaml.schemas": { |     "yaml.schemas": { | ||||||
|         "./blueprints/schema.json": "blueprints/**/*.yaml" |         "./blueprints/schema.json": "blueprints/**/*.yaml" | ||||||
|     } |     }, | ||||||
|  |     "gitlens.autolinks": [ | ||||||
|  |         { | ||||||
|  |             "alphanumeric": true, | ||||||
|  |             "prefix": "#<num>", | ||||||
|  |             "url": "https://github.com/goauthentik/authentik/issues/<num>", | ||||||
|  |             "ignoreCase": false | ||||||
|  |         } | ||||||
|  |     ] | ||||||
| } | } | ||||||
|  | |||||||
| @ -19,7 +19,7 @@ WORKDIR /work/web | |||||||
| RUN npm ci && npm run build | RUN npm ci && npm run build | ||||||
|  |  | ||||||
| # Stage 3: Poetry to requirements.txt export | # Stage 3: Poetry to requirements.txt export | ||||||
| FROM docker.io/python:3.10.7-slim-bullseye AS poetry-locker | FROM docker.io/python:3.11.0-slim-bullseye AS poetry-locker | ||||||
|  |  | ||||||
| WORKDIR /work | WORKDIR /work | ||||||
| COPY ./pyproject.toml /work | COPY ./pyproject.toml /work | ||||||
| @ -30,7 +30,7 @@ RUN pip install --no-cache-dir poetry && \ | |||||||
|     poetry export -f requirements.txt --dev --output requirements-dev.txt |     poetry export -f requirements.txt --dev --output requirements-dev.txt | ||||||
|  |  | ||||||
| # Stage 4: Build go proxy | # Stage 4: Build go proxy | ||||||
| FROM docker.io/golang:1.19.2-bullseye AS go-builder | FROM docker.io/golang:1.19.3-bullseye AS go-builder | ||||||
|  |  | ||||||
| WORKDIR /work | WORKDIR /work | ||||||
|  |  | ||||||
| @ -46,7 +46,7 @@ COPY ./go.sum /work/go.sum | |||||||
| RUN go build -o /work/authentik ./cmd/server/ | RUN go build -o /work/authentik ./cmd/server/ | ||||||
|  |  | ||||||
| # Stage 5: Run | # Stage 5: Run | ||||||
| FROM docker.io/python:3.10.7-slim-bullseye AS final-image | FROM docker.io/python:3.11.0-slim-bullseye AS final-image | ||||||
|  |  | ||||||
| LABEL org.opencontainers.image.url https://goauthentik.io | 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.description goauthentik.io Main server image, see https://goauthentik.io for more info. | ||||||
| @ -62,7 +62,7 @@ COPY --from=poetry-locker /work/requirements-dev.txt / | |||||||
|  |  | ||||||
| RUN apt-get update && \ | RUN apt-get update && \ | ||||||
|     # Required for installing pip packages |     # Required for installing pip packages | ||||||
|     apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev && \ |     apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev zlib1g-dev && \ | ||||||
|     # Required for runtime |     # Required for runtime | ||||||
|     apt-get install -y --no-install-recommends libxmlsec1-openssl libmaxminddb0 && \ |     apt-get install -y --no-install-recommends libxmlsec1-openssl libmaxminddb0 && \ | ||||||
|     # Required for bootstrap & healtcheck |     # Required for bootstrap & healtcheck | ||||||
| @ -80,6 +80,7 @@ RUN apt-get update && \ | |||||||
| COPY ./authentik/ /authentik | COPY ./authentik/ /authentik | ||||||
| COPY ./pyproject.toml / | COPY ./pyproject.toml / | ||||||
| COPY ./xml /xml | COPY ./xml /xml | ||||||
|  | COPY ./locale /locale | ||||||
| COPY ./tests /tests | COPY ./tests /tests | ||||||
| COPY ./manage.py / | COPY ./manage.py / | ||||||
| COPY ./blueprints /blueprints | COPY ./blueprints /blueprints | ||||||
|  | |||||||
							
								
								
									
										695
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										695
									
								
								LICENSE
									
									
									
									
									
								
							| @ -1,674 +1,21 @@ | |||||||
|                     GNU GENERAL PUBLIC LICENSE | MIT License | ||||||
|                        Version 3, 29 June 2007 |  | ||||||
|  | Copyright (c) 2022 Jens Langhammer | ||||||
|  Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> |  | ||||||
|  Everyone is permitted to copy and distribute verbatim copies | Permission is hereby granted, free of charge, to any person obtaining a copy | ||||||
|  of this license document, but changing it is not allowed. | of this software and associated documentation files (the "Software"), to deal | ||||||
|  | in the Software without restriction, including without limitation the rights | ||||||
|                             Preamble | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||||||
|  | copies of the Software, and to permit persons to whom the Software is | ||||||
|   The GNU General Public License is a free, copyleft license for | furnished to do so, subject to the following conditions: | ||||||
| software and other kinds of works. |  | ||||||
|  | The above copyright notice and this permission notice shall be included in all | ||||||
|   The licenses for most software and other practical works are designed | copies or substantial portions of the Software. | ||||||
| to take away your freedom to share and change the works.  By contrast, |  | ||||||
| the GNU General Public License is intended to guarantee your freedom to | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||||||
| share and change all versions of a program--to make sure it remains free | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||||||
| software for all its users.  We, the Free Software Foundation, use the | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||||||
| GNU General Public License for most of our software; it applies also to | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||||||
| any other work released this way by its authors.  You can apply it to | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||||||
| your programs, too. | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||||
|  | SOFTWARE. | ||||||
|   When we speak of free software, we are referring to freedom, not |  | ||||||
| price.  Our General Public Licenses are designed to make sure that you |  | ||||||
| have the freedom to distribute copies of free software (and charge for |  | ||||||
| them if you wish), that you receive source code or can get it if you |  | ||||||
| want it, that you can change the software or use pieces of it in new |  | ||||||
| free programs, and that you know you can do these things. |  | ||||||
|  |  | ||||||
|   To protect your rights, we need to prevent others from denying you |  | ||||||
| these rights or asking you to surrender the rights.  Therefore, you have |  | ||||||
| certain responsibilities if you distribute copies of the software, or if |  | ||||||
| you modify it: responsibilities to respect the freedom of others. |  | ||||||
|  |  | ||||||
|   For example, if you distribute copies of such a program, whether |  | ||||||
| gratis or for a fee, you must pass on to the recipients the same |  | ||||||
| freedoms that you received.  You must make sure that they, too, receive |  | ||||||
| or can get the source code.  And you must show them these terms so they |  | ||||||
| know their rights. |  | ||||||
|  |  | ||||||
|   Developers that use the GNU GPL protect your rights with two steps: |  | ||||||
| (1) assert copyright on the software, and (2) offer you this License |  | ||||||
| giving you legal permission to copy, distribute and/or modify it. |  | ||||||
|  |  | ||||||
|   For the developers' and authors' protection, the GPL clearly explains |  | ||||||
| that there is no warranty for this free software.  For both users' and |  | ||||||
| authors' sake, the GPL requires that modified versions be marked as |  | ||||||
| changed, so that their problems will not be attributed erroneously to |  | ||||||
| authors of previous versions. |  | ||||||
|  |  | ||||||
|   Some devices are designed to deny users access to install or run |  | ||||||
| modified versions of the software inside them, although the manufacturer |  | ||||||
| can do so.  This is fundamentally incompatible with the aim of |  | ||||||
| protecting users' freedom to change the software.  The systematic |  | ||||||
| pattern of such abuse occurs in the area of products for individuals to |  | ||||||
| use, which is precisely where it is most unacceptable.  Therefore, we |  | ||||||
| have designed this version of the GPL to prohibit the practice for those |  | ||||||
| products.  If such problems arise substantially in other domains, we |  | ||||||
| stand ready to extend this provision to those domains in future versions |  | ||||||
| of the GPL, as needed to protect the freedom of users. |  | ||||||
|  |  | ||||||
|   Finally, every program is threatened constantly by software patents. |  | ||||||
| States should not allow patents to restrict development and use of |  | ||||||
| software on general-purpose computers, but in those that do, we wish to |  | ||||||
| avoid the special danger that patents applied to a free program could |  | ||||||
| make it effectively proprietary.  To prevent this, the GPL assures that |  | ||||||
| patents cannot be used to render the program non-free. |  | ||||||
|  |  | ||||||
|   The precise terms and conditions for copying, distribution and |  | ||||||
| modification follow. |  | ||||||
|  |  | ||||||
|                        TERMS AND CONDITIONS |  | ||||||
|  |  | ||||||
|   0. Definitions. |  | ||||||
|  |  | ||||||
|   "This License" refers to version 3 of the GNU General Public License. |  | ||||||
|  |  | ||||||
|   "Copyright" also means copyright-like laws that apply to other kinds of |  | ||||||
| works, such as semiconductor masks. |  | ||||||
|  |  | ||||||
|   "The Program" refers to any copyrightable work licensed under this |  | ||||||
| License.  Each licensee is addressed as "you".  "Licensees" and |  | ||||||
| "recipients" may be individuals or organizations. |  | ||||||
|  |  | ||||||
|   To "modify" a work means to copy from or adapt all or part of the work |  | ||||||
| in a fashion requiring copyright permission, other than the making of an |  | ||||||
| exact copy.  The resulting work is called a "modified version" of the |  | ||||||
| earlier work or a work "based on" the earlier work. |  | ||||||
|  |  | ||||||
|   A "covered work" means either the unmodified Program or a work based |  | ||||||
| on the Program. |  | ||||||
|  |  | ||||||
|   To "propagate" a work means to do anything with it that, without |  | ||||||
| permission, would make you directly or secondarily liable for |  | ||||||
| infringement under applicable copyright law, except executing it on a |  | ||||||
| computer or modifying a private copy.  Propagation includes copying, |  | ||||||
| distribution (with or without modification), making available to the |  | ||||||
| public, and in some countries other activities as well. |  | ||||||
|  |  | ||||||
|   To "convey" a work means any kind of propagation that enables other |  | ||||||
| parties to make or receive copies.  Mere interaction with a user through |  | ||||||
| a computer network, with no transfer of a copy, is not conveying. |  | ||||||
|  |  | ||||||
|   An interactive user interface displays "Appropriate Legal Notices" |  | ||||||
| to the extent that it includes a convenient and prominently visible |  | ||||||
| feature that (1) displays an appropriate copyright notice, and (2) |  | ||||||
| tells the user that there is no warranty for the work (except to the |  | ||||||
| extent that warranties are provided), that licensees may convey the |  | ||||||
| work under this License, and how to view a copy of this License.  If |  | ||||||
| the interface presents a list of user commands or options, such as a |  | ||||||
| menu, a prominent item in the list meets this criterion. |  | ||||||
|  |  | ||||||
|   1. Source Code. |  | ||||||
|  |  | ||||||
|   The "source code" for a work means the preferred form of the work |  | ||||||
| for making modifications to it.  "Object code" means any non-source |  | ||||||
| form of a work. |  | ||||||
|  |  | ||||||
|   A "Standard Interface" means an interface that either is an official |  | ||||||
| standard defined by a recognized standards body, or, in the case of |  | ||||||
| interfaces specified for a particular programming language, one that |  | ||||||
| is widely used among developers working in that language. |  | ||||||
|  |  | ||||||
|   The "System Libraries" of an executable work include anything, other |  | ||||||
| than the work as a whole, that (a) is included in the normal form of |  | ||||||
| packaging a Major Component, but which is not part of that Major |  | ||||||
| Component, and (b) serves only to enable use of the work with that |  | ||||||
| Major Component, or to implement a Standard Interface for which an |  | ||||||
| implementation is available to the public in source code form.  A |  | ||||||
| "Major Component", in this context, means a major essential component |  | ||||||
| (kernel, window system, and so on) of the specific operating system |  | ||||||
| (if any) on which the executable work runs, or a compiler used to |  | ||||||
| produce the work, or an object code interpreter used to run it. |  | ||||||
|  |  | ||||||
|   The "Corresponding Source" for a work in object code form means all |  | ||||||
| the source code needed to generate, install, and (for an executable |  | ||||||
| work) run the object code and to modify the work, including scripts to |  | ||||||
| control those activities.  However, it does not include the work's |  | ||||||
| System Libraries, or general-purpose tools or generally available free |  | ||||||
| programs which are used unmodified in performing those activities but |  | ||||||
| which are not part of the work.  For example, Corresponding Source |  | ||||||
| includes interface definition files associated with source files for |  | ||||||
| the work, and the source code for shared libraries and dynamically |  | ||||||
| linked subprograms that the work is specifically designed to require, |  | ||||||
| such as by intimate data communication or control flow between those |  | ||||||
| subprograms and other parts of the work. |  | ||||||
|  |  | ||||||
|   The Corresponding Source need not include anything that users |  | ||||||
| can regenerate automatically from other parts of the Corresponding |  | ||||||
| Source. |  | ||||||
|  |  | ||||||
|   The Corresponding Source for a work in source code form is that |  | ||||||
| same work. |  | ||||||
|  |  | ||||||
|   2. Basic Permissions. |  | ||||||
|  |  | ||||||
|   All rights granted under this License are granted for the term of |  | ||||||
| copyright on the Program, and are irrevocable provided the stated |  | ||||||
| conditions are met.  This License explicitly affirms your unlimited |  | ||||||
| permission to run the unmodified Program.  The output from running a |  | ||||||
| covered work is covered by this License only if the output, given its |  | ||||||
| content, constitutes a covered work.  This License acknowledges your |  | ||||||
| rights of fair use or other equivalent, as provided by copyright law. |  | ||||||
|  |  | ||||||
|   You may make, run and propagate covered works that you do not |  | ||||||
| convey, without conditions so long as your license otherwise remains |  | ||||||
| in force.  You may convey covered works to others for the sole purpose |  | ||||||
| of having them make modifications exclusively for you, or provide you |  | ||||||
| with facilities for running those works, provided that you comply with |  | ||||||
| the terms of this License in conveying all material for which you do |  | ||||||
| not control copyright.  Those thus making or running the covered works |  | ||||||
| for you must do so exclusively on your behalf, under your direction |  | ||||||
| and control, on terms that prohibit them from making any copies of |  | ||||||
| your copyrighted material outside their relationship with you. |  | ||||||
|  |  | ||||||
|   Conveying under any other circumstances is permitted solely under |  | ||||||
| the conditions stated below.  Sublicensing is not allowed; section 10 |  | ||||||
| makes it unnecessary. |  | ||||||
|  |  | ||||||
|   3. Protecting Users' Legal Rights From Anti-Circumvention Law. |  | ||||||
|  |  | ||||||
|   No covered work shall be deemed part of an effective technological |  | ||||||
| measure under any applicable law fulfilling obligations under article |  | ||||||
| 11 of the WIPO copyright treaty adopted on 20 December 1996, or |  | ||||||
| similar laws prohibiting or restricting circumvention of such |  | ||||||
| measures. |  | ||||||
|  |  | ||||||
|   When you convey a covered work, you waive any legal power to forbid |  | ||||||
| circumvention of technological measures to the extent such circumvention |  | ||||||
| is effected by exercising rights under this License with respect to |  | ||||||
| the covered work, and you disclaim any intention to limit operation or |  | ||||||
| modification of the work as a means of enforcing, against the work's |  | ||||||
| users, your or third parties' legal rights to forbid circumvention of |  | ||||||
| technological measures. |  | ||||||
|  |  | ||||||
|   4. Conveying Verbatim Copies. |  | ||||||
|  |  | ||||||
|   You may convey verbatim copies of the Program's source code as you |  | ||||||
| receive it, in any medium, provided that you conspicuously and |  | ||||||
| appropriately publish on each copy an appropriate copyright notice; |  | ||||||
| keep intact all notices stating that this License and any |  | ||||||
| non-permissive terms added in accord with section 7 apply to the code; |  | ||||||
| keep intact all notices of the absence of any warranty; and give all |  | ||||||
| recipients a copy of this License along with the Program. |  | ||||||
|  |  | ||||||
|   You may charge any price or no price for each copy that you convey, |  | ||||||
| and you may offer support or warranty protection for a fee. |  | ||||||
|  |  | ||||||
|   5. Conveying Modified Source Versions. |  | ||||||
|  |  | ||||||
|   You may convey a work based on the Program, or the modifications to |  | ||||||
| produce it from the Program, in the form of source code under the |  | ||||||
| terms of section 4, provided that you also meet all of these conditions: |  | ||||||
|  |  | ||||||
|     a) The work must carry prominent notices stating that you modified |  | ||||||
|     it, and giving a relevant date. |  | ||||||
|  |  | ||||||
|     b) The work must carry prominent notices stating that it is |  | ||||||
|     released under this License and any conditions added under section |  | ||||||
|     7.  This requirement modifies the requirement in section 4 to |  | ||||||
|     "keep intact all notices". |  | ||||||
|  |  | ||||||
|     c) You must license the entire work, as a whole, under this |  | ||||||
|     License to anyone who comes into possession of a copy.  This |  | ||||||
|     License will therefore apply, along with any applicable section 7 |  | ||||||
|     additional terms, to the whole of the work, and all its parts, |  | ||||||
|     regardless of how they are packaged.  This License gives no |  | ||||||
|     permission to license the work in any other way, but it does not |  | ||||||
|     invalidate such permission if you have separately received it. |  | ||||||
|  |  | ||||||
|     d) If the work has interactive user interfaces, each must display |  | ||||||
|     Appropriate Legal Notices; however, if the Program has interactive |  | ||||||
|     interfaces that do not display Appropriate Legal Notices, your |  | ||||||
|     work need not make them do so. |  | ||||||
|  |  | ||||||
|   A compilation of a covered work with other separate and independent |  | ||||||
| works, which are not by their nature extensions of the covered work, |  | ||||||
| and which are not combined with it such as to form a larger program, |  | ||||||
| in or on a volume of a storage or distribution medium, is called an |  | ||||||
| "aggregate" if the compilation and its resulting copyright are not |  | ||||||
| used to limit the access or legal rights of the compilation's users |  | ||||||
| beyond what the individual works permit.  Inclusion of a covered work |  | ||||||
| in an aggregate does not cause this License to apply to the other |  | ||||||
| parts of the aggregate. |  | ||||||
|  |  | ||||||
|   6. Conveying Non-Source Forms. |  | ||||||
|  |  | ||||||
|   You may convey a covered work in object code form under the terms |  | ||||||
| of sections 4 and 5, provided that you also convey the |  | ||||||
| machine-readable Corresponding Source under the terms of this License, |  | ||||||
| in one of these ways: |  | ||||||
|  |  | ||||||
|     a) Convey the object code in, or embodied in, a physical product |  | ||||||
|     (including a physical distribution medium), accompanied by the |  | ||||||
|     Corresponding Source fixed on a durable physical medium |  | ||||||
|     customarily used for software interchange. |  | ||||||
|  |  | ||||||
|     b) Convey the object code in, or embodied in, a physical product |  | ||||||
|     (including a physical distribution medium), accompanied by a |  | ||||||
|     written offer, valid for at least three years and valid for as |  | ||||||
|     long as you offer spare parts or customer support for that product |  | ||||||
|     model, to give anyone who possesses the object code either (1) a |  | ||||||
|     copy of the Corresponding Source for all the software in the |  | ||||||
|     product that is covered by this License, on a durable physical |  | ||||||
|     medium customarily used for software interchange, for a price no |  | ||||||
|     more than your reasonable cost of physically performing this |  | ||||||
|     conveying of source, or (2) access to copy the |  | ||||||
|     Corresponding Source from a network server at no charge. |  | ||||||
|  |  | ||||||
|     c) Convey individual copies of the object code with a copy of the |  | ||||||
|     written offer to provide the Corresponding Source.  This |  | ||||||
|     alternative is allowed only occasionally and noncommercially, and |  | ||||||
|     only if you received the object code with such an offer, in accord |  | ||||||
|     with subsection 6b. |  | ||||||
|  |  | ||||||
|     d) Convey the object code by offering access from a designated |  | ||||||
|     place (gratis or for a charge), and offer equivalent access to the |  | ||||||
|     Corresponding Source in the same way through the same place at no |  | ||||||
|     further charge.  You need not require recipients to copy the |  | ||||||
|     Corresponding Source along with the object code.  If the place to |  | ||||||
|     copy the object code is a network server, the Corresponding Source |  | ||||||
|     may be on a different server (operated by you or a third party) |  | ||||||
|     that supports equivalent copying facilities, provided you maintain |  | ||||||
|     clear directions next to the object code saying where to find the |  | ||||||
|     Corresponding Source.  Regardless of what server hosts the |  | ||||||
|     Corresponding Source, you remain obligated to ensure that it is |  | ||||||
|     available for as long as needed to satisfy these requirements. |  | ||||||
|  |  | ||||||
|     e) Convey the object code using peer-to-peer transmission, provided |  | ||||||
|     you inform other peers where the object code and Corresponding |  | ||||||
|     Source of the work are being offered to the general public at no |  | ||||||
|     charge under subsection 6d. |  | ||||||
|  |  | ||||||
|   A separable portion of the object code, whose source code is excluded |  | ||||||
| from the Corresponding Source as a System Library, need not be |  | ||||||
| included in conveying the object code work. |  | ||||||
|  |  | ||||||
|   A "User Product" is either (1) a "consumer product", which means any |  | ||||||
| tangible personal property which is normally used for personal, family, |  | ||||||
| or household purposes, or (2) anything designed or sold for incorporation |  | ||||||
| into a dwelling.  In determining whether a product is a consumer product, |  | ||||||
| doubtful cases shall be resolved in favor of coverage.  For a particular |  | ||||||
| product received by a particular user, "normally used" refers to a |  | ||||||
| typical or common use of that class of product, regardless of the status |  | ||||||
| of the particular user or of the way in which the particular user |  | ||||||
| actually uses, or expects or is expected to use, the product.  A product |  | ||||||
| is a consumer product regardless of whether the product has substantial |  | ||||||
| commercial, industrial or non-consumer uses, unless such uses represent |  | ||||||
| the only significant mode of use of the product. |  | ||||||
|  |  | ||||||
|   "Installation Information" for a User Product means any methods, |  | ||||||
| procedures, authorization keys, or other information required to install |  | ||||||
| and execute modified versions of a covered work in that User Product from |  | ||||||
| a modified version of its Corresponding Source.  The information must |  | ||||||
| suffice to ensure that the continued functioning of the modified object |  | ||||||
| code is in no case prevented or interfered with solely because |  | ||||||
| modification has been made. |  | ||||||
|  |  | ||||||
|   If you convey an object code work under this section in, or with, or |  | ||||||
| specifically for use in, a User Product, and the conveying occurs as |  | ||||||
| part of a transaction in which the right of possession and use of the |  | ||||||
| User Product is transferred to the recipient in perpetuity or for a |  | ||||||
| fixed term (regardless of how the transaction is characterized), the |  | ||||||
| Corresponding Source conveyed under this section must be accompanied |  | ||||||
| by the Installation Information.  But this requirement does not apply |  | ||||||
| if neither you nor any third party retains the ability to install |  | ||||||
| modified object code on the User Product (for example, the work has |  | ||||||
| been installed in ROM). |  | ||||||
|  |  | ||||||
|   The requirement to provide Installation Information does not include a |  | ||||||
| requirement to continue to provide support service, warranty, or updates |  | ||||||
| for a work that has been modified or installed by the recipient, or for |  | ||||||
| the User Product in which it has been modified or installed.  Access to a |  | ||||||
| network may be denied when the modification itself materially and |  | ||||||
| adversely affects the operation of the network or violates the rules and |  | ||||||
| protocols for communication across the network. |  | ||||||
|  |  | ||||||
|   Corresponding Source conveyed, and Installation Information provided, |  | ||||||
| in accord with this section must be in a format that is publicly |  | ||||||
| documented (and with an implementation available to the public in |  | ||||||
| source code form), and must require no special password or key for |  | ||||||
| unpacking, reading or copying. |  | ||||||
|  |  | ||||||
|   7. Additional Terms. |  | ||||||
|  |  | ||||||
|   "Additional permissions" are terms that supplement the terms of this |  | ||||||
| License by making exceptions from one or more of its conditions. |  | ||||||
| Additional permissions that are applicable to the entire Program shall |  | ||||||
| be treated as though they were included in this License, to the extent |  | ||||||
| that they are valid under applicable law.  If additional permissions |  | ||||||
| apply only to part of the Program, that part may be used separately |  | ||||||
| under those permissions, but the entire Program remains governed by |  | ||||||
| this License without regard to the additional permissions. |  | ||||||
|  |  | ||||||
|   When you convey a copy of a covered work, you may at your option |  | ||||||
| remove any additional permissions from that copy, or from any part of |  | ||||||
| it.  (Additional permissions may be written to require their own |  | ||||||
| removal in certain cases when you modify the work.)  You may place |  | ||||||
| additional permissions on material, added by you to a covered work, |  | ||||||
| for which you have or can give appropriate copyright permission. |  | ||||||
|  |  | ||||||
|   Notwithstanding any other provision of this License, for material you |  | ||||||
| add to a covered work, you may (if authorized by the copyright holders of |  | ||||||
| that material) supplement the terms of this License with terms: |  | ||||||
|  |  | ||||||
|     a) Disclaiming warranty or limiting liability differently from the |  | ||||||
|     terms of sections 15 and 16 of this License; or |  | ||||||
|  |  | ||||||
|     b) Requiring preservation of specified reasonable legal notices or |  | ||||||
|     author attributions in that material or in the Appropriate Legal |  | ||||||
|     Notices displayed by works containing it; or |  | ||||||
|  |  | ||||||
|     c) Prohibiting misrepresentation of the origin of that material, or |  | ||||||
|     requiring that modified versions of such material be marked in |  | ||||||
|     reasonable ways as different from the original version; or |  | ||||||
|  |  | ||||||
|     d) Limiting the use for publicity purposes of names of licensors or |  | ||||||
|     authors of the material; or |  | ||||||
|  |  | ||||||
|     e) Declining to grant rights under trademark law for use of some |  | ||||||
|     trade names, trademarks, or service marks; or |  | ||||||
|  |  | ||||||
|     f) Requiring indemnification of licensors and authors of that |  | ||||||
|     material by anyone who conveys the material (or modified versions of |  | ||||||
|     it) with contractual assumptions of liability to the recipient, for |  | ||||||
|     any liability that these contractual assumptions directly impose on |  | ||||||
|     those licensors and authors. |  | ||||||
|  |  | ||||||
|   All other non-permissive additional terms are considered "further |  | ||||||
| restrictions" within the meaning of section 10.  If the Program as you |  | ||||||
| received it, or any part of it, contains a notice stating that it is |  | ||||||
| governed by this License along with a term that is a further |  | ||||||
| restriction, you may remove that term.  If a license document contains |  | ||||||
| a further restriction but permits relicensing or conveying under this |  | ||||||
| License, you may add to a covered work material governed by the terms |  | ||||||
| of that license document, provided that the further restriction does |  | ||||||
| not survive such relicensing or conveying. |  | ||||||
|  |  | ||||||
|   If you add terms to a covered work in accord with this section, you |  | ||||||
| must place, in the relevant source files, a statement of the |  | ||||||
| additional terms that apply to those files, or a notice indicating |  | ||||||
| where to find the applicable terms. |  | ||||||
|  |  | ||||||
|   Additional terms, permissive or non-permissive, may be stated in the |  | ||||||
| form of a separately written license, or stated as exceptions; |  | ||||||
| the above requirements apply either way. |  | ||||||
|  |  | ||||||
|   8. Termination. |  | ||||||
|  |  | ||||||
|   You may not propagate or modify a covered work except as expressly |  | ||||||
| provided under this License.  Any attempt otherwise to propagate or |  | ||||||
| modify it is void, and will automatically terminate your rights under |  | ||||||
| this License (including any patent licenses granted under the third |  | ||||||
| paragraph of section 11). |  | ||||||
|  |  | ||||||
|   However, if you cease all violation of this License, then your |  | ||||||
| license from a particular copyright holder is reinstated (a) |  | ||||||
| provisionally, unless and until the copyright holder explicitly and |  | ||||||
| finally terminates your license, and (b) permanently, if the copyright |  | ||||||
| holder fails to notify you of the violation by some reasonable means |  | ||||||
| prior to 60 days after the cessation. |  | ||||||
|  |  | ||||||
|   Moreover, your license from a particular copyright holder is |  | ||||||
| reinstated permanently if the copyright holder notifies you of the |  | ||||||
| violation by some reasonable means, this is the first time you have |  | ||||||
| received notice of violation of this License (for any work) from that |  | ||||||
| copyright holder, and you cure the violation prior to 30 days after |  | ||||||
| your receipt of the notice. |  | ||||||
|  |  | ||||||
|   Termination of your rights under this section does not terminate the |  | ||||||
| licenses of parties who have received copies or rights from you under |  | ||||||
| this License.  If your rights have been terminated and not permanently |  | ||||||
| reinstated, you do not qualify to receive new licenses for the same |  | ||||||
| material under section 10. |  | ||||||
|  |  | ||||||
|   9. Acceptance Not Required for Having Copies. |  | ||||||
|  |  | ||||||
|   You are not required to accept this License in order to receive or |  | ||||||
| run a copy of the Program.  Ancillary propagation of a covered work |  | ||||||
| occurring solely as a consequence of using peer-to-peer transmission |  | ||||||
| to receive a copy likewise does not require acceptance.  However, |  | ||||||
| nothing other than this License grants you permission to propagate or |  | ||||||
| modify any covered work.  These actions infringe copyright if you do |  | ||||||
| not accept this License.  Therefore, by modifying or propagating a |  | ||||||
| covered work, you indicate your acceptance of this License to do so. |  | ||||||
|  |  | ||||||
|   10. Automatic Licensing of Downstream Recipients. |  | ||||||
|  |  | ||||||
|   Each time you convey a covered work, the recipient automatically |  | ||||||
| receives a license from the original licensors, to run, modify and |  | ||||||
| propagate that work, subject to this License.  You are not responsible |  | ||||||
| for enforcing compliance by third parties with this License. |  | ||||||
|  |  | ||||||
|   An "entity transaction" is a transaction transferring control of an |  | ||||||
| organization, or substantially all assets of one, or subdividing an |  | ||||||
| organization, or merging organizations.  If propagation of a covered |  | ||||||
| work results from an entity transaction, each party to that |  | ||||||
| transaction who receives a copy of the work also receives whatever |  | ||||||
| licenses to the work the party's predecessor in interest had or could |  | ||||||
| give under the previous paragraph, plus a right to possession of the |  | ||||||
| Corresponding Source of the work from the predecessor in interest, if |  | ||||||
| the predecessor has it or can get it with reasonable efforts. |  | ||||||
|  |  | ||||||
|   You may not impose any further restrictions on the exercise of the |  | ||||||
| rights granted or affirmed under this License.  For example, you may |  | ||||||
| not impose a license fee, royalty, or other charge for exercise of |  | ||||||
| rights granted under this License, and you may not initiate litigation |  | ||||||
| (including a cross-claim or counterclaim in a lawsuit) alleging that |  | ||||||
| any patent claim is infringed by making, using, selling, offering for |  | ||||||
| sale, or importing the Program or any portion of it. |  | ||||||
|  |  | ||||||
|   11. Patents. |  | ||||||
|  |  | ||||||
|   A "contributor" is a copyright holder who authorizes use under this |  | ||||||
| License of the Program or a work on which the Program is based.  The |  | ||||||
| work thus licensed is called the contributor's "contributor version". |  | ||||||
|  |  | ||||||
|   A contributor's "essential patent claims" are all patent claims |  | ||||||
| owned or controlled by the contributor, whether already acquired or |  | ||||||
| hereafter acquired, that would be infringed by some manner, permitted |  | ||||||
| by this License, of making, using, or selling its contributor version, |  | ||||||
| but do not include claims that would be infringed only as a |  | ||||||
| consequence of further modification of the contributor version.  For |  | ||||||
| purposes of this definition, "control" includes the right to grant |  | ||||||
| patent sublicenses in a manner consistent with the requirements of |  | ||||||
| this License. |  | ||||||
|  |  | ||||||
|   Each contributor grants you a non-exclusive, worldwide, royalty-free |  | ||||||
| patent license under the contributor's essential patent claims, to |  | ||||||
| make, use, sell, offer for sale, import and otherwise run, modify and |  | ||||||
| propagate the contents of its contributor version. |  | ||||||
|  |  | ||||||
|   In the following three paragraphs, a "patent license" is any express |  | ||||||
| agreement or commitment, however denominated, not to enforce a patent |  | ||||||
| (such as an express permission to practice a patent or covenant not to |  | ||||||
| sue for patent infringement).  To "grant" such a patent license to a |  | ||||||
| party means to make such an agreement or commitment not to enforce a |  | ||||||
| patent against the party. |  | ||||||
|  |  | ||||||
|   If you convey a covered work, knowingly relying on a patent license, |  | ||||||
| and the Corresponding Source of the work is not available for anyone |  | ||||||
| to copy, free of charge and under the terms of this License, through a |  | ||||||
| publicly available network server or other readily accessible means, |  | ||||||
| then you must either (1) cause the Corresponding Source to be so |  | ||||||
| available, or (2) arrange to deprive yourself of the benefit of the |  | ||||||
| patent license for this particular work, or (3) arrange, in a manner |  | ||||||
| consistent with the requirements of this License, to extend the patent |  | ||||||
| license to downstream recipients.  "Knowingly relying" means you have |  | ||||||
| actual knowledge that, but for the patent license, your conveying the |  | ||||||
| covered work in a country, or your recipient's use of the covered work |  | ||||||
| in a country, would infringe one or more identifiable patents in that |  | ||||||
| country that you have reason to believe are valid. |  | ||||||
|  |  | ||||||
|   If, pursuant to or in connection with a single transaction or |  | ||||||
| arrangement, you convey, or propagate by procuring conveyance of, a |  | ||||||
| covered work, and grant a patent license to some of the parties |  | ||||||
| receiving the covered work authorizing them to use, propagate, modify |  | ||||||
| or convey a specific copy of the covered work, then the patent license |  | ||||||
| you grant is automatically extended to all recipients of the covered |  | ||||||
| work and works based on it. |  | ||||||
|  |  | ||||||
|   A patent license is "discriminatory" if it does not include within |  | ||||||
| the scope of its coverage, prohibits the exercise of, or is |  | ||||||
| conditioned on the non-exercise of one or more of the rights that are |  | ||||||
| specifically granted under this License.  You may not convey a covered |  | ||||||
| work if you are a party to an arrangement with a third party that is |  | ||||||
| in the business of distributing software, under which you make payment |  | ||||||
| to the third party based on the extent of your activity of conveying |  | ||||||
| the work, and under which the third party grants, to any of the |  | ||||||
| parties who would receive the covered work from you, a discriminatory |  | ||||||
| patent license (a) in connection with copies of the covered work |  | ||||||
| conveyed by you (or copies made from those copies), or (b) primarily |  | ||||||
| for and in connection with specific products or compilations that |  | ||||||
| contain the covered work, unless you entered into that arrangement, |  | ||||||
| or that patent license was granted, prior to 28 March 2007. |  | ||||||
|  |  | ||||||
|   Nothing in this License shall be construed as excluding or limiting |  | ||||||
| any implied license or other defenses to infringement that may |  | ||||||
| otherwise be available to you under applicable patent law. |  | ||||||
|  |  | ||||||
|   12. No Surrender of Others' Freedom. |  | ||||||
|  |  | ||||||
|   If conditions are imposed on you (whether by court order, agreement or |  | ||||||
| otherwise) that contradict the conditions of this License, they do not |  | ||||||
| excuse you from the conditions of this License.  If you cannot convey a |  | ||||||
| covered work so as to satisfy simultaneously your obligations under this |  | ||||||
| License and any other pertinent obligations, then as a consequence you may |  | ||||||
| not convey it at all.  For example, if you agree to terms that obligate you |  | ||||||
| to collect a royalty for further conveying from those to whom you convey |  | ||||||
| the Program, the only way you could satisfy both those terms and this |  | ||||||
| License would be to refrain entirely from conveying the Program. |  | ||||||
|  |  | ||||||
|   13. Use with the GNU Affero General Public License. |  | ||||||
|  |  | ||||||
|   Notwithstanding any other provision of this License, you have |  | ||||||
| permission to link or combine any covered work with a work licensed |  | ||||||
| under version 3 of the GNU Affero General Public License into a single |  | ||||||
| combined work, and to convey the resulting work.  The terms of this |  | ||||||
| License will continue to apply to the part which is the covered work, |  | ||||||
| but the special requirements of the GNU Affero General Public License, |  | ||||||
| section 13, concerning interaction through a network will apply to the |  | ||||||
| combination as such. |  | ||||||
|  |  | ||||||
|   14. Revised Versions of this License. |  | ||||||
|  |  | ||||||
|   The Free Software Foundation may publish revised and/or new versions of |  | ||||||
| the GNU General Public License from time to time.  Such new versions will |  | ||||||
| be similar in spirit to the present version, but may differ in detail to |  | ||||||
| address new problems or concerns. |  | ||||||
|  |  | ||||||
|   Each version is given a distinguishing version number.  If the |  | ||||||
| Program specifies that a certain numbered version of the GNU General |  | ||||||
| Public License "or any later version" applies to it, you have the |  | ||||||
| option of following the terms and conditions either of that numbered |  | ||||||
| version or of any later version published by the Free Software |  | ||||||
| Foundation.  If the Program does not specify a version number of the |  | ||||||
| GNU General Public License, you may choose any version ever published |  | ||||||
| by the Free Software Foundation. |  | ||||||
|  |  | ||||||
|   If the Program specifies that a proxy can decide which future |  | ||||||
| versions of the GNU General Public License can be used, that proxy's |  | ||||||
| public statement of acceptance of a version permanently authorizes you |  | ||||||
| to choose that version for the Program. |  | ||||||
|  |  | ||||||
|   Later license versions may give you additional or different |  | ||||||
| permissions.  However, no additional obligations are imposed on any |  | ||||||
| author or copyright holder as a result of your choosing to follow a |  | ||||||
| later version. |  | ||||||
|  |  | ||||||
|   15. Disclaimer of Warranty. |  | ||||||
|  |  | ||||||
|   THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY |  | ||||||
| APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT |  | ||||||
| HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY |  | ||||||
| OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, |  | ||||||
| THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |  | ||||||
| PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM |  | ||||||
| IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF |  | ||||||
| ALL NECESSARY SERVICING, REPAIR OR CORRECTION. |  | ||||||
|  |  | ||||||
|   16. Limitation of Liability. |  | ||||||
|  |  | ||||||
|   IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING |  | ||||||
| WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS |  | ||||||
| THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY |  | ||||||
| GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE |  | ||||||
| USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF |  | ||||||
| DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD |  | ||||||
| PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), |  | ||||||
| EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF |  | ||||||
| SUCH DAMAGES. |  | ||||||
|  |  | ||||||
|   17. Interpretation of Sections 15 and 16. |  | ||||||
|  |  | ||||||
|   If the disclaimer of warranty and limitation of liability provided |  | ||||||
| above cannot be given local legal effect according to their terms, |  | ||||||
| reviewing courts shall apply local law that most closely approximates |  | ||||||
| an absolute waiver of all civil liability in connection with the |  | ||||||
| Program, unless a warranty or assumption of liability accompanies a |  | ||||||
| copy of the Program in return for a fee. |  | ||||||
|  |  | ||||||
|                      END OF TERMS AND CONDITIONS |  | ||||||
|  |  | ||||||
|             How to Apply These Terms to Your New Programs |  | ||||||
|  |  | ||||||
|   If you develop a new program, and you want it to be of the greatest |  | ||||||
| possible use to the public, the best way to achieve this is to make it |  | ||||||
| free software which everyone can redistribute and change under these terms. |  | ||||||
|  |  | ||||||
|   To do so, attach the following notices to the program.  It is safest |  | ||||||
| to attach them to the start of each source file to most effectively |  | ||||||
| state the exclusion of warranty; and each file should have at least |  | ||||||
| the "copyright" line and a pointer to where the full notice is found. |  | ||||||
|  |  | ||||||
|     <one line to give the program's name and a brief idea of what it does.> |  | ||||||
|     Copyright (C) <year>  <name of author> |  | ||||||
|  |  | ||||||
|     This program is free software: you can redistribute it and/or modify |  | ||||||
|     it under the terms of the GNU General Public License as published by |  | ||||||
|     the Free Software Foundation, either version 3 of the License, or |  | ||||||
|     (at your option) any later version. |  | ||||||
|  |  | ||||||
|     This program is distributed in the hope that it will be useful, |  | ||||||
|     but WITHOUT ANY WARRANTY; without even the implied warranty of |  | ||||||
|     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the |  | ||||||
|     GNU General Public License for more details. |  | ||||||
|  |  | ||||||
|     You should have received a copy of the GNU General Public License |  | ||||||
|     along with this program.  If not, see <https://www.gnu.org/licenses/>. |  | ||||||
|  |  | ||||||
| Also add information on how to contact you by electronic and paper mail. |  | ||||||
|  |  | ||||||
|   If the program does terminal interaction, make it output a short |  | ||||||
| notice like this when it starts in an interactive mode: |  | ||||||
|  |  | ||||||
|     <program>  Copyright (C) <year>  <name of author> |  | ||||||
|     This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. |  | ||||||
|     This is free software, and you are welcome to redistribute it |  | ||||||
|     under certain conditions; type `show c' for details. |  | ||||||
|  |  | ||||||
| The hypothetical commands `show w' and `show c' should show the appropriate |  | ||||||
| parts of the General Public License.  Of course, your program's commands |  | ||||||
| might be different; for a GUI interface, you would use an "about box". |  | ||||||
|  |  | ||||||
|   You should also get your employer (if you work as a programmer) or school, |  | ||||||
| if any, to sign a "copyright disclaimer" for the program, if necessary. |  | ||||||
| For more information on this, and how to apply and follow the GNU GPL, see |  | ||||||
| <https://www.gnu.org/licenses/>. |  | ||||||
|  |  | ||||||
|   The GNU General Public License does not permit incorporating your program |  | ||||||
| into proprietary programs.  If your program is a subroutine library, you |  | ||||||
| may consider it more useful to permit linking proprietary applications with |  | ||||||
| the library.  If this is what you want to do, use the GNU Lesser General |  | ||||||
| Public License instead of this License.  But first, please read |  | ||||||
| <https://www.gnu.org/licenses/why-not-lgpl.html>. |  | ||||||
|  | |||||||
							
								
								
									
										5
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								Makefile
									
									
									
									
									
								
							| @ -69,7 +69,7 @@ gen-build: | |||||||
| 	AUTHENTIK_DEBUG=true ak spectacular --file schema.yml | 	AUTHENTIK_DEBUG=true ak spectacular --file schema.yml | ||||||
|  |  | ||||||
| gen-diff: | gen-diff: | ||||||
| 	git show $(shell git tag -l | tail -n 1):schema.yml > old_schema.yml | 	git show $(shell git describe --abbrev=0):schema.yml > old_schema.yml | ||||||
| 	docker run \ | 	docker run \ | ||||||
| 		--rm -v ${PWD}:/local \ | 		--rm -v ${PWD}:/local \ | ||||||
| 		--user ${UID}:${GID} \ | 		--user ${UID}:${GID} \ | ||||||
| @ -197,7 +197,4 @@ dev-reset: | |||||||
| 	dropdb -U postgres -h localhost authentik | 	dropdb -U postgres -h localhost authentik | ||||||
| 	createdb -U postgres -h localhost authentik | 	createdb -U postgres -h localhost authentik | ||||||
| 	redis-cli -n 0 flushall | 	redis-cli -n 0 flushall | ||||||
| 	redis-cli -n 1 flushall |  | ||||||
| 	redis-cli -n 2 flushall |  | ||||||
| 	redis-cli -n 3 flushall |  | ||||||
| 	make migrate | 	make migrate | ||||||
|  | |||||||
| @ -9,7 +9,6 @@ | |||||||
| [](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml) | [](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml) | ||||||
| [](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml) | [](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml) | ||||||
| [](https://codecov.io/gh/goauthentik/authentik) | [](https://codecov.io/gh/goauthentik/authentik) | ||||||
| [](https://goauthentik.testspace.com/) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| [](https://www.transifex.com/beryjuorg/authentik/) | [](https://www.transifex.com/beryjuorg/authentik/) | ||||||
|  | |||||||
| @ -6,8 +6,8 @@ | |||||||
|  |  | ||||||
| | Version    | Supported          | | | Version    | Supported          | | ||||||
| | ---------- | ------------------ | | | ---------- | ------------------ | | ||||||
| | 2022.9.x   | :white_check_mark: | |  | ||||||
| | 2022.10.x  | :white_check_mark: | | | 2022.10.x  | :white_check_mark: | | ||||||
|  | | 2022.11.x  | :white_check_mark: | | ||||||
|  |  | ||||||
| ## Reporting a Vulnerability | ## Reporting a Vulnerability | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from os import environ | from os import environ | ||||||
| from typing import Optional | from typing import Optional | ||||||
|  |  | ||||||
| __version__ = "2022.10.0" | __version__ = "2022.11.1" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -31,4 +31,5 @@ class AuthentikAPIConfig(AppConfig): | |||||||
|                     "type": "apiKey", |                     "type": "apiKey", | ||||||
|                     "in": "header", |                     "in": "header", | ||||||
|                     "name": "Authorization", |                     "name": "Authorization", | ||||||
|  |                     "scheme": "bearer", | ||||||
|                 } |                 } | ||||||
|  | |||||||
| @ -35,6 +35,7 @@ class ErrorReportingConfigSerializer(PassiveSerializer): | |||||||
|     """Config for error reporting""" |     """Config for error reporting""" | ||||||
|  |  | ||||||
|     enabled = BooleanField(read_only=True) |     enabled = BooleanField(read_only=True) | ||||||
|  |     sentry_dsn = CharField(read_only=True) | ||||||
|     environment = CharField(read_only=True) |     environment = CharField(read_only=True) | ||||||
|     send_pii = BooleanField(read_only=True) |     send_pii = BooleanField(read_only=True) | ||||||
|     traces_sample_rate = FloatField(read_only=True) |     traces_sample_rate = FloatField(read_only=True) | ||||||
| @ -77,6 +78,7 @@ class ConfigView(APIView): | |||||||
|             { |             { | ||||||
|                 "error_reporting": { |                 "error_reporting": { | ||||||
|                     "enabled": CONFIG.y("error_reporting.enabled"), |                     "enabled": CONFIG.y("error_reporting.enabled"), | ||||||
|  |                     "sentry_dsn": CONFIG.y("error_reporting.sentry_dsn"), | ||||||
|                     "environment": CONFIG.y("error_reporting.environment"), |                     "environment": CONFIG.y("error_reporting.environment"), | ||||||
|                     "send_pii": CONFIG.y("error_reporting.send_pii"), |                     "send_pii": CONFIG.y("error_reporting.send_pii"), | ||||||
|                     "traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)), |                     "traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)), | ||||||
|  | |||||||
| @ -53,6 +53,15 @@ | |||||||
|                     "id": { |                     "id": { | ||||||
|                         "type": "string" |                         "type": "string" | ||||||
|                     }, |                     }, | ||||||
|  |                     "state": { | ||||||
|  |                         "type": "string", | ||||||
|  |                         "enum": [ | ||||||
|  |                             "absent", | ||||||
|  |                             "present", | ||||||
|  |                             "created" | ||||||
|  |                         ], | ||||||
|  |                         "default": "present" | ||||||
|  |                     }, | ||||||
|                     "attrs": { |                     "attrs": { | ||||||
|                         "type": "object", |                         "type": "object", | ||||||
|                         "properties": { |                         "properties": { | ||||||
|  | |||||||
| @ -7,7 +7,6 @@ from django.apps import apps | |||||||
|  |  | ||||||
| from authentik.blueprints.apps import ManagedAppConfig | from authentik.blueprints.apps import ManagedAppConfig | ||||||
| from authentik.blueprints.models import BlueprintInstance | from authentik.blueprints.models import BlueprintInstance | ||||||
| from authentik.lib.config import CONFIG |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def apply_blueprint(*files: str): | def apply_blueprint(*files: str): | ||||||
| @ -46,3 +45,13 @@ def reconcile_app(app_name: str): | |||||||
|         return wrapper |         return wrapper | ||||||
|  |  | ||||||
|     return wrapper_outer |     return wrapper_outer | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def load_yaml_fixture(path: str, **kwargs) -> str: | ||||||
|  |     """Load yaml fixture, optionally formatting it with kwargs""" | ||||||
|  |     with open(Path(__file__).resolve().parent / Path(path), "r", encoding="utf-8") as _fixture: | ||||||
|  |         fixture = _fixture.read() | ||||||
|  |         try: | ||||||
|  |             return fixture % kwargs | ||||||
|  |         except TypeError: | ||||||
|  |             return fixture | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								authentik/blueprints/tests/fixtures/state_absent.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								authentik/blueprints/tests/fixtures/state_absent.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | |||||||
|  | version: 1 | ||||||
|  | entries: | ||||||
|  | - identifiers: | ||||||
|  |     name: "%(id)s" | ||||||
|  |     slug: "%(id)s" | ||||||
|  |   model: authentik_flows.flow | ||||||
|  |   state: absent | ||||||
							
								
								
									
										10
									
								
								authentik/blueprints/tests/fixtures/state_created.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								authentik/blueprints/tests/fixtures/state_created.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | |||||||
|  | version: 1 | ||||||
|  | entries: | ||||||
|  | - identifiers: | ||||||
|  |     name: "%(id)s" | ||||||
|  |     slug: "%(id)s" | ||||||
|  |   model: authentik_flows.flow | ||||||
|  |   state: created | ||||||
|  |   attrs: | ||||||
|  |     designation: stage_configuration | ||||||
|  |     title: foo | ||||||
							
								
								
									
										10
									
								
								authentik/blueprints/tests/fixtures/state_present.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								authentik/blueprints/tests/fixtures/state_present.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | |||||||
|  | version: 1 | ||||||
|  | entries: | ||||||
|  | - identifiers: | ||||||
|  |     name: "%(id)s" | ||||||
|  |     slug: "%(id)s" | ||||||
|  |   model: authentik_flows.flow | ||||||
|  |   state: present | ||||||
|  |   attrs: | ||||||
|  |     designation: stage_configuration | ||||||
|  |     title: foo | ||||||
							
								
								
									
										12
									
								
								authentik/blueprints/tests/fixtures/static_prompt_export.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								authentik/blueprints/tests/fixtures/static_prompt_export.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | version: 1 | ||||||
|  | entries: | ||||||
|  | - identifiers: | ||||||
|  |     pk: cb954fd4-65a5-4ad9-b1ee-180ee9559cf4 | ||||||
|  |   model: authentik_stages_prompt.prompt | ||||||
|  |   attrs: | ||||||
|  |     field_key: username | ||||||
|  |     label: Username | ||||||
|  |     type: username | ||||||
|  |     required: true | ||||||
|  |     placeholder: Username | ||||||
|  |     order: 0 | ||||||
							
								
								
									
										10
									
								
								authentik/blueprints/tests/fixtures/tags.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								authentik/blueprints/tests/fixtures/tags.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | |||||||
|  | version: 1 | ||||||
|  | context: | ||||||
|  |     foo: bar | ||||||
|  | entries: | ||||||
|  | - attrs: | ||||||
|  |     expression: return True | ||||||
|  |   identifiers: | ||||||
|  |     name: !Format [foo-%s-%s, !Context foo, !Context bar] | ||||||
|  |   id: default-source-enrollment-if-username | ||||||
|  |   model: authentik_policies_expression.expressionpolicy | ||||||
| @ -1,6 +1,7 @@ | |||||||
| """Test blueprints v1""" | """Test blueprints v1""" | ||||||
| from django.test import TransactionTestCase | from django.test import TransactionTestCase | ||||||
|  |  | ||||||
|  | from authentik.blueprints.tests import load_yaml_fixture | ||||||
| from authentik.blueprints.v1.exporter import FlowExporter | from authentik.blueprints.v1.exporter import FlowExporter | ||||||
| from authentik.blueprints.v1.importer import Importer, transaction_rollback | from authentik.blueprints.v1.importer import Importer, transaction_rollback | ||||||
| from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding | from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding | ||||||
| @ -10,32 +11,6 @@ from authentik.policies.models import PolicyBinding | |||||||
| from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage | from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage | ||||||
| from authentik.stages.user_login.models import UserLoginStage | from authentik.stages.user_login.models import UserLoginStage | ||||||
|  |  | ||||||
| STATIC_PROMPT_EXPORT = """version: 1 |  | ||||||
| entries: |  | ||||||
| - identifiers: |  | ||||||
|     pk: cb954fd4-65a5-4ad9-b1ee-180ee9559cf4 |  | ||||||
|   model: authentik_stages_prompt.prompt |  | ||||||
|   attrs: |  | ||||||
|     field_key: username |  | ||||||
|     label: Username |  | ||||||
|     type: username |  | ||||||
|     required: true |  | ||||||
|     placeholder: Username |  | ||||||
|     order: 0 |  | ||||||
| """ |  | ||||||
|  |  | ||||||
| YAML_TAG_TESTS = """version: 1 |  | ||||||
| context: |  | ||||||
|     foo: bar |  | ||||||
| entries: |  | ||||||
| - attrs: |  | ||||||
|     expression: return True |  | ||||||
|   identifiers: |  | ||||||
|     name: !Format [foo-%s-%s, !Context foo, !Context bar] |  | ||||||
|   id: default-source-enrollment-if-username |  | ||||||
|   model: authentik_policies_expression.expressionpolicy |  | ||||||
| """ |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestBlueprintsV1(TransactionTestCase): | class TestBlueprintsV1(TransactionTestCase): | ||||||
|     """Test Blueprints""" |     """Test Blueprints""" | ||||||
| @ -85,14 +60,14 @@ class TestBlueprintsV1(TransactionTestCase): | |||||||
|         """Test export and import it twice""" |         """Test export and import it twice""" | ||||||
|         count_initial = Prompt.objects.filter(field_key="username").count() |         count_initial = Prompt.objects.filter(field_key="username").count() | ||||||
|  |  | ||||||
|         importer = Importer(STATIC_PROMPT_EXPORT) |         importer = Importer(load_yaml_fixture("fixtures/static_prompt_export.yaml")) | ||||||
|         self.assertTrue(importer.validate()[0]) |         self.assertTrue(importer.validate()[0]) | ||||||
|         self.assertTrue(importer.apply()) |         self.assertTrue(importer.apply()) | ||||||
|  |  | ||||||
|         count_before = Prompt.objects.filter(field_key="username").count() |         count_before = Prompt.objects.filter(field_key="username").count() | ||||||
|         self.assertEqual(count_initial + 1, count_before) |         self.assertEqual(count_initial + 1, count_before) | ||||||
|  |  | ||||||
|         importer = Importer(STATIC_PROMPT_EXPORT) |         importer = Importer(load_yaml_fixture("fixtures/static_prompt_export.yaml")) | ||||||
|         self.assertTrue(importer.apply()) |         self.assertTrue(importer.apply()) | ||||||
|  |  | ||||||
|         self.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before) |         self.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before) | ||||||
| @ -100,7 +75,7 @@ class TestBlueprintsV1(TransactionTestCase): | |||||||
|     def test_import_yaml_tags(self): |     def test_import_yaml_tags(self): | ||||||
|         """Test some yaml tags""" |         """Test some yaml tags""" | ||||||
|         ExpressionPolicy.objects.filter(name="foo-foo-bar").delete() |         ExpressionPolicy.objects.filter(name="foo-foo-bar").delete() | ||||||
|         importer = Importer(YAML_TAG_TESTS, {"bar": "baz"}) |         importer = Importer(load_yaml_fixture("fixtures/tags.yaml"), {"bar": "baz"}) | ||||||
|         self.assertTrue(importer.validate()[0]) |         self.assertTrue(importer.validate()[0]) | ||||||
|         self.assertTrue(importer.apply()) |         self.assertTrue(importer.apply()) | ||||||
|         self.assertTrue(ExpressionPolicy.objects.filter(name="foo-foo-bar")) |         self.assertTrue(ExpressionPolicy.objects.filter(name="foo-foo-bar")) | ||||||
|  | |||||||
							
								
								
									
										82
									
								
								authentik/blueprints/tests/test_v1_state.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								authentik/blueprints/tests/test_v1_state.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,82 @@ | |||||||
|  | """Test blueprints v1""" | ||||||
|  | from django.test import TransactionTestCase | ||||||
|  |  | ||||||
|  | from authentik.blueprints.tests import load_yaml_fixture | ||||||
|  | from authentik.blueprints.v1.importer import Importer | ||||||
|  | from authentik.flows.models import Flow | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestBlueprintsV1State(TransactionTestCase): | ||||||
|  |     """Test Blueprints state attribute""" | ||||||
|  |  | ||||||
|  |     def test_state_present(self): | ||||||
|  |         """Test state present""" | ||||||
|  |         flow_slug = generate_id() | ||||||
|  |         import_yaml = load_yaml_fixture("fixtures/state_present.yaml", id=flow_slug) | ||||||
|  |  | ||||||
|  |         importer = Importer(import_yaml) | ||||||
|  |         self.assertTrue(importer.validate()[0]) | ||||||
|  |         self.assertTrue(importer.apply()) | ||||||
|  |         # Ensure object exists | ||||||
|  |         flow: Flow = Flow.objects.filter(slug=flow_slug).first() | ||||||
|  |         self.assertEqual(flow.slug, flow_slug) | ||||||
|  |  | ||||||
|  |         # Update object | ||||||
|  |         flow.title = "bar" | ||||||
|  |         flow.save() | ||||||
|  |  | ||||||
|  |         flow.refresh_from_db() | ||||||
|  |         self.assertEqual(flow.title, "bar") | ||||||
|  |  | ||||||
|  |         # Ensure importer updates it | ||||||
|  |         importer = Importer(import_yaml) | ||||||
|  |         self.assertTrue(importer.validate()[0]) | ||||||
|  |         self.assertTrue(importer.apply()) | ||||||
|  |         flow: Flow = Flow.objects.filter(slug=flow_slug).first() | ||||||
|  |         self.assertEqual(flow.title, "foo") | ||||||
|  |  | ||||||
|  |     def test_state_created(self): | ||||||
|  |         """Test state created""" | ||||||
|  |         flow_slug = generate_id() | ||||||
|  |         import_yaml = load_yaml_fixture("fixtures/state_created.yaml", id=flow_slug) | ||||||
|  |  | ||||||
|  |         importer = Importer(import_yaml) | ||||||
|  |         self.assertTrue(importer.validate()[0]) | ||||||
|  |         self.assertTrue(importer.apply()) | ||||||
|  |         # Ensure object exists | ||||||
|  |         flow: Flow = Flow.objects.filter(slug=flow_slug).first() | ||||||
|  |         self.assertEqual(flow.slug, flow_slug) | ||||||
|  |  | ||||||
|  |         # Update object | ||||||
|  |         flow.title = "bar" | ||||||
|  |         flow.save() | ||||||
|  |  | ||||||
|  |         flow.refresh_from_db() | ||||||
|  |         self.assertEqual(flow.title, "bar") | ||||||
|  |  | ||||||
|  |         # Ensure importer doesn't update it | ||||||
|  |         importer = Importer(import_yaml) | ||||||
|  |         self.assertTrue(importer.validate()[0]) | ||||||
|  |         self.assertTrue(importer.apply()) | ||||||
|  |         flow: Flow = Flow.objects.filter(slug=flow_slug).first() | ||||||
|  |         self.assertEqual(flow.title, "bar") | ||||||
|  |  | ||||||
|  |     def test_state_absent(self): | ||||||
|  |         """Test state absent""" | ||||||
|  |         flow_slug = generate_id() | ||||||
|  |         import_yaml = load_yaml_fixture("fixtures/state_created.yaml", id=flow_slug) | ||||||
|  |  | ||||||
|  |         importer = Importer(import_yaml) | ||||||
|  |         self.assertTrue(importer.validate()[0]) | ||||||
|  |         self.assertTrue(importer.apply()) | ||||||
|  |         # Ensure object exists | ||||||
|  |         flow: Flow = Flow.objects.filter(slug=flow_slug).first() | ||||||
|  |         self.assertEqual(flow.slug, flow_slug) | ||||||
|  |  | ||||||
|  |         import_yaml = load_yaml_fixture("fixtures/state_absent.yaml", id=flow_slug) | ||||||
|  |         importer = Importer(import_yaml) | ||||||
|  |         self.assertTrue(importer.validate()[0]) | ||||||
|  |         self.assertTrue(importer.apply()) | ||||||
|  |         flow: Flow = Flow.objects.filter(slug=flow_slug).first() | ||||||
|  |         self.assertIsNone(flow) | ||||||
| @ -41,11 +41,20 @@ class BlueprintEntryState: | |||||||
|     instance: Optional[Model] = None |     instance: Optional[Model] = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BlueprintEntryDesiredState(Enum): | ||||||
|  |     """State an entry should be reconciled to""" | ||||||
|  |  | ||||||
|  |     ABSENT = "absent" | ||||||
|  |     PRESENT = "present" | ||||||
|  |     CREATED = "created" | ||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass | @dataclass | ||||||
| class BlueprintEntry: | class BlueprintEntry: | ||||||
|     """Single entry of a blueprint""" |     """Single entry of a blueprint""" | ||||||
|  |  | ||||||
|     model: str |     model: str | ||||||
|  |     state: BlueprintEntryDesiredState = field(default=BlueprintEntryDesiredState.PRESENT) | ||||||
|     identifiers: dict[str, Any] = field(default_factory=dict) |     identifiers: dict[str, Any] = field(default_factory=dict) | ||||||
|     attrs: Optional[dict[str, Any]] = field(default_factory=dict) |     attrs: Optional[dict[str, Any]] = field(default_factory=dict) | ||||||
|  |  | ||||||
| @ -63,7 +72,7 @@ class BlueprintEntry: | |||||||
|         all_attrs = get_attrs(model) |         all_attrs = get_attrs(model) | ||||||
|  |  | ||||||
|         for extra_identifier_name in extra_identifier_names: |         for extra_identifier_name in extra_identifier_names: | ||||||
|             identifiers[extra_identifier_name] = all_attrs.pop(extra_identifier_name) |             identifiers[extra_identifier_name] = all_attrs.pop(extra_identifier_name, None) | ||||||
|         return BlueprintEntry( |         return BlueprintEntry( | ||||||
|             identifiers=identifiers, |             identifiers=identifiers, | ||||||
|             model=f"{model._meta.app_label}.{model._meta.model_name}", |             model=f"{model._meta.app_label}.{model._meta.model_name}", | ||||||
| @ -139,7 +148,7 @@ class KeyOf(YAMLTag): | |||||||
|                 ): |                 ): | ||||||
|                     return _entry._state.instance.pbm_uuid |                     return _entry._state.instance.pbm_uuid | ||||||
|                 return _entry._state.instance.pk |                 return _entry._state.instance.pk | ||||||
|         raise ValueError( |         raise EntryInvalidError( | ||||||
|             f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance" |             f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance" | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @ -227,8 +236,15 @@ class BlueprintDumper(SafeDumper): | |||||||
|         self.add_representer(UUID, lambda self, data: self.represent_str(str(data))) |         self.add_representer(UUID, lambda self, data: self.represent_str(str(data))) | ||||||
|         self.add_representer(OrderedDict, lambda self, data: self.represent_dict(dict(data))) |         self.add_representer(OrderedDict, lambda self, data: self.represent_dict(dict(data))) | ||||||
|         self.add_representer(Enum, lambda self, data: self.represent_str(data.value)) |         self.add_representer(Enum, lambda self, data: self.represent_str(data.value)) | ||||||
|  |         self.add_representer( | ||||||
|  |             BlueprintEntryDesiredState, lambda self, data: self.represent_str(data.value) | ||||||
|  |         ) | ||||||
|         self.add_representer(None, lambda self, data: self.represent_str(str(data))) |         self.add_representer(None, lambda self, data: self.represent_str(str(data))) | ||||||
|  |  | ||||||
|  |     def ignore_aliases(self, data): | ||||||
|  |         """Don't use any YAML anchors""" | ||||||
|  |         return True | ||||||
|  |  | ||||||
|     def represent(self, data) -> None: |     def represent(self, data) -> None: | ||||||
|         if is_dataclass(data): |         if is_dataclass(data): | ||||||
|  |  | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from contextlib import contextmanager | |||||||
| from copy import deepcopy | from copy import deepcopy | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
|  |  | ||||||
|  | from dacite.config import Config | ||||||
| from dacite.core import from_dict | from dacite.core import from_dict | ||||||
| from dacite.exceptions import DaciteError | from dacite.exceptions import DaciteError | ||||||
| from deepmerge import always_merger | from deepmerge import always_merger | ||||||
| @ -20,6 +21,7 @@ from yaml import load | |||||||
| from authentik.blueprints.v1.common import ( | from authentik.blueprints.v1.common import ( | ||||||
|     Blueprint, |     Blueprint, | ||||||
|     BlueprintEntry, |     BlueprintEntry, | ||||||
|  |     BlueprintEntryDesiredState, | ||||||
|     BlueprintEntryState, |     BlueprintEntryState, | ||||||
|     BlueprintLoader, |     BlueprintLoader, | ||||||
|     EntryInvalidError, |     EntryInvalidError, | ||||||
| @ -82,14 +84,16 @@ class Importer: | |||||||
|         self.logger = get_logger() |         self.logger = get_logger() | ||||||
|         import_dict = load(yaml_input, BlueprintLoader) |         import_dict = load(yaml_input, BlueprintLoader) | ||||||
|         try: |         try: | ||||||
|             self.__import = from_dict(Blueprint, import_dict) |             self.__import = from_dict( | ||||||
|  |                 Blueprint, import_dict, config=Config(cast=[BlueprintEntryDesiredState]) | ||||||
|  |             ) | ||||||
|         except DaciteError as exc: |         except DaciteError as exc: | ||||||
|             raise EntryInvalidError from exc |             raise EntryInvalidError from exc | ||||||
|         context = {} |         ctx = {} | ||||||
|         always_merger.merge(context, self.__import.context) |         always_merger.merge(ctx, self.__import.context) | ||||||
|         if context: |         if context: | ||||||
|             always_merger.merge(context, context) |             always_merger.merge(ctx, context) | ||||||
|         self.__import.context = context |         self.__import.context = ctx | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def blueprint(self) -> Blueprint: |     def blueprint(self) -> Blueprint: | ||||||
| @ -135,7 +139,7 @@ class Importer: | |||||||
|             sub_query &= Q(**{identifier: value}) |             sub_query &= Q(**{identifier: value}) | ||||||
|         return main_query | sub_query |         return main_query | sub_query | ||||||
|  |  | ||||||
|     def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer: |     def _validate_single(self, entry: BlueprintEntry) -> Optional[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] = registry.get_model(model_app_label, model_name) |         model: type[SerializerModel] = registry.get_model(model_app_label, model_name) | ||||||
| @ -168,8 +172,11 @@ class Importer: | |||||||
|         existing_models = model.objects.filter(self.__query_from_identifier(updated_identifiers)) |         existing_models = model.objects.filter(self.__query_from_identifier(updated_identifiers)) | ||||||
|  |  | ||||||
|         serializer_kwargs = {} |         serializer_kwargs = {} | ||||||
|         if not isinstance(model(), BaseMetaModel) and existing_models.exists(): |         model_instance = existing_models.first() | ||||||
|             model_instance = existing_models.first() |         if not isinstance(model(), BaseMetaModel) and model_instance: | ||||||
|  |             if entry.state == BlueprintEntryDesiredState.CREATED: | ||||||
|  |                 self.logger.debug("instance exists, skipping") | ||||||
|  |                 return None | ||||||
|             self.logger.debug( |             self.logger.debug( | ||||||
|                 "initialise serializer with instance", |                 "initialise serializer with instance", | ||||||
|                 model=model, |                 model=model, | ||||||
| @ -234,12 +241,25 @@ class Importer: | |||||||
|             except EntryInvalidError as exc: |             except EntryInvalidError as exc: | ||||||
|                 self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc) |                 self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc) | ||||||
|                 return False |                 return False | ||||||
|  |             if not serializer: | ||||||
|  |                 continue | ||||||
|  |  | ||||||
|             model = serializer.save() |             if entry.state in [ | ||||||
|             if "pk" in entry.identifiers: |                 BlueprintEntryDesiredState.PRESENT, | ||||||
|                 self.__pk_map[entry.identifiers["pk"]] = model.pk |                 BlueprintEntryDesiredState.CREATED, | ||||||
|             entry._state = BlueprintEntryState(model) |             ]: | ||||||
|             self.logger.debug("updated model", model=model) |                 model = serializer.save() | ||||||
|  |                 if "pk" in entry.identifiers: | ||||||
|  |                     self.__pk_map[entry.identifiers["pk"]] = model.pk | ||||||
|  |                 entry._state = BlueprintEntryState(model) | ||||||
|  |                 self.logger.debug("updated model", model=model) | ||||||
|  |             elif entry.state == BlueprintEntryDesiredState.ABSENT: | ||||||
|  |                 instance: Optional[Model] = serializer.instance | ||||||
|  |                 if instance: | ||||||
|  |                     instance.delete() | ||||||
|  |                     self.logger.debug("deleted model", mode=instance) | ||||||
|  |                     continue | ||||||
|  |                 self.logger.debug("entry to delete with no instance, skipping") | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|     def validate(self) -> tuple[bool, list[EventDict]]: |     def validate(self) -> tuple[bool, list[EventDict]]: | ||||||
|  | |||||||
| @ -23,10 +23,15 @@ from authentik.admin.api.metrics import CoordinateSerializer | |||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| 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 FilePathSerializer, FileUploadSerializer |  | ||||||
| from authentik.core.models import Application, User | from authentik.core.models import Application, User | ||||||
| from authentik.events.models import EventAction | from authentik.events.models import EventAction | ||||||
| from authentik.events.utils import sanitize_dict | from authentik.events.utils import sanitize_dict | ||||||
|  | from authentik.lib.utils.file import ( | ||||||
|  |     FilePathSerializer, | ||||||
|  |     FileUploadSerializer, | ||||||
|  |     set_file, | ||||||
|  |     set_file_url, | ||||||
|  | ) | ||||||
| from authentik.policies.api.exec import PolicyTestResultSerializer | from authentik.policies.api.exec import PolicyTestResultSerializer | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
| from authentik.policies.types import PolicyResult | from authentik.policies.types import PolicyResult | ||||||
| @ -37,7 +42,7 @@ LOGGER = get_logger() | |||||||
|  |  | ||||||
| def user_app_cache_key(user_pk: str) -> str: | def user_app_cache_key(user_pk: str) -> str: | ||||||
|     """Cache key where application list for user is saved""" |     """Cache key where application list for user is saved""" | ||||||
|     return f"user_app_cache_{user_pk}" |     return f"goauthentik.io/core/app_access/{user_pk}" | ||||||
|  |  | ||||||
|  |  | ||||||
| class ApplicationSerializer(ModelSerializer): | class ApplicationSerializer(ModelSerializer): | ||||||
| @ -224,21 +229,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|     def set_icon(self, request: Request, slug: str): |     def set_icon(self, request: Request, slug: str): | ||||||
|         """Set application icon""" |         """Set application icon""" | ||||||
|         app: Application = self.get_object() |         app: Application = self.get_object() | ||||||
|         icon = request.FILES.get("file", None) |         return set_file(request, app, "meta_icon") | ||||||
|         clear = request.data.get("clear", "false").lower() == "true" |  | ||||||
|         if clear: |  | ||||||
|             # .delete() saves the model by default |  | ||||||
|             app.meta_icon.delete() |  | ||||||
|             return Response({}) |  | ||||||
|         if icon: |  | ||||||
|             app.meta_icon = icon |  | ||||||
|             try: |  | ||||||
|                 app.save() |  | ||||||
|             except PermissionError as exc: |  | ||||||
|                 LOGGER.warning("Failed to save icon", exc=exc) |  | ||||||
|                 return HttpResponseBadRequest() |  | ||||||
|             return Response({}) |  | ||||||
|         return HttpResponseBadRequest() |  | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.change_application") |     @permission_required("authentik_core.change_application") | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
| @ -258,12 +249,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | |||||||
|     def set_icon_url(self, request: Request, slug: str): |     def set_icon_url(self, request: Request, slug: str): | ||||||
|         """Set application icon (as URL)""" |         """Set application icon (as URL)""" | ||||||
|         app: Application = self.get_object() |         app: Application = self.get_object() | ||||||
|         url = request.data.get("url", None) |         return set_file_url(request, app, "meta_icon") | ||||||
|         if url is None: |  | ||||||
|             return HttpResponseBadRequest() |  | ||||||
|         app.meta_icon.name = url |  | ||||||
|         app.save() |  | ||||||
|         return Response({}) |  | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.view_application", ["authentik_events.view_event"]) |     @permission_required("authentik_core.view_application", ["authentik_events.view_event"]) | ||||||
|     @extend_schema(responses={200: CoordinateSerializer(many=True)}) |     @extend_schema(responses={200: CoordinateSerializer(many=True)}) | ||||||
|  | |||||||
| @ -19,6 +19,7 @@ from authentik.core.api.used_by import UsedByMixin | |||||||
| from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer | from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer | ||||||
| from authentik.core.expression.evaluator import PropertyMappingEvaluator | from authentik.core.expression.evaluator import PropertyMappingEvaluator | ||||||
| from authentik.core.models import PropertyMapping | from authentik.core.models import PropertyMapping | ||||||
|  | from authentik.events.utils import sanitize_item | ||||||
| from authentik.lib.utils.reflection import all_subclasses | from authentik.lib.utils.reflection import all_subclasses | ||||||
| from authentik.policies.api.exec import PolicyTestSerializer | from authentik.policies.api.exec import PolicyTestSerializer | ||||||
|  |  | ||||||
| @ -140,7 +141,9 @@ class PropertyMappingViewSet( | |||||||
|                 self.request, |                 self.request, | ||||||
|                 **test_params.validated_data.get("context", {}), |                 **test_params.validated_data.get("context", {}), | ||||||
|             ) |             ) | ||||||
|             response_data["result"] = dumps(result, indent=(4 if format_result else None)) |             response_data["result"] = dumps( | ||||||
|  |                 sanitize_item(result), indent=(4 if format_result else None) | ||||||
|  |             ) | ||||||
|         except Exception as exc:  # pylint: disable=broad-except |         except Exception as exc:  # pylint: disable=broad-except | ||||||
|             response_data["result"] = str(exc) |             response_data["result"] = str(exc) | ||||||
|             response_data["successful"] = False |             response_data["successful"] = False | ||||||
|  | |||||||
| @ -2,10 +2,11 @@ | |||||||
| from typing import Iterable | from typing import Iterable | ||||||
|  |  | ||||||
| from django_filters.rest_framework import DjangoFilterBackend | from django_filters.rest_framework import DjangoFilterBackend | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
| from rest_framework.filters import OrderingFilter, SearchFilter | from rest_framework.filters import OrderingFilter, SearchFilter | ||||||
|  | from rest_framework.parsers import MultiPartParser | ||||||
| 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 ModelSerializer, ReadOnlyField, SerializerMethodField | from rest_framework.serializers import ModelSerializer, ReadOnlyField, SerializerMethodField | ||||||
| @ -13,10 +14,17 @@ from rest_framework.viewsets import GenericViewSet | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions | from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions | ||||||
|  | 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 MetaNameSerializer, TypeCreateSerializer | from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer | ||||||
| from authentik.core.models import Source, UserSourceConnection | from authentik.core.models import Source, UserSourceConnection | ||||||
| from authentik.core.types import UserSettingSerializer | from authentik.core.types import UserSettingSerializer | ||||||
|  | from authentik.lib.utils.file import ( | ||||||
|  |     FilePathSerializer, | ||||||
|  |     FileUploadSerializer, | ||||||
|  |     set_file, | ||||||
|  |     set_file_url, | ||||||
|  | ) | ||||||
| from authentik.lib.utils.reflection import all_subclasses | from authentik.lib.utils.reflection import all_subclasses | ||||||
| from authentik.policies.engine import PolicyEngine | from authentik.policies.engine import PolicyEngine | ||||||
|  |  | ||||||
| @ -28,6 +36,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | |||||||
|  |  | ||||||
|     managed = ReadOnlyField() |     managed = ReadOnlyField() | ||||||
|     component = SerializerMethodField() |     component = SerializerMethodField() | ||||||
|  |     icon = ReadOnlyField(source="get_icon") | ||||||
|  |  | ||||||
|     def get_component(self, obj: Source) -> str: |     def get_component(self, obj: Source) -> str: | ||||||
|         """Get object component so that we know how to edit the object""" |         """Get object component so that we know how to edit the object""" | ||||||
| @ -54,6 +63,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | |||||||
|             "user_matching_mode", |             "user_matching_mode", | ||||||
|             "managed", |             "managed", | ||||||
|             "user_path_template", |             "user_path_template", | ||||||
|  |             "icon", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -75,6 +85,49 @@ class SourceViewSet( | |||||||
|     def get_queryset(self):  # pragma: no cover |     def get_queryset(self):  # pragma: no cover | ||||||
|         return Source.objects.select_subclasses() |         return Source.objects.select_subclasses() | ||||||
|  |  | ||||||
|  |     @permission_required("authentik_core.change_source") | ||||||
|  |     @extend_schema( | ||||||
|  |         request={ | ||||||
|  |             "multipart/form-data": FileUploadSerializer, | ||||||
|  |         }, | ||||||
|  |         responses={ | ||||||
|  |             200: OpenApiResponse(description="Success"), | ||||||
|  |             400: OpenApiResponse(description="Bad request"), | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     @action( | ||||||
|  |         detail=True, | ||||||
|  |         pagination_class=None, | ||||||
|  |         filter_backends=[], | ||||||
|  |         methods=["POST"], | ||||||
|  |         parser_classes=(MultiPartParser,), | ||||||
|  |     ) | ||||||
|  |     # pylint: disable=unused-argument | ||||||
|  |     def set_icon(self, request: Request, slug: str): | ||||||
|  |         """Set source icon""" | ||||||
|  |         source: Source = self.get_object() | ||||||
|  |         return set_file(request, source, "icon") | ||||||
|  |  | ||||||
|  |     @permission_required("authentik_core.change_source") | ||||||
|  |     @extend_schema( | ||||||
|  |         request=FilePathSerializer, | ||||||
|  |         responses={ | ||||||
|  |             200: OpenApiResponse(description="Success"), | ||||||
|  |             400: OpenApiResponse(description="Bad request"), | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     @action( | ||||||
|  |         detail=True, | ||||||
|  |         pagination_class=None, | ||||||
|  |         filter_backends=[], | ||||||
|  |         methods=["POST"], | ||||||
|  |     ) | ||||||
|  |     # pylint: disable=unused-argument | ||||||
|  |     def set_icon_url(self, request: Request, slug: str): | ||||||
|  |         """Set source icon (as URL)""" | ||||||
|  |         source: Source = self.get_object() | ||||||
|  |         return set_file_url(request, source, "icon") | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) |     @extend_schema(responses={200: TypeCreateSerializer(many=True)}) | ||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def types(self, request: Request) -> Response: |     def types(self, request: Request) -> Response: | ||||||
|  | |||||||
| @ -46,7 +46,6 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.admin.api.metrics import CoordinateSerializer | from authentik.admin.api.metrics import CoordinateSerializer | ||||||
| from authentik.api.decorators import permission_required | from authentik.api.decorators import permission_required | ||||||
| from authentik.core.api.groups import GroupSerializer |  | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | ||||||
| from authentik.core.middleware import ( | from authentik.core.middleware import ( | ||||||
| @ -74,6 +73,26 @@ from authentik.tenants.models import Tenant | |||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class UserGroupSerializer(ModelSerializer): | ||||||
|  |     """Simplified Group Serializer for user's groups""" | ||||||
|  |  | ||||||
|  |     attributes = JSONField(required=False) | ||||||
|  |     parent_name = CharField(source="parent.name", read_only=True) | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |  | ||||||
|  |         model = Group | ||||||
|  |         fields = [ | ||||||
|  |             "pk", | ||||||
|  |             "num_pk", | ||||||
|  |             "name", | ||||||
|  |             "is_superuser", | ||||||
|  |             "parent", | ||||||
|  |             "parent_name", | ||||||
|  |             "attributes", | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class UserSerializer(ModelSerializer): | class UserSerializer(ModelSerializer): | ||||||
|     """User Serializer""" |     """User Serializer""" | ||||||
|  |  | ||||||
| @ -83,7 +102,7 @@ class UserSerializer(ModelSerializer): | |||||||
|     groups = PrimaryKeyRelatedField( |     groups = PrimaryKeyRelatedField( | ||||||
|         allow_empty=True, many=True, source="ak_groups", queryset=Group.objects.all() |         allow_empty=True, many=True, source="ak_groups", queryset=Group.objects.all() | ||||||
|     ) |     ) | ||||||
|     groups_obj = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups") |     groups_obj = ListSerializer(child=UserGroupSerializer(), read_only=True, source="ak_groups") | ||||||
|     uid = CharField(read_only=True) |     uid = CharField(read_only=True) | ||||||
|     username = CharField(max_length=150) |     username = CharField(max_length=150) | ||||||
|  |  | ||||||
| @ -470,7 +489,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|     # pylint: disable=invalid-name, unused-argument |     # pylint: disable=invalid-name, unused-argument | ||||||
|     def recovery_email(self, request: Request, pk: int) -> Response: |     def recovery_email(self, request: Request, pk: int) -> Response: | ||||||
|         """Create a temporary link that a user can use to recover their accounts""" |         """Create a temporary link that a user can use to recover their accounts""" | ||||||
|         for_user = self.get_object() |         for_user: User = self.get_object() | ||||||
|         if for_user.email == "": |         if for_user.email == "": | ||||||
|             LOGGER.debug("User doesn't have an email address") |             LOGGER.debug("User doesn't have an email address") | ||||||
|             return Response(status=404) |             return Response(status=404) | ||||||
| @ -488,8 +507,9 @@ class UserViewSet(UsedByMixin, ModelViewSet): | |||||||
|         email_stage: EmailStage = stages.first() |         email_stage: EmailStage = stages.first() | ||||||
|         message = TemplateEmailMessage( |         message = TemplateEmailMessage( | ||||||
|             subject=_(email_stage.subject), |             subject=_(email_stage.subject), | ||||||
|             template_name=email_stage.template, |  | ||||||
|             to=[for_user.email], |             to=[for_user.email], | ||||||
|  |             template_name=email_stage.template, | ||||||
|  |             language=for_user.locale(request), | ||||||
|             template_context={ |             template_context={ | ||||||
|                 "url": link, |                 "url": link, | ||||||
|                 "user": for_user, |                 "user": for_user, | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from rest_framework.fields import BooleanField, CharField, FileField, IntegerField | from rest_framework.fields import CharField, IntegerField | ||||||
| from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError | from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -23,19 +23,6 @@ class PassiveSerializer(Serializer): | |||||||
|         return Model() |         return Model() | ||||||
|  |  | ||||||
|  |  | ||||||
| class FileUploadSerializer(PassiveSerializer): |  | ||||||
|     """Serializer to upload file""" |  | ||||||
|  |  | ||||||
|     file = FileField(required=False) |  | ||||||
|     clear = BooleanField(default=False) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class FilePathSerializer(PassiveSerializer): |  | ||||||
|     """Serializer to upload file""" |  | ||||||
|  |  | ||||||
|     url = CharField() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MetaNameSerializer(PassiveSerializer): | class MetaNameSerializer(PassiveSerializer): | ||||||
|     """Add verbose names to response""" |     """Add verbose names to response""" | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,6 +1,8 @@ | |||||||
| """authentik shell command""" | """authentik shell command""" | ||||||
| import code | import code | ||||||
| import platform | import platform | ||||||
|  | import sys | ||||||
|  | import traceback | ||||||
|  |  | ||||||
| from django.apps import apps | from django.apps import apps | ||||||
| from django.core.management.base import BaseCommand | from django.core.management.base import BaseCommand | ||||||
| @ -89,6 +91,21 @@ class Command(BaseCommand): | |||||||
|             exec(options["command"], namespace)  # nosec # noqa |             exec(options["command"], namespace)  # nosec # noqa | ||||||
|             return |             return | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             hook = sys.__interactivehook__ | ||||||
|  |         except AttributeError: | ||||||
|  |             # Match the behavior of the cpython shell where a missing | ||||||
|  |             # sys.__interactivehook__ is ignored. | ||||||
|  |             pass | ||||||
|  |         else: | ||||||
|  |             try: | ||||||
|  |                 hook() | ||||||
|  |             except Exception:  # pylint: disable=broad-except | ||||||
|  |                 # Match the behavior of the cpython shell where an error in | ||||||
|  |                 # sys.__interactivehook__ prints a warning and the exception | ||||||
|  |                 # and continues. | ||||||
|  |                 print("Failed calling sys.__interactivehook__") | ||||||
|  |                 traceback.print_exc() | ||||||
|         # Try to enable tab-complete |         # Try to enable tab-complete | ||||||
|         try: |         try: | ||||||
|             import readline |             import readline | ||||||
|  | |||||||
| @ -4,6 +4,7 @@ from typing import Callable, Optional | |||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
|  | from django.utils.translation import activate | ||||||
| from sentry_sdk.api import set_tag | from sentry_sdk.api import set_tag | ||||||
| from structlog.contextvars import STRUCTLOG_KEY_PREFIX | from structlog.contextvars import STRUCTLOG_KEY_PREFIX | ||||||
|  |  | ||||||
| @ -29,6 +30,10 @@ class ImpersonateMiddleware: | |||||||
|     def __call__(self, request: HttpRequest) -> HttpResponse: |     def __call__(self, request: HttpRequest) -> HttpResponse: | ||||||
|         # No permission checks are done here, they need to be checked before |         # No permission checks are done here, they need to be checked before | ||||||
|         # SESSION_KEY_IMPERSONATE_USER is set. |         # SESSION_KEY_IMPERSONATE_USER is set. | ||||||
|  |         if request.user.is_authenticated: | ||||||
|  |             locale = request.user.locale(request) | ||||||
|  |             if locale != "": | ||||||
|  |                 activate(locale) | ||||||
|  |  | ||||||
|         if SESSION_KEY_IMPERSONATE_USER in request.session: |         if SESSION_KEY_IMPERSONATE_USER in request.session: | ||||||
|             request.user = request.session[SESSION_KEY_IMPERSONATE_USER] |             request.user = request.session[SESSION_KEY_IMPERSONATE_USER] | ||||||
|  | |||||||
							
								
								
									
										20
									
								
								authentik/core/migrations/0024_source_icon.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								authentik/core/migrations/0024_source_icon.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | # Generated by Django 4.1.3 on 2022-11-15 20:33 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_core", "0023_source_authentik_c_slug_ccb2e5_idx_and_more"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="source", | ||||||
|  |             name="icon", | ||||||
|  |             field=models.FileField( | ||||||
|  |                 default=None, max_length=500, null=True, upload_to="source-icons/" | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -220,6 +220,17 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): | |||||||
|         """Generate a globally unique UID, based on the user ID and the hashed secret key""" |         """Generate a globally unique UID, based on the user ID and the hashed secret key""" | ||||||
|         return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest() |         return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest() | ||||||
|  |  | ||||||
|  |     def locale(self, request: Optional[HttpRequest] = None) -> str: | ||||||
|  |         """Get the locale the user has configured""" | ||||||
|  |         try: | ||||||
|  |             return self.attributes.get("settings", {}).get("locale", "") | ||||||
|  |         # pylint: disable=broad-except | ||||||
|  |         except Exception as exc: | ||||||
|  |             LOGGER.warning("Failed to get default locale", exc=exc) | ||||||
|  |         if request: | ||||||
|  |             return request.tenant.locale | ||||||
|  |         return "" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def avatar(self) -> str: |     def avatar(self) -> str: | ||||||
|         """Get avatar, depending on authentik.avatar setting""" |         """Get avatar, depending on authentik.avatar setting""" | ||||||
| @ -286,7 +297,7 @@ class Provider(SerializerModel): | |||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return self.name |         return str(self.name) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Application(SerializerModel, PolicyBindingModel): | class Application(SerializerModel, PolicyBindingModel): | ||||||
| @ -368,7 +379,7 @@ class Application(SerializerModel, PolicyBindingModel): | |||||||
|             return None |             return None | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return self.name |         return str(self.name) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -410,6 +421,12 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | |||||||
|  |  | ||||||
|     enabled = models.BooleanField(default=True) |     enabled = models.BooleanField(default=True) | ||||||
|     property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) |     property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) | ||||||
|  |     icon = models.FileField( | ||||||
|  |         upload_to="source-icons/", | ||||||
|  |         default=None, | ||||||
|  |         null=True, | ||||||
|  |         max_length=500, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     authentication_flow = models.ForeignKey( |     authentication_flow = models.ForeignKey( | ||||||
|         "authentik_flows.Flow", |         "authentik_flows.Flow", | ||||||
| @ -443,6 +460,16 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | |||||||
|  |  | ||||||
|     objects = InheritanceManager() |     objects = InheritanceManager() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def get_icon(self) -> Optional[str]: | ||||||
|  |         """Get the URL to the Icon. If the name is /static or | ||||||
|  |         starts with http it is returned as-is""" | ||||||
|  |         if not self.icon: | ||||||
|  |             return None | ||||||
|  |         if "://" in self.icon.name or self.icon.name.startswith("/static"): | ||||||
|  |             return self.icon.name | ||||||
|  |         return self.icon.url | ||||||
|  |  | ||||||
|     def get_user_path(self) -> str: |     def get_user_path(self) -> str: | ||||||
|         """Get user path, fallback to default for formatting errors""" |         """Get user path, fallback to default for formatting errors""" | ||||||
|         try: |         try: | ||||||
| @ -470,7 +497,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | |||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return self.name |         return str(self.name) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ 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 | ||||||
| from django.db.models.query_utils import Q | from django.db.models.query_utils import Q | ||||||
| from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest | from django.http import HttpRequest, HttpResponse | ||||||
| from django.shortcuts import redirect | from django.shortcuts import redirect | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| @ -23,8 +23,10 @@ from authentik.flows.planner import ( | |||||||
|     PLAN_CONTEXT_SSO, |     PLAN_CONTEXT_SSO, | ||||||
|     FlowPlanner, |     FlowPlanner, | ||||||
| ) | ) | ||||||
|  | from authentik.flows.stage import StageView | ||||||
| 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.lib.views import bad_request_message | ||||||
| from authentik.policies.denied import AccessDeniedResponse | from authentik.policies.denied import AccessDeniedResponse | ||||||
| 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 | ||||||
| @ -43,6 +45,26 @@ class Action(Enum): | |||||||
|     DENY = "deny" |     DENY = "deny" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MessageStage(StageView): | ||||||
|  |     """Show a pre-configured message after the flow is done""" | ||||||
|  |  | ||||||
|  |     # pylint: disable=unused-argument | ||||||
|  |     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
|  |         """Show a pre-configured message after the flow is done""" | ||||||
|  |         message = getattr(self.executor.current_stage, "message", "") | ||||||
|  |         level = getattr(self.executor.current_stage, "level", messages.SUCCESS) | ||||||
|  |         messages.add_message( | ||||||
|  |             self.request, | ||||||
|  |             level, | ||||||
|  |             message, | ||||||
|  |         ) | ||||||
|  |         return self.executor.stage_ok() | ||||||
|  |  | ||||||
|  |     def post(self, request: HttpRequest) -> HttpResponse: | ||||||
|  |         """Wrapper for post requests""" | ||||||
|  |         return self.get(request) | ||||||
|  |  | ||||||
|  |  | ||||||
| class SourceFlowManager: | class SourceFlowManager: | ||||||
|     """Help sources decide what they should do after authorization. Based on source settings and |     """Help sources decide what they should do after authorization. Based on source settings and | ||||||
|     previous connections, authenticate the user, enroll a new user, link to an existing user |     previous connections, authenticate the user, enroll a new user, link to an existing user | ||||||
| @ -150,16 +172,16 @@ class SourceFlowManager: | |||||||
|             action, connection = self.get_action(**kwargs) |             action, connection = self.get_action(**kwargs) | ||||||
|         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(reverse("authentik_core:root-redirect")) | ||||||
|         self._logger.debug("get_action", action=action, connection=connection) |         self._logger.debug("get_action", action=action, connection=connection) | ||||||
|         try: |         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") | ||||||
|                     return self.handle_existing_user_link(connection) |                     return self.handle_existing_link(connection) | ||||||
|                 if action == Action.AUTH: |                 if action == Action.AUTH: | ||||||
|                     self._logger.debug("Handling auth user") |                     self._logger.debug("Handling auth user") | ||||||
|                     return self.handle_auth_user(connection) |                     return self.handle_auth(connection) | ||||||
|                 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) | ||||||
| @ -198,8 +220,12 @@ class SourceFlowManager: | |||||||
|             ] |             ] | ||||||
|         return [] |         return [] | ||||||
|  |  | ||||||
|     def _handle_login_flow( |     def _prepare_flow( | ||||||
|         self, flow: Flow, connection: UserSourceConnection, **kwargs |         self, | ||||||
|  |         flow: Flow, | ||||||
|  |         connection: UserSourceConnection, | ||||||
|  |         stages: Optional[list[StageView]] = None, | ||||||
|  |         **kwargs, | ||||||
|     ) -> HttpResponse: |     ) -> 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 | ||||||
| @ -219,12 +245,18 @@ class SourceFlowManager: | |||||||
|         ) |         ) | ||||||
|         kwargs.update(self.policy_context) |         kwargs.update(self.policy_context) | ||||||
|         if not flow: |         if not flow: | ||||||
|             return HttpResponseBadRequest() |             return bad_request_message( | ||||||
|  |                 self.request, | ||||||
|  |                 _("Configured flow does not exist."), | ||||||
|  |             ) | ||||||
|         # 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 | ||||||
|         planner = FlowPlanner(flow) |         planner = FlowPlanner(flow) | ||||||
|         plan = planner.plan(self.request, kwargs) |         plan = planner.plan(self.request, kwargs) | ||||||
|         for stage in self.get_stages_to_append(flow): |         for stage in self.get_stages_to_append(flow): | ||||||
|             plan.append_stage(stage=stage) |             plan.append_stage(stage) | ||||||
|  |         if stages: | ||||||
|  |             for stage in stages: | ||||||
|  |                 plan.append_stage(stage) | ||||||
|         self.request.session[SESSION_KEY_PLAN] = plan |         self.request.session[SESSION_KEY_PLAN] = plan | ||||||
|         return redirect_with_qs( |         return redirect_with_qs( | ||||||
|             "authentik_core:if-flow", |             "authentik_core:if-flow", | ||||||
| @ -233,24 +265,35 @@ class SourceFlowManager: | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def handle_auth_user( |     def handle_auth( | ||||||
|         self, |         self, | ||||||
|         connection: UserSourceConnection, |         connection: UserSourceConnection, | ||||||
|     ) -> HttpResponse: |     ) -> HttpResponse: | ||||||
|         """Login user and redirect.""" |         """Login user and redirect.""" | ||||||
|         messages.success( |  | ||||||
|             self.request, |  | ||||||
|             _("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, connection, **flow_kwargs) |         return self._prepare_flow( | ||||||
|  |             self.source.authentication_flow, | ||||||
|  |             connection, | ||||||
|  |             stages=[ | ||||||
|  |                 in_memory_stage( | ||||||
|  |                     MessageStage, | ||||||
|  |                     message=_( | ||||||
|  |                         "Successfully authenticated with %(source)s!" % {"source": self.source.name} | ||||||
|  |                     ), | ||||||
|  |                 ) | ||||||
|  |             ], | ||||||
|  |             **flow_kwargs, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def handle_existing_user_link( |     def handle_existing_link( | ||||||
|         self, |         self, | ||||||
|         connection: UserSourceConnection, |         connection: UserSourceConnection, | ||||||
|     ) -> HttpResponse: |     ) -> HttpResponse: | ||||||
|         """Handler when the user was already authenticated and linked an external source |         """Handler when the user was already authenticated and linked an external source | ||||||
|         to their account.""" |         to their account.""" | ||||||
|  |         # When request isn't authenticated we jump straight to auth | ||||||
|  |         if not self.request.user.is_authenticated: | ||||||
|  |             return self.handle_auth(connection) | ||||||
|         # Connection has already been saved |         # Connection has already been saved | ||||||
|         Event.new( |         Event.new( | ||||||
|             EventAction.SOURCE_LINKED, |             EventAction.SOURCE_LINKED, | ||||||
| @ -261,9 +304,6 @@ class SourceFlowManager: | |||||||
|             self.request, |             self.request, | ||||||
|             _("Successfully linked %(source)s!" % {"source": self.source.name}), |             _("Successfully linked %(source)s!" % {"source": self.source.name}), | ||||||
|         ) |         ) | ||||||
|         # When request isn't authenticated we jump straight to auth |  | ||||||
|         if not self.request.user.is_authenticated: |  | ||||||
|             return self.handle_auth_user(connection) |  | ||||||
|         return redirect( |         return redirect( | ||||||
|             reverse( |             reverse( | ||||||
|                 "authentik_core:if-user", |                 "authentik_core:if-user", | ||||||
| @ -276,18 +316,24 @@ class SourceFlowManager: | |||||||
|         connection: UserSourceConnection, |         connection: UserSourceConnection, | ||||||
|     ) -> HttpResponse: |     ) -> HttpResponse: | ||||||
|         """User was not authenticated and previous request was not authenticated.""" |         """User was not authenticated and previous request was not authenticated.""" | ||||||
|         messages.success( |  | ||||||
|             self.request, |  | ||||||
|             _("Successfully authenticated with %(source)s!" % {"source": self.source.name}), |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         # 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 | ||||||
|         if not self.source.enrollment_flow: |         if not self.source.enrollment_flow: | ||||||
|             self._logger.warning("source has no enrollment flow") |             self._logger.warning("source has no enrollment flow") | ||||||
|             return HttpResponseBadRequest() |             return bad_request_message( | ||||||
|         return self._handle_login_flow( |                 self.request, | ||||||
|  |                 _("Source is not configured for enrollment."), | ||||||
|  |             ) | ||||||
|  |         return self._prepare_flow( | ||||||
|             self.source.enrollment_flow, |             self.source.enrollment_flow, | ||||||
|             connection, |             connection, | ||||||
|  |             stages=[ | ||||||
|  |                 in_memory_stage( | ||||||
|  |                     MessageStage, | ||||||
|  |                     message=_( | ||||||
|  |                         "Successfully authenticated with %(source)s!" % {"source": self.source.name} | ||||||
|  |                     ), | ||||||
|  |                 ) | ||||||
|  |             ], | ||||||
|             **{ |             **{ | ||||||
|                 PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), |                 PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), | ||||||
|                 PLAN_CONTEXT_USER_PATH: self.source.get_user_path(), |                 PLAN_CONTEXT_USER_PATH: self.source.get_user_path(), | ||||||
|  | |||||||
| @ -1,6 +1,9 @@ | |||||||
|  | {% load i18n %} | ||||||
|  | {% get_current_language as LANGUAGE_CODE %} | ||||||
|  |  | ||||||
| <script> | <script> | ||||||
|     window.authentik = {}; |     window.authentik = {}; | ||||||
|     window.authentik.locale = "{{ tenant.default_locale }}"; |     window.authentik.locale = "{{ LANGUAGE_CODE }}"; | ||||||
|     window.authentik.config = JSON.parse('{{ config_json|escapejs }}'); |     window.authentik.config = JSON.parse('{{ config_json|escapejs }}'); | ||||||
|     window.authentik.tenant = JSON.parse('{{ tenant_json|escapejs }}'); |     window.authentik.tenant = JSON.parse('{{ tenant_json|escapejs }}'); | ||||||
|     window.addEventListener("DOMContentLoaded", () => { |     window.addEventListener("DOMContentLoaded", () => { | ||||||
|  | |||||||
| @ -13,8 +13,8 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <ak-message-container data-refresh-on-locale="true"></ak-message-container> | <ak-message-container></ak-message-container> | ||||||
| <ak-interface-admin data-refresh-on-locale="true"> | <ak-interface-admin> | ||||||
|     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> |     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> |         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||||
|             <div class="pf-c-empty-state__content"> |             <div class="pf-c-empty-state__content"> | ||||||
|  | |||||||
| @ -29,8 +29,8 @@ window.authentik.flow = { | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <ak-message-container data-refresh-on-locale="true"></ak-message-container> | <ak-message-container></ak-message-container> | ||||||
| <ak-flow-executor data-refresh-on-locale="true"> | <ak-flow-executor> | ||||||
|     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> |     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> |         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||||
|             <div class="pf-c-empty-state__content"> |             <div class="pf-c-empty-state__content"> | ||||||
|  | |||||||
| @ -13,8 +13,8 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block body %} | {% block body %} | ||||||
| <ak-message-container data-refresh-on-locale="true"></ak-message-container> | <ak-message-container></ak-message-container> | ||||||
| <ak-interface-user data-refresh-on-locale="true"> | <ak-interface-user> | ||||||
|     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> |     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> |         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||||
|             <div class="pf-c-empty-state__content"> |             <div class="pf-c-empty-state__content"> | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| """authentik events models""" | """authentik events models""" | ||||||
| import time | import time | ||||||
| from collections import Counter | from collections import Counter | ||||||
|  | from copy import deepcopy | ||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
| from inspect import currentframe | from inspect import currentframe | ||||||
| from smtplib import SMTPException | from smtplib import SMTPException | ||||||
| @ -210,7 +211,7 @@ class Event(SerializerModel, ExpiringModel): | |||||||
|             current = currentframe() |             current = currentframe() | ||||||
|             parent = current.f_back |             parent = current.f_back | ||||||
|             app = parent.f_globals["__name__"] |             app = parent.f_globals["__name__"] | ||||||
|         cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs)) |         cleaned_kwargs = cleanse_dict(sanitize_dict(deepcopy(kwargs))) | ||||||
|         event = Event(action=action, app=app, context=cleaned_kwargs) |         event = Event(action=action, app=app, context=cleaned_kwargs) | ||||||
|         return event |         return event | ||||||
|  |  | ||||||
| @ -293,7 +294,7 @@ class Event(SerializerModel, ExpiringModel): | |||||||
|         return f"{self.action}: {self.context}" |         return f"{self.action}: {self.context}" | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         return f"<Event action={self.action} user={self.user} context={self.context}>" |         return f"Event action={self.action} user={self.user} context={self.context}" | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -445,8 +446,9 @@ class NotificationTransport(SerializerModel): | |||||||
|             subject += notification.body[:75] |             subject += notification.body[:75] | ||||||
|         mail = TemplateEmailMessage( |         mail = TemplateEmailMessage( | ||||||
|             subject=subject, |             subject=subject, | ||||||
|             template_name="email/generic.html", |  | ||||||
|             to=[notification.user.email], |             to=[notification.user.email], | ||||||
|  |             language=notification.user.locale(), | ||||||
|  |             template_name="email/generic.html", | ||||||
|             template_context={ |             template_context={ | ||||||
|                 "title": subject, |                 "title": subject, | ||||||
|                 "body": notification.body, |                 "body": notification.body, | ||||||
|  | |||||||
| @ -15,6 +15,7 @@ from authentik.events.models import Event, EventAction | |||||||
| from authentik.lib.utils.errors import exception_to_string | from authentik.lib.utils.errors import exception_to_string | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | CACHE_KEY_PREFIX = "goauthentik.io/events/tasks/" | ||||||
|  |  | ||||||
|  |  | ||||||
| class TaskResultStatus(Enum): | class TaskResultStatus(Enum): | ||||||
| @ -70,16 +71,16 @@ class TaskInfo: | |||||||
|     @staticmethod |     @staticmethod | ||||||
|     def all() -> dict[str, "TaskInfo"]: |     def all() -> dict[str, "TaskInfo"]: | ||||||
|         """Get all TaskInfo objects""" |         """Get all TaskInfo objects""" | ||||||
|         return cache.get_many(cache.keys("task_*")) |         return cache.get_many(cache.keys(CACHE_KEY_PREFIX + "*")) | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def by_name(name: str) -> Optional["TaskInfo"]: |     def by_name(name: str) -> Optional["TaskInfo"]: | ||||||
|         """Get TaskInfo Object by name""" |         """Get TaskInfo Object by name""" | ||||||
|         return cache.get(f"task_{name}", None) |         return cache.get(CACHE_KEY_PREFIX + name, None) | ||||||
|  |  | ||||||
|     def delete(self): |     def delete(self): | ||||||
|         """Delete task info from cache""" |         """Delete task info from cache""" | ||||||
|         return cache.delete(f"task_{self.task_name}") |         return cache.delete(CACHE_KEY_PREFIX + self.task_name) | ||||||
|  |  | ||||||
|     def set_prom_metrics(self): |     def set_prom_metrics(self): | ||||||
|         """Update prometheus metrics""" |         """Update prometheus metrics""" | ||||||
| @ -98,9 +99,9 @@ class TaskInfo: | |||||||
|  |  | ||||||
|     def save(self, timeout_hours=6): |     def save(self, timeout_hours=6): | ||||||
|         """Save task into cache""" |         """Save task into cache""" | ||||||
|         key = f"task_{self.task_name}" |         key = CACHE_KEY_PREFIX + self.task_name | ||||||
|         if self.result.uid: |         if self.result.uid: | ||||||
|             key += f"_{self.result.uid}" |             key += f"/{self.result.uid}" | ||||||
|             self.task_name += f"_{self.result.uid}" |             self.task_name += f"_{self.result.uid}" | ||||||
|         self.set_prom_metrics() |         self.set_prom_metrics() | ||||||
|         cache.set(key, self, timeout=timeout_hours * 60 * 60) |         cache.set(key, self, timeout=timeout_hours * 60 * 60) | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ | |||||||
| import re | import re | ||||||
| from dataclasses import asdict, is_dataclass | from dataclasses import asdict, is_dataclass | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  | from types import GeneratorType | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
| @ -93,6 +94,8 @@ def sanitize_item(value: Any) -> Any: | |||||||
|         value = asdict(value) |         value = asdict(value) | ||||||
|     if isinstance(value, dict): |     if isinstance(value, dict): | ||||||
|         return sanitize_dict(value) |         return sanitize_dict(value) | ||||||
|  |     if isinstance(value, GeneratorType): | ||||||
|  |         return sanitize_item(list(value)) | ||||||
|     if isinstance(value, list): |     if isinstance(value, list): | ||||||
|         new_values = [] |         new_values = [] | ||||||
|         for item in value: |         for item in value: | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """Flow API Views""" | """Flow API Views""" | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| from django.http import HttpResponse | from django.http import HttpResponse | ||||||
| from django.http.response import HttpResponseBadRequest |  | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| @ -19,19 +18,19 @@ from authentik.api.decorators import permission_required | |||||||
| from authentik.blueprints.v1.exporter import FlowExporter | from authentik.blueprints.v1.exporter import FlowExporter | ||||||
| from authentik.blueprints.v1.importer import Importer | from authentik.blueprints.v1.importer import Importer | ||||||
| from authentik.core.api.used_by import UsedByMixin | from authentik.core.api.used_by import UsedByMixin | ||||||
| from authentik.core.api.utils import ( | from authentik.core.api.utils import CacheSerializer, LinkSerializer, PassiveSerializer | ||||||
|     CacheSerializer, |  | ||||||
|     FilePathSerializer, |  | ||||||
|     FileUploadSerializer, |  | ||||||
|     LinkSerializer, |  | ||||||
|     PassiveSerializer, |  | ||||||
| ) |  | ||||||
| from authentik.events.utils import sanitize_dict | from authentik.events.utils import sanitize_dict | ||||||
| from authentik.flows.api.flows_diagram import FlowDiagram, FlowDiagramSerializer | from authentik.flows.api.flows_diagram import FlowDiagram, FlowDiagramSerializer | ||||||
| from authentik.flows.exceptions import FlowNonApplicableException | from authentik.flows.exceptions import FlowNonApplicableException | ||||||
| from authentik.flows.models import Flow | from authentik.flows.models import Flow | ||||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key | from authentik.flows.planner import CACHE_PREFIX, PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key | ||||||
| from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN | from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN | ||||||
|  | from authentik.lib.utils.file import ( | ||||||
|  |     FilePathSerializer, | ||||||
|  |     FileUploadSerializer, | ||||||
|  |     set_file, | ||||||
|  |     set_file_url, | ||||||
|  | ) | ||||||
| from authentik.lib.views import bad_request_message | from authentik.lib.views import bad_request_message | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| @ -122,7 +121,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def cache_info(self, request: Request) -> Response: |     def cache_info(self, request: Request) -> Response: | ||||||
|         """Info about cached flows""" |         """Info about cached flows""" | ||||||
|         return Response(data={"count": len(cache.keys("flow_*"))}) |         return Response(data={"count": len(cache.keys(f"{CACHE_PREFIX}*"))}) | ||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_flows.clear_flow_cache"]) |     @permission_required(None, ["authentik_flows.clear_flow_cache"]) | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
| @ -135,7 +134,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|     @action(detail=False, methods=["POST"]) |     @action(detail=False, methods=["POST"]) | ||||||
|     def cache_clear(self, request: Request) -> Response: |     def cache_clear(self, request: Request) -> Response: | ||||||
|         """Clear flow cache""" |         """Clear flow cache""" | ||||||
|         keys = cache.keys("flow_*") |         keys = cache.keys(f"{CACHE_PREFIX}*") | ||||||
|         cache.delete_many(keys) |         cache.delete_many(keys) | ||||||
|         LOGGER.debug("Cleared flow cache", keys=len(keys)) |         LOGGER.debug("Cleared flow cache", keys=len(keys)) | ||||||
|         return Response(status=204) |         return Response(status=204) | ||||||
| @ -249,25 +248,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|     def set_background(self, request: Request, slug: str): |     def set_background(self, request: Request, slug: str): | ||||||
|         """Set Flow background""" |         """Set Flow background""" | ||||||
|         flow: Flow = self.get_object() |         flow: Flow = self.get_object() | ||||||
|         background = request.FILES.get("file", None) |         return set_file(request, flow, "background") | ||||||
|         clear = request.data.get("clear", "false").lower() == "true" |  | ||||||
|         if clear: |  | ||||||
|             if flow.background_url.startswith("/media"): |  | ||||||
|                 # .delete() saves the model by default |  | ||||||
|                 flow.background.delete() |  | ||||||
|             else: |  | ||||||
|                 flow.background = None |  | ||||||
|                 flow.save() |  | ||||||
|             return Response({}) |  | ||||||
|         if background: |  | ||||||
|             flow.background = background |  | ||||||
|             try: |  | ||||||
|                 flow.save() |  | ||||||
|             except PermissionError as exc: |  | ||||||
|                 LOGGER.warning("Failed to save icon", exc=exc) |  | ||||||
|                 return HttpResponseBadRequest() |  | ||||||
|             return Response({}) |  | ||||||
|         return HttpResponseBadRequest() |  | ||||||
|  |  | ||||||
|     @permission_required("authentik_core.change_application") |     @permission_required("authentik_core.change_application") | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
| @ -287,12 +268,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|     def set_background_url(self, request: Request, slug: str): |     def set_background_url(self, request: Request, slug: str): | ||||||
|         """Set Flow background (as URL)""" |         """Set Flow background (as URL)""" | ||||||
|         flow: Flow = self.get_object() |         flow: Flow = self.get_object() | ||||||
|         url = request.data.get("url", None) |         return set_file_url(request, flow, "background") | ||||||
|         if not url: |  | ||||||
|             return HttpResponseBadRequest() |  | ||||||
|         flow.background.name = url |  | ||||||
|         flow.save() |  | ||||||
|         return Response({}) |  | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
|  | |||||||
| @ -27,11 +27,12 @@ PLAN_CONTEXT_SOURCE = "source" | |||||||
| # was restored. | # was restored. | ||||||
| PLAN_CONTEXT_IS_RESTORED = "is_restored" | PLAN_CONTEXT_IS_RESTORED = "is_restored" | ||||||
| CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_flows")) | CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_flows")) | ||||||
|  | CACHE_PREFIX = "goauthentik.io/flows/planner/" | ||||||
|  |  | ||||||
|  |  | ||||||
| def cache_key(flow: Flow, user: Optional[User] = None) -> str: | def cache_key(flow: Flow, user: Optional[User] = None) -> str: | ||||||
|     """Generate Cache key for flow""" |     """Generate Cache key for flow""" | ||||||
|     prefix = f"flow_{flow.pk}" |     prefix = CACHE_PREFIX + str(flow.pk) | ||||||
|     if user: |     if user: | ||||||
|         prefix += f"#{user.pk}" |         prefix += f"#{user.pk}" | ||||||
|     return prefix |     return prefix | ||||||
| @ -141,6 +142,7 @@ class FlowPlanner: | |||||||
|             # First off, check the flow's direct policy bindings |             # First off, check the flow's direct policy bindings | ||||||
|             # to make sure the user even has access to the flow |             # to make sure the user even has access to the flow | ||||||
|             engine = PolicyEngine(self.flow, user, request) |             engine = PolicyEngine(self.flow, user, request) | ||||||
|  |             engine.use_cache = self.use_cache | ||||||
|             if default_context: |             if default_context: | ||||||
|                 span.set_data("default_context", cleanse_dict(default_context)) |                 span.set_data("default_context", cleanse_dict(default_context)) | ||||||
|                 engine.request.context.update(default_context) |                 engine.request.context.update(default_context) | ||||||
| @ -206,6 +208,7 @@ class FlowPlanner: | |||||||
|                         stage=stage, |                         stage=stage, | ||||||
|                     ) |                     ) | ||||||
|                     engine = PolicyEngine(binding, user, request) |                     engine = PolicyEngine(binding, user, request) | ||||||
|  |                     engine.use_cache = self.use_cache | ||||||
|                     engine.request.context["flow_plan"] = plan |                     engine.request.context["flow_plan"] = plan | ||||||
|                     engine.request.context.update(plan.context) |                     engine.request.context.update(plan.context) | ||||||
|                     engine.build() |                     engine.build() | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ from django.dispatch import receiver | |||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.flows.apps import GAUGE_FLOWS_CACHED | from authentik.flows.apps import GAUGE_FLOWS_CACHED | ||||||
|  | from authentik.flows.planner import CACHE_PREFIX | ||||||
| from authentik.root.monitoring import monitoring_set | from authentik.root.monitoring import monitoring_set | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| @ -21,7 +22,7 @@ def delete_cache_prefix(prefix: str) -> int: | |||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def monitoring_set_flows(sender, **kwargs): | def monitoring_set_flows(sender, **kwargs): | ||||||
|     """set flow gauges""" |     """set flow gauges""" | ||||||
|     GAUGE_FLOWS_CACHED.set(len(cache.keys("flow_*") or [])) |     GAUGE_FLOWS_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}*") or [])) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
|  | |||||||
| @ -44,6 +44,7 @@ from authentik.flows.models import ( | |||||||
|     Stage, |     Stage, | ||||||
| ) | ) | ||||||
| from authentik.flows.planner import ( | from authentik.flows.planner import ( | ||||||
|  |     CACHE_PREFIX, | ||||||
|     PLAN_CONTEXT_IS_RESTORED, |     PLAN_CONTEXT_IS_RESTORED, | ||||||
|     PLAN_CONTEXT_PENDING_USER, |     PLAN_CONTEXT_PENDING_USER, | ||||||
|     PLAN_CONTEXT_REDIRECT, |     PLAN_CONTEXT_REDIRECT, | ||||||
| @ -216,7 +217,7 @@ class FlowExecutorView(APIView): | |||||||
|                 self._logger.warning( |                 self._logger.warning( | ||||||
|                     "f(exec): found incompatible flow plan, invalidating run", exc=exc |                     "f(exec): found incompatible flow plan, invalidating run", exc=exc | ||||||
|                 ) |                 ) | ||||||
|                 keys = cache.keys("flow_*") |                 keys = cache.keys(f"{CACHE_PREFIX}*") | ||||||
|                 cache.delete_many(keys) |                 cache.delete_many(keys) | ||||||
|                 return self.stage_invalid() |                 return self.stage_invalid() | ||||||
|             if not next_binding: |             if not next_binding: | ||||||
| @ -253,9 +254,9 @@ class FlowExecutorView(APIView): | |||||||
|             action=EventAction.SYSTEM_EXCEPTION, |             action=EventAction.SYSTEM_EXCEPTION, | ||||||
|             message=exception_to_string(exc), |             message=exception_to_string(exc), | ||||||
|         ).from_http(self.request) |         ).from_http(self.request) | ||||||
|         return to_stage_response( |         challenge = FlowErrorChallenge(self.request, exc) | ||||||
|             self.request, HttpChallengeResponse(FlowErrorChallenge(self.request, exc)) |         challenge.is_valid() | ||||||
|         ) |         return to_stage_response(self.request, HttpChallengeResponse(challenge)) | ||||||
|  |  | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         responses={ |         responses={ | ||||||
| @ -351,7 +352,7 @@ class FlowExecutorView(APIView): | |||||||
|             # from the cache. If there are errors, just delete all cached flows |             # from the cache. If there are errors, just delete all cached flows | ||||||
|             _ = plan.has_stages |             _ = plan.has_stages | ||||||
|         except Exception:  # pylint: disable=broad-except |         except Exception:  # pylint: disable=broad-except | ||||||
|             keys = cache.keys("flow_*") |             keys = cache.keys(f"{CACHE_PREFIX}*") | ||||||
|             cache.delete_many(keys) |             cache.delete_many(keys) | ||||||
|             return self._initiate_plan() |             return self._initiate_plan() | ||||||
|         return plan |         return plan | ||||||
|  | |||||||
| @ -19,10 +19,7 @@ redis: | |||||||
|   password: '' |   password: '' | ||||||
|   tls: false |   tls: false | ||||||
|   tls_reqs: "none" |   tls_reqs: "none" | ||||||
|   cache_db: 0 |   db: 0 | ||||||
|   message_queue_db: 1 |  | ||||||
|   ws_db: 2 |  | ||||||
|   outpost_session_db: 3 |  | ||||||
|   cache_timeout: 300 |   cache_timeout: 300 | ||||||
|   cache_timeout_flows: 300 |   cache_timeout_flows: 300 | ||||||
|   cache_timeout_policies: 300 |   cache_timeout_policies: 300 | ||||||
| @ -35,6 +32,7 @@ log_level: info | |||||||
| # Error reporting, sends stacktrace to sentry.beryju.org | # Error reporting, sends stacktrace to sentry.beryju.org | ||||||
| error_reporting: | error_reporting: | ||||||
|   enabled: false |   enabled: false | ||||||
|  |   sentry_dsn: https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8 | ||||||
|   environment: customer |   environment: customer | ||||||
|   send_pii: false |   send_pii: false | ||||||
|   sample_rate: 0.1 |   sample_rate: 0.1 | ||||||
|  | |||||||
| @ -99,13 +99,16 @@ class BaseEvaluator: | |||||||
|     def expr_event_create(self, action: str, **kwargs): |     def expr_event_create(self, action: str, **kwargs): | ||||||
|         """Create event with supplied data and try to extract as much relevant data |         """Create event with supplied data and try to extract as much relevant data | ||||||
|         from the context""" |         from the context""" | ||||||
|  |         # If the result was a complex variable, we don't want to re-use it | ||||||
|  |         self._context.pop("result", None) | ||||||
|  |         self._context.pop("handler", None) | ||||||
|         kwargs["context"] = self._context |         kwargs["context"] = self._context | ||||||
|         event = Event.new( |         event = Event.new( | ||||||
|             action, |             action, | ||||||
|             app=self._filename, |             app=self._filename, | ||||||
|             **kwargs, |             **kwargs, | ||||||
|         ) |         ) | ||||||
|         if "request" in self._context and isinstance(PolicyRequest, self._context["request"]): |         if "request" in self._context and isinstance(self._context["request"], PolicyRequest): | ||||||
|             policy_request: PolicyRequest = self._context["request"] |             policy_request: PolicyRequest = self._context["request"] | ||||||
|             if policy_request.http_request: |             if policy_request.http_request: | ||||||
|                 event.from_http(policy_request) |                 event.from_http(policy_request) | ||||||
|  | |||||||
| @ -34,7 +34,6 @@ from authentik.lib.utils.http import authentik_user_agent | |||||||
| from authentik.lib.utils.reflection import class_to_path, get_env | from authentik.lib.utils.reflection import class_to_path, get_env | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SentryWSMiddleware(BaseMiddleware): | class SentryWSMiddleware(BaseMiddleware): | ||||||
| @ -71,7 +70,7 @@ def sentry_init(**sentry_init_kwargs): | |||||||
|     kwargs.update(**sentry_init_kwargs) |     kwargs.update(**sentry_init_kwargs) | ||||||
|     # pylint: disable=abstract-class-instantiated |     # pylint: disable=abstract-class-instantiated | ||||||
|     sentry_sdk_init( |     sentry_sdk_init( | ||||||
|         dsn=SENTRY_DSN, |         dsn=CONFIG.y("error_reporting.sentry_dsn"), | ||||||
|         integrations=[ |         integrations=[ | ||||||
|             DjangoIntegration(transaction_style="function_name"), |             DjangoIntegration(transaction_style="function_name"), | ||||||
|             CeleryIntegration(), |             CeleryIntegration(), | ||||||
|  | |||||||
							
								
								
									
										55
									
								
								authentik/lib/utils/file.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								authentik/lib/utils/file.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,55 @@ | |||||||
|  | """file utils""" | ||||||
|  | from django.db.models import Model | ||||||
|  | from django.http import HttpResponseBadRequest | ||||||
|  | from rest_framework.fields import BooleanField, CharField, FileField | ||||||
|  | from rest_framework.request import Request | ||||||
|  | from rest_framework.response import Response | ||||||
|  | from structlog import get_logger | ||||||
|  |  | ||||||
|  | from authentik.core.api.utils import PassiveSerializer | ||||||
|  |  | ||||||
|  | LOGGER = get_logger() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FileUploadSerializer(PassiveSerializer): | ||||||
|  |     """Serializer to upload file""" | ||||||
|  |  | ||||||
|  |     file = FileField(required=False) | ||||||
|  |     clear = BooleanField(default=False) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FilePathSerializer(PassiveSerializer): | ||||||
|  |     """Serializer to upload file""" | ||||||
|  |  | ||||||
|  |     url = CharField() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def set_file(request: Request, obj: Model, field: str): | ||||||
|  |     """Upload file""" | ||||||
|  |     field = getattr(obj, field) | ||||||
|  |     icon = request.FILES.get("file", None) | ||||||
|  |     clear = request.data.get("clear", "false").lower() == "true" | ||||||
|  |     if clear: | ||||||
|  |         # .delete() saves the model by default | ||||||
|  |         field.delete() | ||||||
|  |         return Response({}) | ||||||
|  |     if icon: | ||||||
|  |         field = icon | ||||||
|  |         try: | ||||||
|  |             obj.save() | ||||||
|  |         except PermissionError as exc: | ||||||
|  |             LOGGER.warning("Failed to save file", exc=exc) | ||||||
|  |             return HttpResponseBadRequest() | ||||||
|  |         return Response({}) | ||||||
|  |     return HttpResponseBadRequest() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def set_file_url(request: Request, obj: Model, field: str): | ||||||
|  |     """Set file field to URL""" | ||||||
|  |     field = getattr(obj, field) | ||||||
|  |     url = request.data.get("url", None) | ||||||
|  |     if url is None: | ||||||
|  |         return HttpResponseBadRequest() | ||||||
|  |     field.name = url | ||||||
|  |     obj.save() | ||||||
|  |     return Response({}) | ||||||
| @ -143,7 +143,7 @@ class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer): | |||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|         model = KubernetesServiceConnection |         model = KubernetesServiceConnection | ||||||
|         fields = ServiceConnectionSerializer.Meta.fields + ["kubeconfig"] |         fields = ServiceConnectionSerializer.Meta.fields + ["kubeconfig", "verify_ssl"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class KubernetesServiceConnectionViewSet(UsedByMixin, ModelViewSet): | class KubernetesServiceConnectionViewSet(UsedByMixin, ModelViewSet): | ||||||
|  | |||||||
| @ -36,6 +36,7 @@ class KubernetesClient(ApiClient, BaseClient): | |||||||
|                 load_incluster_config(client_configuration=config) |                 load_incluster_config(client_configuration=config) | ||||||
|             else: |             else: | ||||||
|                 load_kube_config_from_dict(connection.kubeconfig, client_configuration=config) |                 load_kube_config_from_dict(connection.kubeconfig, client_configuration=config) | ||||||
|  |             config.verify_ssl = connection.verify_ssl | ||||||
|             super().__init__(config) |             super().__init__(config) | ||||||
|         except ConfigException as exc: |         except ConfigException as exc: | ||||||
|             raise ServiceConnectionInvalid(exc) from exc |             raise ServiceConnectionInvalid(exc) from exc | ||||||
|  | |||||||
| @ -0,0 +1,20 @@ | |||||||
|  | # Generated by Django 4.1.3 on 2022-11-14 12:56 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_outposts", "0001_squashed_0017_outpost_managed"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="kubernetesserviceconnection", | ||||||
|  |             name="verify_ssl", | ||||||
|  |             field=models.BooleanField( | ||||||
|  |                 default=True, help_text="Verify SSL Certificates of the Kubernetes API endpoint" | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -53,7 +53,7 @@ class ServiceConnectionInvalid(SentryIgnoredException): | |||||||
| class OutpostConfig: | class OutpostConfig: | ||||||
|     """Configuration an outpost uses to configure it self""" |     """Configuration an outpost uses to configure it self""" | ||||||
|  |  | ||||||
|     # update website/docs/outposts/outposts.md |     # update website/docs/outposts/_config.md | ||||||
|  |  | ||||||
|     authentik_host: str = "" |     authentik_host: str = "" | ||||||
|     authentik_host_insecure: bool = False |     authentik_host_insecure: bool = False | ||||||
| @ -62,16 +62,17 @@ class OutpostConfig: | |||||||
|     log_level: str = CONFIG.y("log_level") |     log_level: str = CONFIG.y("log_level") | ||||||
|     object_naming_template: str = field(default="ak-outpost-%(name)s") |     object_naming_template: str = field(default="ak-outpost-%(name)s") | ||||||
|  |  | ||||||
|  |     container_image: Optional[str] = field(default=None) | ||||||
|  |  | ||||||
|     docker_network: Optional[str] = field(default=None) |     docker_network: Optional[str] = field(default=None) | ||||||
|     docker_map_ports: bool = field(default=True) |     docker_map_ports: bool = field(default=True) | ||||||
|     docker_labels: Optional[dict[str, str]] = field(default=None) |     docker_labels: Optional[dict[str, str]] = field(default=None) | ||||||
|  |  | ||||||
|     container_image: Optional[str] = field(default=None) |  | ||||||
|  |  | ||||||
|     kubernetes_replicas: int = field(default=1) |     kubernetes_replicas: int = field(default=1) | ||||||
|     kubernetes_namespace: str = field(default_factory=get_namespace) |     kubernetes_namespace: str = field(default_factory=get_namespace) | ||||||
|     kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict) |     kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict) | ||||||
|     kubernetes_ingress_secret_name: str = field(default="authentik-outpost-tls") |     kubernetes_ingress_secret_name: str = field(default="authentik-outpost-tls") | ||||||
|  |     kubernetes_ingress_class_name: Optional[str] = field(default=None) | ||||||
|     kubernetes_service_type: str = field(default="ClusterIP") |     kubernetes_service_type: str = field(default="ClusterIP") | ||||||
|     kubernetes_disabled_components: list[str] = field(default_factory=list) |     kubernetes_disabled_components: list[str] = field(default_factory=list) | ||||||
|     kubernetes_image_pull_secrets: list[str] = field(default_factory=list) |     kubernetes_image_pull_secrets: list[str] = field(default_factory=list) | ||||||
| @ -224,6 +225,9 @@ class KubernetesServiceConnection(SerializerModel, OutpostServiceConnection): | |||||||
|         ), |         ), | ||||||
|         blank=True, |         blank=True, | ||||||
|     ) |     ) | ||||||
|  |     verify_ssl = models.BooleanField( | ||||||
|  |         default=True, help_text=_("Verify SSL Certificates of the Kubernetes API endpoint") | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> Serializer: |     def serializer(self) -> Serializer: | ||||||
| @ -288,7 +292,7 @@ class Outpost(SerializerModel, ManagedModel): | |||||||
|     @property |     @property | ||||||
|     def state_cache_prefix(self) -> str: |     def state_cache_prefix(self) -> str: | ||||||
|         """Key by which the outposts status is saved""" |         """Key by which the outposts status is saved""" | ||||||
|         return f"outpost_{self.uuid.hex}_state" |         return f"goauthentik.io/outposts/{self.uuid.hex}_state" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def state(self) -> list["OutpostState"]: |     def state(self) -> list["OutpostState"]: | ||||||
|  | |||||||
| @ -21,7 +21,7 @@ from authentik.lib.utils.reflection import all_subclasses | |||||||
| from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer | from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer | ||||||
| from authentik.policies.models import Policy, PolicyBinding | from authentik.policies.models import Policy, PolicyBinding | ||||||
| from authentik.policies.process import PolicyProcess | from authentik.policies.process import PolicyProcess | ||||||
| from authentik.policies.types import PolicyRequest | from authentik.policies.types import CACHE_PREFIX, PolicyRequest | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -114,7 +114,7 @@ class PolicyViewSet( | |||||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) |     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||||
|     def cache_info(self, request: Request) -> Response: |     def cache_info(self, request: Request) -> Response: | ||||||
|         """Info about cached policies""" |         """Info about cached policies""" | ||||||
|         return Response(data={"count": len(cache.keys("policy_*"))}) |         return Response(data={"count": len(cache.keys(f"{CACHE_PREFIX}*"))}) | ||||||
|  |  | ||||||
|     @permission_required(None, ["authentik_policies.clear_policy_cache"]) |     @permission_required(None, ["authentik_policies.clear_policy_cache"]) | ||||||
|     @extend_schema( |     @extend_schema( | ||||||
| @ -127,7 +127,7 @@ class PolicyViewSet( | |||||||
|     @action(detail=False, methods=["POST"]) |     @action(detail=False, methods=["POST"]) | ||||||
|     def cache_clear(self, request: Request) -> Response: |     def cache_clear(self, request: Request) -> Response: | ||||||
|         """Clear policy cache""" |         """Clear policy cache""" | ||||||
|         keys = cache.keys("policy_*") |         keys = cache.keys(f"{CACHE_PREFIX}*") | ||||||
|         cache.delete_many(keys) |         cache.delete_many(keys) | ||||||
|         LOGGER.debug("Cleared Policy cache", keys=len(keys)) |         LOGGER.debug("Cleared Policy cache", keys=len(keys)) | ||||||
|         # Also delete user application cache |         # Also delete user application cache | ||||||
|  | |||||||
| @ -1,13 +1,17 @@ | |||||||
| """evaluator tests""" | """evaluator tests""" | ||||||
| from django.test import TestCase | from django.test import RequestFactory, TestCase | ||||||
| from guardian.shortcuts import get_anonymous_user | from guardian.shortcuts import get_anonymous_user | ||||||
| from rest_framework.serializers import ValidationError | from rest_framework.serializers import ValidationError | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from authentik.core.models import Application | ||||||
|  | from authentik.lib.generators import generate_id | ||||||
| from authentik.policies.exceptions import PolicyException | from authentik.policies.exceptions import PolicyException | ||||||
| from authentik.policies.expression.api import ExpressionPolicySerializer | from authentik.policies.expression.api import ExpressionPolicySerializer | ||||||
| from authentik.policies.expression.evaluator import PolicyEvaluator | from authentik.policies.expression.evaluator import PolicyEvaluator | ||||||
| from authentik.policies.expression.models import ExpressionPolicy | from authentik.policies.expression.models import ExpressionPolicy | ||||||
|  | from authentik.policies.models import PolicyBinding | ||||||
|  | from authentik.policies.process import PolicyProcess | ||||||
| from authentik.policies.types import PolicyRequest | from authentik.policies.types import PolicyRequest | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -15,7 +19,15 @@ class TestEvaluator(TestCase): | |||||||
|     """Evaluator tests""" |     """Evaluator tests""" | ||||||
|  |  | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|  |         factory = RequestFactory() | ||||||
|  |         self.http_request = factory.get("/") | ||||||
|  |         self.obj = Application.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             slug=generate_id(), | ||||||
|  |         ) | ||||||
|         self.request = PolicyRequest(user=get_anonymous_user()) |         self.request = PolicyRequest(user=get_anonymous_user()) | ||||||
|  |         self.request.obj = self.obj | ||||||
|  |         self.request.http_request = self.http_request | ||||||
|  |  | ||||||
|     def test_full(self): |     def test_full(self): | ||||||
|         """Test full with Policy instance""" |         """Test full with Policy instance""" | ||||||
| @ -63,6 +75,41 @@ class TestEvaluator(TestCase): | |||||||
|         with self.assertRaises(ValidationError): |         with self.assertRaises(ValidationError): | ||||||
|             evaluator.validate(template) |             evaluator.validate(template) | ||||||
|  |  | ||||||
|  |     def test_execution_logging(self): | ||||||
|  |         """test execution_logging""" | ||||||
|  |         expr = ExpressionPolicy.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             execution_logging=True, | ||||||
|  |             expression="ak_message(request.http_request.path)\nreturn True", | ||||||
|  |         ) | ||||||
|  |         evaluator = PolicyEvaluator("test") | ||||||
|  |         evaluator.set_policy_request(self.request) | ||||||
|  |         proc = PolicyProcess(PolicyBinding(policy=expr), request=self.request, connection=None) | ||||||
|  |         res = proc.profiling_wrapper() | ||||||
|  |         self.assertEqual(res.messages, ("/",)) | ||||||
|  |  | ||||||
|  |     def test_call_policy(self): | ||||||
|  |         """test ak_call_policy""" | ||||||
|  |         expr = ExpressionPolicy.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             execution_logging=True, | ||||||
|  |             expression="ak_message(request.http_request.path)\nreturn True", | ||||||
|  |         ) | ||||||
|  |         tmpl = ( | ||||||
|  |             """ | ||||||
|  |         ak_message(request.http_request.path) | ||||||
|  |         res = ak_call_policy('%s') | ||||||
|  |         ak_message(request.http_request.path) | ||||||
|  |         for msg in res.messages: | ||||||
|  |             ak_message(msg) | ||||||
|  |         """ | ||||||
|  |             % expr.name | ||||||
|  |         ) | ||||||
|  |         evaluator = PolicyEvaluator("test") | ||||||
|  |         evaluator.set_policy_request(self.request) | ||||||
|  |         res = evaluator.evaluate(tmpl) | ||||||
|  |         self.assertEqual(res.messages, ("/", "/", "/")) | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestExpressionPolicyAPI(APITestCase): | class TestExpressionPolicyAPI(APITestCase): | ||||||
|     """Test expression policy's API""" |     """Test expression policy's API""" | ||||||
|  | |||||||
| @ -15,7 +15,7 @@ LOGGER = get_logger() | |||||||
|  |  | ||||||
|  |  | ||||||
| class HaveIBeenPwendPolicy(Policy): | class HaveIBeenPwendPolicy(Policy): | ||||||
|     """Check if password is on HaveIBeenPwned's list by uploading the first |     """DEPRECATED. Check if password is on HaveIBeenPwned's list by uploading the first | ||||||
|     5 characters of the SHA1 Hash.""" |     5 characters of the SHA1 Hash.""" | ||||||
|  |  | ||||||
|     password_field = models.TextField( |     password_field = models.TextField( | ||||||
|  | |||||||
| @ -41,6 +41,9 @@ class PolicyBindingModel(models.Model): | |||||||
|  |  | ||||||
|     objects = InheritanceManager() |     objects = InheritanceManager() | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"PolicyBindingModel {self.pbm_uuid}" | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         verbose_name = _("Policy Binding Model") |         verbose_name = _("Policy Binding Model") | ||||||
|         verbose_name_plural = _("Policy Binding Models") |         verbose_name_plural = _("Policy Binding Models") | ||||||
| @ -135,6 +138,7 @@ class PolicyBinding(SerializerModel): | |||||||
|             return f"Binding from {self.target} #{self.order} to {suffix}" |             return f"Binding from {self.target} #{self.order} to {suffix}" | ||||||
|         except PolicyBinding.target.RelatedObjectDoesNotExist:  # pylint: disable=no-member |         except PolicyBinding.target.RelatedObjectDoesNotExist:  # pylint: disable=no-member | ||||||
|             return f"Binding - #{self.order} to {suffix}" |             return f"Binding - #{self.order} to {suffix}" | ||||||
|  |         return "" | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
| @ -175,7 +179,7 @@ class Policy(SerializerModel, CreatedUpdatedModel): | |||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return self.name |         return str(self.name) | ||||||
|  |  | ||||||
|     def passes(self, request: PolicyRequest) -> PolicyResult:  # pragma: no cover |     def passes(self, request: PolicyRequest) -> PolicyResult:  # pragma: no cover | ||||||
|         """Check if request passes this policy""" |         """Check if request passes this policy""" | ||||||
|  | |||||||
| @ -20,6 +20,11 @@ class PasswordPolicySerializer(PolicySerializer): | |||||||
|             "length_min", |             "length_min", | ||||||
|             "symbol_charset", |             "symbol_charset", | ||||||
|             "error_message", |             "error_message", | ||||||
|  |             "check_static_rules", | ||||||
|  |             "check_have_i_been_pwned", | ||||||
|  |             "check_zxcvbn", | ||||||
|  |             "hibp_allowed_count", | ||||||
|  |             "zxcvbn_score_threshold", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -0,0 +1,73 @@ | |||||||
|  | # Generated by Django 4.1.3 on 2022-11-14 09:23 | ||||||
|  | from django.apps.registry import Apps | ||||||
|  | from django.db import migrations, models | ||||||
|  | from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def migrate_hibp_policy(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  |     db_alias = schema_editor.connection.alias | ||||||
|  |  | ||||||
|  |     HaveIBeenPwendPolicy = apps.get_model("authentik_policies_hibp", "HaveIBeenPwendPolicy") | ||||||
|  |     PasswordPolicy = apps.get_model("authentik_policies_password", "PasswordPolicy") | ||||||
|  |  | ||||||
|  |     PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") | ||||||
|  |  | ||||||
|  |     for old_policy in HaveIBeenPwendPolicy.objects.using(db_alias).all(): | ||||||
|  |         new_policy = PasswordPolicy.objects.using(db_alias).create( | ||||||
|  |             name=old_policy.name, | ||||||
|  |             hibp_allowed_count=old_policy.allowed_count, | ||||||
|  |             password_field=old_policy.password_field, | ||||||
|  |             execution_logging=old_policy.execution_logging, | ||||||
|  |             check_static_rules=False, | ||||||
|  |             check_have_i_been_pwned=True, | ||||||
|  |         ) | ||||||
|  |         PolicyBinding.objects.using(db_alias).filter(policy=old_policy).update(policy=new_policy) | ||||||
|  |         old_policy.delete() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ("authentik_policies_hibp", "0003_haveibeenpwendpolicy_authentik_p_policy__6957d7_idx"), | ||||||
|  |         ("authentik_policies_password", "0004_passwordpolicy_authentik_p_policy__855e80_idx"), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="passwordpolicy", | ||||||
|  |             name="check_have_i_been_pwned", | ||||||
|  |             field=models.BooleanField(default=False), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="passwordpolicy", | ||||||
|  |             name="check_static_rules", | ||||||
|  |             field=models.BooleanField(default=True), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="passwordpolicy", | ||||||
|  |             name="check_zxcvbn", | ||||||
|  |             field=models.BooleanField(default=False), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="passwordpolicy", | ||||||
|  |             name="hibp_allowed_count", | ||||||
|  |             field=models.PositiveIntegerField( | ||||||
|  |                 default=0, | ||||||
|  |                 help_text="How many times the password hash is allowed to be on haveibeenpwned", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="passwordpolicy", | ||||||
|  |             name="zxcvbn_score_threshold", | ||||||
|  |             field=models.PositiveIntegerField( | ||||||
|  |                 default=2, | ||||||
|  |                 help_text="If the zxcvbn score is equal or less than this value, the policy will fail.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |         migrations.AlterField( | ||||||
|  |             model_name="passwordpolicy", | ||||||
|  |             name="error_message", | ||||||
|  |             field=models.TextField(blank=True), | ||||||
|  |         ), | ||||||
|  |         migrations.RunPython(migrate_hibp_policy), | ||||||
|  |     ] | ||||||
| @ -1,11 +1,14 @@ | |||||||
| """user field matcher models""" | """password policy""" | ||||||
| import re | import re | ||||||
|  | from hashlib import sha1 | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| 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.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  | from zxcvbn import zxcvbn | ||||||
|  |  | ||||||
|  | from authentik.lib.utils.http import get_http_session | ||||||
| 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 | ||||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||||
| @ -24,13 +27,27 @@ class PasswordPolicy(Policy): | |||||||
|         help_text=_("Field key to check, field keys defined in Prompt stages are available."), |         help_text=_("Field key to check, field keys defined in Prompt stages are available."), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     check_static_rules = models.BooleanField(default=True) | ||||||
|  |     check_have_i_been_pwned = models.BooleanField(default=False) | ||||||
|  |     check_zxcvbn = models.BooleanField(default=False) | ||||||
|  |  | ||||||
|     amount_digits = models.PositiveIntegerField(default=0) |     amount_digits = models.PositiveIntegerField(default=0) | ||||||
|     amount_uppercase = models.PositiveIntegerField(default=0) |     amount_uppercase = models.PositiveIntegerField(default=0) | ||||||
|     amount_lowercase = models.PositiveIntegerField(default=0) |     amount_lowercase = models.PositiveIntegerField(default=0) | ||||||
|     amount_symbols = models.PositiveIntegerField(default=0) |     amount_symbols = models.PositiveIntegerField(default=0) | ||||||
|     length_min = models.PositiveIntegerField(default=0) |     length_min = models.PositiveIntegerField(default=0) | ||||||
|     symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ") |     symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ") | ||||||
|     error_message = models.TextField() |     error_message = models.TextField(blank=True) | ||||||
|  |  | ||||||
|  |     hibp_allowed_count = models.PositiveIntegerField( | ||||||
|  |         default=0, | ||||||
|  |         help_text=_("How many times the password hash is allowed to be on haveibeenpwned"), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     zxcvbn_score_threshold = models.PositiveIntegerField( | ||||||
|  |         default=2, | ||||||
|  |         help_text=_("If the zxcvbn score is equal or less than this value, the policy will fail."), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> type[BaseSerializer]: |     def serializer(self) -> type[BaseSerializer]: | ||||||
| @ -42,48 +59,103 @@ class PasswordPolicy(Policy): | |||||||
|     def component(self) -> str: |     def component(self) -> str: | ||||||
|         return "ak-policy-password-form" |         return "ak-policy-password-form" | ||||||
|  |  | ||||||
|     # pylint: disable=too-many-return-statements |  | ||||||
|     def passes(self, request: PolicyRequest) -> PolicyResult: |     def passes(self, request: PolicyRequest) -> PolicyResult: | ||||||
|         if ( |         password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get( | ||||||
|             self.password_field not in request.context |             self.password_field, request.context.get(self.password_field) | ||||||
|             and self.password_field not in request.context.get(PLAN_CONTEXT_PROMPT, {}) |         ) | ||||||
|         ): |         if not password: | ||||||
|             LOGGER.warning( |             LOGGER.warning( | ||||||
|                 "Password field not set in Policy Request", |                 "Password field not set in Policy Request", | ||||||
|                 field=self.password_field, |                 field=self.password_field, | ||||||
|                 fields=request.context.keys(), |                 fields=request.context.keys(), | ||||||
|                 prompt_fields=request.context.get(PLAN_CONTEXT_PROMPT, {}).keys(), |  | ||||||
|             ) |             ) | ||||||
|             return PolicyResult(False, _("Password not set in context")) |             return PolicyResult(False, _("Password not set in context")) | ||||||
|  |         password = str(password) | ||||||
|  |  | ||||||
|         if self.password_field in request.context: |         if self.check_static_rules: | ||||||
|             password = request.context[self.password_field] |             static_result = self.passes_static(password, request) | ||||||
|         else: |             if not static_result.passing: | ||||||
|             password = request.context[PLAN_CONTEXT_PROMPT][self.password_field] |                 return static_result | ||||||
|  |         if self.check_have_i_been_pwned: | ||||||
|  |             hibp_result = self.passes_hibp(password, request) | ||||||
|  |             if not hibp_result.passing: | ||||||
|  |                 return hibp_result | ||||||
|  |         if self.check_zxcvbn: | ||||||
|  |             zxcvbn_result = self.passes_zxcvbn(password, request) | ||||||
|  |             if not zxcvbn_result.passing: | ||||||
|  |                 return zxcvbn_result | ||||||
|  |         return PolicyResult(True) | ||||||
|  |  | ||||||
|  |     # pylint: disable=too-many-return-statements | ||||||
|  |     def passes_static(self, password: str, request: PolicyRequest) -> PolicyResult: | ||||||
|  |         """Check static rules""" | ||||||
|         if len(password) < self.length_min: |         if len(password) < self.length_min: | ||||||
|             LOGGER.debug("password failed", reason="length") |             LOGGER.debug("password failed", check="static", reason="length") | ||||||
|             return PolicyResult(False, self.error_message) |             return PolicyResult(False, self.error_message) | ||||||
|  |  | ||||||
|         if self.amount_digits > 0 and len(RE_DIGITS.findall(password)) < self.amount_digits: |         if self.amount_digits > 0 and len(RE_DIGITS.findall(password)) < self.amount_digits: | ||||||
|             LOGGER.debug("password failed", reason="amount_digits") |             LOGGER.debug("password failed", check="static", reason="amount_digits") | ||||||
|             return PolicyResult(False, self.error_message) |             return PolicyResult(False, self.error_message) | ||||||
|         if self.amount_lowercase > 0 and len(RE_LOWER.findall(password)) < self.amount_lowercase: |         if self.amount_lowercase > 0 and len(RE_LOWER.findall(password)) < self.amount_lowercase: | ||||||
|             LOGGER.debug("password failed", reason="amount_lowercase") |             LOGGER.debug("password failed", check="static", reason="amount_lowercase") | ||||||
|             return PolicyResult(False, self.error_message) |             return PolicyResult(False, self.error_message) | ||||||
|         if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_lowercase: |         if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_lowercase: | ||||||
|             LOGGER.debug("password failed", reason="amount_uppercase") |             LOGGER.debug("password failed", check="static", reason="amount_uppercase") | ||||||
|             return PolicyResult(False, self.error_message) |             return PolicyResult(False, self.error_message) | ||||||
|         if self.amount_symbols > 0: |         if self.amount_symbols > 0: | ||||||
|             count = 0 |             count = 0 | ||||||
|             for symbol in self.symbol_charset: |             for symbol in self.symbol_charset: | ||||||
|                 count += password.count(symbol) |                 count += password.count(symbol) | ||||||
|             if count < self.amount_symbols: |             if count < self.amount_symbols: | ||||||
|                 LOGGER.debug("password failed", reason="amount_symbols") |                 LOGGER.debug("password failed", check="static", reason="amount_symbols") | ||||||
|                 return PolicyResult(False, self.error_message) |                 return PolicyResult(False, self.error_message) | ||||||
|  |  | ||||||
|         return PolicyResult(True) |         return PolicyResult(True) | ||||||
|  |  | ||||||
|  |     def check_hibp(self, short_hash: str) -> str: | ||||||
|  |         """Check the haveibeenpwned API""" | ||||||
|  |         url = f"https://api.pwnedpasswords.com/range/{short_hash}" | ||||||
|  |         return get_http_session().get(url).text | ||||||
|  |  | ||||||
|  |     def passes_hibp(self, password: str, request: PolicyRequest) -> PolicyResult: | ||||||
|  |         """Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5 | ||||||
|  |         characters of Password in request and checks if full hash is in response. Returns 0 | ||||||
|  |         if Password is not in result otherwise the count of how many times it was used.""" | ||||||
|  |         pw_hash = sha1(password.encode("utf-8")).hexdigest()  # nosec | ||||||
|  |         result = self.check_hibp(pw_hash[:5]) | ||||||
|  |         final_count = 0 | ||||||
|  |         for line in result.split("\r\n"): | ||||||
|  |             full_hash, count = line.split(":") | ||||||
|  |             if pw_hash[5:] == full_hash.lower(): | ||||||
|  |                 final_count = int(count) | ||||||
|  |         LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5]) | ||||||
|  |         if final_count > self.hibp_allowed_count: | ||||||
|  |             LOGGER.debug("password failed", check="hibp", count=final_count) | ||||||
|  |             message = _("Password exists on %(count)d online lists." % {"count": final_count}) | ||||||
|  |             return PolicyResult(False, message) | ||||||
|  |         return PolicyResult(True) | ||||||
|  |  | ||||||
|  |     def passes_zxcvbn(self, password: str, request: PolicyRequest) -> PolicyResult: | ||||||
|  |         """Check Dropbox's zxcvbn password estimator""" | ||||||
|  |         user_inputs = [] | ||||||
|  |         if request.user.is_authenticated: | ||||||
|  |             user_inputs.append(request.user.username) | ||||||
|  |             user_inputs.append(request.user.name) | ||||||
|  |             user_inputs.append(request.user.email) | ||||||
|  |         if request.http_request: | ||||||
|  |             user_inputs.append(request.http_request.tenant.branding_title) | ||||||
|  |         # Only calculate result for the first 100 characters, as with over 100 char | ||||||
|  |         # long passwords we can be reasonably sure that they'll surpass the score anyways | ||||||
|  |         # See https://github.com/dropbox/zxcvbn#runtime-latency | ||||||
|  |         results = zxcvbn(password[:100], user_inputs) | ||||||
|  |         LOGGER.debug("password failed", check="zxcvbn", score=results["score"]) | ||||||
|  |         result = PolicyResult(results["score"] > self.zxcvbn_score_threshold) | ||||||
|  |         if isinstance(results["feedback"]["warning"], list): | ||||||
|  |             result.messages += tuple(results["feedback"]["warning"]) | ||||||
|  |         if isinstance(results["feedback"]["suggestions"], list): | ||||||
|  |             result.messages += tuple(results["feedback"]["suggestions"]) | ||||||
|  |         return result | ||||||
|  |  | ||||||
|     class Meta(Policy.PolicyMeta): |     class Meta(Policy.PolicyMeta): | ||||||
|  |  | ||||||
|         verbose_name = _("Password Policy") |         verbose_name = _("Password Policy") | ||||||
|  | |||||||
							
								
								
									
										50
									
								
								authentik/policies/password/tests/test_hibp.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								authentik/policies/password/tests/test_hibp.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,50 @@ | |||||||
|  | """Password Policy HIBP tests""" | ||||||
|  | from django.test import TestCase | ||||||
|  | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
|  | from authentik.lib.generators import generate_key | ||||||
|  | from authentik.policies.password.models import PasswordPolicy | ||||||
|  | from authentik.policies.types import PolicyRequest, PolicyResult | ||||||
|  | from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestPasswordPolicyHIBP(TestCase): | ||||||
|  |     """Test Password Policy (haveibeenpwned)""" | ||||||
|  |  | ||||||
|  |     def test_invalid(self): | ||||||
|  |         """Test without password""" | ||||||
|  |         policy = PasswordPolicy.objects.create( | ||||||
|  |             check_have_i_been_pwned=True, | ||||||
|  |             check_static_rules=False, | ||||||
|  |             name="test_invalid", | ||||||
|  |         ) | ||||||
|  |         request = PolicyRequest(get_anonymous_user()) | ||||||
|  |         result: PolicyResult = policy.passes(request) | ||||||
|  |         self.assertFalse(result.passing) | ||||||
|  |         self.assertEqual(result.messages[0], "Password not set in context") | ||||||
|  |  | ||||||
|  |     def test_false(self): | ||||||
|  |         """Failing password case""" | ||||||
|  |         policy = PasswordPolicy.objects.create( | ||||||
|  |             check_have_i_been_pwned=True, | ||||||
|  |             check_static_rules=False, | ||||||
|  |             name="test_false", | ||||||
|  |         ) | ||||||
|  |         request = PolicyRequest(get_anonymous_user()) | ||||||
|  |         request.context[PLAN_CONTEXT_PROMPT] = {"password": "password"}  # nosec | ||||||
|  |         result: PolicyResult = policy.passes(request) | ||||||
|  |         self.assertFalse(result.passing) | ||||||
|  |         self.assertTrue(result.messages[0].startswith("Password exists on ")) | ||||||
|  |  | ||||||
|  |     def test_true(self): | ||||||
|  |         """Positive password case""" | ||||||
|  |         policy = PasswordPolicy.objects.create( | ||||||
|  |             check_have_i_been_pwned=True, | ||||||
|  |             check_static_rules=False, | ||||||
|  |             name="test_true", | ||||||
|  |         ) | ||||||
|  |         request = PolicyRequest(get_anonymous_user()) | ||||||
|  |         request.context[PLAN_CONTEXT_PROMPT] = {"password": generate_key()} | ||||||
|  |         result: PolicyResult = policy.passes(request) | ||||||
|  |         self.assertTrue(result.passing) | ||||||
|  |         self.assertEqual(result.messages, tuple()) | ||||||
							
								
								
									
										50
									
								
								authentik/policies/password/tests/test_zxcvbn.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								authentik/policies/password/tests/test_zxcvbn.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,50 @@ | |||||||
|  | """Password Policy zxcvbn tests""" | ||||||
|  | from django.test import TestCase | ||||||
|  | from guardian.shortcuts import get_anonymous_user | ||||||
|  |  | ||||||
|  | from authentik.lib.generators import generate_key | ||||||
|  | from authentik.policies.password.models import PasswordPolicy | ||||||
|  | from authentik.policies.types import PolicyRequest, PolicyResult | ||||||
|  | from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestPasswordPolicyZxcvbn(TestCase): | ||||||
|  |     """Test Password Policy (zxcvbn)""" | ||||||
|  |  | ||||||
|  |     def test_invalid(self): | ||||||
|  |         """Test without password""" | ||||||
|  |         policy = PasswordPolicy.objects.create( | ||||||
|  |             check_zxcvbn=True, | ||||||
|  |             check_static_rules=False, | ||||||
|  |             name="test_invalid", | ||||||
|  |         ) | ||||||
|  |         request = PolicyRequest(get_anonymous_user()) | ||||||
|  |         result: PolicyResult = policy.passes(request) | ||||||
|  |         self.assertFalse(result.passing) | ||||||
|  |         self.assertEqual(result.messages[0], "Password not set in context") | ||||||
|  |  | ||||||
|  |     def test_false(self): | ||||||
|  |         """Failing password case""" | ||||||
|  |         policy = PasswordPolicy.objects.create( | ||||||
|  |             check_zxcvbn=True, | ||||||
|  |             check_static_rules=False, | ||||||
|  |             name="test_false", | ||||||
|  |         ) | ||||||
|  |         request = PolicyRequest(get_anonymous_user()) | ||||||
|  |         request.context[PLAN_CONTEXT_PROMPT] = {"password": "password"}  # nosec | ||||||
|  |         result: PolicyResult = policy.passes(request) | ||||||
|  |         self.assertFalse(result.passing, result.messages) | ||||||
|  |         self.assertEqual(result.messages[0], "Add another word or two. Uncommon words are better.") | ||||||
|  |  | ||||||
|  |     def test_true(self): | ||||||
|  |         """Positive password case""" | ||||||
|  |         policy = PasswordPolicy.objects.create( | ||||||
|  |             check_zxcvbn=True, | ||||||
|  |             check_static_rules=False, | ||||||
|  |             name="test_true", | ||||||
|  |         ) | ||||||
|  |         request = PolicyRequest(get_anonymous_user()) | ||||||
|  |         request.context[PLAN_CONTEXT_PROMPT] = {"password": generate_key()} | ||||||
|  |         result: PolicyResult = policy.passes(request) | ||||||
|  |         self.assertTrue(result.passing) | ||||||
|  |         self.assertEqual(result.messages, tuple()) | ||||||
| @ -14,7 +14,7 @@ from authentik.lib.utils.errors import exception_to_string | |||||||
| from authentik.policies.apps import HIST_POLICIES_EXECUTION_TIME | from authentik.policies.apps import HIST_POLICIES_EXECUTION_TIME | ||||||
| from authentik.policies.exceptions import PolicyException | from authentik.policies.exceptions import PolicyException | ||||||
| from authentik.policies.models import PolicyBinding | from authentik.policies.models import PolicyBinding | ||||||
| from authentik.policies.types import PolicyRequest, PolicyResult | from authentik.policies.types import CACHE_PREFIX, PolicyRequest, PolicyResult | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -25,7 +25,7 @@ PROCESS_CLASS = FORK_CTX.Process | |||||||
|  |  | ||||||
| def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str: | def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str: | ||||||
|     """Generate Cache key for policy""" |     """Generate Cache key for policy""" | ||||||
|     prefix = f"policy_{binding.policy_binding_uuid.hex}_" |     prefix = f"{CACHE_PREFIX}{binding.policy_binding_uuid.hex}_" | ||||||
|     if request.http_request and hasattr(request.http_request, "session"): |     if request.http_request and hasattr(request.http_request, "session"): | ||||||
|         prefix += f"_{request.http_request.session.session_key}" |         prefix += f"_{request.http_request.session.session_key}" | ||||||
|     if request.user: |     if request.user: | ||||||
| @ -56,8 +56,6 @@ class PolicyProcess(PROCESS_CLASS): | |||||||
|  |  | ||||||
|     def create_event(self, action: str, message: str, **kwargs): |     def create_event(self, action: str, message: str, **kwargs): | ||||||
|         """Create event with common values from `self.request` and `self.binding`.""" |         """Create event with common values from `self.request` and `self.binding`.""" | ||||||
|         # Keep a reference to http_request even if its None, because cleanse_dict will remove it |  | ||||||
|         http_request = self.request.http_request |  | ||||||
|         event = Event.new( |         event = Event.new( | ||||||
|             action=action, |             action=action, | ||||||
|             message=message, |             message=message, | ||||||
| @ -67,8 +65,8 @@ class PolicyProcess(PROCESS_CLASS): | |||||||
|             **kwargs, |             **kwargs, | ||||||
|         ) |         ) | ||||||
|         event.set_user(self.request.user) |         event.set_user(self.request.user) | ||||||
|         if http_request: |         if self.request.http_request: | ||||||
|             event.from_http(http_request) |             event.from_http(self.request.http_request) | ||||||
|         else: |         else: | ||||||
|             event.save() |             event.save() | ||||||
|  |  | ||||||
| @ -103,7 +101,7 @@ class PolicyProcess(PROCESS_CLASS): | |||||||
|             LOGGER.debug("P_ENG(proc): error", exc=src_exc) |             LOGGER.debug("P_ENG(proc): error", exc=src_exc) | ||||||
|             policy_result = PolicyResult(False, str(src_exc)) |             policy_result = PolicyResult(False, str(src_exc)) | ||||||
|         policy_result.source_binding = self.binding |         policy_result.source_binding = self.binding | ||||||
|         if not self.request.debug: |         if self.request.should_cache: | ||||||
|             key = cache_key(self.binding, self.request) |             key = cache_key(self.binding, self.request) | ||||||
|             cache.set(key, policy_result, CACHE_TIMEOUT) |             cache.set(key, policy_result, CACHE_TIMEOUT) | ||||||
|         LOGGER.debug( |         LOGGER.debug( | ||||||
|  | |||||||
| @ -23,12 +23,12 @@ def update_score(request: HttpRequest, identifier: str, amount: int): | |||||||
|     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 | ||||||
|         score = cache.get_or_set( |         score = cache.get_or_set( | ||||||
|             CACHE_KEY_PREFIX + remote_ip + identifier, |             CACHE_KEY_PREFIX + remote_ip + "/" + identifier, | ||||||
|             {"ip": remote_ip, "identifier": identifier, "score": 0}, |             {"ip": remote_ip, "identifier": identifier, "score": 0}, | ||||||
|             CACHE_TIMEOUT, |             CACHE_TIMEOUT, | ||||||
|         ) |         ) | ||||||
|         score["score"] += amount |         score["score"] += amount | ||||||
|         cache.set(CACHE_KEY_PREFIX + remote_ip + identifier, score) |         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) | ||||||
|  |  | ||||||
|  | |||||||
| @ -32,7 +32,7 @@ class TestReputationPolicy(TestCase): | |||||||
|         ) |         ) | ||||||
|         # Test value in cache |         # Test value in cache | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username), |             cache.get(CACHE_KEY_PREFIX + self.test_ip + "/" + self.test_username), | ||||||
|             {"ip": "127.0.0.1", "identifier": "test", "score": -1}, |             {"ip": "127.0.0.1", "identifier": "test", "score": -1}, | ||||||
|         ) |         ) | ||||||
|         # Save cache and check db values |         # Save cache and check db values | ||||||
| @ -47,7 +47,7 @@ class TestReputationPolicy(TestCase): | |||||||
|         ) |         ) | ||||||
|         # Test value in cache |         # Test value in cache | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username), |             cache.get(CACHE_KEY_PREFIX + self.test_ip + "/" + self.test_username), | ||||||
|             {"ip": "127.0.0.1", "identifier": "test", "score": -1}, |             {"ip": "127.0.0.1", "identifier": "test", "score": -1}, | ||||||
|         ) |         ) | ||||||
|         # Save cache and check db values |         # Save cache and check db values | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ from structlog.stdlib import get_logger | |||||||
|  |  | ||||||
| from authentik.core.api.applications import user_app_cache_key | from authentik.core.api.applications import user_app_cache_key | ||||||
| from authentik.policies.apps import GAUGE_POLICIES_CACHED | from authentik.policies.apps import GAUGE_POLICIES_CACHED | ||||||
|  | from authentik.policies.types import CACHE_PREFIX | ||||||
| from authentik.root.monitoring import monitoring_set | from authentik.root.monitoring import monitoring_set | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
| @ -15,7 +16,7 @@ LOGGER = get_logger() | |||||||
| # pylint: disable=unused-argument | # pylint: disable=unused-argument | ||||||
| def monitoring_set_policies(sender, **kwargs): | def monitoring_set_policies(sender, **kwargs): | ||||||
|     """set policy gauges""" |     """set policy gauges""" | ||||||
|     GAUGE_POLICIES_CACHED.set(len(cache.keys("policy_*") or [])) |     GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}_*") or [])) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(post_save) | @receiver(post_save) | ||||||
| @ -27,7 +28,7 @@ def invalidate_policy_cache(sender, instance, **_): | |||||||
|     if isinstance(instance, Policy): |     if isinstance(instance, Policy): | ||||||
|         total = 0 |         total = 0 | ||||||
|         for binding in PolicyBinding.objects.filter(policy=instance): |         for binding in PolicyBinding.objects.filter(policy=instance): | ||||||
|             prefix = f"policy_{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}*" |             prefix = f"{CACHE_PREFIX}{binding.policy_binding_uuid.hex}_{binding.policy.pk.hex}*" | ||||||
|             keys = cache.keys(prefix) |             keys = cache.keys(prefix) | ||||||
|             total += len(keys) |             total += len(keys) | ||||||
|             cache.delete_many(keys) |             cache.delete_many(keys) | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ from authentik.policies.engine import PolicyEngine | |||||||
| from authentik.policies.expression.models import ExpressionPolicy | from authentik.policies.expression.models import ExpressionPolicy | ||||||
| from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode | from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode | ||||||
| from authentik.policies.tests.test_process import clear_policy_cache | from authentik.policies.tests.test_process import clear_policy_cache | ||||||
|  | from authentik.policies.types import CACHE_PREFIX | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestPolicyEngine(TestCase): | class TestPolicyEngine(TestCase): | ||||||
| @ -101,8 +102,8 @@ class TestPolicyEngine(TestCase): | |||||||
|         pbm = PolicyBindingModel.objects.create() |         pbm = PolicyBindingModel.objects.create() | ||||||
|         binding = PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0) |         binding = PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0) | ||||||
|         engine = PolicyEngine(pbm, self.user) |         engine = PolicyEngine(pbm, self.user) | ||||||
|         self.assertEqual(len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 0) |         self.assertEqual(len(cache.keys(f"{CACHE_PREFIX}{binding.policy_binding_uuid.hex}*")), 0) | ||||||
|         self.assertEqual(engine.build().passing, False) |         self.assertEqual(engine.build().passing, False) | ||||||
|         self.assertEqual(len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 1) |         self.assertEqual(len(cache.keys(f"{CACHE_PREFIX}{binding.policy_binding_uuid.hex}*")), 1) | ||||||
|         self.assertEqual(engine.build().passing, False) |         self.assertEqual(engine.build().passing, False) | ||||||
|         self.assertEqual(len(cache.keys(f"policy_{binding.policy_binding_uuid.hex}*")), 1) |         self.assertEqual(len(cache.keys(f"{CACHE_PREFIX}{binding.policy_binding_uuid.hex}*")), 1) | ||||||
|  | |||||||
| @ -10,12 +10,12 @@ from authentik.policies.dummy.models import DummyPolicy | |||||||
| from authentik.policies.expression.models import ExpressionPolicy | from authentik.policies.expression.models import ExpressionPolicy | ||||||
| from authentik.policies.models import Policy, PolicyBinding | from authentik.policies.models import Policy, PolicyBinding | ||||||
| from authentik.policies.process import PolicyProcess | from authentik.policies.process import PolicyProcess | ||||||
| from authentik.policies.types import PolicyRequest | from authentik.policies.types import CACHE_PREFIX, PolicyRequest | ||||||
|  |  | ||||||
|  |  | ||||||
| def clear_policy_cache(): | def clear_policy_cache(): | ||||||
|     """Ensure no policy-related keys are still cached""" |     """Ensure no policy-related keys are still cached""" | ||||||
|     keys = cache.keys("policy_*") |     keys = cache.keys(f"{CACHE_PREFIX}*") | ||||||
|     cache.delete(keys) |     cache.delete(keys) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -16,6 +16,7 @@ if TYPE_CHECKING: | |||||||
|     from authentik.policies.models import PolicyBinding |     from authentik.policies.models import PolicyBinding | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  | CACHE_PREFIX = "goauthentik.io/policies/" | ||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass | @dataclass | ||||||
| @ -45,6 +46,15 @@ class PolicyRequest: | |||||||
|             return |             return | ||||||
|         self.context["geoip"] = GEOIP_READER.city(client_ip) |         self.context["geoip"] = GEOIP_READER.city(client_ip) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def should_cache(self) -> bool: | ||||||
|  |         """Check if this request's result should be cached""" | ||||||
|  |         if not self.user.is_authenticated: | ||||||
|  |             return False | ||||||
|  |         if self.debug: | ||||||
|  |             return False | ||||||
|  |         return True | ||||||
|  |  | ||||||
|     def __repr__(self) -> str: |     def __repr__(self) -> str: | ||||||
|         return self.__str__() |         return self.__str__() | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,9 +2,8 @@ | |||||||
| import base64 | import base64 | ||||||
| import binascii | import binascii | ||||||
| import json | import json | ||||||
| import time |  | ||||||
| from dataclasses import asdict, dataclass, field | from dataclasses import asdict, dataclass, field | ||||||
| from datetime import datetime | from datetime import datetime, timedelta | ||||||
| from hashlib import sha256 | from hashlib import sha256 | ||||||
| from typing import Any, Optional | from typing import Any, Optional | ||||||
| from urllib.parse import urlparse, urlunparse | from urllib.parse import urlparse, urlunparse | ||||||
| @ -14,7 +13,7 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey | |||||||
| from dacite.core import from_dict | from dacite.core import from_dict | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.utils import dateformat, timezone | from django.utils import timezone | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from jwt import encode | from jwt import encode | ||||||
| from rest_framework.serializers import Serializer | from rest_framework.serializers import Serializer | ||||||
| @ -25,7 +24,7 @@ from authentik.events.models import Event, EventAction | |||||||
| from authentik.events.utils import get_user | from authentik.events.utils import get_user | ||||||
| from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key | from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key | ||||||
| from authentik.lib.models import SerializerModel | from authentik.lib.models import SerializerModel | ||||||
| from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator | from authentik.lib.utils.time import timedelta_string_validator | ||||||
| from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config | from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config | ||||||
| from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT | from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT | ||||||
| from authentik.sources.oauth.models import OAuthSource | from authentik.sources.oauth.models import OAuthSource | ||||||
| @ -237,14 +236,18 @@ class OAuth2Provider(Provider): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     def create_refresh_token( |     def create_refresh_token( | ||||||
|         self, user: User, scope: list[str], request: HttpRequest |         self, | ||||||
|  |         user: User, | ||||||
|  |         scope: list[str], | ||||||
|  |         request: HttpRequest, | ||||||
|  |         expiry: timedelta, | ||||||
|     ) -> "RefreshToken": |     ) -> "RefreshToken": | ||||||
|         """Create and populate a RefreshToken object.""" |         """Create and populate a RefreshToken object.""" | ||||||
|         token = RefreshToken( |         token = RefreshToken( | ||||||
|             user=user, |             user=user, | ||||||
|             provider=self, |             provider=self, | ||||||
|             refresh_token=base64.urlsafe_b64encode(generate_key().encode()).decode(), |             refresh_token=base64.urlsafe_b64encode(generate_key().encode()).decode(), | ||||||
|             expires=timezone.now() + timedelta_from_string(self.token_validity), |             expires=timezone.now() + expiry, | ||||||
|             scope=scope, |             scope=scope, | ||||||
|         ) |         ) | ||||||
|         token.access_token = token.create_access_token(user, request) |         token.access_token = token.create_access_token(user, request) | ||||||
| @ -484,18 +487,21 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): | |||||||
|             ) |             ) | ||||||
|  |  | ||||||
|         # Convert datetimes into timestamps. |         # Convert datetimes into timestamps. | ||||||
|         now = int(time.time()) |         now = datetime.now() | ||||||
|         iat_time = now |         iat_time = int(now.timestamp()) | ||||||
|         exp_time = int(dateformat.format(self.expires, "U")) |         exp_time = int(self.expires.timestamp()) | ||||||
|         # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time |         # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time | ||||||
|         auth_events = Event.objects.filter(action=EventAction.LOGIN, user=get_user(user)).order_by( |         auth_event = ( | ||||||
|             "-created" |             Event.objects.filter(action=EventAction.LOGIN, user=get_user(user)) | ||||||
|  |             .order_by("-created") | ||||||
|  |             .first() | ||||||
|         ) |         ) | ||||||
|         # Fallback in case we can't find any login events |         # Fallback in case we can't find any login events | ||||||
|         auth_time = datetime.now() |         auth_time = now | ||||||
|         if auth_events.exists(): |         if auth_event: | ||||||
|             auth_time = auth_events.first().created |             auth_time = auth_event.created | ||||||
|         auth_time = int(dateformat.format(auth_time, "U")) |  | ||||||
|  |         auth_timestamp = int(auth_time.timestamp()) | ||||||
|  |  | ||||||
|         token = IDToken( |         token = IDToken( | ||||||
|             iss=self.provider.get_issuer(request), |             iss=self.provider.get_issuer(request), | ||||||
| @ -503,7 +509,7 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): | |||||||
|             aud=self.provider.client_id, |             aud=self.provider.client_id, | ||||||
|             exp=exp_time, |             exp=exp_time, | ||||||
|             iat=iat_time, |             iat=iat_time, | ||||||
|             auth_time=auth_time, |             auth_time=auth_timestamp, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Include (or not) user standard claims in the id_token. |         # Include (or not) user standard claims in the id_token. | ||||||
|  | |||||||
| @ -1,11 +1,13 @@ | |||||||
| """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.timezone import now | ||||||
|  |  | ||||||
| from authentik.core.models import Application | from authentik.core.models import Application | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||||
| from authentik.flows.challenge import ChallengeTypes | from authentik.flows.challenge import ChallengeTypes | ||||||
| from authentik.lib.generators import generate_id, generate_key | from authentik.lib.generators import generate_id, generate_key | ||||||
|  | from authentik.lib.utils.time import timedelta_from_string | ||||||
| from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, RedirectUriError | from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, RedirectUriError | ||||||
| from authentik.providers.oauth2.models import ( | from authentik.providers.oauth2.models import ( | ||||||
|     AuthorizationCode, |     AuthorizationCode, | ||||||
| @ -250,6 +252,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             client_id="test", |             client_id="test", | ||||||
|             authorization_flow=flow, |             authorization_flow=flow, | ||||||
|             redirect_uris="foo://localhost", |             redirect_uris="foo://localhost", | ||||||
|  |             access_code_validity="seconds=100", | ||||||
|         ) |         ) | ||||||
|         Application.objects.create(name="app", slug="app", provider=provider) |         Application.objects.create(name="app", slug="app", provider=provider) | ||||||
|         state = generate_id() |         state = generate_id() | ||||||
| @ -277,6 +280,11 @@ class TestAuthorize(OAuthTestCase): | |||||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", |                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |         self.assertAlmostEqual( | ||||||
|  |             code.expires.timestamp() - now().timestamp(), | ||||||
|  |             timedelta_from_string(provider.access_code_validity).total_seconds(), | ||||||
|  |             delta=5, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_full_implicit(self): |     def test_full_implicit(self): | ||||||
|         """Test full authorization""" |         """Test full authorization""" | ||||||
| @ -288,6 +296,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             authorization_flow=flow, |             authorization_flow=flow, | ||||||
|             redirect_uris="http://localhost", |             redirect_uris="http://localhost", | ||||||
|             signing_key=self.keypair, |             signing_key=self.keypair, | ||||||
|  |             access_code_validity="seconds=100", | ||||||
|         ) |         ) | ||||||
|         Application.objects.create(name="app", slug="app", provider=provider) |         Application.objects.create(name="app", slug="app", provider=provider) | ||||||
|         state = generate_id() |         state = generate_id() | ||||||
| @ -308,6 +317,7 @@ class TestAuthorize(OAuthTestCase): | |||||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||||
|         ) |         ) | ||||||
|         token: RefreshToken = RefreshToken.objects.filter(user=user).first() |         token: RefreshToken = RefreshToken.objects.filter(user=user).first() | ||||||
|  |         expires = timedelta_from_string(provider.access_code_validity).total_seconds() | ||||||
|         self.assertJSONEqual( |         self.assertJSONEqual( | ||||||
|             response.content.decode(), |             response.content.decode(), | ||||||
|             { |             { | ||||||
| @ -316,11 +326,16 @@ class TestAuthorize(OAuthTestCase): | |||||||
|                 "to": ( |                 "to": ( | ||||||
|                     f"http://localhost#access_token={token.access_token}" |                     f"http://localhost#access_token={token.access_token}" | ||||||
|                     f"&id_token={provider.encode(token.id_token.to_dict())}&token_type=bearer" |                     f"&id_token={provider.encode(token.id_token.to_dict())}&token_type=bearer" | ||||||
|                     f"&expires_in=60&state={state}" |                     f"&expires_in={int(expires)}&state={state}" | ||||||
|                 ), |                 ), | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         self.validate_jwt(token, provider) |         jwt = self.validate_jwt(token, provider) | ||||||
|  |         self.assertAlmostEqual( | ||||||
|  |             jwt["exp"] - now().timestamp(), | ||||||
|  |             expires, | ||||||
|  |             delta=5, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_full_form_post_id_token(self): |     def test_full_form_post_id_token(self): | ||||||
|         """Test full authorization (form_post response)""" |         """Test full authorization (form_post response)""" | ||||||
|  | |||||||
| @ -1,4 +1,6 @@ | |||||||
| """OAuth test helpers""" | """OAuth test helpers""" | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from jwt import decode | from jwt import decode | ||||||
|  |  | ||||||
| @ -25,7 +27,7 @@ class OAuthTestCase(TestCase): | |||||||
|         cls.keypair = create_test_cert() |         cls.keypair = create_test_cert() | ||||||
|         super().setUpClass() |         super().setUpClass() | ||||||
|  |  | ||||||
|     def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider): |     def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider) -> dict[str, Any]: | ||||||
|         """Validate that all required fields are set""" |         """Validate that all required fields are set""" | ||||||
|         key, alg = provider.get_jwt_key() |         key, alg = provider.get_jwt_key() | ||||||
|         if alg != JWTAlgorithms.HS256: |         if alg != JWTAlgorithms.HS256: | ||||||
| @ -40,3 +42,4 @@ class OAuthTestCase(TestCase): | |||||||
|         for key in self.required_jwt_keys: |         for key in self.required_jwt_keys: | ||||||
|             self.assertIsNotNone(jwt[key], f"Key {key} is missing in access_token") |             self.assertIsNotNone(jwt[key], f"Key {key} is missing in access_token") | ||||||
|             self.assertIsNotNone(id_token[key], f"Key {key} is missing in id_token") |             self.assertIsNotNone(id_token[key], f"Key {key} is missing in id_token") | ||||||
|  |         return jwt | ||||||
|  | |||||||
| @ -261,7 +261,7 @@ class OAuthAuthorizationParams: | |||||||
|             code.code_challenge = self.code_challenge |             code.code_challenge = self.code_challenge | ||||||
|             code.code_challenge_method = self.code_challenge_method |             code.code_challenge_method = self.code_challenge_method | ||||||
|  |  | ||||||
|         code.expires_at = timezone.now() + timedelta_from_string(self.provider.access_code_validity) |         code.expires = timezone.now() + timedelta_from_string(self.provider.access_code_validity) | ||||||
|         code.scope = self.scope |         code.scope = self.scope | ||||||
|         code.nonce = self.nonce |         code.nonce = self.nonce | ||||||
|         code.is_open_id = SCOPE_OPENID in self.scope |         code.is_open_id = SCOPE_OPENID in self.scope | ||||||
| @ -525,6 +525,7 @@ class OAuthFulfillmentStage(StageView): | |||||||
|             user=self.request.user, |             user=self.request.user, | ||||||
|             scope=self.params.scope, |             scope=self.params.scope, | ||||||
|             request=self.request, |             request=self.request, | ||||||
|  |             expiry=timedelta_from_string(self.provider.access_code_validity), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Check if response_type must include access_token in the response. |         # Check if response_type must include access_token in the response. | ||||||
|  | |||||||
| @ -443,6 +443,7 @@ class TokenView(View): | |||||||
|             user=self.params.authorization_code.user, |             user=self.params.authorization_code.user, | ||||||
|             scope=self.params.authorization_code.scope, |             scope=self.params.authorization_code.scope, | ||||||
|             request=self.request, |             request=self.request, | ||||||
|  |             expiry=timedelta_from_string(self.provider.token_validity), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         if self.params.authorization_code.is_open_id: |         if self.params.authorization_code.is_open_id: | ||||||
| @ -478,6 +479,7 @@ class TokenView(View): | |||||||
|             user=self.params.refresh_token.user, |             user=self.params.refresh_token.user, | ||||||
|             scope=self.params.scope, |             scope=self.params.scope, | ||||||
|             request=self.request, |             request=self.request, | ||||||
|  |             expiry=timedelta_from_string(self.provider.token_validity), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # If the Token has an id_token it's an Authentication request. |         # If the Token has an id_token it's an Authentication request. | ||||||
| @ -509,6 +511,7 @@ class TokenView(View): | |||||||
|             user=self.params.user, |             user=self.params.user, | ||||||
|             scope=self.params.scope, |             scope=self.params.scope, | ||||||
|             request=self.request, |             request=self.request, | ||||||
|  |             expiry=timedelta_from_string(self.provider.token_validity), | ||||||
|         ) |         ) | ||||||
|         refresh_token.id_token = refresh_token.create_id_token( |         refresh_token.id_token = refresh_token.create_id_token( | ||||||
|             user=self.params.user, |             user=self.params.user, | ||||||
| @ -535,6 +538,7 @@ class TokenView(View): | |||||||
|             user=self.params.device_code.user, |             user=self.params.device_code.user, | ||||||
|             scope=self.params.device_code.scope, |             scope=self.params.device_code.scope, | ||||||
|             request=self.request, |             request=self.request, | ||||||
|  |             expiry=timedelta_from_string(self.provider.token_validity), | ||||||
|         ) |         ) | ||||||
|         refresh_token.id_token = refresh_token.create_id_token( |         refresh_token.id_token = refresh_token.create_id_token( | ||||||
|             user=self.params.device_code.user, |             user=self.params.device_code.user, | ||||||
|  | |||||||
| @ -159,9 +159,15 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]): | |||||||
|                 hosts=tls_hosts, |                 hosts=tls_hosts, | ||||||
|                 secret_name=self.controller.outpost.config.kubernetes_ingress_secret_name, |                 secret_name=self.controller.outpost.config.kubernetes_ingress_secret_name, | ||||||
|             ) |             ) | ||||||
|  |         spec = V1IngressSpec( | ||||||
|  |             rules=rules, | ||||||
|  |             tls=[tls_config], | ||||||
|  |         ) | ||||||
|  |         if self.controller.outpost.config.kubernetes_ingress_class_name: | ||||||
|  |             spec.ingress_class_name = self.controller.outpost.config.kubernetes_ingress_class_name | ||||||
|         return V1Ingress( |         return V1Ingress( | ||||||
|             metadata=meta, |             metadata=meta, | ||||||
|             spec=V1IngressSpec(rules=rules, tls=[tls_config]), |             spec=spec, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def create(self, reference: V1Ingress): |     def create(self, reference: V1Ingress): | ||||||
|  | |||||||
| @ -2,6 +2,8 @@ | |||||||
| from channels.generic.websocket import JsonWebsocketConsumer | from channels.generic.websocket import JsonWebsocketConsumer | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
|  |  | ||||||
|  | from authentik.root.messages.storage import CACHE_PREFIX | ||||||
|  |  | ||||||
|  |  | ||||||
| class MessageConsumer(JsonWebsocketConsumer): | class MessageConsumer(JsonWebsocketConsumer): | ||||||
|     """Consumer which sends django.contrib.messages Messages over WS. |     """Consumer which sends django.contrib.messages Messages over WS. | ||||||
| @ -12,11 +14,13 @@ class MessageConsumer(JsonWebsocketConsumer): | |||||||
|     def connect(self): |     def connect(self): | ||||||
|         self.accept() |         self.accept() | ||||||
|         self.session_key = self.scope["session"].session_key |         self.session_key = self.scope["session"].session_key | ||||||
|         cache.set(f"user_{self.session_key}_messages_{self.channel_name}", True, None) |         if not self.session_key: | ||||||
|  |             return | ||||||
|  |         cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None) | ||||||
|  |  | ||||||
|     # pylint: disable=unused-argument |     # pylint: disable=unused-argument | ||||||
|     def disconnect(self, code): |     def disconnect(self, code): | ||||||
|         cache.delete(f"user_{self.session_key}_messages_{self.channel_name}") |         cache.delete(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}") | ||||||
|  |  | ||||||
|     def event_update(self, event: dict): |     def event_update(self, event: dict): | ||||||
|         """Event handler which is called by Messages Storage backend""" |         """Event handler which is called by Messages Storage backend""" | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ from django.core.cache import cache | |||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
|  |  | ||||||
| SESSION_KEY = "_messages" | SESSION_KEY = "_messages" | ||||||
|  | CACHE_PREFIX = "goauthentik.io/root/messages_" | ||||||
|  |  | ||||||
|  |  | ||||||
| class ChannelsStorage(SessionStorage): | class ChannelsStorage(SessionStorage): | ||||||
| @ -18,7 +19,7 @@ class ChannelsStorage(SessionStorage): | |||||||
|         self.channel = get_channel_layer() |         self.channel = get_channel_layer() | ||||||
|  |  | ||||||
|     def _store(self, messages: list[Message], response, *args, **kwargs): |     def _store(self, messages: list[Message], response, *args, **kwargs): | ||||||
|         prefix = f"user_{self.request.session.session_key}_messages_" |         prefix = f"{CACHE_PREFIX}{self.request.session.session_key}_messages_" | ||||||
|         keys = cache.keys(f"{prefix}*") |         keys = cache.keys(f"{prefix}*") | ||||||
|         # if no active connections are open, fallback to storing messages in the |         # if no active connections are open, fallback to storing messages in the | ||||||
|         # session, so they can always be retrieved |         # session, so they can always be retrieved | ||||||
|  | |||||||
| @ -134,7 +134,7 @@ SPECTACULAR_SETTINGS = { | |||||||
|     }, |     }, | ||||||
|     "AUTHENTICATION_WHITELIST": ["authentik.api.authentication.TokenAuthentication"], |     "AUTHENTICATION_WHITELIST": ["authentik.api.authentication.TokenAuthentication"], | ||||||
|     "LICENSE": { |     "LICENSE": { | ||||||
|         "name": "GNU GPLv3", |         "name": "MIT", | ||||||
|         "url": "https://github.com/goauthentik/authentik/blob/main/LICENSE", |         "url": "https://github.com/goauthentik/authentik/blob/main/LICENSE", | ||||||
|     }, |     }, | ||||||
|     "ENUM_NAME_OVERRIDES": { |     "ENUM_NAME_OVERRIDES": { | ||||||
| @ -145,6 +145,7 @@ SPECTACULAR_SETTINGS = { | |||||||
|         "ProxyMode": "authentik.providers.proxy.models.ProxyMode", |         "ProxyMode": "authentik.providers.proxy.models.ProxyMode", | ||||||
|         "PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes", |         "PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes", | ||||||
|         "LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode", |         "LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode", | ||||||
|  |         "UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification", | ||||||
|     }, |     }, | ||||||
|     "ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False, |     "ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False, | ||||||
|     "POSTPROCESSING_HOOKS": [ |     "POSTPROCESSING_HOOKS": [ | ||||||
| @ -195,9 +196,10 @@ _redis_url = ( | |||||||
| CACHES = { | CACHES = { | ||||||
|     "default": { |     "default": { | ||||||
|         "BACKEND": "django_redis.cache.RedisCache", |         "BACKEND": "django_redis.cache.RedisCache", | ||||||
|         "LOCATION": f"{_redis_url}/{CONFIG.y('redis.cache_db')}", |         "LOCATION": f"{_redis_url}/{CONFIG.y('redis.db')}", | ||||||
|         "TIMEOUT": int(CONFIG.y("redis.cache_timeout", 300)), |         "TIMEOUT": int(CONFIG.y("redis.cache_timeout", 300)), | ||||||
|         "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, |         "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, | ||||||
|  |         "KEY_PREFIX": "authentik_cache", | ||||||
|     } |     } | ||||||
| } | } | ||||||
| DJANGO_REDIS_SCAN_ITERSIZE = 1000 | DJANGO_REDIS_SCAN_ITERSIZE = 1000 | ||||||
| @ -255,7 +257,8 @@ CHANNEL_LAYERS = { | |||||||
|     "default": { |     "default": { | ||||||
|         "BACKEND": "channels_redis.core.RedisChannelLayer", |         "BACKEND": "channels_redis.core.RedisChannelLayer", | ||||||
|         "CONFIG": { |         "CONFIG": { | ||||||
|             "hosts": [f"{_redis_url}/{CONFIG.y('redis.ws_db')}"], |             "hosts": [f"{_redis_url}/{CONFIG.y('redis.db')}"], | ||||||
|  |             "prefix": "authentik_channels", | ||||||
|         }, |         }, | ||||||
|     }, |     }, | ||||||
| } | } | ||||||
| @ -338,12 +341,8 @@ CELERY_BEAT_SCHEDULE = { | |||||||
| } | } | ||||||
| CELERY_TASK_CREATE_MISSING_QUEUES = True | CELERY_TASK_CREATE_MISSING_QUEUES = True | ||||||
| CELERY_TASK_DEFAULT_QUEUE = "authentik" | CELERY_TASK_DEFAULT_QUEUE = "authentik" | ||||||
| CELERY_BROKER_URL = ( | CELERY_BROKER_URL = f"{_redis_url}/{CONFIG.y('redis.db')}{REDIS_CELERY_TLS_REQUIREMENTS}" | ||||||
|     f"{_redis_url}/{CONFIG.y('redis.message_queue_db')}{REDIS_CELERY_TLS_REQUIREMENTS}" | CELERY_RESULT_BACKEND = f"{_redis_url}/{CONFIG.y('redis.db')}{REDIS_CELERY_TLS_REQUIREMENTS}" | ||||||
| ) |  | ||||||
| CELERY_RESULT_BACKEND = ( |  | ||||||
|     f"{_redis_url}/{CONFIG.y('redis.message_queue_db')}{REDIS_CELERY_TLS_REQUIREMENTS}" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| # Sentry integration | # Sentry integration | ||||||
| env = get_env() | env = get_env() | ||||||
|  | |||||||
| @ -166,7 +166,7 @@ class LDAPPropertyMapping(PropertyMapping): | |||||||
|         return LDAPPropertyMappingSerializer |         return LDAPPropertyMappingSerializer | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return self.name |         return str(self.name) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|  | |||||||
| @ -35,6 +35,7 @@ class OAuthSourceSerializer(SourceSerializer): | |||||||
|  |  | ||||||
|     provider_type = ChoiceField(choices=registry.get_name_tuple()) |     provider_type = ChoiceField(choices=registry.get_name_tuple()) | ||||||
|     callback_url = SerializerMethodField() |     callback_url = SerializerMethodField() | ||||||
|  |     type = SerializerMethodField() | ||||||
|  |  | ||||||
|     def get_callback_url(self, instance: OAuthSource) -> str: |     def get_callback_url(self, instance: OAuthSource) -> str: | ||||||
|         """Get OAuth Callback URL""" |         """Get OAuth Callback URL""" | ||||||
| @ -46,8 +47,6 @@ class OAuthSourceSerializer(SourceSerializer): | |||||||
|             return relative_url |             return relative_url | ||||||
|         return self.context["request"].build_absolute_uri(relative_url) |         return self.context["request"].build_absolute_uri(relative_url) | ||||||
|  |  | ||||||
|     type = SerializerMethodField() |  | ||||||
|  |  | ||||||
|     @extend_schema_field(SourceTypeSerializer) |     @extend_schema_field(SourceTypeSerializer) | ||||||
|     def get_type(self, instance: OAuthSource) -> SourceTypeSerializer: |     def get_type(self, instance: OAuthSource) -> SourceTypeSerializer: | ||||||
|         """Get source's type configuration""" |         """Get source's type configuration""" | ||||||
|  | |||||||
| @ -75,15 +75,20 @@ class OAuthSource(Source): | |||||||
|     def ui_login_button(self, request: HttpRequest) -> UILoginButton: |     def ui_login_button(self, request: HttpRequest) -> UILoginButton: | ||||||
|         provider_type = self.type |         provider_type = self.type | ||||||
|         provider = provider_type() |         provider = provider_type() | ||||||
|  |         icon = self.get_icon | ||||||
|  |         if not icon: | ||||||
|  |             icon = provider.icon_url() | ||||||
|         return UILoginButton( |         return UILoginButton( | ||||||
|             name=self.name, |             name=self.name, | ||||||
|             icon_url=provider.icon_url(), |  | ||||||
|             challenge=provider.login_challenge(self, request), |             challenge=provider.login_challenge(self, request), | ||||||
|  |             icon_url=icon, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def ui_user_settings(self) -> Optional[UserSettingSerializer]: |     def ui_user_settings(self) -> Optional[UserSettingSerializer]: | ||||||
|         provider_type = self.type |         provider_type = self.type | ||||||
|         provider = provider_type() |         icon = self.get_icon | ||||||
|  |         if not icon: | ||||||
|  |             icon = provider_type().icon_url() | ||||||
|         return UserSettingSerializer( |         return UserSettingSerializer( | ||||||
|             data={ |             data={ | ||||||
|                 "title": self.name, |                 "title": self.name, | ||||||
| @ -92,7 +97,7 @@ class OAuthSource(Source): | |||||||
|                     "authentik_sources_oauth:oauth-client-login", |                     "authentik_sources_oauth:oauth-client-login", | ||||||
|                     kwargs={"source_slug": self.slug}, |                     kwargs={"source_slug": self.slug}, | ||||||
|                 ), |                 ), | ||||||
|                 "icon_url": provider.icon_url(), |                 "icon_url": icon, | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -64,6 +64,9 @@ class PlexSource(Source): | |||||||
|         return PlexSourceSerializer |         return PlexSourceSerializer | ||||||
|  |  | ||||||
|     def ui_login_button(self, request: HttpRequest) -> UILoginButton: |     def ui_login_button(self, request: HttpRequest) -> UILoginButton: | ||||||
|  |         icon = self.get_icon | ||||||
|  |         if not icon: | ||||||
|  |             icon = static("authentik/sources/plex.svg") | ||||||
|         return UILoginButton( |         return UILoginButton( | ||||||
|             challenge=PlexAuthenticationChallenge( |             challenge=PlexAuthenticationChallenge( | ||||||
|                 { |                 { | ||||||
| @ -73,17 +76,20 @@ class PlexSource(Source): | |||||||
|                     "slug": self.slug, |                     "slug": self.slug, | ||||||
|                 } |                 } | ||||||
|             ), |             ), | ||||||
|             icon_url=static("authentik/sources/plex.svg"), |             icon_url=icon, | ||||||
|             name=self.name, |             name=self.name, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def ui_user_settings(self) -> Optional[UserSettingSerializer]: |     def ui_user_settings(self) -> Optional[UserSettingSerializer]: | ||||||
|  |         icon = self.get_icon | ||||||
|  |         if not icon: | ||||||
|  |             icon = static("authentik/sources/plex.svg") | ||||||
|         return UserSettingSerializer( |         return UserSettingSerializer( | ||||||
|             data={ |             data={ | ||||||
|                 "title": self.name, |                 "title": self.name, | ||||||
|                 "component": "ak-user-settings-source-plex", |                 "component": "ak-user-settings-source-plex", | ||||||
|                 "configure_url": self.client_id, |                 "configure_url": self.client_id, | ||||||
|                 "icon_url": static("authentik/sources/plex.svg"), |                 "icon_url": icon, | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -40,7 +40,27 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet): | |||||||
|     queryset = SAMLSource.objects.all() |     queryset = SAMLSource.objects.all() | ||||||
|     serializer_class = SAMLSourceSerializer |     serializer_class = SAMLSourceSerializer | ||||||
|     lookup_field = "slug" |     lookup_field = "slug" | ||||||
|     filterset_fields = "__all__" |     filterset_fields = [ | ||||||
|  |         "name", | ||||||
|  |         "slug", | ||||||
|  |         "enabled", | ||||||
|  |         "authentication_flow", | ||||||
|  |         "enrollment_flow", | ||||||
|  |         "managed", | ||||||
|  |         "policy_engine_mode", | ||||||
|  |         "user_matching_mode", | ||||||
|  |         "pre_authentication_flow", | ||||||
|  |         "issuer", | ||||||
|  |         "sso_url", | ||||||
|  |         "slo_url", | ||||||
|  |         "allow_idp_initiated", | ||||||
|  |         "name_id_policy", | ||||||
|  |         "binding_type", | ||||||
|  |         "signing_kp", | ||||||
|  |         "digest_algorithm", | ||||||
|  |         "signature_algorithm", | ||||||
|  |         "temporary_user_delete_after", | ||||||
|  |     ] | ||||||
|     search_fields = ["name", "slug"] |     search_fields = ["name", "slug"] | ||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|  | |||||||
| @ -191,9 +191,13 @@ class SAMLSource(Source): | |||||||
|                 } |                 } | ||||||
|             ), |             ), | ||||||
|             name=self.name, |             name=self.name, | ||||||
|  |             icon_url=self.get_icon, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def ui_user_settings(self) -> Optional[UserSettingSerializer]: |     def ui_user_settings(self) -> Optional[UserSettingSerializer]: | ||||||
|  |         icon = self.get_icon | ||||||
|  |         if not icon: | ||||||
|  |             icon = static(f"authentik/sources/{self.slug}.svg") | ||||||
|         return UserSettingSerializer( |         return UserSettingSerializer( | ||||||
|             data={ |             data={ | ||||||
|                 "title": self.name, |                 "title": self.name, | ||||||
| @ -202,7 +206,7 @@ class SAMLSource(Source): | |||||||
|                     "authentik_sources_saml:login", |                     "authentik_sources_saml:login", | ||||||
|                     kwargs={"source_slug": self.slug}, |                     kwargs={"source_slug": self.slug}, | ||||||
|                 ), |                 ), | ||||||
|                 "icon_url": static(f"authentik/sources/{self.slug}.svg"), |                 "icon_url": icon, | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  | |||||||
| @ -202,10 +202,10 @@ class ResponseProcessor: | |||||||
|         """Get all attributes sent""" |         """Get all attributes sent""" | ||||||
|         attributes = {} |         attributes = {} | ||||||
|         assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion") |         assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion") | ||||||
|         if not assertion: |         if assertion is None: | ||||||
|             raise ValueError("Assertion element not found") |             raise ValueError("Assertion element not found") | ||||||
|         attribute_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AttributeStatement") |         attribute_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AttributeStatement") | ||||||
|         if not attribute_statement: |         if attribute_statement is None: | ||||||
|             raise ValueError("Attribute statement element not found") |             raise ValueError("Attribute statement element not found") | ||||||
|         # Get all attributes and their values into a dict |         # Get all attributes and their values into a dict | ||||||
|         for attribute in attribute_statement.iterchildren(): |         for attribute in attribute_statement.iterchildren(): | ||||||
| @ -216,6 +216,7 @@ class ResponseProcessor: | |||||||
|         # Flatten all lists in the dict |         # Flatten all lists in the dict | ||||||
|         for key, value in attributes.items(): |         for key, value in attributes.items(): | ||||||
|             attributes[key] = BaseEvaluator.expr_flatten(value) |             attributes[key] = BaseEvaluator.expr_flatten(value) | ||||||
|  |         attributes["username"] = self._get_name_id().text | ||||||
|         return attributes |         return attributes | ||||||
|  |  | ||||||
|     def prepare_flow_manager(self) -> SourceFlowManager: |     def prepare_flow_manager(self) -> SourceFlowManager: | ||||||
|  | |||||||
| @ -31,7 +31,7 @@ RESPONSE_SUCCESS = """<?xml version="1.0" encoding="UTF-8" standalone="no"?> | |||||||
|     <saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_346001c5708ffd118c40edbc0c72fc60" IssueInstant="2022-10-14T14:11:49.590Z" Version="2.0"> |     <saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_346001c5708ffd118c40edbc0c72fc60" IssueInstant="2022-10-14T14:11:49.590Z" Version="2.0"> | ||||||
|         <saml2:Issuer>https://accounts.google.com/o/saml2?idpid=</saml2:Issuer> |         <saml2:Issuer>https://accounts.google.com/o/saml2?idpid=</saml2:Issuer> | ||||||
|         <saml2:Subject> |         <saml2:Subject> | ||||||
|             <saml2:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">jens@beryju.org</saml2:NameID> |             <saml2:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">jens@goauthentik.io</saml2:NameID> | ||||||
|             <saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"> |             <saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"> | ||||||
|                 <saml2:SubjectConfirmationData InResponseTo="_157fb504b59f4ae3919f74896a6b8565" NotOnOrAfter="2022-10-14T14:16:49.590Z" Recipient="https://127.0.0.1:9443/source/saml/google/acs/"></saml2:SubjectConfirmationData> |                 <saml2:SubjectConfirmationData InResponseTo="_157fb504b59f4ae3919f74896a6b8565" NotOnOrAfter="2022-10-14T14:16:49.590Z" Recipient="https://127.0.0.1:9443/source/saml/google/acs/"></saml2:SubjectConfirmationData> | ||||||
|             </saml2:SubjectConfirmation> |             </saml2:SubjectConfirmation> | ||||||
| @ -109,4 +109,7 @@ class TestResponseProcessor(TestCase): | |||||||
|         parser = ResponseProcessor(self.source, request) |         parser = ResponseProcessor(self.source, request) | ||||||
|         parser.parse() |         parser.parse() | ||||||
|         sfm = parser.prepare_flow_manager() |         sfm = parser.prepare_flow_manager() | ||||||
|         self.assertEqual(sfm.enroll_info, {"email": "foo@bar.baz", "name": "foo", "sn": "bar"}) |         self.assertEqual( | ||||||
|  |             sfm.enroll_info, | ||||||
|  |             {"email": "foo@bar.baz", "name": "foo", "sn": "bar", "username": "jens@goauthentik.io"}, | ||||||
|  |         ) | ||||||
|  | |||||||
| @ -118,12 +118,12 @@ class AuthenticatorDuoStageViewSet(UsedByMixin, ModelViewSet): | |||||||
|             .first() |             .first() | ||||||
|         ) |         ) | ||||||
|         if not user: |         if not user: | ||||||
|             return Response(data={"non_field_errors": ["user does not exist"]}, status=400) |             return Response(data={"non_field_errors": ["User does not exist."]}, status=400) | ||||||
|         device = DuoDevice.objects.filter( |         device = DuoDevice.objects.filter( | ||||||
|             duo_user_id=request.data.get("duo_user_id"), user=user, stage=stage |             duo_user_id=request.data.get("duo_user_id"), user=user, stage=stage | ||||||
|         ).first() |         ).first() | ||||||
|         if device: |         if device: | ||||||
|             return Response(data={"non_field_errors": ["device exists already"]}, status=400) |             return Response(data={"non_field_errors": ["Device exists already."]}, status=400) | ||||||
|         DuoDevice.objects.create( |         DuoDevice.objects.create( | ||||||
|             duo_user_id=request.data.get("duo_user_id"), |             duo_user_id=request.data.get("duo_user_id"), | ||||||
|             user=user, |             user=user, | ||||||
|  | |||||||
| @ -99,7 +99,7 @@ class DuoDevice(SerializerModel, Device): | |||||||
|         return DuoDeviceSerializer |         return DuoDeviceSerializer | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return self.name or str(self.user) |         return str(self.name) or str(self.user) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|  | |||||||
| @ -216,7 +216,7 @@ class SMSDevice(SerializerModel, SideChannelDevice): | |||||||
|         return valid |         return valid | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return self.name or str(self.user) |         return str(self.name) or str(self.user) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|         verbose_name = _("SMS Device") |         verbose_name = _("SMS Device") | ||||||
|  | |||||||
| @ -7,6 +7,9 @@ from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUse | |||||||
| from authentik.flows.stage import ChallengeStageView | from authentik.flows.stage import ChallengeStageView | ||||||
| from authentik.stages.authenticator_static.models import AuthenticatorStaticStage | from authentik.stages.authenticator_static.models import AuthenticatorStaticStage | ||||||
|  |  | ||||||
|  | SESSION_STATIC_DEVICE = "static_device" | ||||||
|  | SESSION_STATIC_TOKENS = "static_device_tokens" | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthenticatorStaticChallenge(WithUserInfoChallenge): | class AuthenticatorStaticChallenge(WithUserInfoChallenge): | ||||||
|     """Static authenticator challenge""" |     """Static authenticator challenge""" | ||||||
| @ -27,8 +30,7 @@ class AuthenticatorStaticStageView(ChallengeStageView): | |||||||
|     response_class = AuthenticatorStaticChallengeResponse |     response_class = AuthenticatorStaticChallengeResponse | ||||||
|  |  | ||||||
|     def get_challenge(self, *args, **kwargs) -> AuthenticatorStaticChallenge: |     def get_challenge(self, *args, **kwargs) -> AuthenticatorStaticChallenge: | ||||||
|         user = self.get_pending_user() |         tokens: list[StaticToken] = self.request.session[SESSION_STATIC_TOKENS] | ||||||
|         tokens: list[StaticToken] = StaticToken.objects.filter(device__user=user) |  | ||||||
|         return AuthenticatorStaticChallenge( |         return AuthenticatorStaticChallenge( | ||||||
|             data={ |             data={ | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |                 "type": ChallengeTypes.NATIVE.value, | ||||||
| @ -44,25 +46,22 @@ class AuthenticatorStaticStageView(ChallengeStageView): | |||||||
|  |  | ||||||
|         stage: AuthenticatorStaticStage = self.executor.current_stage |         stage: AuthenticatorStaticStage = self.executor.current_stage | ||||||
|  |  | ||||||
|         devices = StaticDevice.objects.filter(user=user) |         if SESSION_STATIC_DEVICE not in self.request.session: | ||||||
|         # Currently, this stage only supports one device per user. If the user already |             device = StaticDevice(user=user, confirmed=False, name="Static Token") | ||||||
|         # has a device, just skip to the next stage |             tokens = [] | ||||||
|         if devices.exists(): |             for _ in range(0, stage.token_count): | ||||||
|             if not any(x.confirmed for x in devices): |                 tokens.append(StaticToken(device=device, token=StaticToken.random_token())) | ||||||
|                 return super().get(request, *args, **kwargs) |             self.request.session[SESSION_STATIC_DEVICE] = device | ||||||
|             return self.executor.stage_ok() |             self.request.session[SESSION_STATIC_TOKENS] = tokens | ||||||
|  |  | ||||||
|         device = StaticDevice.objects.create(user=user, confirmed=False, name="Static Token") |  | ||||||
|         for _ in range(0, stage.token_count): |  | ||||||
|             StaticToken.objects.create(device=device, token=StaticToken.random_token()) |  | ||||||
|         return super().get(request, *args, **kwargs) |         return super().get(request, *args, **kwargs) | ||||||
|  |  | ||||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: |     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||||
|         """Verify OTP Token""" |         """Verify OTP Token""" | ||||||
|         user = self.get_pending_user() |         device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE] | ||||||
|         device: StaticDevice = StaticDevice.objects.filter(user=user).first() |  | ||||||
|         if not device: |  | ||||||
|             return self.executor.stage_invalid() |  | ||||||
|         device.confirmed = True |         device.confirmed = True | ||||||
|         device.save() |         device.save() | ||||||
|  |         for token in self.request.session[SESSION_STATIC_TOKENS]: | ||||||
|  |             token.save() | ||||||
|  |         del self.request.session[SESSION_STATIC_DEVICE] | ||||||
|  |         del self.request.session[SESSION_STATIC_TOKENS] | ||||||
|         return self.executor.stage_ok() |         return self.executor.stage_ok() | ||||||
|  | |||||||
| @ -17,6 +17,8 @@ from authentik.flows.stage import ChallengeStageView | |||||||
| from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage | from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage | ||||||
| from authentik.stages.authenticator_totp.settings import OTP_TOTP_ISSUER | from authentik.stages.authenticator_totp.settings import OTP_TOTP_ISSUER | ||||||
|  |  | ||||||
|  | SESSION_TOTP_DEVICE = "totp_device" | ||||||
|  |  | ||||||
|  |  | ||||||
| class AuthenticatorTOTPChallenge(WithUserInfoChallenge): | class AuthenticatorTOTPChallenge(WithUserInfoChallenge): | ||||||
|     """TOTP Setup challenge""" |     """TOTP Setup challenge""" | ||||||
| @ -49,8 +51,7 @@ class AuthenticatorTOTPStageView(ChallengeStageView): | |||||||
|     response_class = AuthenticatorTOTPChallengeResponse |     response_class = AuthenticatorTOTPChallengeResponse | ||||||
|  |  | ||||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: |     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||||
|         user = self.get_pending_user() |         device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE] | ||||||
|         device: TOTPDevice = TOTPDevice.objects.filter(user=user).first() |  | ||||||
|         return AuthenticatorTOTPChallenge( |         return AuthenticatorTOTPChallenge( | ||||||
|             data={ |             data={ | ||||||
|                 "type": ChallengeTypes.NATIVE.value, |                 "type": ChallengeTypes.NATIVE.value, | ||||||
| @ -62,8 +63,7 @@ class AuthenticatorTOTPStageView(ChallengeStageView): | |||||||
|  |  | ||||||
|     def get_response_instance(self, data: QueryDict) -> ChallengeResponse: |     def get_response_instance(self, data: QueryDict) -> ChallengeResponse: | ||||||
|         response = super().get_response_instance(data) |         response = super().get_response_instance(data) | ||||||
|         user = self.get_pending_user() |         response.device = self.request.session.get(SESSION_TOTP_DEVICE) | ||||||
|         response.device = TOTPDevice.objects.filter(user=user).first() |  | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: |     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||||
| @ -74,17 +74,18 @@ class AuthenticatorTOTPStageView(ChallengeStageView): | |||||||
|  |  | ||||||
|         stage: AuthenticatorTOTPStage = self.executor.current_stage |         stage: AuthenticatorTOTPStage = self.executor.current_stage | ||||||
|  |  | ||||||
|         TOTPDevice.objects.create( |         if SESSION_TOTP_DEVICE not in self.request.session: | ||||||
|             user=user, confirmed=False, digits=stage.digits, name="TOTP Authenticator" |             device = TOTPDevice( | ||||||
|         ) |                 user=user, confirmed=False, digits=stage.digits, name="TOTP Authenticator" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             self.request.session[SESSION_TOTP_DEVICE] = device | ||||||
|         return super().get(request, *args, **kwargs) |         return super().get(request, *args, **kwargs) | ||||||
|  |  | ||||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: |     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||||
|         """TOTP Token is validated by challenge""" |         """TOTP Token is validated by challenge""" | ||||||
|         user = self.get_pending_user() |         device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE] | ||||||
|         device: TOTPDevice = TOTPDevice.objects.filter(user=user).first() |  | ||||||
|         if not device: |  | ||||||
|             return self.executor.stage_invalid() |  | ||||||
|         device.confirmed = True |         device.confirmed = True | ||||||
|         device.save() |         device.save() | ||||||
|  |         del self.request.session[SESSION_TOTP_DEVICE] | ||||||
|         return self.executor.stage_ok() |         return self.executor.stage_ok() | ||||||
|  | |||||||
| @ -31,6 +31,7 @@ class AuthenticatorValidateStageSerializer(StageSerializer): | |||||||
|             "device_classes", |             "device_classes", | ||||||
|             "configuration_stages", |             "configuration_stages", | ||||||
|             "last_auth_threshold", |             "last_auth_threshold", | ||||||
|  |             "webauthn_user_verification", | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -29,8 +29,8 @@ from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE | |||||||
| from authentik.lib.utils.http import get_client_ip | from authentik.lib.utils.http import get_client_ip | ||||||
| from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice | from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice | ||||||
| from authentik.stages.authenticator_sms.models import SMSDevice | from authentik.stages.authenticator_sms.models import SMSDevice | ||||||
| from authentik.stages.authenticator_validate.models import DeviceClasses | from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses | ||||||
| from authentik.stages.authenticator_webauthn.models import WebAuthnDevice | from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice | ||||||
| from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE | from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE | ||||||
| from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id | from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id | ||||||
| from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TITLE | from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TITLE | ||||||
| @ -46,29 +46,35 @@ class DeviceChallenge(PassiveSerializer): | |||||||
|     challenge = JSONField() |     challenge = JSONField() | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_challenge_for_device(request: HttpRequest, device: Device) -> dict: | def get_challenge_for_device( | ||||||
|  |     request: HttpRequest, stage: AuthenticatorValidateStage, device: Device | ||||||
|  | ) -> dict: | ||||||
|     """Generate challenge for a single device""" |     """Generate challenge for a single device""" | ||||||
|     if isinstance(device, WebAuthnDevice): |     if isinstance(device, WebAuthnDevice): | ||||||
|         return get_webauthn_challenge(request, device) |         return get_webauthn_challenge(request, stage, device) | ||||||
|     # Code-based challenges have no hints |     # Code-based challenges have no hints | ||||||
|     return {} |     return {} | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_webauthn_challenge_without_user(request: HttpRequest) -> dict: | def get_webauthn_challenge_without_user( | ||||||
|  |     request: HttpRequest, stage: AuthenticatorValidateStage | ||||||
|  | ) -> dict: | ||||||
|     """Same as `get_webauthn_challenge`, but allows any client device. We can then later check |     """Same as `get_webauthn_challenge`, but allows any client device. We can then later check | ||||||
|     who the device belongs to.""" |     who the device belongs to.""" | ||||||
|     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) |     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) | ||||||
|     authentication_options = generate_authentication_options( |     authentication_options = generate_authentication_options( | ||||||
|         rp_id=get_rp_id(request), |         rp_id=get_rp_id(request), | ||||||
|         allow_credentials=[], |         allow_credentials=[], | ||||||
|  |         user_verification=stage.webauthn_user_verification, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge |     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge | ||||||
|  |  | ||||||
|     return loads(options_to_json(authentication_options)) |     return loads(options_to_json(authentication_options)) | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_webauthn_challenge(request: HttpRequest, device: Optional[WebAuthnDevice] = None) -> dict: | def get_webauthn_challenge( | ||||||
|  |     request: HttpRequest, stage: AuthenticatorValidateStage, device: Optional[WebAuthnDevice] = None | ||||||
|  | ) -> dict: | ||||||
|     """Send the client a challenge that we'll check later""" |     """Send the client a challenge that we'll check later""" | ||||||
|     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) |     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) | ||||||
|  |  | ||||||
| @ -83,6 +89,7 @@ def get_webauthn_challenge(request: HttpRequest, device: Optional[WebAuthnDevice | |||||||
|     authentication_options = generate_authentication_options( |     authentication_options = generate_authentication_options( | ||||||
|         rp_id=get_rp_id(request), |         rp_id=get_rp_id(request), | ||||||
|         allow_credentials=allowed_credentials, |         allow_credentials=allowed_credentials, | ||||||
|  |         user_verification=stage.webauthn_user_verification, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge |     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge | ||||||
| @ -129,6 +136,8 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) - | |||||||
|     if not device: |     if not device: | ||||||
|         raise ValidationError("Invalid device") |         raise ValidationError("Invalid device") | ||||||
|  |  | ||||||
|  |     stage: AuthenticatorValidateStage = stage_view.executor.current_stage | ||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         authentication_verification = verify_authentication_response( |         authentication_verification = verify_authentication_response( | ||||||
|             credential=AuthenticationCredential.parse_raw(dumps(data)), |             credential=AuthenticationCredential.parse_raw(dumps(data)), | ||||||
| @ -137,7 +146,7 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) - | |||||||
|             expected_origin=get_origin(request), |             expected_origin=get_origin(request), | ||||||
|             credential_public_key=base64url_to_bytes(device.public_key), |             credential_public_key=base64url_to_bytes(device.public_key), | ||||||
|             credential_current_sign_count=device.sign_count, |             credential_current_sign_count=device.sign_count, | ||||||
|             require_user_verification=False, |             require_user_verification=stage.webauthn_user_verification == UserVerification.REQUIRED, | ||||||
|         ) |         ) | ||||||
|     except InvalidAuthenticationResponse as exc: |     except InvalidAuthenticationResponse as exc: | ||||||
|         LOGGER.warning("Assertion failed", exc=exc) |         LOGGER.warning("Assertion failed", exc=exc) | ||||||
|  | |||||||
| @ -0,0 +1,29 @@ | |||||||
|  | # Generated by Django 4.1.3 on 2022-11-21 16:45 | ||||||
|  |  | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Migration(migrations.Migration): | ||||||
|  |  | ||||||
|  |     dependencies = [ | ||||||
|  |         ( | ||||||
|  |             "authentik_stages_authenticator_validate", | ||||||
|  |             "0011_authenticatorvalidatestage_last_auth_threshold", | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     operations = [ | ||||||
|  |         migrations.AddField( | ||||||
|  |             model_name="authenticatorvalidatestage", | ||||||
|  |             name="webauthn_user_verification", | ||||||
|  |             field=models.TextField( | ||||||
|  |                 choices=[ | ||||||
|  |                     ("required", "Required"), | ||||||
|  |                     ("preferred", "Preferred"), | ||||||
|  |                     ("discouraged", "Discouraged"), | ||||||
|  |                 ], | ||||||
|  |                 default="preferred", | ||||||
|  |                 help_text="Enforce user verification for WebAuthn devices.", | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ] | ||||||
| @ -8,6 +8,7 @@ from rest_framework.serializers import BaseSerializer | |||||||
|  |  | ||||||
| from authentik.flows.models import NotConfiguredAction, Stage | from authentik.flows.models import NotConfiguredAction, Stage | ||||||
| from authentik.lib.utils.time import timedelta_string_validator | from authentik.lib.utils.time import timedelta_string_validator | ||||||
|  | from authentik.stages.authenticator_webauthn.models import UserVerification | ||||||
|  |  | ||||||
|  |  | ||||||
| class DeviceClasses(models.TextChoices): | class DeviceClasses(models.TextChoices): | ||||||
| @ -69,6 +70,12 @@ class AuthenticatorValidateStage(Stage): | |||||||
|         ), |         ), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     webauthn_user_verification = models.TextField( | ||||||
|  |         help_text=_("Enforce user verification for WebAuthn devices."), | ||||||
|  |         choices=UserVerification.choices, | ||||||
|  |         default=UserVerification.PREFERRED, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def serializer(self) -> type[BaseSerializer]: |     def serializer(self) -> type[BaseSerializer]: | ||||||
|         from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer |         from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer | ||||||
|  | |||||||
| @ -177,7 +177,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | |||||||
|                 data={ |                 data={ | ||||||
|                     "device_class": device_class, |                     "device_class": device_class, | ||||||
|                     "device_uid": device.pk, |                     "device_uid": device.pk, | ||||||
|                     "challenge": get_challenge_for_device(self.request, device), |                     "challenge": get_challenge_for_device(self.request, stage, device), | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|             challenge.is_valid() |             challenge.is_valid() | ||||||
| @ -194,7 +194,10 @@ class AuthenticatorValidateStageView(ChallengeStageView): | |||||||
|             data={ |             data={ | ||||||
|                 "device_class": DeviceClasses.WEBAUTHN, |                 "device_class": DeviceClasses.WEBAUTHN, | ||||||
|                 "device_uid": -1, |                 "device_uid": -1, | ||||||
|                 "challenge": get_webauthn_challenge_without_user(self.request), |                 "challenge": get_webauthn_challenge_without_user( | ||||||
|  |                     self.request, | ||||||
|  |                     self.executor.current_stage, | ||||||
|  |                 ), | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|         challenge.is_valid() |         challenge.is_valid() | ||||||
|  | |||||||
| @ -46,15 +46,13 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase): | |||||||
|         with patch( |         with patch( | ||||||
|             "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.auth_client", |             "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.auth_client", | ||||||
|             MagicMock( |             MagicMock( | ||||||
|                 MagicMock( |                 return_value=MagicMock( | ||||||
|                     return_value=MagicMock( |                     auth=MagicMock( | ||||||
|                         auth=MagicMock( |                         return_value={ | ||||||
|                             return_value={ |                             "result": "allow", | ||||||
|                                 "result": "allow", |                             "status": "allow", | ||||||
|                                 "status": "allow", |                             "status_msg": "Success. Logging you in...", | ||||||
|                                 "status_msg": "Success. Logging you in...", |                         } | ||||||
|                             } |  | ||||||
|                         ) |  | ||||||
|                     ) |                     ) | ||||||
|                 ) |                 ) | ||||||
|             ), |             ), | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| """Test validator stage""" | """Test validator stage""" | ||||||
| from datetime import datetime, timedelta | from datetime import datetime, timedelta | ||||||
| from hashlib import sha256 | from hashlib import sha256 | ||||||
| from http.cookies import SimpleCookie |  | ||||||
| from time import sleep | from time import sleep | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| @ -76,7 +75,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): | |||||||
|             component="ak-stage-authenticator-validate", |             component="ak-stage-authenticator-validate", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def test_last_auth_threshold_valid(self) -> SimpleCookie: |     def test_last_auth_threshold_valid(self): | ||||||
|         """Test last_auth_threshold""" |         """Test last_auth_threshold""" | ||||||
|         ident_stage = IdentificationStage.objects.create( |         ident_stage = IdentificationStage.objects.create( | ||||||
|             name=generate_id(), |             name=generate_id(), | ||||||
| @ -115,12 +114,47 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): | |||||||
|         ) |         ) | ||||||
|         self.assertIn(COOKIE_NAME_MFA, response.cookies) |         self.assertIn(COOKIE_NAME_MFA, response.cookies) | ||||||
|         self.assertStageResponse(response, component="xak-flow-redirect", to="/") |         self.assertStageResponse(response, component="xak-flow-redirect", to="/") | ||||||
|         return response.cookies |  | ||||||
|  |  | ||||||
|     def test_last_auth_skip(self): |     def test_last_auth_skip(self): | ||||||
|         """Test valid cookie""" |         """Test valid cookie""" | ||||||
|         cookies = self.test_last_auth_threshold_valid() |         ident_stage = IdentificationStage.objects.create( | ||||||
|         mfa_cookie = cookies[COOKIE_NAME_MFA] |             name=generate_id(), | ||||||
|  |             user_fields=[ | ||||||
|  |                 UserFields.USERNAME, | ||||||
|  |             ], | ||||||
|  |         ) | ||||||
|  |         device: TOTPDevice = TOTPDevice.objects.create( | ||||||
|  |             user=self.user, | ||||||
|  |             confirmed=True, | ||||||
|  |         ) | ||||||
|  |         stage = AuthenticatorValidateStage.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             last_auth_threshold="hours=1", | ||||||
|  |             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||||
|  |             device_classes=[DeviceClasses.TOTP], | ||||||
|  |         ) | ||||||
|  |         stage.configuration_stages.set([ident_stage]) | ||||||
|  |         FlowStageBinding.objects.create(target=self.flow, stage=ident_stage, order=0) | ||||||
|  |         FlowStageBinding.objects.create(target=self.flow, stage=stage, order=1) | ||||||
|  |  | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||||
|  |             {"uid_field": self.user.username}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||||
|  |         ) | ||||||
|  |         # Verify token once here to set last_t etc | ||||||
|  |         totp = TOTP(device.bin_key) | ||||||
|  |         sleep(1) | ||||||
|  |         response = self.client.post( | ||||||
|  |             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||||
|  |             {"code": str(totp.token())}, | ||||||
|  |         ) | ||||||
|  |         self.assertIn(COOKIE_NAME_MFA, response.cookies) | ||||||
|  |         self.assertStageResponse(response, component="xak-flow-redirect", to="/") | ||||||
|  |         mfa_cookie = response.cookies[COOKIE_NAME_MFA] | ||||||
|         self.client.logout() |         self.client.logout() | ||||||
|         self.client.cookies[COOKIE_NAME_MFA] = mfa_cookie |         self.client.cookies[COOKIE_NAME_MFA] = mfa_cookie | ||||||
|         response = self.client.post( |         response = self.client.post( | ||||||
| @ -260,7 +294,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): | |||||||
|             not_configured_action=NotConfiguredAction.CONFIGURE, |             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||||
|             device_classes=[DeviceClasses.TOTP], |             device_classes=[DeviceClasses.TOTP], | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(get_challenge_for_device(request, totp_device), {}) |         self.assertEqual(get_challenge_for_device(request, stage, totp_device), {}) | ||||||
|         with self.assertRaises(ValidationError): |         with self.assertRaises(ValidationError): | ||||||
|             validate_challenge_code( |             validate_challenge_code( | ||||||
|                 "1234", StageView(FlowExecutorView(current_stage=stage), request=request), self.user |                 "1234", StageView(FlowExecutorView(current_stage=stage), request=request), self.user | ||||||
|  | |||||||
| @ -21,7 +21,7 @@ from authentik.stages.authenticator_validate.challenge import ( | |||||||
| ) | ) | ||||||
| from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses | from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses | ||||||
| from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView | from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView | ||||||
| from authentik.stages.authenticator_webauthn.models import WebAuthnDevice | from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice | ||||||
| from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE | from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE | ||||||
| from authentik.stages.identification.models import IdentificationStage, UserFields | from authentik.stages.identification.models import IdentificationStage, UserFields | ||||||
|  |  | ||||||
| @ -90,8 +90,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|             last_auth_threshold="milliseconds=0", |             last_auth_threshold="milliseconds=0", | ||||||
|             not_configured_action=NotConfiguredAction.CONFIGURE, |             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||||
|             device_classes=[DeviceClasses.WEBAUTHN], |             device_classes=[DeviceClasses.WEBAUTHN], | ||||||
|  |             webauthn_user_verification=UserVerification.PREFERRED, | ||||||
|         ) |         ) | ||||||
|         challenge = get_challenge_for_device(request, webauthn_device) |         challenge = get_challenge_for_device(request, stage, webauthn_device) | ||||||
|         del challenge["challenge"] |         del challenge["challenge"] | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             challenge, |             challenge, | ||||||
| @ -118,6 +119,13 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|         request = get_request("/") |         request = get_request("/") | ||||||
|         request.user = self.user |         request.user = self.user | ||||||
|  |  | ||||||
|  |         stage = AuthenticatorValidateStage.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             last_auth_threshold="milliseconds=0", | ||||||
|  |             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||||
|  |             device_classes=[DeviceClasses.WEBAUTHN], | ||||||
|  |             webauthn_user_verification=UserVerification.PREFERRED, | ||||||
|  |         ) | ||||||
|         webauthn_device = WebAuthnDevice.objects.create( |         webauthn_device = WebAuthnDevice.objects.create( | ||||||
|             user=self.user, |             user=self.user, | ||||||
|             public_key=( |             public_key=( | ||||||
| @ -128,7 +136,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|             sign_count=0, |             sign_count=0, | ||||||
|             rp_id=generate_id(), |             rp_id=generate_id(), | ||||||
|         ) |         ) | ||||||
|         challenge = get_challenge_for_device(request, webauthn_device) |         challenge = get_challenge_for_device(request, stage, webauthn_device) | ||||||
|         webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] |         webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             challenge, |             challenge, | ||||||
| @ -149,7 +157,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|     def test_get_challenge_userless(self): |     def test_get_challenge_userless(self): | ||||||
|         """Test webauthn (userless)""" |         """Test webauthn (userless)""" | ||||||
|         request = get_request("/") |         request = get_request("/") | ||||||
|  |         stage = AuthenticatorValidateStage.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |         ) | ||||||
|         WebAuthnDevice.objects.create( |         WebAuthnDevice.objects.create( | ||||||
|             user=self.user, |             user=self.user, | ||||||
|             public_key=( |             public_key=( | ||||||
| @ -160,7 +170,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | |||||||
|             sign_count=0, |             sign_count=0, | ||||||
|             rp_id=generate_id(), |             rp_id=generate_id(), | ||||||
|         ) |         ) | ||||||
|         challenge = get_webauthn_challenge_without_user(request) |         challenge = get_webauthn_challenge_without_user(request, stage) | ||||||
|         webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] |         webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] | ||||||
|         self.assertEqual( |         self.assertEqual( | ||||||
|             challenge, |             challenge, | ||||||
|  | |||||||
| @ -146,7 +146,7 @@ class WebAuthnDevice(SerializerModel, Device): | |||||||
|         return WebAuthnDeviceSerializer |         return WebAuthnDeviceSerializer | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return self.name or str(self.user) |         return str(self.name) or str(self.user) | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|  |  | ||||||
|  | |||||||
| @ -28,8 +28,8 @@ class Command(BaseCommand): | |||||||
|             delete_stage = True |             delete_stage = True | ||||||
|         message = TemplateEmailMessage( |         message = TemplateEmailMessage( | ||||||
|             subject="authentik Test-Email", |             subject="authentik Test-Email", | ||||||
|             template_name="email/setup.html", |  | ||||||
|             to=[options["to"]], |             to=[options["to"]], | ||||||
|  |             template_name="email/setup.html", | ||||||
|             template_context={}, |             template_context={}, | ||||||
|         ) |         ) | ||||||
|         try: |         try: | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	