Compare commits
	
		
			124 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | 
@ -1,5 +1,5 @@
 | 
			
		||||
[bumpversion]
 | 
			
		||||
current_version = 2022.10.1
 | 
			
		||||
current_version = 2022.11.0
 | 
			
		||||
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:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										15
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							@ -20,15 +20,20 @@
 | 
			
		||||
    "todo-tree.tree.showCountsInTree": true,
 | 
			
		||||
    "todo-tree.tree.showBadges": true,
 | 
			
		||||
    "python.formatting.provider": "black",
 | 
			
		||||
    "yaml.customTags": [
 | 
			
		||||
        "!Find sequence",
 | 
			
		||||
        "!KeyOf scalar"
 | 
			
		||||
    ],
 | 
			
		||||
    "yaml.customTags": ["!Find sequence", "!KeyOf scalar"],
 | 
			
		||||
    "typescript.preferences.importModuleSpecifier": "non-relative",
 | 
			
		||||
    "typescript.preferences.importModuleSpecifierEnding": "index",
 | 
			
		||||
    "typescript.tsdk": "./web/node_modules/typescript/lib",
 | 
			
		||||
    "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.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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/)
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
from os import environ
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
__version__ = "2022.10.1"
 | 
			
		||||
__version__ = "2022.11.0"
 | 
			
		||||
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)),
 | 
			
		||||
 | 
			
		||||
@ -85,11 +85,11 @@ class Importer:
 | 
			
		||||
            self.__import = from_dict(Blueprint, import_dict)
 | 
			
		||||
        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:
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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/"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -297,7 +297,7 @@ class Provider(SerializerModel):
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
        return str(self.name)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Application(SerializerModel, PolicyBindingModel):
 | 
			
		||||
@ -379,7 +379,7 @@ class Application(SerializerModel, PolicyBindingModel):
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
        return str(self.name)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
@ -421,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",
 | 
			
		||||
@ -454,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:
 | 
			
		||||
@ -481,7 +497,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
        return str(self.name)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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:
 | 
			
		||||
@ -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,
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
@ -111,5 +111,5 @@ class TestResponseProcessor(TestCase):
 | 
			
		||||
        sfm = parser.prepare_flow_manager()
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            sfm.enroll_info,
 | 
			
		||||
            {"email": "foo@bar.baz", "name": "foo", "sn": "bar", "username": "jens@beryju.org"},
 | 
			
		||||
            {"email": "foo@bar.baz", "name": "foo", "sn": "bar", "username": "jens@goauthentik.io"},
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -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")
 | 
			
		||||
        for _ in range(0, stage.token_count):
 | 
			
		||||
            StaticToken.objects.create(device=device, token=StaticToken.random_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):
 | 
			
		||||
                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(
 | 
			
		||||
            user=user, confirmed=False, digits=stage.digits, name="TOTP Authenticator"
 | 
			
		||||
        )
 | 
			
		||||
        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()
 | 
			
		||||
 | 
			
		||||
