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] | ||||
| current_version = 2022.10.0 | ||||
| current_version = 2022.11.1 | ||||
| tag = True | ||||
| commit = True | ||||
| 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 | ||||
|       uses: actions/setup-python@v3 | ||||
|       with: | ||||
|         python-version: '3.10' | ||||
|         python-version: '3.11' | ||||
|         cache: 'poetry' | ||||
|     - name: Setup node | ||||
|       uses: actions/setup-node@v3.1.0 | ||||
| @ -25,7 +25,7 @@ runs: | ||||
|       shell: bash | ||||
|       run: | | ||||
|         docker-compose -f .github/actions/setup/docker-compose.yml up -d | ||||
|         poetry env use python3.10 | ||||
|         poetry env use python3.11 | ||||
|         poetry install | ||||
|         cd web && npm ci | ||||
|     - 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 | ||||
|   test-migrations-from-stable: | ||||
|     runs-on: ubuntu-latest | ||||
|     continue-on-error: true | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|         with: | ||||
| @ -83,17 +84,10 @@ jobs: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - uses: testspace-com/setup-testspace@v1 | ||||
|         with: | ||||
|           domain: ${{github.repository_owner}} | ||||
|       - name: run unittest | ||||
|         run: | | ||||
|           poetry run make test | ||||
|           poetry run coverage xml | ||||
|       - name: run testspace | ||||
|         if: ${{ always() }} | ||||
|         run: | | ||||
|           testspace [unittest]unittest.xml --link=codecov | ||||
|       - if: ${{ always() }} | ||||
|         uses: codecov/codecov-action@v3 | ||||
|         with: | ||||
| @ -104,19 +98,12 @@ jobs: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - uses: testspace-com/setup-testspace@v1 | ||||
|         with: | ||||
|           domain: ${{github.repository_owner}} | ||||
|       - name: Create k8s Kind Cluster | ||||
|         uses: helm/kind-action@v1.4.0 | ||||
|       - name: run integration | ||||
|         run: | | ||||
|           poetry run make test-integration | ||||
|           poetry run coverage xml | ||||
|       - name: run testspace | ||||
|         if: ${{ always() }} | ||||
|         run: | | ||||
|           testspace [integration]unittest.xml --link=codecov | ||||
|       - if: ${{ always() }} | ||||
|         uses: codecov/codecov-action@v3 | ||||
|         with: | ||||
| @ -127,9 +114,6 @@ jobs: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - uses: testspace-com/setup-testspace@v1 | ||||
|         with: | ||||
|           domain: ${{github.repository_owner}} | ||||
|       - name: Setup e2e env (chrome, etc) | ||||
|         run: | | ||||
|           docker-compose -f tests/e2e/docker-compose.yml up -d | ||||
| @ -149,10 +133,6 @@ jobs: | ||||
|         run: | | ||||
|           poetry run make test-e2e-provider | ||||
|           poetry run coverage xml | ||||
|       - name: run testspace | ||||
|         if: ${{ always() }} | ||||
|         run: | | ||||
|           testspace [e2e-provider]unittest.xml --link=codecov | ||||
|       - if: ${{ always() }} | ||||
|         uses: codecov/codecov-action@v3 | ||||
|         with: | ||||
| @ -163,9 +143,6 @@ jobs: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - uses: testspace-com/setup-testspace@v1 | ||||
|         with: | ||||
|           domain: ${{github.repository_owner}} | ||||
|       - name: Setup e2e env (chrome, etc) | ||||
|         run: | | ||||
|           docker-compose -f tests/e2e/docker-compose.yml up -d | ||||
| @ -185,10 +162,6 @@ jobs: | ||||
|         run: | | ||||
|           poetry run make test-e2e-rest | ||||
|           poetry run coverage xml | ||||
|       - name: run testspace | ||||
|         if: ${{ always() }} | ||||
|         run: | | ||||
|           testspace [e2e-rest]unittest.xml --link=codecov | ||||
|       - if: ${{ always() }} | ||||
|         uses: codecov/codecov-action@v3 | ||||
|         with: | ||||
|  | ||||
							
								
								
									
										12
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -22,7 +22,9 @@ | ||||
|     "python.formatting.provider": "black", | ||||
|     "yaml.customTags": [ | ||||
|         "!Find sequence", | ||||
|         "!KeyOf scalar" | ||||
|         "!KeyOf scalar", | ||||
|         "!Context scalar", | ||||
|         "!Format sequence" | ||||
|     ], | ||||
|     "typescript.preferences.importModuleSpecifier": "non-relative", | ||||
|     "typescript.preferences.importModuleSpecifierEnding": "index", | ||||
| @ -30,5 +32,13 @@ | ||||
|     "typescript.enablePromptUseWorkspaceTsdk": true, | ||||
|     "yaml.schemas": { | ||||
|         "./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 | ||||
|  | ||||
| # 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 | ||||
| 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 | ||||
|  | ||||
| # 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 | ||||
|  | ||||
| @ -46,7 +46,7 @@ COPY ./go.sum /work/go.sum | ||||
| RUN go build -o /work/authentik ./cmd/server/ | ||||
|  | ||||
| # 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.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 && \ | ||||
|     # 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 | ||||
|     apt-get install -y --no-install-recommends libxmlsec1-openssl libmaxminddb0 && \ | ||||
|     # Required for bootstrap & healtcheck | ||||
| @ -80,6 +80,7 @@ RUN apt-get update && \ | ||||
| COPY ./authentik/ /authentik | ||||
| COPY ./pyproject.toml / | ||||
| COPY ./xml /xml | ||||
| COPY ./locale /locale | ||||
| COPY ./tests /tests | ||||
| COPY ./manage.py / | ||||
| COPY ./blueprints /blueprints | ||||
|  | ||||
							
								
								
									
										695
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										695
									
								
								LICENSE
									
									
									
									
									
								
							| @ -1,674 +1,21 @@ | ||||
|                     GNU GENERAL PUBLIC LICENSE | ||||
|                        Version 3, 29 June 2007 | ||||
|  | ||||
|  Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> | ||||
|  Everyone is permitted to copy and distribute verbatim copies | ||||
|  of this license document, but changing it is not allowed. | ||||
|  | ||||
|                             Preamble | ||||
|  | ||||
|   The GNU General Public License is a free, copyleft license for | ||||
| software and other kinds of works. | ||||
|  | ||||
|   The licenses for most software and other practical works are designed | ||||
| 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 | ||||
| share and change all versions of a program--to make sure it remains free | ||||
| software for all its users.  We, the Free Software Foundation, use the | ||||
| GNU General Public License for most of our software; it applies also to | ||||
| any other work released this way by its authors.  You can apply it to | ||||
| your programs, too. | ||||
|  | ||||
|   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>. | ||||
| MIT License | ||||
|  | ||||
| Copyright (c) 2022 Jens Langhammer | ||||
|  | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| of this software and associated documentation files (the "Software"), to deal | ||||
| in the Software without restriction, including without limitation the rights | ||||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||||
| copies of the Software, and to permit persons to whom the Software is | ||||
| furnished to do so, subject to the following conditions: | ||||
|  | ||||
| The above copyright notice and this permission notice shall be included in all | ||||
| copies or substantial portions of the Software. | ||||
|  | ||||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| SOFTWARE. | ||||
|  | ||||
							
								
								
									
										5
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								Makefile
									
									
									
									
									
								
							| @ -69,7 +69,7 @@ gen-build: | ||||
| 	AUTHENTIK_DEBUG=true ak spectacular --file schema.yml | ||||
|  | ||||
| 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 \ | ||||
| 		--rm -v ${PWD}:/local \ | ||||
| 		--user ${UID}:${GID} \ | ||||
| @ -197,7 +197,4 @@ dev-reset: | ||||
| 	dropdb -U postgres -h localhost authentik | ||||
| 	createdb -U postgres -h localhost authentik | ||||
| 	redis-cli -n 0 flushall | ||||
| 	redis-cli -n 1 flushall | ||||
| 	redis-cli -n 2 flushall | ||||
| 	redis-cli -n 3 flushall | ||||
| 	make migrate | ||||
|  | ||||
| @ -9,7 +9,6 @@ | ||||
| [](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml) | ||||
| [](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml) | ||||
| [](https://codecov.io/gh/goauthentik/authentik) | ||||
| [](https://goauthentik.testspace.com/) | ||||
|  | ||||
|  | ||||
| [](https://www.transifex.com/beryjuorg/authentik/) | ||||
|  | ||||
| @ -6,8 +6,8 @@ | ||||
|  | ||||
| | Version    | Supported          | | ||||
| | ---------- | ------------------ | | ||||
| | 2022.9.x   | :white_check_mark: | | ||||
| | 2022.10.x  | :white_check_mark: | | ||||
| | 2022.11.x  | :white_check_mark: | | ||||
|  | ||||
| ## Reporting a Vulnerability | ||||
|  | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| from os import environ | ||||
| from typing import Optional | ||||
|  | ||||
| __version__ = "2022.10.0" | ||||
| __version__ = "2022.11.1" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -31,4 +31,5 @@ class AuthentikAPIConfig(AppConfig): | ||||
|                     "type": "apiKey", | ||||
|                     "in": "header", | ||||
|                     "name": "Authorization", | ||||
|                     "scheme": "bearer", | ||||
|                 } | ||||
|  | ||||
| @ -35,6 +35,7 @@ class ErrorReportingConfigSerializer(PassiveSerializer): | ||||
|     """Config for error reporting""" | ||||
|  | ||||
|     enabled = BooleanField(read_only=True) | ||||
|     sentry_dsn = CharField(read_only=True) | ||||
|     environment = CharField(read_only=True) | ||||
|     send_pii = BooleanField(read_only=True) | ||||
|     traces_sample_rate = FloatField(read_only=True) | ||||
| @ -77,6 +78,7 @@ class ConfigView(APIView): | ||||
|             { | ||||
|                 "error_reporting": { | ||||
|                     "enabled": CONFIG.y("error_reporting.enabled"), | ||||
|                     "sentry_dsn": CONFIG.y("error_reporting.sentry_dsn"), | ||||
|                     "environment": CONFIG.y("error_reporting.environment"), | ||||
|                     "send_pii": CONFIG.y("error_reporting.send_pii"), | ||||
|                     "traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)), | ||||
|  | ||||
| @ -53,6 +53,15 @@ | ||||
|                     "id": { | ||||
|                         "type": "string" | ||||
|                     }, | ||||
|                     "state": { | ||||
|                         "type": "string", | ||||
|                         "enum": [ | ||||
|                             "absent", | ||||
|                             "present", | ||||
|                             "created" | ||||
|                         ], | ||||
|                         "default": "present" | ||||
|                     }, | ||||
|                     "attrs": { | ||||
|                         "type": "object", | ||||
|                         "properties": { | ||||
|  | ||||
| @ -7,7 +7,6 @@ from django.apps import apps | ||||
|  | ||||
| from authentik.blueprints.apps import ManagedAppConfig | ||||
| from authentik.blueprints.models import BlueprintInstance | ||||
| from authentik.lib.config import CONFIG | ||||
|  | ||||
|  | ||||
| def apply_blueprint(*files: str): | ||||
| @ -46,3 +45,13 @@ def reconcile_app(app_name: str): | ||||
|         return wrapper | ||||
|  | ||||
|     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""" | ||||
| from django.test import TransactionTestCase | ||||
|  | ||||
| from authentik.blueprints.tests import load_yaml_fixture | ||||
| from authentik.blueprints.v1.exporter import FlowExporter | ||||
| from authentik.blueprints.v1.importer import Importer, transaction_rollback | ||||
| 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.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): | ||||
|     """Test Blueprints""" | ||||
| @ -85,14 +60,14 @@ class TestBlueprintsV1(TransactionTestCase): | ||||
|         """Test export and import it twice""" | ||||
|         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.apply()) | ||||
|  | ||||
|         count_before = Prompt.objects.filter(field_key="username").count() | ||||
|         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.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before) | ||||
| @ -100,7 +75,7 @@ class TestBlueprintsV1(TransactionTestCase): | ||||
|     def test_import_yaml_tags(self): | ||||
|         """Test some yaml tags""" | ||||
|         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.apply()) | ||||
|         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 | ||||
|  | ||||
|  | ||||
| class BlueprintEntryDesiredState(Enum): | ||||
|     """State an entry should be reconciled to""" | ||||
|  | ||||
|     ABSENT = "absent" | ||||
|     PRESENT = "present" | ||||
|     CREATED = "created" | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class BlueprintEntry: | ||||
|     """Single entry of a blueprint""" | ||||
|  | ||||
|     model: str | ||||
|     state: BlueprintEntryDesiredState = field(default=BlueprintEntryDesiredState.PRESENT) | ||||
|     identifiers: 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) | ||||
|  | ||||
|         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( | ||||
|             identifiers=identifiers, | ||||
|             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.pk | ||||
|         raise ValueError( | ||||
|         raise EntryInvalidError( | ||||
|             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(OrderedDict, lambda self, data: self.represent_dict(dict(data))) | ||||
|         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))) | ||||
|  | ||||
|     def ignore_aliases(self, data): | ||||
|         """Don't use any YAML anchors""" | ||||
|         return True | ||||
|  | ||||
|     def represent(self, data) -> None: | ||||
|         if is_dataclass(data): | ||||
|  | ||||
|  | ||||
| @ -3,6 +3,7 @@ from contextlib import contextmanager | ||||
| from copy import deepcopy | ||||
| from typing import Any, Optional | ||||
|  | ||||
| from dacite.config import Config | ||||
| from dacite.core import from_dict | ||||
| from dacite.exceptions import DaciteError | ||||
| from deepmerge import always_merger | ||||
| @ -20,6 +21,7 @@ from yaml import load | ||||
| from authentik.blueprints.v1.common import ( | ||||
|     Blueprint, | ||||
|     BlueprintEntry, | ||||
|     BlueprintEntryDesiredState, | ||||
|     BlueprintEntryState, | ||||
|     BlueprintLoader, | ||||
|     EntryInvalidError, | ||||
| @ -82,14 +84,16 @@ class Importer: | ||||
|         self.logger = get_logger() | ||||
|         import_dict = load(yaml_input, BlueprintLoader) | ||||
|         try: | ||||
|             self.__import = from_dict(Blueprint, import_dict) | ||||
|             self.__import = from_dict( | ||||
|                 Blueprint, import_dict, config=Config(cast=[BlueprintEntryDesiredState]) | ||||
|             ) | ||||
|         except DaciteError as exc: | ||||
|             raise EntryInvalidError from exc | ||||
|         context = {} | ||||
|         always_merger.merge(context, self.__import.context) | ||||
|         ctx = {} | ||||
|         always_merger.merge(ctx, self.__import.context) | ||||
|         if context: | ||||
|             always_merger.merge(context, context) | ||||
|         self.__import.context = context | ||||
|             always_merger.merge(ctx, context) | ||||
|         self.__import.context = ctx | ||||
|  | ||||
|     @property | ||||
|     def blueprint(self) -> Blueprint: | ||||
| @ -135,7 +139,7 @@ class Importer: | ||||
|             sub_query &= Q(**{identifier: value}) | ||||
|         return main_query | sub_query | ||||
|  | ||||
|     def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer: | ||||
|     def _validate_single(self, entry: BlueprintEntry) -> Optional[BaseSerializer]: | ||||
|         """Validate a single entry""" | ||||
|         model_app_label, model_name = entry.model.split(".") | ||||
|         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)) | ||||
|  | ||||
|         serializer_kwargs = {} | ||||
|         if not isinstance(model(), BaseMetaModel) and existing_models.exists(): | ||||
|         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( | ||||
|                 "initialise serializer with instance", | ||||
|                 model=model, | ||||
| @ -234,12 +241,25 @@ class Importer: | ||||
|             except EntryInvalidError as exc: | ||||
|                 self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc) | ||||
|                 return False | ||||
|             if not serializer: | ||||
|                 continue | ||||
|  | ||||
|             if entry.state in [ | ||||
|                 BlueprintEntryDesiredState.PRESENT, | ||||
|                 BlueprintEntryDesiredState.CREATED, | ||||
|             ]: | ||||
|                 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 | ||||
|  | ||||
|     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.core.api.providers import ProviderSerializer | ||||
| 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.events.models import EventAction | ||||
| 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.engine import PolicyEngine | ||||
| from authentik.policies.types import PolicyResult | ||||
| @ -37,7 +42,7 @@ LOGGER = get_logger() | ||||
|  | ||||
| def user_app_cache_key(user_pk: str) -> str: | ||||
|     """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): | ||||
| @ -224,21 +229,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | ||||
|     def set_icon(self, request: Request, slug: str): | ||||
|         """Set application icon""" | ||||
|         app: Application = self.get_object() | ||||
|         icon = request.FILES.get("file", None) | ||||
|         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() | ||||
|         return set_file(request, app, "meta_icon") | ||||
|  | ||||
|     @permission_required("authentik_core.change_application") | ||||
|     @extend_schema( | ||||
| @ -258,12 +249,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | ||||
|     def set_icon_url(self, request: Request, slug: str): | ||||
|         """Set application icon (as URL)""" | ||||
|         app: Application = self.get_object() | ||||
|         url = request.data.get("url", None) | ||||
|         if url is None: | ||||
|             return HttpResponseBadRequest() | ||||
|         app.meta_icon.name = url | ||||
|         app.save() | ||||
|         return Response({}) | ||||
|         return set_file_url(request, app, "meta_icon") | ||||
|  | ||||
|     @permission_required("authentik_core.view_application", ["authentik_events.view_event"]) | ||||
|     @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.expression.evaluator import PropertyMappingEvaluator | ||||
| from authentik.core.models import PropertyMapping | ||||
| from authentik.events.utils import sanitize_item | ||||
| from authentik.lib.utils.reflection import all_subclasses | ||||
| from authentik.policies.api.exec import PolicyTestSerializer | ||||
|  | ||||
| @ -140,7 +141,9 @@ class PropertyMappingViewSet( | ||||
|                 self.request, | ||||
|                 **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 | ||||
|             response_data["result"] = str(exc) | ||||
|             response_data["successful"] = False | ||||
|  | ||||
| @ -2,10 +2,11 @@ | ||||
| from typing import Iterable | ||||
|  | ||||
| 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.decorators import action | ||||
| from rest_framework.filters import OrderingFilter, SearchFilter | ||||
| from rest_framework.parsers import MultiPartParser | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| 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 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.utils import MetaNameSerializer, TypeCreateSerializer | ||||
| from authentik.core.models import Source, UserSourceConnection | ||||
| 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.policies.engine import PolicyEngine | ||||
|  | ||||
| @ -28,6 +36,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | ||||
|  | ||||
|     managed = ReadOnlyField() | ||||
|     component = SerializerMethodField() | ||||
|     icon = ReadOnlyField(source="get_icon") | ||||
|  | ||||
|     def get_component(self, obj: Source) -> str: | ||||
|         """Get object component so that we know how to edit the object""" | ||||
| @ -54,6 +63,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): | ||||
|             "user_matching_mode", | ||||
|             "managed", | ||||
|             "user_path_template", | ||||
|             "icon", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| @ -75,6 +85,49 @@ class SourceViewSet( | ||||
|     def get_queryset(self):  # pragma: no cover | ||||
|         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)}) | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     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.api.decorators import permission_required | ||||
| from authentik.core.api.groups import GroupSerializer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict | ||||
| from authentik.core.middleware import ( | ||||
| @ -74,6 +73,26 @@ from authentik.tenants.models import Tenant | ||||
| 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): | ||||
|     """User Serializer""" | ||||
|  | ||||
| @ -83,7 +102,7 @@ class UserSerializer(ModelSerializer): | ||||
|     groups = PrimaryKeyRelatedField( | ||||
|         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) | ||||
|     username = CharField(max_length=150) | ||||
|  | ||||
| @ -470,7 +489,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|     # pylint: disable=invalid-name, unused-argument | ||||
|     def recovery_email(self, request: Request, pk: int) -> Response: | ||||
|         """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 == "": | ||||
|             LOGGER.debug("User doesn't have an email address") | ||||
|             return Response(status=404) | ||||
| @ -488,8 +507,9 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|         email_stage: EmailStage = stages.first() | ||||
|         message = TemplateEmailMessage( | ||||
|             subject=_(email_stage.subject), | ||||
|             template_name=email_stage.template, | ||||
|             to=[for_user.email], | ||||
|             template_name=email_stage.template, | ||||
|             language=for_user.locale(request), | ||||
|             template_context={ | ||||
|                 "url": link, | ||||
|                 "user": for_user, | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| from typing import Any | ||||
|  | ||||
| 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 | ||||
|  | ||||
|  | ||||
| @ -23,19 +23,6 @@ class PassiveSerializer(Serializer): | ||||
|         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): | ||||
|     """Add verbose names to response""" | ||||
|  | ||||
|  | ||||
| @ -1,6 +1,8 @@ | ||||
| """authentik shell command""" | ||||
| import code | ||||
| import platform | ||||
| import sys | ||||
| import traceback | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.core.management.base import BaseCommand | ||||
| @ -89,6 +91,21 @@ class Command(BaseCommand): | ||||
|             exec(options["command"], namespace)  # nosec # noqa | ||||
|             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: | ||||
|             import readline | ||||
|  | ||||
| @ -4,6 +4,7 @@ from typing import Callable, Optional | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.utils.translation import activate | ||||
| from sentry_sdk.api import set_tag | ||||
| from structlog.contextvars import STRUCTLOG_KEY_PREFIX | ||||
|  | ||||
| @ -29,6 +30,10 @@ class ImpersonateMiddleware: | ||||
|     def __call__(self, request: HttpRequest) -> HttpResponse: | ||||
|         # No permission checks are done here, they need to be checked before | ||||
|         # 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: | ||||
|             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""" | ||||
|         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 | ||||
|     def avatar(self) -> str: | ||||
|         """Get avatar, depending on authentik.avatar setting""" | ||||
| @ -286,7 +297,7 @@ class Provider(SerializerModel): | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|         return str(self.name) | ||||
|  | ||||
|  | ||||
| class Application(SerializerModel, PolicyBindingModel): | ||||
| @ -368,7 +379,7 @@ class Application(SerializerModel, PolicyBindingModel): | ||||
|             return None | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|         return str(self.name) | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
| @ -410,6 +421,12 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | ||||
|  | ||||
|     enabled = models.BooleanField(default=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( | ||||
|         "authentik_flows.Flow", | ||||
| @ -443,6 +460,16 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | ||||
|  | ||||
|     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: | ||||
|         """Get user path, fallback to default for formatting errors""" | ||||
|         try: | ||||
| @ -470,7 +497,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel): | ||||
|         return None | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|         return str(self.name) | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|  | ||||
| @ -5,7 +5,7 @@ from typing import Any, Optional | ||||
| from django.contrib import messages | ||||
| from django.db import IntegrityError | ||||
| 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.urls import reverse | ||||
| from django.utils.translation import gettext as _ | ||||
| @ -23,8 +23,10 @@ from authentik.flows.planner import ( | ||||
|     PLAN_CONTEXT_SSO, | ||||
|     FlowPlanner, | ||||
| ) | ||||
| from authentik.flows.stage import StageView | ||||
| 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.views import bad_request_message | ||||
| from authentik.policies.denied import AccessDeniedResponse | ||||
| from authentik.policies.utils import delete_none_keys | ||||
| from authentik.stages.password import BACKEND_INBUILT | ||||
| @ -43,6 +45,26 @@ class Action(Enum): | ||||
|     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: | ||||
|     """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 | ||||
| @ -150,16 +172,16 @@ class SourceFlowManager: | ||||
|             action, connection = self.get_action(**kwargs) | ||||
|         except IntegrityError as 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) | ||||
|         try: | ||||
|             if connection: | ||||
|                 if action == Action.LINK: | ||||
|                     self._logger.debug("Linking existing user") | ||||
|                     return self.handle_existing_user_link(connection) | ||||
|                     return self.handle_existing_link(connection) | ||||
|                 if action == Action.AUTH: | ||||
|                     self._logger.debug("Handling auth user") | ||||
|                     return self.handle_auth_user(connection) | ||||
|                     return self.handle_auth(connection) | ||||
|                 if action == Action.ENROLL: | ||||
|                     self._logger.debug("Handling enrollment of new user") | ||||
|                     return self.handle_enroll(connection) | ||||
| @ -198,8 +220,12 @@ class SourceFlowManager: | ||||
|             ] | ||||
|         return [] | ||||
|  | ||||
|     def _handle_login_flow( | ||||
|         self, flow: Flow, connection: UserSourceConnection, **kwargs | ||||
|     def _prepare_flow( | ||||
|         self, | ||||
|         flow: Flow, | ||||
|         connection: UserSourceConnection, | ||||
|         stages: Optional[list[StageView]] = None, | ||||
|         **kwargs, | ||||
|     ) -> HttpResponse: | ||||
|         """Prepare Authentication Plan, redirect user FlowExecutor""" | ||||
|         # Ensure redirect is carried through when user was trying to | ||||
| @ -219,12 +245,18 @@ class SourceFlowManager: | ||||
|         ) | ||||
|         kwargs.update(self.policy_context) | ||||
|         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 | ||||
|         planner = FlowPlanner(flow) | ||||
|         plan = planner.plan(self.request, kwargs) | ||||
|         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 | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
| @ -233,24 +265,35 @@ class SourceFlowManager: | ||||
|         ) | ||||
|  | ||||
|     # pylint: disable=unused-argument | ||||
|     def handle_auth_user( | ||||
|     def handle_auth( | ||||
|         self, | ||||
|         connection: UserSourceConnection, | ||||
|     ) -> HttpResponse: | ||||
|         """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} | ||||
|         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, | ||||
|         connection: UserSourceConnection, | ||||
|     ) -> HttpResponse: | ||||
|         """Handler when the user was already authenticated and linked an external source | ||||
|         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 | ||||
|         Event.new( | ||||
|             EventAction.SOURCE_LINKED, | ||||
| @ -261,9 +304,6 @@ class SourceFlowManager: | ||||
|             self.request, | ||||
|             _("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( | ||||
|             reverse( | ||||
|                 "authentik_core:if-user", | ||||
| @ -276,18 +316,24 @@ class SourceFlowManager: | ||||
|         connection: UserSourceConnection, | ||||
|     ) -> HttpResponse: | ||||
|         """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 | ||||
|         if not self.source.enrollment_flow: | ||||
|             self._logger.warning("source has no enrollment flow") | ||||
|             return HttpResponseBadRequest() | ||||
|         return self._handle_login_flow( | ||||
|             return bad_request_message( | ||||
|                 self.request, | ||||
|                 _("Source is not configured for enrollment."), | ||||
|             ) | ||||
|         return self._prepare_flow( | ||||
|             self.source.enrollment_flow, | ||||
|             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_USER_PATH: self.source.get_user_path(), | ||||
|  | ||||
| @ -1,6 +1,9 @@ | ||||
| {% load i18n %} | ||||
| {% get_current_language as LANGUAGE_CODE %} | ||||
|  | ||||
| <script> | ||||
|     window.authentik = {}; | ||||
|     window.authentik.locale = "{{ tenant.default_locale }}"; | ||||
|     window.authentik.locale = "{{ LANGUAGE_CODE }}"; | ||||
|     window.authentik.config = JSON.parse('{{ config_json|escapejs }}'); | ||||
|     window.authentik.tenant = JSON.parse('{{ tenant_json|escapejs }}'); | ||||
|     window.addEventListener("DOMContentLoaded", () => { | ||||
|  | ||||
| @ -13,8 +13,8 @@ | ||||
| {% endblock %} | ||||
|  | ||||
| {% block body %} | ||||
| <ak-message-container data-refresh-on-locale="true"></ak-message-container> | ||||
| <ak-interface-admin data-refresh-on-locale="true"> | ||||
| <ak-message-container></ak-message-container> | ||||
| <ak-interface-admin> | ||||
|     <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__content"> | ||||
|  | ||||
| @ -29,8 +29,8 @@ window.authentik.flow = { | ||||
| {% endblock %} | ||||
|  | ||||
| {% block body %} | ||||
| <ak-message-container data-refresh-on-locale="true"></ak-message-container> | ||||
| <ak-flow-executor data-refresh-on-locale="true"> | ||||
| <ak-message-container></ak-message-container> | ||||
| <ak-flow-executor> | ||||
|     <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__content"> | ||||
|  | ||||
| @ -13,8 +13,8 @@ | ||||
| {% endblock %} | ||||
|  | ||||
| {% block body %} | ||||
| <ak-message-container data-refresh-on-locale="true"></ak-message-container> | ||||
| <ak-interface-user data-refresh-on-locale="true"> | ||||
| <ak-message-container></ak-message-container> | ||||
| <ak-interface-user> | ||||
|     <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__content"> | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| """authentik events models""" | ||||
| import time | ||||
| from collections import Counter | ||||
| from copy import deepcopy | ||||
| from datetime import timedelta | ||||
| from inspect import currentframe | ||||
| from smtplib import SMTPException | ||||
| @ -210,7 +211,7 @@ class Event(SerializerModel, ExpiringModel): | ||||
|             current = currentframe() | ||||
|             parent = current.f_back | ||||
|             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) | ||||
|         return event | ||||
|  | ||||
| @ -293,7 +294,7 @@ class Event(SerializerModel, ExpiringModel): | ||||
|         return f"{self.action}: {self.context}" | ||||
|  | ||||
|     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: | ||||
|  | ||||
| @ -445,8 +446,9 @@ class NotificationTransport(SerializerModel): | ||||
|             subject += notification.body[:75] | ||||
|         mail = TemplateEmailMessage( | ||||
|             subject=subject, | ||||
|             template_name="email/generic.html", | ||||
|             to=[notification.user.email], | ||||
|             language=notification.user.locale(), | ||||
|             template_name="email/generic.html", | ||||
|             template_context={ | ||||
|                 "title": subject, | ||||
|                 "body": notification.body, | ||||
|  | ||||
| @ -15,6 +15,7 @@ from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| CACHE_KEY_PREFIX = "goauthentik.io/events/tasks/" | ||||
|  | ||||
|  | ||||
| class TaskResultStatus(Enum): | ||||
| @ -70,16 +71,16 @@ class TaskInfo: | ||||
|     @staticmethod | ||||
|     def all() -> dict[str, "TaskInfo"]: | ||||
|         """Get all TaskInfo objects""" | ||||
|         return cache.get_many(cache.keys("task_*")) | ||||
|         return cache.get_many(cache.keys(CACHE_KEY_PREFIX + "*")) | ||||
|  | ||||
|     @staticmethod | ||||
|     def by_name(name: str) -> Optional["TaskInfo"]: | ||||
|         """Get TaskInfo Object by name""" | ||||
|         return cache.get(f"task_{name}", None) | ||||
|         return cache.get(CACHE_KEY_PREFIX + name, None) | ||||
|  | ||||
|     def delete(self): | ||||
|         """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): | ||||
|         """Update prometheus metrics""" | ||||
| @ -98,9 +99,9 @@ class TaskInfo: | ||||
|  | ||||
|     def save(self, timeout_hours=6): | ||||
|         """Save task into cache""" | ||||
|         key = f"task_{self.task_name}" | ||||
|         key = CACHE_KEY_PREFIX + self.task_name | ||||
|         if self.result.uid: | ||||
|             key += f"_{self.result.uid}" | ||||
|             key += f"/{self.result.uid}" | ||||
|             self.task_name += f"_{self.result.uid}" | ||||
|         self.set_prom_metrics() | ||||
|         cache.set(key, self, timeout=timeout_hours * 60 * 60) | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
| import re | ||||
| from dataclasses import asdict, is_dataclass | ||||
| from pathlib import Path | ||||
| from types import GeneratorType | ||||
| from typing import Any, Optional | ||||
| from uuid import UUID | ||||
|  | ||||
| @ -93,6 +94,8 @@ def sanitize_item(value: Any) -> Any: | ||||
|         value = asdict(value) | ||||
|     if isinstance(value, dict): | ||||
|         return sanitize_dict(value) | ||||
|     if isinstance(value, GeneratorType): | ||||
|         return sanitize_item(list(value)) | ||||
|     if isinstance(value, list): | ||||
|         new_values = [] | ||||
|         for item in value: | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| """Flow API Views""" | ||||
| from django.core.cache import cache | ||||
| from django.http import HttpResponse | ||||
| from django.http.response import HttpResponseBadRequest | ||||
| from django.urls import reverse | ||||
| from django.utils.translation import gettext as _ | ||||
| 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.importer import Importer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import ( | ||||
|     CacheSerializer, | ||||
|     FilePathSerializer, | ||||
|     FileUploadSerializer, | ||||
|     LinkSerializer, | ||||
|     PassiveSerializer, | ||||
| ) | ||||
| from authentik.core.api.utils import CacheSerializer, LinkSerializer, PassiveSerializer | ||||
| from authentik.events.utils import sanitize_dict | ||||
| from authentik.flows.api.flows_diagram import FlowDiagram, FlowDiagramSerializer | ||||
| from authentik.flows.exceptions import FlowNonApplicableException | ||||
| 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.lib.utils.file import ( | ||||
|     FilePathSerializer, | ||||
|     FileUploadSerializer, | ||||
|     set_file, | ||||
|     set_file_url, | ||||
| ) | ||||
| from authentik.lib.views import bad_request_message | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| @ -122,7 +121,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def cache_info(self, request: Request) -> Response: | ||||
|         """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"]) | ||||
|     @extend_schema( | ||||
| @ -135,7 +134,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | ||||
|     @action(detail=False, methods=["POST"]) | ||||
|     def cache_clear(self, request: Request) -> Response: | ||||
|         """Clear flow cache""" | ||||
|         keys = cache.keys("flow_*") | ||||
|         keys = cache.keys(f"{CACHE_PREFIX}*") | ||||
|         cache.delete_many(keys) | ||||
|         LOGGER.debug("Cleared flow cache", keys=len(keys)) | ||||
|         return Response(status=204) | ||||
| @ -249,25 +248,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | ||||
|     def set_background(self, request: Request, slug: str): | ||||
|         """Set Flow background""" | ||||
|         flow: Flow = self.get_object() | ||||
|         background = request.FILES.get("file", None) | ||||
|         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() | ||||
|         return set_file(request, flow, "background") | ||||
|  | ||||
|     @permission_required("authentik_core.change_application") | ||||
|     @extend_schema( | ||||
| @ -287,12 +268,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | ||||
|     def set_background_url(self, request: Request, slug: str): | ||||
|         """Set Flow background (as URL)""" | ||||
|         flow: Flow = self.get_object() | ||||
|         url = request.data.get("url", None) | ||||
|         if not url: | ||||
|             return HttpResponseBadRequest() | ||||
|         flow.background.name = url | ||||
|         flow.save() | ||||
|         return Response({}) | ||||
|         return set_file_url(request, flow, "background") | ||||
|  | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
|  | ||||
| @ -27,11 +27,12 @@ PLAN_CONTEXT_SOURCE = "source" | ||||
| # was restored. | ||||
| PLAN_CONTEXT_IS_RESTORED = "is_restored" | ||||
| 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: | ||||
|     """Generate Cache key for flow""" | ||||
|     prefix = f"flow_{flow.pk}" | ||||
|     prefix = CACHE_PREFIX + str(flow.pk) | ||||
|     if user: | ||||
|         prefix += f"#{user.pk}" | ||||
|     return prefix | ||||
| @ -141,6 +142,7 @@ class FlowPlanner: | ||||
|             # First off, check the flow's direct policy bindings | ||||
|             # to make sure the user even has access to the flow | ||||
|             engine = PolicyEngine(self.flow, user, request) | ||||
|             engine.use_cache = self.use_cache | ||||
|             if default_context: | ||||
|                 span.set_data("default_context", cleanse_dict(default_context)) | ||||
|                 engine.request.context.update(default_context) | ||||
| @ -206,6 +208,7 @@ class FlowPlanner: | ||||
|                         stage=stage, | ||||
|                     ) | ||||
|                     engine = PolicyEngine(binding, user, request) | ||||
|                     engine.use_cache = self.use_cache | ||||
|                     engine.request.context["flow_plan"] = plan | ||||
|                     engine.request.context.update(plan.context) | ||||
|                     engine.build() | ||||
|  | ||||
| @ -5,6 +5,7 @@ from django.dispatch import receiver | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.flows.apps import GAUGE_FLOWS_CACHED | ||||
| from authentik.flows.planner import CACHE_PREFIX | ||||
| from authentik.root.monitoring import monitoring_set | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| @ -21,7 +22,7 @@ def delete_cache_prefix(prefix: str) -> int: | ||||
| # pylint: disable=unused-argument | ||||
| def monitoring_set_flows(sender, **kwargs): | ||||
|     """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) | ||||
|  | ||||
| @ -44,6 +44,7 @@ from authentik.flows.models import ( | ||||
|     Stage, | ||||
| ) | ||||
| from authentik.flows.planner import ( | ||||
|     CACHE_PREFIX, | ||||
|     PLAN_CONTEXT_IS_RESTORED, | ||||
|     PLAN_CONTEXT_PENDING_USER, | ||||
|     PLAN_CONTEXT_REDIRECT, | ||||
| @ -216,7 +217,7 @@ class FlowExecutorView(APIView): | ||||
|                 self._logger.warning( | ||||
|                     "f(exec): found incompatible flow plan, invalidating run", exc=exc | ||||
|                 ) | ||||
|                 keys = cache.keys("flow_*") | ||||
|                 keys = cache.keys(f"{CACHE_PREFIX}*") | ||||
|                 cache.delete_many(keys) | ||||
|                 return self.stage_invalid() | ||||
|             if not next_binding: | ||||
| @ -253,9 +254,9 @@ class FlowExecutorView(APIView): | ||||
|             action=EventAction.SYSTEM_EXCEPTION, | ||||
|             message=exception_to_string(exc), | ||||
|         ).from_http(self.request) | ||||
|         return to_stage_response( | ||||
|             self.request, HttpChallengeResponse(FlowErrorChallenge(self.request, exc)) | ||||
|         ) | ||||
|         challenge = FlowErrorChallenge(self.request, exc) | ||||
|         challenge.is_valid() | ||||
|         return to_stage_response(self.request, HttpChallengeResponse(challenge)) | ||||
|  | ||||
|     @extend_schema( | ||||
|         responses={ | ||||
| @ -351,7 +352,7 @@ class FlowExecutorView(APIView): | ||||
|             # from the cache. If there are errors, just delete all cached flows | ||||
|             _ = plan.has_stages | ||||
|         except Exception:  # pylint: disable=broad-except | ||||
|             keys = cache.keys("flow_*") | ||||
|             keys = cache.keys(f"{CACHE_PREFIX}*") | ||||
|             cache.delete_many(keys) | ||||
|             return self._initiate_plan() | ||||
|         return plan | ||||
|  | ||||
| @ -19,10 +19,7 @@ redis: | ||||
|   password: '' | ||||
|   tls: false | ||||
|   tls_reqs: "none" | ||||
|   cache_db: 0 | ||||
|   message_queue_db: 1 | ||||
|   ws_db: 2 | ||||
|   outpost_session_db: 3 | ||||
|   db: 0 | ||||
|   cache_timeout: 300 | ||||
|   cache_timeout_flows: 300 | ||||
|   cache_timeout_policies: 300 | ||||
| @ -35,6 +32,7 @@ log_level: info | ||||
| # Error reporting, sends stacktrace to sentry.beryju.org | ||||
| error_reporting: | ||||
|   enabled: false | ||||
|   sentry_dsn: https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8 | ||||
|   environment: customer | ||||
|   send_pii: false | ||||
|   sample_rate: 0.1 | ||||
|  | ||||
| @ -99,13 +99,16 @@ class BaseEvaluator: | ||||
|     def expr_event_create(self, action: str, **kwargs): | ||||
|         """Create event with supplied data and try to extract as much relevant data | ||||
|         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 | ||||
|         event = Event.new( | ||||
|             action, | ||||
|             app=self._filename, | ||||
|             **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"] | ||||
|             if policy_request.http_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 | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8" | ||||
|  | ||||
|  | ||||
| class SentryWSMiddleware(BaseMiddleware): | ||||
| @ -71,7 +70,7 @@ def sentry_init(**sentry_init_kwargs): | ||||
|     kwargs.update(**sentry_init_kwargs) | ||||
|     # pylint: disable=abstract-class-instantiated | ||||
|     sentry_sdk_init( | ||||
|         dsn=SENTRY_DSN, | ||||
|         dsn=CONFIG.y("error_reporting.sentry_dsn"), | ||||
|         integrations=[ | ||||
|             DjangoIntegration(transaction_style="function_name"), | ||||
|             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: | ||||
|  | ||||
|         model = KubernetesServiceConnection | ||||
|         fields = ServiceConnectionSerializer.Meta.fields + ["kubeconfig"] | ||||
|         fields = ServiceConnectionSerializer.Meta.fields + ["kubeconfig", "verify_ssl"] | ||||
|  | ||||
|  | ||||
| class KubernetesServiceConnectionViewSet(UsedByMixin, ModelViewSet): | ||||
|  | ||||
| @ -36,6 +36,7 @@ class KubernetesClient(ApiClient, BaseClient): | ||||
|                 load_incluster_config(client_configuration=config) | ||||
|             else: | ||||
|                 load_kube_config_from_dict(connection.kubeconfig, client_configuration=config) | ||||
|             config.verify_ssl = connection.verify_ssl | ||||
|             super().__init__(config) | ||||
|         except ConfigException as 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: | ||||
|     """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_insecure: bool = False | ||||
| @ -62,16 +62,17 @@ class OutpostConfig: | ||||
|     log_level: str = CONFIG.y("log_level") | ||||
|     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_map_ports: bool = field(default=True) | ||||
|     docker_labels: Optional[dict[str, str]] = field(default=None) | ||||
|  | ||||
|     container_image: Optional[str] = field(default=None) | ||||
|  | ||||
|     kubernetes_replicas: int = field(default=1) | ||||
|     kubernetes_namespace: str = field(default_factory=get_namespace) | ||||
|     kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict) | ||||
|     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_disabled_components: 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, | ||||
|     ) | ||||
|     verify_ssl = models.BooleanField( | ||||
|         default=True, help_text=_("Verify SSL Certificates of the Kubernetes API endpoint") | ||||
|     ) | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> Serializer: | ||||
| @ -288,7 +292,7 @@ class Outpost(SerializerModel, ManagedModel): | ||||
|     @property | ||||
|     def state_cache_prefix(self) -> str: | ||||
|         """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 | ||||
|     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.models import Policy, PolicyBinding | ||||
| from authentik.policies.process import PolicyProcess | ||||
| from authentik.policies.types import PolicyRequest | ||||
| from authentik.policies.types import CACHE_PREFIX, PolicyRequest | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -114,7 +114,7 @@ class PolicyViewSet( | ||||
|     @action(detail=False, pagination_class=None, filter_backends=[]) | ||||
|     def cache_info(self, request: Request) -> Response: | ||||
|         """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"]) | ||||
|     @extend_schema( | ||||
| @ -127,7 +127,7 @@ class PolicyViewSet( | ||||
|     @action(detail=False, methods=["POST"]) | ||||
|     def cache_clear(self, request: Request) -> Response: | ||||
|         """Clear policy cache""" | ||||
|         keys = cache.keys("policy_*") | ||||
|         keys = cache.keys(f"{CACHE_PREFIX}*") | ||||
|         cache.delete_many(keys) | ||||
|         LOGGER.debug("Cleared Policy cache", keys=len(keys)) | ||||
|         # Also delete user application cache | ||||
|  | ||||
| @ -1,13 +1,17 @@ | ||||
| """evaluator tests""" | ||||
| from django.test import TestCase | ||||
| from django.test import RequestFactory, TestCase | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
| from rest_framework.serializers import ValidationError | ||||
| 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.expression.api import ExpressionPolicySerializer | ||||
| from authentik.policies.expression.evaluator import PolicyEvaluator | ||||
| 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 | ||||
|  | ||||
|  | ||||
| @ -15,7 +19,15 @@ class TestEvaluator(TestCase): | ||||
|     """Evaluator tests""" | ||||
|  | ||||
|     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.obj = self.obj | ||||
|         self.request.http_request = self.http_request | ||||
|  | ||||
|     def test_full(self): | ||||
|         """Test full with Policy instance""" | ||||
| @ -63,6 +75,41 @@ class TestEvaluator(TestCase): | ||||
|         with self.assertRaises(ValidationError): | ||||
|             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): | ||||
|     """Test expression policy's API""" | ||||
|  | ||||
| @ -15,7 +15,7 @@ LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| 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.""" | ||||
|  | ||||
|     password_field = models.TextField( | ||||
|  | ||||
| @ -41,6 +41,9 @@ class PolicyBindingModel(models.Model): | ||||
|  | ||||
|     objects = InheritanceManager() | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"PolicyBindingModel {self.pbm_uuid}" | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Policy Binding Model") | ||||
|         verbose_name_plural = _("Policy Binding Models") | ||||
| @ -135,6 +138,7 @@ class PolicyBinding(SerializerModel): | ||||
|             return f"Binding from {self.target} #{self.order} to {suffix}" | ||||
|         except PolicyBinding.target.RelatedObjectDoesNotExist:  # pylint: disable=no-member | ||||
|             return f"Binding - #{self.order} to {suffix}" | ||||
|         return "" | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
| @ -175,7 +179,7 @@ class Policy(SerializerModel, CreatedUpdatedModel): | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|         return str(self.name) | ||||
|  | ||||
|     def passes(self, request: PolicyRequest) -> PolicyResult:  # pragma: no cover | ||||
|         """Check if request passes this policy""" | ||||
|  | ||||
| @ -20,6 +20,11 @@ class PasswordPolicySerializer(PolicySerializer): | ||||
|             "length_min", | ||||
|             "symbol_charset", | ||||
|             "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 | ||||
| from hashlib import sha1 | ||||
|  | ||||
| from django.db import models | ||||
| from django.utils.translation import gettext as _ | ||||
| from rest_framework.serializers import BaseSerializer | ||||
| 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.types import PolicyRequest, PolicyResult | ||||
| 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."), | ||||
|     ) | ||||
|  | ||||
|     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_uppercase = models.PositiveIntegerField(default=0) | ||||
|     amount_lowercase = models.PositiveIntegerField(default=0) | ||||
|     amount_symbols = models.PositiveIntegerField(default=0) | ||||
|     length_min = models.PositiveIntegerField(default=0) | ||||
|     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 | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
| @ -42,48 +59,103 @@ class PasswordPolicy(Policy): | ||||
|     def component(self) -> str: | ||||
|         return "ak-policy-password-form" | ||||
|  | ||||
|     # pylint: disable=too-many-return-statements | ||||
|     def passes(self, request: PolicyRequest) -> PolicyResult: | ||||
|         if ( | ||||
|             self.password_field not in request.context | ||||
|             and self.password_field not in request.context.get(PLAN_CONTEXT_PROMPT, {}) | ||||
|         ): | ||||
|         password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get( | ||||
|             self.password_field, request.context.get(self.password_field) | ||||
|         ) | ||||
|         if not password: | ||||
|             LOGGER.warning( | ||||
|                 "Password field not set in Policy Request", | ||||
|                 field=self.password_field, | ||||
|                 fields=request.context.keys(), | ||||
|                 prompt_fields=request.context.get(PLAN_CONTEXT_PROMPT, {}).keys(), | ||||
|             ) | ||||
|             return PolicyResult(False, _("Password not set in context")) | ||||
|         password = str(password) | ||||
|  | ||||
|         if self.password_field in request.context: | ||||
|             password = request.context[self.password_field] | ||||
|         else: | ||||
|             password = request.context[PLAN_CONTEXT_PROMPT][self.password_field] | ||||
|         if self.check_static_rules: | ||||
|             static_result = self.passes_static(password, request) | ||||
|             if not static_result.passing: | ||||
|                 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: | ||||
|             LOGGER.debug("password failed", reason="length") | ||||
|             LOGGER.debug("password failed", check="static", reason="length") | ||||
|             return PolicyResult(False, self.error_message) | ||||
|  | ||||
|         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) | ||||
|         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) | ||||
|         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) | ||||
|         if self.amount_symbols > 0: | ||||
|             count = 0 | ||||
|             for symbol in self.symbol_charset: | ||||
|                 count += password.count(symbol) | ||||
|             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(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): | ||||
|  | ||||
|         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.exceptions import PolicyException | ||||
| 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() | ||||
|  | ||||
| @ -25,7 +25,7 @@ PROCESS_CLASS = FORK_CTX.Process | ||||
|  | ||||
| def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str: | ||||
|     """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"): | ||||
|         prefix += f"_{request.http_request.session.session_key}" | ||||
|     if request.user: | ||||
| @ -56,8 +56,6 @@ class PolicyProcess(PROCESS_CLASS): | ||||
|  | ||||
|     def create_event(self, action: str, message: str, **kwargs): | ||||
|         """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( | ||||
|             action=action, | ||||
|             message=message, | ||||
| @ -67,8 +65,8 @@ class PolicyProcess(PROCESS_CLASS): | ||||
|             **kwargs, | ||||
|         ) | ||||
|         event.set_user(self.request.user) | ||||
|         if http_request: | ||||
|             event.from_http(http_request) | ||||
|         if self.request.http_request: | ||||
|             event.from_http(self.request.http_request) | ||||
|         else: | ||||
|             event.save() | ||||
|  | ||||
| @ -103,7 +101,7 @@ class PolicyProcess(PROCESS_CLASS): | ||||
|             LOGGER.debug("P_ENG(proc): error", exc=src_exc) | ||||
|             policy_result = PolicyResult(False, str(src_exc)) | ||||
|         policy_result.source_binding = self.binding | ||||
|         if not self.request.debug: | ||||
|         if self.request.should_cache: | ||||
|             key = cache_key(self.binding, self.request) | ||||
|             cache.set(key, policy_result, CACHE_TIMEOUT) | ||||
|         LOGGER.debug( | ||||
|  | ||||
| @ -23,12 +23,12 @@ def update_score(request: HttpRequest, identifier: str, amount: int): | ||||
|     try: | ||||
|         # We only update the cache here, as its faster than writing to the DB | ||||
|         score = cache.get_or_set( | ||||
|             CACHE_KEY_PREFIX + remote_ip + identifier, | ||||
|             CACHE_KEY_PREFIX + remote_ip + "/" + identifier, | ||||
|             {"ip": remote_ip, "identifier": identifier, "score": 0}, | ||||
|             CACHE_TIMEOUT, | ||||
|         ) | ||||
|         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: | ||||
|         LOGGER.warning("failed to set reputation", exc=exc) | ||||
|  | ||||
|  | ||||
| @ -32,7 +32,7 @@ class TestReputationPolicy(TestCase): | ||||
|         ) | ||||
|         # Test value in cache | ||||
|         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}, | ||||
|         ) | ||||
|         # Save cache and check db values | ||||
| @ -47,7 +47,7 @@ class TestReputationPolicy(TestCase): | ||||
|         ) | ||||
|         # Test value in cache | ||||
|         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}, | ||||
|         ) | ||||
|         # 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.policies.apps import GAUGE_POLICIES_CACHED | ||||
| from authentik.policies.types import CACHE_PREFIX | ||||
| from authentik.root.monitoring import monitoring_set | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| @ -15,7 +16,7 @@ LOGGER = get_logger() | ||||
| # pylint: disable=unused-argument | ||||
| def monitoring_set_policies(sender, **kwargs): | ||||
|     """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) | ||||
| @ -27,7 +28,7 @@ def invalidate_policy_cache(sender, instance, **_): | ||||
|     if isinstance(instance, Policy): | ||||
|         total = 0 | ||||
|         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) | ||||
|             total += len(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.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode | ||||
| from authentik.policies.tests.test_process import clear_policy_cache | ||||
| from authentik.policies.types import CACHE_PREFIX | ||||
|  | ||||
|  | ||||
| class TestPolicyEngine(TestCase): | ||||
| @ -101,8 +102,8 @@ class TestPolicyEngine(TestCase): | ||||
|         pbm = PolicyBindingModel.objects.create() | ||||
|         binding = PolicyBinding.objects.create(target=pbm, policy=self.policy_false, order=0) | ||||
|         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(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(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.models import Policy, PolicyBinding | ||||
| from authentik.policies.process import PolicyProcess | ||||
| from authentik.policies.types import PolicyRequest | ||||
| from authentik.policies.types import CACHE_PREFIX, PolicyRequest | ||||
|  | ||||
|  | ||||
| def clear_policy_cache(): | ||||
|     """Ensure no policy-related keys are still cached""" | ||||
|     keys = cache.keys("policy_*") | ||||
|     keys = cache.keys(f"{CACHE_PREFIX}*") | ||||
|     cache.delete(keys) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -16,6 +16,7 @@ if TYPE_CHECKING: | ||||
|     from authentik.policies.models import PolicyBinding | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| CACHE_PREFIX = "goauthentik.io/policies/" | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| @ -45,6 +46,15 @@ class PolicyRequest: | ||||
|             return | ||||
|         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: | ||||
|         return self.__str__() | ||||
|  | ||||
|  | ||||
| @ -2,9 +2,8 @@ | ||||
| import base64 | ||||
| import binascii | ||||
| import json | ||||
| import time | ||||
| from dataclasses import asdict, dataclass, field | ||||
| from datetime import datetime | ||||
| from datetime import datetime, timedelta | ||||
| from hashlib import sha256 | ||||
| from typing import Any, Optional | ||||
| 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 django.db import models | ||||
| 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 jwt import encode | ||||
| 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.lib.generators import generate_code_fixed_length, generate_id, generate_key | ||||
| 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.constants import ACR_AUTHENTIK_DEFAULT | ||||
| from authentik.sources.oauth.models import OAuthSource | ||||
| @ -237,14 +236,18 @@ class OAuth2Provider(Provider): | ||||
|     ) | ||||
|  | ||||
|     def create_refresh_token( | ||||
|         self, user: User, scope: list[str], request: HttpRequest | ||||
|         self, | ||||
|         user: User, | ||||
|         scope: list[str], | ||||
|         request: HttpRequest, | ||||
|         expiry: timedelta, | ||||
|     ) -> "RefreshToken": | ||||
|         """Create and populate a RefreshToken object.""" | ||||
|         token = RefreshToken( | ||||
|             user=user, | ||||
|             provider=self, | ||||
|             refresh_token=base64.urlsafe_b64encode(generate_key().encode()).decode(), | ||||
|             expires=timezone.now() + timedelta_from_string(self.token_validity), | ||||
|             expires=timezone.now() + expiry, | ||||
|             scope=scope, | ||||
|         ) | ||||
|         token.access_token = token.create_access_token(user, request) | ||||
| @ -484,18 +487,21 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): | ||||
|             ) | ||||
|  | ||||
|         # Convert datetimes into timestamps. | ||||
|         now = int(time.time()) | ||||
|         iat_time = now | ||||
|         exp_time = int(dateformat.format(self.expires, "U")) | ||||
|         now = datetime.now() | ||||
|         iat_time = int(now.timestamp()) | ||||
|         exp_time = int(self.expires.timestamp()) | ||||
|         # 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( | ||||
|             "-created" | ||||
|         auth_event = ( | ||||
|             Event.objects.filter(action=EventAction.LOGIN, user=get_user(user)) | ||||
|             .order_by("-created") | ||||
|             .first() | ||||
|         ) | ||||
|         # Fallback in case we can't find any login events | ||||
|         auth_time = datetime.now() | ||||
|         if auth_events.exists(): | ||||
|             auth_time = auth_events.first().created | ||||
|         auth_time = int(dateformat.format(auth_time, "U")) | ||||
|         auth_time = now | ||||
|         if auth_event: | ||||
|             auth_time = auth_event.created | ||||
|  | ||||
|         auth_timestamp = int(auth_time.timestamp()) | ||||
|  | ||||
|         token = IDToken( | ||||
|             iss=self.provider.get_issuer(request), | ||||
| @ -503,7 +509,7 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): | ||||
|             aud=self.provider.client_id, | ||||
|             exp=exp_time, | ||||
|             iat=iat_time, | ||||
|             auth_time=auth_time, | ||||
|             auth_time=auth_timestamp, | ||||
|         ) | ||||
|  | ||||
|         # Include (or not) user standard claims in the id_token. | ||||
|  | ||||
| @ -1,11 +1,13 @@ | ||||
| """Test authorize view""" | ||||
| from django.test import RequestFactory | ||||
| from django.urls import reverse | ||||
| from django.utils.timezone import now | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||
| from authentik.flows.challenge import ChallengeTypes | ||||
| 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.models import ( | ||||
|     AuthorizationCode, | ||||
| @ -250,6 +252,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|             client_id="test", | ||||
|             authorization_flow=flow, | ||||
|             redirect_uris="foo://localhost", | ||||
|             access_code_validity="seconds=100", | ||||
|         ) | ||||
|         Application.objects.create(name="app", slug="app", provider=provider) | ||||
|         state = generate_id() | ||||
| @ -277,6 +280,11 @@ class TestAuthorize(OAuthTestCase): | ||||
|                 "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): | ||||
|         """Test full authorization""" | ||||
| @ -288,6 +296,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|             authorization_flow=flow, | ||||
|             redirect_uris="http://localhost", | ||||
|             signing_key=self.keypair, | ||||
|             access_code_validity="seconds=100", | ||||
|         ) | ||||
|         Application.objects.create(name="app", slug="app", provider=provider) | ||||
|         state = generate_id() | ||||
| @ -308,6 +317,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         token: RefreshToken = RefreshToken.objects.filter(user=user).first() | ||||
|         expires = timedelta_from_string(provider.access_code_validity).total_seconds() | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
| @ -316,11 +326,16 @@ class TestAuthorize(OAuthTestCase): | ||||
|                 "to": ( | ||||
|                     f"http://localhost#access_token={token.access_token}" | ||||
|                     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): | ||||
|         """Test full authorization (form_post response)""" | ||||
|  | ||||
| @ -1,4 +1,6 @@ | ||||
| """OAuth test helpers""" | ||||
| from typing import Any | ||||
|  | ||||
| from django.test import TestCase | ||||
| from jwt import decode | ||||
|  | ||||
| @ -25,7 +27,7 @@ class OAuthTestCase(TestCase): | ||||
|         cls.keypair = create_test_cert() | ||||
|         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""" | ||||
|         key, alg = provider.get_jwt_key() | ||||
|         if alg != JWTAlgorithms.HS256: | ||||
| @ -40,3 +42,4 @@ class OAuthTestCase(TestCase): | ||||
|         for key in self.required_jwt_keys: | ||||
|             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") | ||||
|         return jwt | ||||
|  | ||||
| @ -261,7 +261,7 @@ class OAuthAuthorizationParams: | ||||
|             code.code_challenge = self.code_challenge | ||||
|             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.nonce = self.nonce | ||||
|         code.is_open_id = SCOPE_OPENID in self.scope | ||||
| @ -525,6 +525,7 @@ class OAuthFulfillmentStage(StageView): | ||||
|             user=self.request.user, | ||||
|             scope=self.params.scope, | ||||
|             request=self.request, | ||||
|             expiry=timedelta_from_string(self.provider.access_code_validity), | ||||
|         ) | ||||
|  | ||||
|         # Check if response_type must include access_token in the response. | ||||
|  | ||||
| @ -443,6 +443,7 @@ class TokenView(View): | ||||
|             user=self.params.authorization_code.user, | ||||
|             scope=self.params.authorization_code.scope, | ||||
|             request=self.request, | ||||
|             expiry=timedelta_from_string(self.provider.token_validity), | ||||
|         ) | ||||
|  | ||||
|         if self.params.authorization_code.is_open_id: | ||||
| @ -478,6 +479,7 @@ class TokenView(View): | ||||
|             user=self.params.refresh_token.user, | ||||
|             scope=self.params.scope, | ||||
|             request=self.request, | ||||
|             expiry=timedelta_from_string(self.provider.token_validity), | ||||
|         ) | ||||
|  | ||||
|         # If the Token has an id_token it's an Authentication request. | ||||
| @ -509,6 +511,7 @@ class TokenView(View): | ||||
|             user=self.params.user, | ||||
|             scope=self.params.scope, | ||||
|             request=self.request, | ||||
|             expiry=timedelta_from_string(self.provider.token_validity), | ||||
|         ) | ||||
|         refresh_token.id_token = refresh_token.create_id_token( | ||||
|             user=self.params.user, | ||||
| @ -535,6 +538,7 @@ class TokenView(View): | ||||
|             user=self.params.device_code.user, | ||||
|             scope=self.params.device_code.scope, | ||||
|             request=self.request, | ||||
|             expiry=timedelta_from_string(self.provider.token_validity), | ||||
|         ) | ||||
|         refresh_token.id_token = refresh_token.create_id_token( | ||||
|             user=self.params.device_code.user, | ||||
|  | ||||
| @ -159,9 +159,15 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]): | ||||
|                 hosts=tls_hosts, | ||||
|                 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( | ||||
|             metadata=meta, | ||||
|             spec=V1IngressSpec(rules=rules, tls=[tls_config]), | ||||
|             spec=spec, | ||||
|         ) | ||||
|  | ||||
|     def create(self, reference: V1Ingress): | ||||
|  | ||||
| @ -2,6 +2,8 @@ | ||||
| from channels.generic.websocket import JsonWebsocketConsumer | ||||
| from django.core.cache import cache | ||||
|  | ||||
| from authentik.root.messages.storage import CACHE_PREFIX | ||||
|  | ||||
|  | ||||
| class MessageConsumer(JsonWebsocketConsumer): | ||||
|     """Consumer which sends django.contrib.messages Messages over WS. | ||||
| @ -12,11 +14,13 @@ class MessageConsumer(JsonWebsocketConsumer): | ||||
|     def connect(self): | ||||
|         self.accept() | ||||
|         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 | ||||
|     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): | ||||
|         """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 | ||||
|  | ||||
| SESSION_KEY = "_messages" | ||||
| CACHE_PREFIX = "goauthentik.io/root/messages_" | ||||
|  | ||||
|  | ||||
| class ChannelsStorage(SessionStorage): | ||||
| @ -18,7 +19,7 @@ class ChannelsStorage(SessionStorage): | ||||
|         self.channel = get_channel_layer() | ||||
|  | ||||
|     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}*") | ||||
|         # if no active connections are open, fallback to storing messages in the | ||||
|         # session, so they can always be retrieved | ||||
|  | ||||
| @ -134,7 +134,7 @@ SPECTACULAR_SETTINGS = { | ||||
|     }, | ||||
|     "AUTHENTICATION_WHITELIST": ["authentik.api.authentication.TokenAuthentication"], | ||||
|     "LICENSE": { | ||||
|         "name": "GNU GPLv3", | ||||
|         "name": "MIT", | ||||
|         "url": "https://github.com/goauthentik/authentik/blob/main/LICENSE", | ||||
|     }, | ||||
|     "ENUM_NAME_OVERRIDES": { | ||||
| @ -145,6 +145,7 @@ SPECTACULAR_SETTINGS = { | ||||
|         "ProxyMode": "authentik.providers.proxy.models.ProxyMode", | ||||
|         "PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes", | ||||
|         "LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode", | ||||
|         "UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification", | ||||
|     }, | ||||
|     "ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False, | ||||
|     "POSTPROCESSING_HOOKS": [ | ||||
| @ -195,9 +196,10 @@ _redis_url = ( | ||||
| CACHES = { | ||||
|     "default": { | ||||
|         "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)), | ||||
|         "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, | ||||
|         "KEY_PREFIX": "authentik_cache", | ||||
|     } | ||||
| } | ||||
| DJANGO_REDIS_SCAN_ITERSIZE = 1000 | ||||
| @ -255,7 +257,8 @@ CHANNEL_LAYERS = { | ||||
|     "default": { | ||||
|         "BACKEND": "channels_redis.core.RedisChannelLayer", | ||||
|         "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_DEFAULT_QUEUE = "authentik" | ||||
| CELERY_BROKER_URL = ( | ||||
|     f"{_redis_url}/{CONFIG.y('redis.message_queue_db')}{REDIS_CELERY_TLS_REQUIREMENTS}" | ||||
| ) | ||||
| CELERY_RESULT_BACKEND = ( | ||||
|     f"{_redis_url}/{CONFIG.y('redis.message_queue_db')}{REDIS_CELERY_TLS_REQUIREMENTS}" | ||||
| ) | ||||
| CELERY_BROKER_URL = f"{_redis_url}/{CONFIG.y('redis.db')}{REDIS_CELERY_TLS_REQUIREMENTS}" | ||||
| CELERY_RESULT_BACKEND = f"{_redis_url}/{CONFIG.y('redis.db')}{REDIS_CELERY_TLS_REQUIREMENTS}" | ||||
|  | ||||
| # Sentry integration | ||||
| env = get_env() | ||||
|  | ||||
| @ -166,7 +166,7 @@ class LDAPPropertyMapping(PropertyMapping): | ||||
|         return LDAPPropertyMappingSerializer | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|         return str(self.name) | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|  | ||||
| @ -35,6 +35,7 @@ class OAuthSourceSerializer(SourceSerializer): | ||||
|  | ||||
|     provider_type = ChoiceField(choices=registry.get_name_tuple()) | ||||
|     callback_url = SerializerMethodField() | ||||
|     type = SerializerMethodField() | ||||
|  | ||||
|     def get_callback_url(self, instance: OAuthSource) -> str: | ||||
|         """Get OAuth Callback URL""" | ||||
| @ -46,8 +47,6 @@ class OAuthSourceSerializer(SourceSerializer): | ||||
|             return relative_url | ||||
|         return self.context["request"].build_absolute_uri(relative_url) | ||||
|  | ||||
|     type = SerializerMethodField() | ||||
|  | ||||
|     @extend_schema_field(SourceTypeSerializer) | ||||
|     def get_type(self, instance: OAuthSource) -> SourceTypeSerializer: | ||||
|         """Get source's type configuration""" | ||||
|  | ||||
| @ -75,15 +75,20 @@ class OAuthSource(Source): | ||||
|     def ui_login_button(self, request: HttpRequest) -> UILoginButton: | ||||
|         provider_type = self.type | ||||
|         provider = provider_type() | ||||
|         icon = self.get_icon | ||||
|         if not icon: | ||||
|             icon = provider.icon_url() | ||||
|         return UILoginButton( | ||||
|             name=self.name, | ||||
|             icon_url=provider.icon_url(), | ||||
|             challenge=provider.login_challenge(self, request), | ||||
|             icon_url=icon, | ||||
|         ) | ||||
|  | ||||
|     def ui_user_settings(self) -> Optional[UserSettingSerializer]: | ||||
|         provider_type = self.type | ||||
|         provider = provider_type() | ||||
|         icon = self.get_icon | ||||
|         if not icon: | ||||
|             icon = provider_type().icon_url() | ||||
|         return UserSettingSerializer( | ||||
|             data={ | ||||
|                 "title": self.name, | ||||
| @ -92,7 +97,7 @@ class OAuthSource(Source): | ||||
|                     "authentik_sources_oauth:oauth-client-login", | ||||
|                     kwargs={"source_slug": self.slug}, | ||||
|                 ), | ||||
|                 "icon_url": provider.icon_url(), | ||||
|                 "icon_url": icon, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @ -64,6 +64,9 @@ class PlexSource(Source): | ||||
|         return PlexSourceSerializer | ||||
|  | ||||
|     def ui_login_button(self, request: HttpRequest) -> UILoginButton: | ||||
|         icon = self.get_icon | ||||
|         if not icon: | ||||
|             icon = static("authentik/sources/plex.svg") | ||||
|         return UILoginButton( | ||||
|             challenge=PlexAuthenticationChallenge( | ||||
|                 { | ||||
| @ -73,17 +76,20 @@ class PlexSource(Source): | ||||
|                     "slug": self.slug, | ||||
|                 } | ||||
|             ), | ||||
|             icon_url=static("authentik/sources/plex.svg"), | ||||
|             icon_url=icon, | ||||
|             name=self.name, | ||||
|         ) | ||||
|  | ||||
|     def ui_user_settings(self) -> Optional[UserSettingSerializer]: | ||||
|         icon = self.get_icon | ||||
|         if not icon: | ||||
|             icon = static("authentik/sources/plex.svg") | ||||
|         return UserSettingSerializer( | ||||
|             data={ | ||||
|                 "title": self.name, | ||||
|                 "component": "ak-user-settings-source-plex", | ||||
|                 "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() | ||||
|     serializer_class = SAMLSourceSerializer | ||||
|     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"] | ||||
|     ordering = ["name"] | ||||
|  | ||||
|  | ||||
| @ -191,9 +191,13 @@ class SAMLSource(Source): | ||||
|                 } | ||||
|             ), | ||||
|             name=self.name, | ||||
|             icon_url=self.get_icon, | ||||
|         ) | ||||
|  | ||||
|     def ui_user_settings(self) -> Optional[UserSettingSerializer]: | ||||
|         icon = self.get_icon | ||||
|         if not icon: | ||||
|             icon = static(f"authentik/sources/{self.slug}.svg") | ||||
|         return UserSettingSerializer( | ||||
|             data={ | ||||
|                 "title": self.name, | ||||
| @ -202,7 +206,7 @@ class SAMLSource(Source): | ||||
|                     "authentik_sources_saml:login", | ||||
|                     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""" | ||||
|         attributes = {} | ||||
|         assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion") | ||||
|         if not assertion: | ||||
|         if assertion is None: | ||||
|             raise ValueError("Assertion element not found") | ||||
|         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") | ||||
|         # Get all attributes and their values into a dict | ||||
|         for attribute in attribute_statement.iterchildren(): | ||||
| @ -216,6 +216,7 @@ class ResponseProcessor: | ||||
|         # Flatten all lists in the dict | ||||
|         for key, value in attributes.items(): | ||||
|             attributes[key] = BaseEvaluator.expr_flatten(value) | ||||
|         attributes["username"] = self._get_name_id().text | ||||
|         return attributes | ||||
|  | ||||
|     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:Issuer>https://accounts.google.com/o/saml2?idpid=</saml2:Issuer> | ||||
|         <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: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> | ||||
| @ -109,4 +109,7 @@ class TestResponseProcessor(TestCase): | ||||
|         parser = ResponseProcessor(self.source, request) | ||||
|         parser.parse() | ||||
|         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() | ||||
|         ) | ||||
|         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( | ||||
|             duo_user_id=request.data.get("duo_user_id"), user=user, stage=stage | ||||
|         ).first() | ||||
|         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( | ||||
|             duo_user_id=request.data.get("duo_user_id"), | ||||
|             user=user, | ||||
|  | ||||
| @ -99,7 +99,7 @@ class DuoDevice(SerializerModel, Device): | ||||
|         return DuoDeviceSerializer | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name or str(self.user) | ||||
|         return str(self.name) or str(self.user) | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|  | ||||
| @ -216,7 +216,7 @@ class SMSDevice(SerializerModel, SideChannelDevice): | ||||
|         return valid | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name or str(self.user) | ||||
|         return str(self.name) or str(self.user) | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("SMS Device") | ||||
|  | ||||
| @ -7,6 +7,9 @@ from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUse | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.stages.authenticator_static.models import AuthenticatorStaticStage | ||||
|  | ||||
| SESSION_STATIC_DEVICE = "static_device" | ||||
| SESSION_STATIC_TOKENS = "static_device_tokens" | ||||
|  | ||||
|  | ||||
| class AuthenticatorStaticChallenge(WithUserInfoChallenge): | ||||
|     """Static authenticator challenge""" | ||||
| @ -27,8 +30,7 @@ class AuthenticatorStaticStageView(ChallengeStageView): | ||||
|     response_class = AuthenticatorStaticChallengeResponse | ||||
|  | ||||
|     def get_challenge(self, *args, **kwargs) -> AuthenticatorStaticChallenge: | ||||
|         user = self.get_pending_user() | ||||
|         tokens: list[StaticToken] = StaticToken.objects.filter(device__user=user) | ||||
|         tokens: list[StaticToken] = self.request.session[SESSION_STATIC_TOKENS] | ||||
|         return AuthenticatorStaticChallenge( | ||||
|             data={ | ||||
|                 "type": ChallengeTypes.NATIVE.value, | ||||
| @ -44,25 +46,22 @@ class AuthenticatorStaticStageView(ChallengeStageView): | ||||
|  | ||||
|         stage: AuthenticatorStaticStage = self.executor.current_stage | ||||
|  | ||||
|         devices = StaticDevice.objects.filter(user=user) | ||||
|         # Currently, this stage only supports one device per user. If the user already | ||||
|         # has a device, just skip to the next stage | ||||
|         if devices.exists(): | ||||
|             if not any(x.confirmed for x in devices): | ||||
|                 return super().get(request, *args, **kwargs) | ||||
|             return self.executor.stage_ok() | ||||
|  | ||||
|         device = StaticDevice.objects.create(user=user, confirmed=False, name="Static Token") | ||||
|         if SESSION_STATIC_DEVICE not in self.request.session: | ||||
|             device = StaticDevice(user=user, confirmed=False, name="Static Token") | ||||
|             tokens = [] | ||||
|             for _ in range(0, stage.token_count): | ||||
|             StaticToken.objects.create(device=device, token=StaticToken.random_token()) | ||||
|                 tokens.append(StaticToken(device=device, token=StaticToken.random_token())) | ||||
|             self.request.session[SESSION_STATIC_DEVICE] = device | ||||
|             self.request.session[SESSION_STATIC_TOKENS] = tokens | ||||
|         return super().get(request, *args, **kwargs) | ||||
|  | ||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||
|         """Verify OTP Token""" | ||||
|         user = self.get_pending_user() | ||||
|         device: StaticDevice = StaticDevice.objects.filter(user=user).first() | ||||
|         if not device: | ||||
|             return self.executor.stage_invalid() | ||||
|         device: StaticDevice = self.request.session[SESSION_STATIC_DEVICE] | ||||
|         device.confirmed = True | ||||
|         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() | ||||
|  | ||||
| @ -17,6 +17,8 @@ from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage | ||||
| from authentik.stages.authenticator_totp.settings import OTP_TOTP_ISSUER | ||||
|  | ||||
| SESSION_TOTP_DEVICE = "totp_device" | ||||
|  | ||||
|  | ||||
| class AuthenticatorTOTPChallenge(WithUserInfoChallenge): | ||||
|     """TOTP Setup challenge""" | ||||
| @ -49,8 +51,7 @@ class AuthenticatorTOTPStageView(ChallengeStageView): | ||||
|     response_class = AuthenticatorTOTPChallengeResponse | ||||
|  | ||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         user = self.get_pending_user() | ||||
|         device: TOTPDevice = TOTPDevice.objects.filter(user=user).first() | ||||
|         device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE] | ||||
|         return AuthenticatorTOTPChallenge( | ||||
|             data={ | ||||
|                 "type": ChallengeTypes.NATIVE.value, | ||||
| @ -62,8 +63,7 @@ class AuthenticatorTOTPStageView(ChallengeStageView): | ||||
|  | ||||
|     def get_response_instance(self, data: QueryDict) -> ChallengeResponse: | ||||
|         response = super().get_response_instance(data) | ||||
|         user = self.get_pending_user() | ||||
|         response.device = TOTPDevice.objects.filter(user=user).first() | ||||
|         response.device = self.request.session.get(SESSION_TOTP_DEVICE) | ||||
|         return response | ||||
|  | ||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||
| @ -74,17 +74,18 @@ class AuthenticatorTOTPStageView(ChallengeStageView): | ||||
|  | ||||
|         stage: AuthenticatorTOTPStage = self.executor.current_stage | ||||
|  | ||||
|         TOTPDevice.objects.create( | ||||
|         if SESSION_TOTP_DEVICE not in self.request.session: | ||||
|             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) | ||||
|  | ||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||
|         """TOTP Token is validated by challenge""" | ||||
|         user = self.get_pending_user() | ||||
|         device: TOTPDevice = TOTPDevice.objects.filter(user=user).first() | ||||
|         if not device: | ||||
|             return self.executor.stage_invalid() | ||||
|         device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE] | ||||
|         device.confirmed = True | ||||
|         device.save() | ||||
|         del self.request.session[SESSION_TOTP_DEVICE] | ||||
|         return self.executor.stage_ok() | ||||
|  | ||||
| @ -31,6 +31,7 @@ class AuthenticatorValidateStageSerializer(StageSerializer): | ||||
|             "device_classes", | ||||
|             "configuration_stages", | ||||
|             "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.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice | ||||
| from authentik.stages.authenticator_sms.models import SMSDevice | ||||
| from authentik.stages.authenticator_validate.models import DeviceClasses | ||||
| from authentik.stages.authenticator_webauthn.models import WebAuthnDevice | ||||
| from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses | ||||
| 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.utils import get_origin, get_rp_id | ||||
| from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TITLE | ||||
| @ -46,29 +46,35 @@ class DeviceChallenge(PassiveSerializer): | ||||
|     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""" | ||||
|     if isinstance(device, WebAuthnDevice): | ||||
|         return get_webauthn_challenge(request, device) | ||||
|         return get_webauthn_challenge(request, stage, device) | ||||
|     # Code-based challenges have no hints | ||||
|     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 | ||||
|     who the device belongs to.""" | ||||
|     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) | ||||
|     authentication_options = generate_authentication_options( | ||||
|         rp_id=get_rp_id(request), | ||||
|         allow_credentials=[], | ||||
|         user_verification=stage.webauthn_user_verification, | ||||
|     ) | ||||
|  | ||||
|     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge | ||||
|  | ||||
|     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""" | ||||
|     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( | ||||
|         rp_id=get_rp_id(request), | ||||
|         allow_credentials=allowed_credentials, | ||||
|         user_verification=stage.webauthn_user_verification, | ||||
|     ) | ||||
|  | ||||
|     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: | ||||
|         raise ValidationError("Invalid device") | ||||
|  | ||||
|     stage: AuthenticatorValidateStage = stage_view.executor.current_stage | ||||
|  | ||||
|     try: | ||||
|         authentication_verification = verify_authentication_response( | ||||
|             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), | ||||
|             credential_public_key=base64url_to_bytes(device.public_key), | ||||
|             credential_current_sign_count=device.sign_count, | ||||
|             require_user_verification=False, | ||||
|             require_user_verification=stage.webauthn_user_verification == UserVerification.REQUIRED, | ||||
|         ) | ||||
|     except InvalidAuthenticationResponse as 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.lib.utils.time import timedelta_string_validator | ||||
| from authentik.stages.authenticator_webauthn.models import UserVerification | ||||
|  | ||||
|  | ||||
| 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 | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer | ||||
|  | ||||
| @ -177,7 +177,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | ||||
|                 data={ | ||||
|                     "device_class": device_class, | ||||
|                     "device_uid": device.pk, | ||||
|                     "challenge": get_challenge_for_device(self.request, device), | ||||
|                     "challenge": get_challenge_for_device(self.request, stage, device), | ||||
|                 } | ||||
|             ) | ||||
|             challenge.is_valid() | ||||
| @ -194,7 +194,10 @@ class AuthenticatorValidateStageView(ChallengeStageView): | ||||
|             data={ | ||||
|                 "device_class": DeviceClasses.WEBAUTHN, | ||||
|                 "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() | ||||
|  | ||||
| @ -45,7 +45,6 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase): | ||||
|         ) | ||||
|         with patch( | ||||
|             "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.auth_client", | ||||
|             MagicMock( | ||||
|             MagicMock( | ||||
|                 return_value=MagicMock( | ||||
|                     auth=MagicMock( | ||||
| @ -56,7 +55,6 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase): | ||||
|                         } | ||||
|                     ) | ||||
|                 ) | ||||
|                 ) | ||||
|             ), | ||||
|         ): | ||||
|             self.assertEqual( | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| """Test validator stage""" | ||||
| from datetime import datetime, timedelta | ||||
| from hashlib import sha256 | ||||
| from http.cookies import SimpleCookie | ||||
| from time import sleep | ||||
|  | ||||
| from django.conf import settings | ||||
| @ -76,7 +75,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): | ||||
|             component="ak-stage-authenticator-validate", | ||||
|         ) | ||||
|  | ||||
|     def test_last_auth_threshold_valid(self) -> SimpleCookie: | ||||
|     def test_last_auth_threshold_valid(self): | ||||
|         """Test last_auth_threshold""" | ||||
|         ident_stage = IdentificationStage.objects.create( | ||||
|             name=generate_id(), | ||||
| @ -115,12 +114,47 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): | ||||
|         ) | ||||
|         self.assertIn(COOKIE_NAME_MFA, response.cookies) | ||||
|         self.assertStageResponse(response, component="xak-flow-redirect", to="/") | ||||
|         return response.cookies | ||||
|  | ||||
|     def test_last_auth_skip(self): | ||||
|         """Test valid cookie""" | ||||
|         cookies = self.test_last_auth_threshold_valid() | ||||
|         mfa_cookie = cookies[COOKIE_NAME_MFA] | ||||
|         ident_stage = IdentificationStage.objects.create( | ||||
|             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.cookies[COOKIE_NAME_MFA] = mfa_cookie | ||||
|         response = self.client.post( | ||||
| @ -260,7 +294,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): | ||||
|             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||
|             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): | ||||
|             validate_challenge_code( | ||||
|                 "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.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.identification.models import IdentificationStage, UserFields | ||||
|  | ||||
| @ -90,8 +90,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             last_auth_threshold="milliseconds=0", | ||||
|             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||
|             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"] | ||||
|         self.assertEqual( | ||||
|             challenge, | ||||
| @ -118,6 +119,13 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|         request = get_request("/") | ||||
|         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( | ||||
|             user=self.user, | ||||
|             public_key=( | ||||
| @ -128,7 +136,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             sign_count=0, | ||||
|             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] | ||||
|         self.assertEqual( | ||||
|             challenge, | ||||
| @ -149,7 +157,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|     def test_get_challenge_userless(self): | ||||
|         """Test webauthn (userless)""" | ||||
|         request = get_request("/") | ||||
|  | ||||
|         stage = AuthenticatorValidateStage.objects.create( | ||||
|             name=generate_id(), | ||||
|         ) | ||||
|         WebAuthnDevice.objects.create( | ||||
|             user=self.user, | ||||
|             public_key=( | ||||
| @ -160,7 +170,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             sign_count=0, | ||||
|             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] | ||||
|         self.assertEqual( | ||||
|             challenge, | ||||
|  | ||||
| @ -146,7 +146,7 @@ class WebAuthnDevice(SerializerModel, Device): | ||||
|         return WebAuthnDeviceSerializer | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name or str(self.user) | ||||
|         return str(self.name) or str(self.user) | ||||
|  | ||||
|     class Meta: | ||||
|  | ||||
|  | ||||
| @ -28,8 +28,8 @@ class Command(BaseCommand): | ||||
|             delete_stage = True | ||||
|         message = TemplateEmailMessage( | ||||
|             subject="authentik Test-Email", | ||||
|             template_name="email/setup.html", | ||||
|             to=[options["to"]], | ||||
|             template_name="email/setup.html", | ||||
|             template_context={}, | ||||
|         ) | ||||
|         try: | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	