@ -46,15 +46,13 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase):
 | 
			
		||||
        with patch(
 | 
			
		||||
            "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.auth_client",
 | 
			
		||||
            MagicMock(
 | 
			
		||||
                MagicMock(
 | 
			
		||||
                    return_value=MagicMock(
 | 
			
		||||
                        auth=MagicMock(
 | 
			
		||||
                            return_value={
 | 
			
		||||
                                "result": "allow",
 | 
			
		||||
                                "status": "allow",
 | 
			
		||||
                                "status_msg": "Success. Logging you in...",
 | 
			
		||||
                            }
 | 
			
		||||
                        )
 | 
			
		||||
                return_value=MagicMock(
 | 
			
		||||
                    auth=MagicMock(
 | 
			
		||||
                        return_value={
 | 
			
		||||
                            "result": "allow",
 | 
			
		||||
                            "status": "allow",
 | 
			
		||||
                            "status_msg": "Success. Logging you in...",
 | 
			
		||||
                        }
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
            ),
 | 
			
		||||
 | 
			
		||||
@ -260,7 +260,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:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,9 +3,7 @@ from typing import Optional
 | 
			
		||||
 | 
			
		||||
from deepmerge import always_merger
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django.http.response import HttpResponseBadRequest
 | 
			
		||||
 | 
			
		||||
from authentik.flows.models import in_memory_stage
 | 
			
		||||
from authentik.flows.stage import StageView
 | 
			
		||||
from authentik.flows.views.executor import SESSION_KEY_GET
 | 
			
		||||
from authentik.stages.invitation.models import Invitation, InvitationStage
 | 
			
		||||
@ -63,27 +61,6 @@ class InvitationStageView(StageView):
 | 
			
		||||
 | 
			
		||||
        invitation_used.send(sender=self, request=request, invitation=invite)
 | 
			
		||||
        if invite.single_use:
 | 
			
		||||
            self.executor.plan.append_stage(in_memory_stage(InvitationFinalStageView))
 | 
			
		||||
        return self.executor.stage_ok()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InvitationFinalStageView(StageView):
 | 
			
		||||
    """Final stage which is injected by invitation stage. Deletes
 | 
			
		||||
    the used invitation."""
 | 
			
		||||
 | 
			
		||||
    def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Call get as this request may be called with post"""
 | 
			
		||||
        return self.get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Delete invitation if single_use is active"""
 | 
			
		||||
        invitation: Invitation = self.executor.plan.context.get(INVITATION, None)
 | 
			
		||||
        if not invitation:
 | 
			
		||||
            self.logger.warning("InvitationFinalStageView stage called without invitation")
 | 
			
		||||
            return HttpResponseBadRequest
 | 
			
		||||
        token = str(invitation.invite_uuid)
 | 
			
		||||
        if invitation.single_use:
 | 
			
		||||
            invitation.delete()
 | 
			
		||||
            self.logger.debug("Deleted invitation", token=token)
 | 
			
		||||
        del self.executor.plan.context[INVITATION]
 | 
			
		||||
            invite.delete()
 | 
			
		||||
            self.logger.debug("Deleted invitation", token=str(invite.invite_uuid))
 | 
			
		||||
        return self.executor.stage_ok()
 | 
			
		||||
 | 
			
		||||
@ -38,7 +38,7 @@ func main() {
 | 
			
		||||
 | 
			
		||||
	if config.Get().ErrorReporting.Enabled {
 | 
			
		||||
		err := sentry.Init(sentry.ClientOptions{
 | 
			
		||||
			Dsn:              config.Get().ErrorReporting.DSN,
 | 
			
		||||
			Dsn:              config.Get().ErrorReporting.SentryDSN,
 | 
			
		||||
			AttachStacktrace: true,
 | 
			
		||||
			TracesSampler:    sentryutils.SamplerFunc(config.Get().ErrorReporting.SampleRate),
 | 
			
		||||
			Release:          fmt.Sprintf("authentik@%s", constants.VERSION),
 | 
			
		||||
 | 
			
		||||
@ -32,7 +32,7 @@ services:
 | 
			
		||||
    volumes:
 | 
			
		||||
      - redis:/data
 | 
			
		||||
  server:
 | 
			
		||||
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.10.1}
 | 
			
		||||
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.11.0}
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    command: server
 | 
			
		||||
    environment:
 | 
			
		||||
@ -52,7 +52,7 @@ services:
 | 
			
		||||
      - "0.0.0.0:${AUTHENTIK_PORT_HTTP:-9000}:9000"
 | 
			
		||||
      - "0.0.0.0:${AUTHENTIK_PORT_HTTPS:-9443}:9443"
 | 
			
		||||
  worker:
 | 
			
		||||
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.10.1}
 | 
			
		||||
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.11.0}
 | 
			
		||||
    restart: unless-stopped
 | 
			
		||||
    command: worker
 | 
			
		||||
    environment:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										8
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								go.mod
									
									
									
									
									
								
							@ -5,7 +5,7 @@ go 1.18
 | 
			
		||||
require (
 | 
			
		||||
	github.com/Netflix/go-env v0.0.0-20210215222557-e437a7e7f9fb
 | 
			
		||||
	github.com/coreos/go-oidc v2.2.1+incompatible
 | 
			
		||||
	github.com/getsentry/sentry-go v0.14.0
 | 
			
		||||
	github.com/getsentry/sentry-go v0.15.0
 | 
			
		||||
	github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
 | 
			
		||||
	github.com/go-ldap/ldap/v3 v3.4.4
 | 
			
		||||
	github.com/go-openapi/runtime v0.24.2
 | 
			
		||||
@ -21,11 +21,11 @@ require (
 | 
			
		||||
	github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
 | 
			
		||||
	github.com/nmcclain/ldap v0.0.0-20210720162743-7f8d1e44eeba
 | 
			
		||||
	github.com/pires/go-proxyproto v0.6.2
 | 
			
		||||
	github.com/prometheus/client_golang v1.13.0
 | 
			
		||||
	github.com/prometheus/client_golang v1.14.0
 | 
			
		||||
	github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b
 | 
			
		||||
	github.com/sirupsen/logrus v1.9.0
 | 
			
		||||
	github.com/stretchr/testify v1.8.1
 | 
			
		||||
	goauthentik.io/api/v3 v3.2022100.1
 | 
			
		||||
	goauthentik.io/api/v3 v3.2022101.8
 | 
			
		||||
	golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b
 | 
			
		||||
	golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f
 | 
			
		||||
	gopkg.in/boj/redistore.v1 v1.0.0-20160128113310-fc113767cd6b
 | 
			
		||||
@ -62,7 +62,7 @@ require (
 | 
			
		||||
	github.com/opentracing/opentracing-go v1.2.0 // indirect
 | 
			
		||||
	github.com/pmezard/go-difflib v1.0.0 // indirect
 | 
			
		||||
	github.com/pquerna/cachecontrol v0.0.0-20201205024021-ac21108117ac // indirect
 | 
			
		||||
	github.com/prometheus/client_model v0.2.0 // indirect
 | 
			
		||||
	github.com/prometheus/client_model v0.3.0 // indirect
 | 
			
		||||
	github.com/prometheus/common v0.37.0 // indirect
 | 
			
		||||
	github.com/prometheus/procfs v0.8.0 // indirect
 | 
			
		||||
	go.mongodb.org/mongo-driver v1.10.0 // indirect
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										15
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								go.sum
									
									
									
									
									
								
							@ -77,8 +77,8 @@ github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8S
 | 
			
		||||
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 | 
			
		||||
github.com/garyburd/redigo v1.6.2 h1:yE/pwKCrbLpLpQICzYTeZ7JsTA/C53wFTJHaEtRqniM=
 | 
			
		||||
github.com/garyburd/redigo v1.6.2/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
 | 
			
		||||
github.com/getsentry/sentry-go v0.14.0 h1:rlOBkuFZRKKdUnKO+0U3JclRDQKlRu5vVQtkWSQvC70=
 | 
			
		||||
github.com/getsentry/sentry-go v0.14.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
 | 
			
		||||
github.com/getsentry/sentry-go v0.15.0 h1:CP9bmA7pralrVUedYZsmIHWpq/pBtXTSew7xvVpfLaA=
 | 
			
		||||
github.com/getsentry/sentry-go v0.15.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
 | 
			
		||||
github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
 | 
			
		||||
github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
 | 
			
		||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
 | 
			
		||||
@ -305,13 +305,14 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn
 | 
			
		||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
 | 
			
		||||
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
 | 
			
		||||
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
 | 
			
		||||
github.com/prometheus/client_golang v1.13.0 h1:b71QUfeo5M8gq2+evJdTPfZhYMAU0uKPkyPJ7TPsloU=
 | 
			
		||||
github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ=
 | 
			
		||||
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
 | 
			
		||||
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
 | 
			
		||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 | 
			
		||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 | 
			
		||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 | 
			
		||||
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
 | 
			
		||||
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 | 
			
		||||
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
 | 
			
		||||
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
 | 
			
		||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
 | 
			
		||||
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
 | 
			
		||||
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
 | 
			
		||||
@ -375,8 +376,8 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 | 
			
		||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 | 
			
		||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 | 
			
		||||
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
 | 
			
		||||
goauthentik.io/api/v3 v3.2022100.1 h1:9QmSNmLZmNlIyAWSmG2YpQKtr5LsoDopPsRxwbyzANI=
 | 
			
		||||
goauthentik.io/api/v3 v3.2022100.1/go.mod h1:QM9J32HgYE4gL71lWAfAoXSPdSmLVLW08itfLI3Mo10=
 | 
			
		||||
goauthentik.io/api/v3 v3.2022101.8 h1:s3dzv/4PQrvmmmxZ2Hte96CEmozk4vMHx7PSCzMi4Nw=
 | 
			
		||||
goauthentik.io/api/v3 v3.2022101.8/go.mod h1:QM9J32HgYE4gL71lWAfAoXSPdSmLVLW08itfLI3Mo10=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
 | 
			
		||||
 | 
			
		||||
@ -40,7 +40,6 @@ func defaultConfig() *Config {
 | 
			
		||||
		LogLevel: "info",
 | 
			
		||||
		ErrorReporting: ErrorReportingConfig{
 | 
			
		||||
			Enabled:    false,
 | 
			
		||||
			DSN:        "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8",
 | 
			
		||||
			SampleRate: 1,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
@ -63,11 +62,11 @@ func (c *Config) Setup(paths ...string) {
 | 
			
		||||
func (c *Config) LoadConfig(path string) error {
 | 
			
		||||
	raw, err := os.ReadFile(path)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("Failed to load config file: %w", err)
 | 
			
		||||
		return fmt.Errorf("failed to load config file: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	err = yaml.Unmarshal(raw, c)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("Failed to parse YAML: %w", err)
 | 
			
		||||
		return fmt.Errorf("failed to parse YAML: %w", err)
 | 
			
		||||
	}
 | 
			
		||||
	c.walkScheme(c)
 | 
			
		||||
	log.WithField("path", path).Debug("Loaded config")
 | 
			
		||||
 | 
			
		||||
@ -17,10 +17,7 @@ type RedisConfig struct {
 | 
			
		||||
	Password               string `yaml:"password" env:"AUTHENTIK_REDIS__PASSWORD"`
 | 
			
		||||
	TLS                    bool   `yaml:"tls" env:"AUTHENTIK_REDIS__TLS"`
 | 
			
		||||
	TLSReqs                string `yaml:"tls_reqs" env:"AUTHENTIK_REDIS__TLS_REQS"`
 | 
			
		||||
	CacheDB                int    `yaml:"cache_db" env:"AUTHENTIK_REDIS__CACHE_DB"`
 | 
			
		||||
	MessageQueueDB         int    `yaml:"message_queue_db" env:"AUTHENTIK_REDIS__MESSAGE_QUEUE_DB"`
 | 
			
		||||
	WSDB                   int    `yaml:"ws_db" env:"AUTHENTIK_REDIS__WS_DB"`
 | 
			
		||||
	OutpostSessionDB       int    `yaml:"outpost_session_db" env:"AUTHENTIK_REDIS__OUTPOST_SESSION_DB"`
 | 
			
		||||
	DB                     int    `yaml:"cache_db" env:"AUTHENTIK_REDIS__CACHE_DB"`
 | 
			
		||||
	CacheTimeout           int    `yaml:"cache_timeout" env:"AUTHENTIK_REDIS__CACHE_TIMEOUT"`
 | 
			
		||||
	CacheTimeoutFlows      int    `yaml:"cache_timeout_flows" env:"AUTHENTIK_REDIS__CACHE_TIMEOUT_FLOWS"`
 | 
			
		||||
	CacheTimeoutPolicies   int    `yaml:"cache_timeout_policies" env:"AUTHENTIK_REDIS__CACHE_TIMEOUT_POLICIES"`
 | 
			
		||||
@ -41,10 +38,10 @@ type PathsConfig struct {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ErrorReportingConfig struct {
 | 
			
		||||
	Enabled     bool   `yaml:"enabled" env:"AUTHENTIK_ERROR_REPORTING__ENABLED"`
 | 
			
		||||
	Environment string `yaml:"environment" env:"AUTHENTIK_ERROR_REPORTING__ENVIRONMENT"`
 | 
			
		||||
	SendPII     bool   `yaml:"send_pii" env:"AUTHENTIK_ERROR_REPORTING__SEND_PII"`
 | 
			
		||||
	DSN         string
 | 
			
		||||
	Enabled     bool    `yaml:"enabled" env:"AUTHENTIK_ERROR_REPORTING__ENABLED"`
 | 
			
		||||
	SentryDSN   string  `yaml:"sentry_dsn" env:"AUTHENTIK_ERROR_REPORTING__SENTRY_DSN"`
 | 
			
		||||
	Environment string  `yaml:"environment" env:"AUTHENTIK_ERROR_REPORTING__ENVIRONMENT"`
 | 
			
		||||
	SendPII     bool    `yaml:"send_pii" env:"AUTHENTIK_ERROR_REPORTING__SEND_PII"`
 | 
			
		||||
	SampleRate  float64 `yaml:"sample_rate" env:"AUTHENTIK_ERROR_REPORTING__SAMPLE_RATE"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -29,4 +29,4 @@ func UserAgent() string {
 | 
			
		||||
	return fmt.Sprintf("authentik@%s", FullVersion())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const VERSION = "2022.10.1"
 | 
			
		||||
const VERSION = "2022.11.0"
 | 
			
		||||
 | 
			
		||||
@ -44,12 +44,11 @@ func doGlobalSetup(outpost api.Outpost, globalConfig *api.Config) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if globalConfig.ErrorReporting.Enabled {
 | 
			
		||||
		dsn := "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8"
 | 
			
		||||
		if !initialSetup {
 | 
			
		||||
			l.WithField("env", globalConfig.ErrorReporting.Environment).Debug("Error reporting enabled")
 | 
			
		||||
		}
 | 
			
		||||
		err := sentry.Init(sentry.ClientOptions{
 | 
			
		||||
			Dsn:           dsn,
 | 
			
		||||
			Dsn:           globalConfig.ErrorReporting.SentryDsn,
 | 
			
		||||
			Environment:   globalConfig.ErrorReporting.Environment,
 | 
			
		||||
			TracesSampler: sentryutils.SamplerFunc(float64(globalConfig.ErrorReporting.TracesSampleRate)),
 | 
			
		||||
			Release:       fmt.Sprintf("authentik@%s", constants.VERSION),
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,7 @@ func TestSecret() string {
 | 
			
		||||
 | 
			
		||||
func MockConfig() api.Config {
 | 
			
		||||
	return *api.NewConfig(
 | 
			
		||||
		*api.NewErrorReportingConfig(false, "test", false, 0.0),
 | 
			
		||||
		*api.NewErrorReportingConfig(false, "https://foo.bar/9", "test", false, 0.0),
 | 
			
		||||
		[]api.CapabilitiesEnum{},
 | 
			
		||||
		100,
 | 
			
		||||
		100,
 | 
			
		||||
 | 
			
		||||
@ -16,11 +16,12 @@ import (
 | 
			
		||||
func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL) sessions.Store {
 | 
			
		||||
	var store sessions.Store
 | 
			
		||||
	if config.Get().Redis.Host != "" {
 | 
			
		||||
		rs, err := redistore.NewRediStoreWithDB(10, "tcp", fmt.Sprintf("%s:%d", config.Get().Redis.Host, config.Get().Redis.Port), config.Get().Redis.Password, strconv.Itoa(config.Get().Redis.OutpostSessionDB), []byte(*p.CookieSecret))
 | 
			
		||||
		rs, err := redistore.NewRediStoreWithDB(10, "tcp", fmt.Sprintf("%s:%d", config.Get().Redis.Host, config.Get().Redis.Port), config.Get().Redis.Password, strconv.Itoa(config.Get().Redis.DB), []byte(*p.CookieSecret))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			panic(err)
 | 
			
		||||
		}
 | 
			
		||||
		rs.SetMaxLength(math.MaxInt)
 | 
			
		||||
		rs.SetKeyPrefix("authentik_proxy_session_")
 | 
			
		||||
		if p.TokenValidity.IsSet() {
 | 
			
		||||
			t := p.TokenValidity.Get()
 | 
			
		||||
			// Add one to the validity to ensure we don't have a session with indefinite length
 | 
			
		||||
 | 
			
		||||
@ -3,8 +3,11 @@ package web
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"goauthentik.io/internal/config"
 | 
			
		||||
@ -14,41 +17,62 @@ type SentryRequest struct {
 | 
			
		||||
	DSN string `json:"dsn"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (ws *WebServer) APISentryProxy(rw http.ResponseWriter, r *http.Request) {
 | 
			
		||||
func (ws *WebServer) APISentryProxy() http.HandlerFunc {
 | 
			
		||||
	fallbackHandler := func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		w.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
	}
 | 
			
		||||
	if !config.Get().ErrorReporting.Enabled {
 | 
			
		||||
		ws.log.Debug("error reporting disabled")
 | 
			
		||||
		rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
		return
 | 
			
		||||
		return fallbackHandler
 | 
			
		||||
	}
 | 
			
		||||
	fb := &bytes.Buffer{}
 | 
			
		||||
	_, err := io.Copy(fb, r.Body)
 | 
			
		||||
	dsn, err := url.Parse(config.Get().ErrorReporting.SentryDSN)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		ws.log.Debug("failed to read body")
 | 
			
		||||
		rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
		return
 | 
			
		||||
		ws.log.WithError(err).Warning("invalid sentry DSN")
 | 
			
		||||
		return fallbackHandler
 | 
			
		||||
	}
 | 
			
		||||
	lines := strings.Split(fb.String(), "\n")
 | 
			
		||||
	if len(lines) < 1 {
 | 
			
		||||
		rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	sd := SentryRequest{}
 | 
			
		||||
	err = json.Unmarshal([]byte(lines[0]), &sd)
 | 
			
		||||
	projectId, err := strconv.Atoi(strings.TrimPrefix(dsn.Path, "/"))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		ws.log.WithError(err).Warning("failed to parse sentry request")
 | 
			
		||||
		rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
		return
 | 
			
		||||
		ws.log.WithError(err).Warning("failed to get sentry project id")
 | 
			
		||||
		return fallbackHandler
 | 
			
		||||
	}
 | 
			
		||||
	if sd.DSN != config.Get().ErrorReporting.DSN {
 | 
			
		||||
		ws.log.WithField("have", sd.DSN).WithField("expected", config.Get().ErrorReporting.DSN).Debug("invalid DSN")
 | 
			
		||||
		rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
		return
 | 
			
		||||
	return func(rw http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		fb := &bytes.Buffer{}
 | 
			
		||||
		_, err := io.Copy(fb, r.Body)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			ws.log.Debug("failed to read body")
 | 
			
		||||
			rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		lines := strings.Split(fb.String(), "\n")
 | 
			
		||||
		if len(lines) < 1 {
 | 
			
		||||
			rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		sd := SentryRequest{}
 | 
			
		||||
		err = json.Unmarshal([]byte(lines[0]), &sd)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			ws.log.WithError(err).Warning("failed to parse sentry request")
 | 
			
		||||
			rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		if sd.DSN != config.Get().ErrorReporting.SentryDSN {
 | 
			
		||||
			rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		res, err := http.DefaultClient.Post(
 | 
			
		||||
			fmt.Sprintf(
 | 
			
		||||
				"https://%s/api/%d/envelope/",
 | 
			
		||||
				dsn.Host,
 | 
			
		||||
				projectId,
 | 
			
		||||
			),
 | 
			
		||||
			"application/x-sentry-envelope",
 | 
			
		||||
			fb,
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			ws.log.WithError(err).Warning("failed to proxy sentry")
 | 
			
		||||
			rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		rw.WriteHeader(res.StatusCode)
 | 
			
		||||
	}
 | 
			
		||||
	res, err := http.DefaultClient.Post("https://sentry.beryju.org/api/8/envelope/", "application/octet-stream", fb)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		ws.log.WithError(err).Warning("failed to proxy sentry")
 | 
			
		||||
		rw.WriteHeader(http.StatusBadRequest)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	rw.WriteHeader(res.StatusCode)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -53,7 +53,7 @@ func NewWebServer(g *gounicorn.GoUnicorn) *WebServer {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (ws *WebServer) configureRoutes() {
 | 
			
		||||
	ws.m.Path("/api/v3/sentry/").HandlerFunc(ws.APISentryProxy)
 | 
			
		||||
	ws.m.Path("/api/v3/sentry/").HandlerFunc(ws.APISentryProxy())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (ws *WebServer) Start() {
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
# Stage 1: Build
 | 
			
		||||
FROM docker.io/golang:1.19.2-bullseye AS builder
 | 
			
		||||
FROM docker.io/golang:1.19.3-bullseye AS builder
 | 
			
		||||
 | 
			
		||||
WORKDIR /go/src/goauthentik.io
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -35,7 +35,7 @@ while True:
 | 
			
		||||
    except OperationalError as exc:
 | 
			
		||||
        sleep(1)
 | 
			
		||||
        CONFIG.log("info", f"PostgreSQL connection failed, retrying... ({exc})")
 | 
			
		||||
    CONFIG.log("info", "PostgreSQL connection successful")
 | 
			
		||||
CONFIG.log("info", "PostgreSQL connection successful")
 | 
			
		||||
 | 
			
		||||
REDIS_PROTOCOL_PREFIX = "redis://"
 | 
			
		||||
if CONFIG.y_bool("redis.tls", False):
 | 
			
		||||
@ -43,7 +43,7 @@ if CONFIG.y_bool("redis.tls", False):
 | 
			
		||||
REDIS_URL = (
 | 
			
		||||
    f"{REDIS_PROTOCOL_PREFIX}:"
 | 
			
		||||
    f"{quote_plus(CONFIG.y('redis.password'))}@{quote_plus(CONFIG.y('redis.host'))}:"
 | 
			
		||||
    f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.ws_db')}"
 | 
			
		||||
    f"{int(CONFIG.y('redis.port'))}/{CONFIG.y('redis.db')}"
 | 
			
		||||
)
 | 
			
		||||
while True:
 | 
			
		||||
    try:
 | 
			
		||||
@ -53,6 +53,6 @@ while True:
 | 
			
		||||
    except RedisError as exc:
 | 
			
		||||
        sleep(1)
 | 
			
		||||
        CONFIG.log("info", f"Redis Connection failed, retrying... ({exc})", redis_url=REDIS_URL)
 | 
			
		||||
    CONFIG.log("info", "Redis Connection successful")
 | 
			
		||||
CONFIG.log("info", "Redis Connection successful")
 | 
			
		||||
 | 
			
		||||
CONFIG.log("info", "Finished authentik bootstrap")
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1317
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1317
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -7,7 +7,7 @@ ENV NODE_ENV=production
 | 
			
		||||
RUN cd /static && npm ci && npm run build-proxy
 | 
			
		||||
 | 
			
		||||
# Stage 2: Build
 | 
			
		||||
FROM docker.io/golang:1.19.2-bullseye AS builder
 | 
			
		||||
FROM docker.io/golang:1.19.3-bullseye AS builder
 | 
			
		||||
 | 
			
		||||
WORKDIR /go/src/goauthentik.io
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -12,12 +12,12 @@ reportOptionalMemberAccess = false
 | 
			
		||||
# so we have to disable those for now
 | 
			
		||||
reportGeneralTypeIssues = false
 | 
			
		||||
verboseOutput = false
 | 
			
		||||
pythonVersion = "3.10"
 | 
			
		||||
pythonVersion = "3.11"
 | 
			
		||||
pythonPlatform = "All"
 | 
			
		||||
 | 
			
		||||
[tool.black]
 | 
			
		||||
line-length = 100
 | 
			
		||||
target-version = ['py310']
 | 
			
		||||
target-version = ['py311']
 | 
			
		||||
exclude = 'node_modules'
 | 
			
		||||
 | 
			
		||||
[tool.isort]
 | 
			
		||||
@ -100,7 +100,7 @@ addopts = "-p no:celery --junitxml=unittest.xml"
 | 
			
		||||
 | 
			
		||||
[tool.poetry]
 | 
			
		||||
name = "authentik"
 | 
			
		||||
version = "2022.10.1"
 | 
			
		||||
version = "2022.11.0"
 | 
			
		||||
description = ""
 | 
			
		||||
authors = ["authentik Team <hello@goauthentik.io>"]
 | 
			
		||||
 | 
			
		||||
@ -124,25 +124,29 @@ djangorestframework = "*"
 | 
			
		||||
djangorestframework-guardian = "*"
 | 
			
		||||
docker = "*"
 | 
			
		||||
drf-spectacular = "*"
 | 
			
		||||
dumb-init = "*"
 | 
			
		||||
duo-client = "*"
 | 
			
		||||
facebook-sdk = "*"
 | 
			
		||||
flower = "*"
 | 
			
		||||
geoip2 = "*"
 | 
			
		||||
gunicorn = "*"
 | 
			
		||||
kubernetes = "*"
 | 
			
		||||
ldap3 = "*"
 | 
			
		||||
lxml = "*"
 | 
			
		||||
opencontainers = {extras = ["reggie"],version = "*"}
 | 
			
		||||
packaging = "*"
 | 
			
		||||
paramiko = "*"
 | 
			
		||||
psycopg2-binary = "*"
 | 
			
		||||
pycryptodome = "*"
 | 
			
		||||
pyjwt = "*"
 | 
			
		||||
python = "^3.10"
 | 
			
		||||
python = "^3.11"
 | 
			
		||||
pyyaml = "*"
 | 
			
		||||
requests-oauthlib = "*"
 | 
			
		||||
sentry-sdk = "*"
 | 
			
		||||
service_identity = "*"
 | 
			
		||||
structlog = "*"
 | 
			
		||||
swagger-spec-validator = "*"
 | 
			
		||||
twilio = "*"
 | 
			
		||||
twisted = "*"
 | 
			
		||||
ua-parser = "*"
 | 
			
		||||
urllib3 = {extras = ["secure"],version = "*"}
 | 
			
		||||
@ -150,10 +154,7 @@ uvicorn = {extras = ["standard"],version = "*"}
 | 
			
		||||
webauthn = "*"
 | 
			
		||||
wsproto = "*"
 | 
			
		||||
xmlsec = "*"
 | 
			
		||||
twilio = "*"
 | 
			
		||||
dumb-init = "*"
 | 
			
		||||
flower = "*"
 | 
			
		||||
opencontainers = {extras = ["reggie"],version = "*"}
 | 
			
		||||
zxcvbn = "*"
 | 
			
		||||
 | 
			
		||||
[tool.poetry.dev-dependencies]
 | 
			
		||||
bandit = "*"
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										278
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										278
									
								
								schema.yml
									
									
									
									
									
								
							@ -1,12 +1,12 @@
 | 
			
		||||
openapi: 3.0.3
 | 
			
		||||
info:
 | 
			
		||||
  title: authentik
 | 
			
		||||
  version: 2022.10.1
 | 
			
		||||
  version: 2022.11.0
 | 
			
		||||
  description: Making authentication simple.
 | 
			
		||||
  contact:
 | 
			
		||||
    email: hello@goauthentik.io
 | 
			
		||||
  license:
 | 
			
		||||
    name: GNU GPLv3
 | 
			
		||||
    name: MIT
 | 
			
		||||
    url: https://github.com/goauthentik/authentik/blob/main/LICENSE
 | 
			
		||||
paths:
 | 
			
		||||
  /admin/apps/:
 | 
			
		||||
@ -11434,6 +11434,18 @@ paths:
 | 
			
		||||
        name: amount_uppercase
 | 
			
		||||
        schema:
 | 
			
		||||
          type: integer
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: check_have_i_been_pwned
 | 
			
		||||
        schema:
 | 
			
		||||
          type: boolean
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: check_static_rules
 | 
			
		||||
        schema:
 | 
			
		||||
          type: boolean
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: check_zxcvbn
 | 
			
		||||
        schema:
 | 
			
		||||
          type: boolean
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: created
 | 
			
		||||
        schema:
 | 
			
		||||
@ -11447,6 +11459,10 @@ paths:
 | 
			
		||||
        name: execution_logging
 | 
			
		||||
        schema:
 | 
			
		||||
          type: boolean
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: hibp_allowed_count
 | 
			
		||||
        schema:
 | 
			
		||||
          type: integer
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: last_updated
 | 
			
		||||
        schema:
 | 
			
		||||
@ -11497,6 +11513,10 @@ paths:
 | 
			
		||||
        name: symbol_charset
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: zxcvbn_score_threshold
 | 
			
		||||
        schema:
 | 
			
		||||
          type: integer
 | 
			
		||||
      tags:
 | 
			
		||||
      - policies
 | 
			
		||||
      security:
 | 
			
		||||
@ -11530,7 +11550,6 @@ paths:
 | 
			
		||||
          application/json:
 | 
			
		||||
            schema:
 | 
			
		||||
              $ref: '#/components/schemas/PasswordPolicyRequest'
 | 
			
		||||
        required: true
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
@ -11605,7 +11624,6 @@ paths:
 | 
			
		||||
          application/json:
 | 
			
		||||
            schema:
 | 
			
		||||
              $ref: '#/components/schemas/PasswordPolicyRequest'
 | 
			
		||||
        required: true
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
@ -15777,6 +15795,69 @@ paths:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
  /sources/all/{slug}/set_icon/:
 | 
			
		||||
    post:
 | 
			
		||||
      operationId: sources_all_set_icon_create
 | 
			
		||||
      description: Set source icon
 | 
			
		||||
      parameters:
 | 
			
		||||
      - in: path
 | 
			
		||||
        name: slug
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          description: Internal source name, used in URLs.
 | 
			
		||||
        required: true
 | 
			
		||||
      tags:
 | 
			
		||||
      - sources
 | 
			
		||||
      requestBody:
 | 
			
		||||
        content:
 | 
			
		||||
          multipart/form-data:
 | 
			
		||||
            schema:
 | 
			
		||||
              $ref: '#/components/schemas/FileUploadRequest'
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          description: Success
 | 
			
		||||
        '400':
 | 
			
		||||
          description: Bad request
 | 
			
		||||
        '403':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
  /sources/all/{slug}/set_icon_url/:
 | 
			
		||||
    post:
 | 
			
		||||
      operationId: sources_all_set_icon_url_create
 | 
			
		||||
      description: Set source icon (as URL)
 | 
			
		||||
      parameters:
 | 
			
		||||
      - in: path
 | 
			
		||||
        name: slug
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          description: Internal source name, used in URLs.
 | 
			
		||||
        required: true
 | 
			
		||||
      tags:
 | 
			
		||||
      - sources
 | 
			
		||||
      requestBody:
 | 
			
		||||
        content:
 | 
			
		||||
          application/json:
 | 
			
		||||
            schema:
 | 
			
		||||
              $ref: '#/components/schemas/FilePathRequest'
 | 
			
		||||
        required: true
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          description: Success
 | 
			
		||||
        '400':
 | 
			
		||||
          description: Bad request
 | 
			
		||||
        '403':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
  /sources/all/{slug}/used_by/:
 | 
			
		||||
    get:
 | 
			
		||||
      operationId: sources_all_used_by_list
 | 
			
		||||
@ -17074,20 +17155,6 @@ paths:
 | 
			
		||||
        description: Number of results to return per page.
 | 
			
		||||
        schema:
 | 
			
		||||
          type: integer
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: pbm_uuid
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: policies
 | 
			
		||||
        schema:
 | 
			
		||||
          type: array
 | 
			
		||||
          items:
 | 
			
		||||
            type: string
 | 
			
		||||
            format: uuid
 | 
			
		||||
        explode: true
 | 
			
		||||
        style: form
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: policy_engine_mode
 | 
			
		||||
        schema:
 | 
			
		||||
@ -17100,15 +17167,6 @@ paths:
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: property_mappings
 | 
			
		||||
        schema:
 | 
			
		||||
          type: array
 | 
			
		||||
          items:
 | 
			
		||||
            type: string
 | 
			
		||||
            format: uuid
 | 
			
		||||
        explode: true
 | 
			
		||||
        style: form
 | 
			
		||||
      - name: search
 | 
			
		||||
        required: false
 | 
			
		||||
        in: query
 | 
			
		||||
@ -17158,10 +17216,6 @@ paths:
 | 
			
		||||
          - username_link
 | 
			
		||||
        description: How the source determines if an existing user should be authenticated
 | 
			
		||||
          or a new user enrolled.
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: user_path_template
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
      tags:
 | 
			
		||||
      - sources
 | 
			
		||||
      security:
 | 
			
		||||
@ -25788,6 +25842,10 @@ components:
 | 
			
		||||
          type: string
 | 
			
		||||
          description: If any of the user's device has been used within this threshold,
 | 
			
		||||
            this stage will be skipped
 | 
			
		||||
        webauthn_user_verification:
 | 
			
		||||
          allOf:
 | 
			
		||||
          - $ref: '#/components/schemas/UserVerificationEnum'
 | 
			
		||||
          description: Enforce user verification for WebAuthn devices.
 | 
			
		||||
      required:
 | 
			
		||||
      - component
 | 
			
		||||
      - meta_model_name
 | 
			
		||||
@ -25826,6 +25884,10 @@ components:
 | 
			
		||||
          minLength: 1
 | 
			
		||||
          description: If any of the user's device has been used within this threshold,
 | 
			
		||||
            this stage will be skipped
 | 
			
		||||
        webauthn_user_verification:
 | 
			
		||||
          allOf:
 | 
			
		||||
          - $ref: '#/components/schemas/UserVerificationEnum'
 | 
			
		||||
          description: Enforce user verification for WebAuthn devices.
 | 
			
		||||
      required:
 | 
			
		||||
      - name
 | 
			
		||||
    AuthenticatorValidationChallenge:
 | 
			
		||||
@ -27144,6 +27206,9 @@ components:
 | 
			
		||||
        enabled:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        sentry_dsn:
 | 
			
		||||
          type: string
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        environment:
 | 
			
		||||
          type: string
 | 
			
		||||
          readOnly: true
 | 
			
		||||
@ -27158,6 +27223,7 @@ components:
 | 
			
		||||
      - enabled
 | 
			
		||||
      - environment
 | 
			
		||||
      - send_pii
 | 
			
		||||
      - sentry_dsn
 | 
			
		||||
      - traces_sample_rate
 | 
			
		||||
    Event:
 | 
			
		||||
      type: object
 | 
			
		||||
@ -28488,6 +28554,9 @@ components:
 | 
			
		||||
          additionalProperties: {}
 | 
			
		||||
          description: Paste your kubeconfig here. authentik will automatically use
 | 
			
		||||
            the currently selected context.
 | 
			
		||||
        verify_ssl:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: Verify SSL Certificates of the Kubernetes API endpoint
 | 
			
		||||
      required:
 | 
			
		||||
      - component
 | 
			
		||||
      - meta_model_name
 | 
			
		||||
@ -28511,6 +28580,9 @@ components:
 | 
			
		||||
          additionalProperties: {}
 | 
			
		||||
          description: Paste your kubeconfig here. authentik will automatically use
 | 
			
		||||
            the currently selected context.
 | 
			
		||||
        verify_ssl:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: Verify SSL Certificates of the Kubernetes API endpoint
 | 
			
		||||
      required:
 | 
			
		||||
      - name
 | 
			
		||||
    LDAPAPIAccessMode:
 | 
			
		||||
@ -28843,6 +28915,10 @@ components:
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        user_path_template:
 | 
			
		||||
          type: string
 | 
			
		||||
        icon:
 | 
			
		||||
          type: string
 | 
			
		||||
          nullable: true
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        server_uri:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uri
 | 
			
		||||
@ -28905,6 +28981,7 @@ components:
 | 
			
		||||
      required:
 | 
			
		||||
      - base_dn
 | 
			
		||||
      - component
 | 
			
		||||
      - icon
 | 
			
		||||
      - managed
 | 
			
		||||
      - meta_model_name
 | 
			
		||||
      - name
 | 
			
		||||
@ -29634,6 +29711,10 @@ components:
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        user_path_template:
 | 
			
		||||
          type: string
 | 
			
		||||
        icon:
 | 
			
		||||
          type: string
 | 
			
		||||
          nullable: true
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        provider_type:
 | 
			
		||||
          $ref: '#/components/schemas/ProviderTypeEnum'
 | 
			
		||||
        request_token_url:
 | 
			
		||||
@ -29679,6 +29760,7 @@ components:
 | 
			
		||||
      - callback_url
 | 
			
		||||
      - component
 | 
			
		||||
      - consumer_key
 | 
			
		||||
      - icon
 | 
			
		||||
      - managed
 | 
			
		||||
      - meta_model_name
 | 
			
		||||
      - name
 | 
			
		||||
@ -32883,10 +32965,26 @@ components:
 | 
			
		||||
          type: string
 | 
			
		||||
        error_message:
 | 
			
		||||
          type: string
 | 
			
		||||
        check_static_rules:
 | 
			
		||||
          type: boolean
 | 
			
		||||
        check_have_i_been_pwned:
 | 
			
		||||
          type: boolean
 | 
			
		||||
        check_zxcvbn:
 | 
			
		||||
          type: boolean
 | 
			
		||||
        hibp_allowed_count:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: 0
 | 
			
		||||
          description: How many times the password hash is allowed to be on haveibeenpwned
 | 
			
		||||
        zxcvbn_score_threshold:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: 0
 | 
			
		||||
          description: If the zxcvbn score is equal or less than this value, the policy
 | 
			
		||||
            will fail.
 | 
			
		||||
      required:
 | 
			
		||||
      - bound_to
 | 
			
		||||
      - component
 | 
			
		||||
      - error_message
 | 
			
		||||
      - meta_model_name
 | 
			
		||||
      - pk
 | 
			
		||||
      - verbose_name
 | 
			
		||||
@ -32932,9 +33030,23 @@ components:
 | 
			
		||||
          minLength: 1
 | 
			
		||||
        error_message:
 | 
			
		||||
          type: string
 | 
			
		||||
          minLength: 1
 | 
			
		||||
      required:
 | 
			
		||||
      - error_message
 | 
			
		||||
        check_static_rules:
 | 
			
		||||
          type: boolean
 | 
			
		||||
        check_have_i_been_pwned:
 | 
			
		||||
          type: boolean
 | 
			
		||||
        check_zxcvbn:
 | 
			
		||||
          type: boolean
 | 
			
		||||
        hibp_allowed_count:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: 0
 | 
			
		||||
          description: How many times the password hash is allowed to be on haveibeenpwned
 | 
			
		||||
        zxcvbn_score_threshold:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: 0
 | 
			
		||||
          description: If the zxcvbn score is equal or less than this value, the policy
 | 
			
		||||
            will fail.
 | 
			
		||||
    PasswordStage:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: PasswordStage Serializer
 | 
			
		||||
@ -33222,6 +33334,10 @@ components:
 | 
			
		||||
          minLength: 1
 | 
			
		||||
          description: If any of the user's device has been used within this threshold,
 | 
			
		||||
            this stage will be skipped
 | 
			
		||||
        webauthn_user_verification:
 | 
			
		||||
          allOf:
 | 
			
		||||
          - $ref: '#/components/schemas/UserVerificationEnum'
 | 
			
		||||
          description: Enforce user verification for WebAuthn devices.
 | 
			
		||||
    PatchedBlueprintInstanceRequest:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: Info about a single blueprint instance file
 | 
			
		||||
@ -33714,6 +33830,9 @@ components:
 | 
			
		||||
          additionalProperties: {}
 | 
			
		||||
          description: Paste your kubeconfig here. authentik will automatically use
 | 
			
		||||
            the currently selected context.
 | 
			
		||||
        verify_ssl:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: Verify SSL Certificates of the Kubernetes API endpoint
 | 
			
		||||
    PatchedLDAPPropertyMappingRequest:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: LDAP PropertyMapping Serializer
 | 
			
		||||
@ -34191,7 +34310,23 @@ components:
 | 
			
		||||
          minLength: 1
 | 
			
		||||
        error_message:
 | 
			
		||||
          type: string
 | 
			
		||||
          minLength: 1
 | 
			
		||||
        check_static_rules:
 | 
			
		||||
          type: boolean
 | 
			
		||||
        check_have_i_been_pwned:
 | 
			
		||||
          type: boolean
 | 
			
		||||
        check_zxcvbn:
 | 
			
		||||
          type: boolean
 | 
			
		||||
        hibp_allowed_count:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: 0
 | 
			
		||||
          description: How many times the password hash is allowed to be on haveibeenpwned
 | 
			
		||||
        zxcvbn_score_threshold:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: 0
 | 
			
		||||
          description: If the zxcvbn score is equal or less than this value, the policy
 | 
			
		||||
            will fail.
 | 
			
		||||
    PatchedPasswordStageRequest:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: PasswordStage Serializer
 | 
			
		||||
@ -34998,6 +35133,10 @@ components:
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        user_path_template:
 | 
			
		||||
          type: string
 | 
			
		||||
        icon:
 | 
			
		||||
          type: string
 | 
			
		||||
          nullable: true
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        client_id:
 | 
			
		||||
          type: string
 | 
			
		||||
          description: Client identifier used to talk to Plex.
 | 
			
		||||
@ -35015,6 +35154,7 @@ components:
 | 
			
		||||
          description: Plex token used to check friends
 | 
			
		||||
      required:
 | 
			
		||||
      - component
 | 
			
		||||
      - icon
 | 
			
		||||
      - managed
 | 
			
		||||
      - meta_model_name
 | 
			
		||||
      - name
 | 
			
		||||
@ -36418,6 +36558,10 @@ components:
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        user_path_template:
 | 
			
		||||
          type: string
 | 
			
		||||
        icon:
 | 
			
		||||
          type: string
 | 
			
		||||
          nullable: true
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        pre_authentication_flow:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
@ -36466,6 +36610,7 @@ components:
 | 
			
		||||
            doesn''t log out manually. (Format: hours=1;minutes=2;seconds=3).'
 | 
			
		||||
      required:
 | 
			
		||||
      - component
 | 
			
		||||
      - icon
 | 
			
		||||
      - managed
 | 
			
		||||
      - meta_model_name
 | 
			
		||||
      - name
 | 
			
		||||
@ -36859,8 +37004,13 @@ components:
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        user_path_template:
 | 
			
		||||
          type: string
 | 
			
		||||
        icon:
 | 
			
		||||
          type: string
 | 
			
		||||
          nullable: true
 | 
			
		||||
          readOnly: true
 | 
			
		||||
      required:
 | 
			
		||||
      - component
 | 
			
		||||
      - icon
 | 
			
		||||
      - managed
 | 
			
		||||
      - meta_model_name
 | 
			
		||||
      - name
 | 
			
		||||
@ -37477,7 +37627,7 @@ components:
 | 
			
		||||
        groups_obj:
 | 
			
		||||
          type: array
 | 
			
		||||
          items:
 | 
			
		||||
            $ref: '#/components/schemas/Group'
 | 
			
		||||
            $ref: '#/components/schemas/UserGroup'
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        email:
 | 
			
		||||
          type: string
 | 
			
		||||
@ -37579,6 +37729,59 @@ components:
 | 
			
		||||
      - username
 | 
			
		||||
      - upn
 | 
			
		||||
      type: string
 | 
			
		||||
    UserGroup:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: Simplified Group Serializer for user's groups
 | 
			
		||||
      properties:
 | 
			
		||||
        pk:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
          readOnly: true
 | 
			
		||||
          title: Group uuid
 | 
			
		||||
        num_pk:
 | 
			
		||||
          type: integer
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        name:
 | 
			
		||||
          type: string
 | 
			
		||||
          maxLength: 80
 | 
			
		||||
        is_superuser:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: Users added to this group will be superusers.
 | 
			
		||||
        parent:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
          nullable: true
 | 
			
		||||
        parent_name:
 | 
			
		||||
          type: string
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        attributes:
 | 
			
		||||
          type: object
 | 
			
		||||
          additionalProperties: {}
 | 
			
		||||
      required:
 | 
			
		||||
      - name
 | 
			
		||||
      - num_pk
 | 
			
		||||
      - parent_name
 | 
			
		||||
      - pk
 | 
			
		||||
    UserGroupRequest:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: Simplified Group Serializer for user's groups
 | 
			
		||||
      properties:
 | 
			
		||||
        name:
 | 
			
		||||
          type: string
 | 
			
		||||
          minLength: 1
 | 
			
		||||
          maxLength: 80
 | 
			
		||||
        is_superuser:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: Users added to this group will be superusers.
 | 
			
		||||
        parent:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
          nullable: true
 | 
			
		||||
        attributes:
 | 
			
		||||
          type: object
 | 
			
		||||
          additionalProperties: {}
 | 
			
		||||
      required:
 | 
			
		||||
      - name
 | 
			
		||||
    UserLoginStage:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: UserLoginStage Serializer
 | 
			
		||||
@ -38128,5 +38331,6 @@ components:
 | 
			
		||||
      type: apiKey
 | 
			
		||||
      in: header
 | 
			
		||||
      name: Authorization
 | 
			
		||||
      scheme: bearer
 | 
			
		||||
servers:
 | 
			
		||||
- url: /api/v3/
 | 
			
		||||
 | 
			
		||||
@ -1 +1,6 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 21 21"><title>MS-SymbolLockup</title><rect x="1" y="1" width="9" height="9"/><rect x="1" y="11" width="9" height="9"/><rect x="11" y="1" width="9" height="9"/><rect x="11" y="11" width="9" height="9"/></svg>
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
 | 
			
		||||
    <path fill="#000000" d="M1 1h10v10H1z"/>
 | 
			
		||||
    <path fill="#000000" d="M12 1h10v10H12z"/>
 | 
			
		||||
    <path fill="#000000" d="M1 12h10v10H1z"/>
 | 
			
		||||
    <path fill="#000000" d="M12 12h10v10H12z"/>
 | 
			
		||||
</svg>
 | 
			
		||||
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 284 B After Width: | Height: | Size: 254 B  | 
@ -1,22 +1,4 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<!-- Generator: Adobe Illustrator 20.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
 | 
			
		||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
 | 
			
		||||
	 viewBox="0 0 400 400" style="enable-background:new 0 0 400 400;" xml:space="preserve">
 | 
			
		||||
<style type="text/css">
 | 
			
		||||
	.st0{fill:#1DA1F2;}
 | 
			
		||||
	.st1{fill:#FFFFFF;}
 | 
			
		||||
</style>
 | 
			
		||||
<g id="Dark_Blue">
 | 
			
		||||
	<rect class="st0" width="400" height="400"/>
 | 
			
		||||
</g>
 | 
			
		||||
<g id="Logo__x2014__FIXED">
 | 
			
		||||
	<path class="st1" d="M153.6,301.6c94.3,0,145.9-78.2,145.9-145.9c0-2.2,0-4.4-0.1-6.6c10-7.2,18.7-16.3,25.6-26.6
 | 
			
		||||
		c-9.2,4.1-19.1,6.8-29.5,8.1c10.6-6.3,18.7-16.4,22.6-28.4c-9.9,5.9-20.9,10.1-32.6,12.4c-9.4-10-22.7-16.2-37.4-16.2
 | 
			
		||||
		c-28.3,0-51.3,23-51.3,51.3c0,4,0.5,7.9,1.3,11.7c-42.6-2.1-80.4-22.6-105.7-53.6c-4.4,7.6-6.9,16.4-6.9,25.8
 | 
			
		||||
		c0,17.8,9.1,33.5,22.8,42.7c-8.4-0.3-16.3-2.6-23.2-6.4c0,0.2,0,0.4,0,0.7c0,24.8,17.7,45.6,41.1,50.3c-4.3,1.2-8.8,1.8-13.5,1.8
 | 
			
		||||
		c-3.3,0-6.5-0.3-9.6-0.9c6.5,20.4,25.5,35.2,47.9,35.6c-17.6,13.8-39.7,22-63.7,22c-4.1,0-8.2-0.2-12.2-0.7
 | 
			
		||||
		C97.7,293.1,124.7,301.6,153.6,301.6"/>
 | 
			
		||||
</g>
 | 
			
		||||
<g id="Annotations">
 | 
			
		||||
</g>
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 248 204">
 | 
			
		||||
	<path fill="#000000" d="M221.95 51.29c.15 2.17.15 4.34.15 6.53 0 66.73-50.8 143.69-143.69 143.69v-.04c-27.44.04-54.31-7.82-77.41-22.64 3.99.48 8 .72 12.02.73 22.74.02 44.83-7.61 62.72-21.66-21.61-.41-40.56-14.5-47.18-35.07 7.57 1.46 15.37 1.16 22.8-.87-23.56-4.76-40.51-25.46-40.51-49.5v-.64c7.02 3.91 14.88 6.08 22.92 6.32C11.58 63.31 4.74 33.79 18.14 10.71c25.64 31.55 63.47 50.73 104.08 52.76-4.07-17.54 1.49-35.92 14.61-48.25 20.34-19.12 52.33-18.14 71.45 2.19 11.31-2.23 22.15-6.38 32.07-12.26-3.77 11.69-11.66 21.62-22.2 27.93 10.01-1.18 19.79-3.86 29-7.95-6.78 10.16-15.32 19.01-25.2 26.16z"/>
 | 
			
		||||
</svg>
 | 
			
		||||
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 732 B  | 
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user