Compare commits
	
		
			191 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 430a207865 | |||
| 894873b373 | |||
| 1ce2a1b846 | |||
| 4731ccfafe | |||
| 3e9c28d0a4 | |||
| 12d4394d73 | |||
| b872e7072d | |||
| b0ea657b18 | |||
| a5f26b2ce0 | |||
| c1b9b5c5e2 | |||
| b288393cd4 | |||
| 767ffc09d0 | |||
| 446dc0a17b | |||
| c85474ec37 | |||
| 3a59b75f4a | |||
| 8deac81364 | |||
| 98485c528e | |||
| 1a5b626f96 | |||
| 5736a1542c | |||
| 43854dc828 | |||
| 64af78110a | |||
| 59be3c7746 | |||
| 9e30f01fce | |||
| fc8fe5317a | |||
| 92090ced9f | |||
| ce47d4cf39 | |||
| c11367553e | |||
| c61529e4d4 | |||
| 8709f3300c | |||
| e78bc1b32f | |||
| 89c4a7b4a4 | |||
| 9701907b82 | |||
| 7ac73bfcf9 | |||
| 1423d5d45b | |||
| 768ff67e8c | |||
| b13deefd91 | |||
| 69e445211e | |||
| ada8fc2a55 | |||
| d9cc45f9ce | |||
| 515a402db7 | |||
| 813f70b806 | |||
| a302a72379 | |||
| e390f5b2d1 | |||
| f09305a444 | |||
| 60189ce9ca | |||
| fdc445e6a1 | |||
| e3f8afcf80 | |||
| 9e2e8132a6 | |||
| 26f9bbeefa | |||
| 49b6c71079 | |||
| 97acc77e0a | |||
| eb1e0427c1 | |||
| 6e0c9acb34 | |||
| b75d659707 | |||
| 8894861a59 | |||
| 7878755acd | |||
| 2b62d6646e | |||
| 4f81f750ce | |||
| fa216e2e93 | |||
| 181bd903be | |||
| 23c69c456a | |||
| c73fce4f58 | |||
| bd0ef69ece | |||
| 19ee98b36d | |||
| 75d4246b79 | |||
| d2fd84d98c | |||
| 678378403b | |||
| 7f32d0eb9a | |||
| f1b3598a0f | |||
| 07767c9376 | |||
| 5a3f9d1417 | |||
| 44a6303c91 | |||
| 5f7f80fdee | |||
| a332a465ef | |||
| 8b16fed926 | |||
| be10dd629b | |||
| a6a868cbc1 | |||
| a9ed275f4e | |||
| fbc5378158 | |||
| 20210b614d | |||
| 063877a615 | |||
| a73d50d379 | |||
| 9568f4dbd6 | |||
| 9b2ceb0d44 | |||
| 2deb185550 | |||
| 69d4719687 | |||
| d31e566873 | |||
| 0ddcefce80 | |||
| 4c45d35507 | |||
| 829e49275d | |||
| 143309448e | |||
| 1f038ecee2 | |||
| 1b1f2ea72c | |||
| 6e1a54753e | |||
| 67d1f06c91 | |||
| d37de6bc00 | |||
| 8deced771d | |||
| c380512cc8 | |||
| e0b06bc4de | |||
| 1bd6107ec7 | |||
| ce1409fb6c | |||
| b6b97f4706 | |||
| cd12e177ea | |||
| 31c6ea9fda | |||
| 20931ccc1d | |||
| 9c9f441cff | |||
| 36822c128c | |||
| 29d3fdaa1d | |||
| ac5167b8a3 | |||
| 0db434a922 | |||
| 3c0675486c | |||
| f6d56e7e29 | |||
| fac56390a0 | |||
| c6e3229f0b | |||
| ace30933bd | |||
| d313f1576b | |||
| ac07576676 | |||
| df42480284 | |||
| d2f722f032 | |||
| a8fdcab927 | |||
| 0cba3c7788 | |||
| 0d414ec0ea | |||
| c42b34a46b | |||
| 7a1050300d | |||
| a64e87a6b1 | |||
| 81e9f2d608 | |||
| ddbd8153e2 | |||
| f7037b9f33 | |||
| 67a6fa6399 | |||
| a35b8f5862 | |||
| 5b7c6f1b0e | |||
| 662101fd1f | |||
| 3f633460a8 | |||
| be2d1a522a | |||
| d6f5b8e421 | |||
| b424c5dd27 | |||
| 2a83d79ace | |||
| 1ed24a5eef | |||
| f2961cb536 | |||
| 4d66e42708 | |||
| bd3a721753 | |||
| 25c3086d7a | |||
| 1bdd09342a | |||
| ad6d773d26 | |||
| b555ccd549 | |||
| 9445354b31 | |||
| a42f2f7217 | |||
| d1aa1f46da | |||
| a1be924fa4 | |||
| db60427e21 | |||
| d3e2f41561 | |||
| 8840f6ef63 | |||
| 3b103b22e2 | |||
| 158f4c1c4c | |||
| 42606a499b | |||
| c0841120bf | |||
| 61442a7e4a | |||
| 98876df5c5 | |||
| a9680d6088 | |||
| 7eb6320d74 | |||
| 47aba4a996 | |||
| 643b36b732 | |||
| 001869641d | |||
| bec538c543 | |||
| c63ba3f378 | |||
| 0fb2b5550a | |||
| 762294c0f9 | |||
| 2a2ab94e97 | |||
| 53cab07a48 | |||
| 2604dc14fe | |||
| 06f67c738c | |||
| 1b001060a3 | |||
| a960ce9454 | |||
| 439bdc54d6 | |||
| e6b5810e03 | |||
| 89b73a4d89 | |||
| ed3f36e72a | |||
| 78b711ec9d | |||
| 4be0a707b1 | |||
| 1e73b42c58 | |||
| 3df3bceccb | |||
| a4370458cb | |||
| 742bad4080 | |||
| f15946e216 | |||
| b54415dcde | |||
| 471293ba25 | |||
| 3e7320734c | |||
| 3131e557d9 | |||
| 1efc7eecbf | |||
| 15ec6a9284 | |||
| dc1359a763 | 
@ -1,5 +1,5 @@
 | 
			
		||||
[bumpversion]
 | 
			
		||||
current_version = 2022.12.2
 | 
			
		||||
current_version = 2023.1.1
 | 
			
		||||
tag = True
 | 
			
		||||
commit = True
 | 
			
		||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
 | 
			
		||||
 | 
			
		||||
@ -38,6 +38,14 @@ runs:
 | 
			
		||||
            AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
 | 
			
		||||
            ```
 | 
			
		||||
 | 
			
		||||
            For arm64, use these values:
 | 
			
		||||
 | 
			
		||||
            ```shell
 | 
			
		||||
            AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
 | 
			
		||||
            AUTHENTIK_TAG=${{ inputs.tag }}-arm64
 | 
			
		||||
            AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
 | 
			
		||||
            ```
 | 
			
		||||
 | 
			
		||||
            Afterwards, run the upgrade commands from the latest release notes.
 | 
			
		||||
          </details>
 | 
			
		||||
          <details>
 | 
			
		||||
@ -54,6 +62,17 @@ runs:
 | 
			
		||||
                tag: ${{ inputs.tag }}
 | 
			
		||||
            ```
 | 
			
		||||
 | 
			
		||||
            For arm64, use these values:
 | 
			
		||||
 | 
			
		||||
            ```yaml
 | 
			
		||||
            authentik:
 | 
			
		||||
                outposts:
 | 
			
		||||
                    container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
 | 
			
		||||
            image:
 | 
			
		||||
                repository: ghcr.io/goauthentik/dev-server
 | 
			
		||||
                tag: ${{ inputs.tag }}-arm64
 | 
			
		||||
            ```
 | 
			
		||||
 | 
			
		||||
            Afterwards, run the upgrade commands from the latest release notes.
 | 
			
		||||
          </details>
 | 
			
		||||
        edit-mode: replace
 | 
			
		||||
 | 
			
		||||
@ -17,6 +17,9 @@ outputs:
 | 
			
		||||
  sha:
 | 
			
		||||
    description: "sha"
 | 
			
		||||
    value: ${{ steps.ev.outputs.sha }}
 | 
			
		||||
  shortHash:
 | 
			
		||||
    description: "shortHash"
 | 
			
		||||
    value: ${{ steps.ev.outputs.shortHash }}
 | 
			
		||||
  version:
 | 
			
		||||
    description: "version"
 | 
			
		||||
    value: ${{ steps.ev.outputs.version }}
 | 
			
		||||
@ -53,6 +56,7 @@ runs:
 | 
			
		||||
            print("branchNameContainer=%s" % safe_branch_name, file=_output)
 | 
			
		||||
            print("timestamp=%s" % int(time()), file=_output)
 | 
			
		||||
            print("sha=%s" % os.environ["GITHUB_SHA"], file=_output)
 | 
			
		||||
            print("shortHash=%s" % os.environ["GITHUB_SHA"][:7], file=_output)
 | 
			
		||||
            print("shouldBuild=%s" % should_build, file=_output)
 | 
			
		||||
            print("version=%s" % version, file=_output)
 | 
			
		||||
            print("versionFamily=%s" % version_family, file=_output)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										103
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										103
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							@ -102,14 +102,31 @@ jobs:
 | 
			
		||||
        uses: helm/kind-action@v1.5.0
 | 
			
		||||
      - name: run integration
 | 
			
		||||
        run: |
 | 
			
		||||
          poetry run make test-integration
 | 
			
		||||
          poetry run coverage run manage.py test tests/integration
 | 
			
		||||
          poetry run coverage xml
 | 
			
		||||
      - if: ${{ always() }}
 | 
			
		||||
        uses: codecov/codecov-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          flags: integration
 | 
			
		||||
  test-e2e-provider:
 | 
			
		||||
  test-e2e:
 | 
			
		||||
    name: test-e2e (${{ matrix.job.name }})
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    strategy:
 | 
			
		||||
      fail-fast: false
 | 
			
		||||
      matrix:
 | 
			
		||||
        job:
 | 
			
		||||
          - name: proxy
 | 
			
		||||
            glob: tests/e2e/test_provider_proxy*
 | 
			
		||||
          - name: oauth
 | 
			
		||||
            glob: tests/e2e/test_provider_oauth2* tests/e2e/test_source_oauth*
 | 
			
		||||
          - name: oauth-oidc
 | 
			
		||||
            glob: tests/e2e/test_provider_oidc*
 | 
			
		||||
          - name: saml
 | 
			
		||||
            glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
 | 
			
		||||
          - name: ldap
 | 
			
		||||
            glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
 | 
			
		||||
          - name: flows
 | 
			
		||||
            glob: tests/e2e/test_flows*
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - name: Setup authentik env
 | 
			
		||||
@ -131,36 +148,7 @@ jobs:
 | 
			
		||||
          npm run build
 | 
			
		||||
      - name: run e2e
 | 
			
		||||
        run: |
 | 
			
		||||
          poetry run make test-e2e-provider
 | 
			
		||||
          poetry run coverage xml
 | 
			
		||||
      - if: ${{ always() }}
 | 
			
		||||
        uses: codecov/codecov-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          flags: e2e
 | 
			
		||||
  test-e2e-rest:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - name: Setup authentik env
 | 
			
		||||
        uses: ./.github/actions/setup
 | 
			
		||||
      - name: Setup e2e env (chrome, etc)
 | 
			
		||||
        run: |
 | 
			
		||||
          docker-compose -f tests/e2e/docker-compose.yml up -d
 | 
			
		||||
      - id: cache-web
 | 
			
		||||
        uses: actions/cache@v3
 | 
			
		||||
        with:
 | 
			
		||||
          path: web/dist
 | 
			
		||||
          key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
 | 
			
		||||
      - name: prepare web ui
 | 
			
		||||
        if: steps.cache-web.outputs.cache-hit != 'true'
 | 
			
		||||
        working-directory: web/
 | 
			
		||||
        run: |
 | 
			
		||||
          npm ci
 | 
			
		||||
          make -C .. gen-client-ts
 | 
			
		||||
          npm run build
 | 
			
		||||
      - name: run e2e
 | 
			
		||||
        run: |
 | 
			
		||||
          poetry run make test-e2e-rest
 | 
			
		||||
          poetry run coverage run manage.py test ${{ matrix.job.glob }}
 | 
			
		||||
          poetry run coverage xml
 | 
			
		||||
      - if: ${{ always() }}
 | 
			
		||||
        uses: codecov/codecov-action@v3
 | 
			
		||||
@ -173,8 +161,7 @@ jobs:
 | 
			
		||||
      - test-migrations-from-stable
 | 
			
		||||
      - test-unittest
 | 
			
		||||
      - test-integration
 | 
			
		||||
      - test-e2e-rest
 | 
			
		||||
      - test-e2e-provider
 | 
			
		||||
      - test-e2e
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - run: echo mark
 | 
			
		||||
@ -182,11 +169,6 @@ jobs:
 | 
			
		||||
    needs: ci-core-mark
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    timeout-minutes: 120
 | 
			
		||||
    strategy:
 | 
			
		||||
      fail-fast: false
 | 
			
		||||
      matrix:
 | 
			
		||||
        arch:
 | 
			
		||||
          - 'linux/amd64'
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - name: Set up QEMU
 | 
			
		||||
@ -205,7 +187,7 @@ jobs:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
      - name: Build Docker Image
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          secrets: |
 | 
			
		||||
@ -214,14 +196,49 @@ jobs:
 | 
			
		||||
          push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
 | 
			
		||||
          tags: |
 | 
			
		||||
            ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}
 | 
			
		||||
            ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
 | 
			
		||||
            ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
 | 
			
		||||
          build-args: |
 | 
			
		||||
            GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
 | 
			
		||||
            VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
 | 
			
		||||
          platforms: ${{ matrix.arch }}
 | 
			
		||||
      - name: Comment on PR
 | 
			
		||||
        if: github.event_name == 'pull_request'
 | 
			
		||||
        continue-on-error: true
 | 
			
		||||
        uses: ./.github/actions/comment-pr-instructions
 | 
			
		||||
        with:
 | 
			
		||||
          tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
 | 
			
		||||
          tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
 | 
			
		||||
  build-arm64:
 | 
			
		||||
    needs: ci-core-mark
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    timeout-minutes: 120
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - name: Set up QEMU
 | 
			
		||||
        uses: docker/setup-qemu-action@v2.1.0
 | 
			
		||||
      - name: Set up Docker Buildx
 | 
			
		||||
        uses: docker/setup-buildx-action@v2
 | 
			
		||||
      - name: prepare variables
 | 
			
		||||
        uses: ./.github/actions/docker-push-variables
 | 
			
		||||
        id: ev
 | 
			
		||||
        env:
 | 
			
		||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
      - name: Login to Container Registry
 | 
			
		||||
        uses: docker/login-action@v2
 | 
			
		||||
        if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
 | 
			
		||||
        with:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
      - name: Build Docker Image
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          secrets: |
 | 
			
		||||
            GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
 | 
			
		||||
            GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
 | 
			
		||||
          push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
 | 
			
		||||
          tags: |
 | 
			
		||||
            ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-arm64
 | 
			
		||||
            ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}-arm64
 | 
			
		||||
          build-args: |
 | 
			
		||||
            GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
 | 
			
		||||
            VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
 | 
			
		||||
          platforms: linux/arm64
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										7
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							@ -28,6 +28,8 @@ jobs:
 | 
			
		||||
        run: make gen-client-go
 | 
			
		||||
      - name: golangci-lint
 | 
			
		||||
        uses: golangci/golangci-lint-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          args: --timeout 5000s
 | 
			
		||||
  test-unittest:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
@ -80,13 +82,12 @@ jobs:
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
      - name: Generate API
 | 
			
		||||
        run: make gen-client-go
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
      - name: Build Docker Image
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
 | 
			
		||||
          tags: |
 | 
			
		||||
            ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}
 | 
			
		||||
            ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}
 | 
			
		||||
            ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }}
 | 
			
		||||
          file: ${{ matrix.type }}.Dockerfile
 | 
			
		||||
          build-args: |
 | 
			
		||||
@ -111,7 +112,7 @@ jobs:
 | 
			
		||||
      - uses: actions/setup-go@v3
 | 
			
		||||
        with:
 | 
			
		||||
          go-version: "^1.17"
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										10
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							@ -15,7 +15,7 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
@ -31,7 +31,7 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
@ -47,7 +47,7 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
@ -63,7 +63,7 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
@ -95,7 +95,7 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							@ -15,7 +15,7 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							@ -11,12 +11,12 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Delete 'dev' containers older than a week
 | 
			
		||||
        uses: sondrelg/container-retention-policy@v1
 | 
			
		||||
        uses: snok/container-retention-policy@v1
 | 
			
		||||
        with:
 | 
			
		||||
          image-names: dev-server,dev-ldap,dev-proxy
 | 
			
		||||
          cut-off: One week ago UTC
 | 
			
		||||
          account-type: org
 | 
			
		||||
          org-name: goauthentik
 | 
			
		||||
          untagged-only: false
 | 
			
		||||
          token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
 | 
			
		||||
          token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
          skip-tags: gh-next,gh-main
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										11
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							@ -27,11 +27,11 @@ jobs:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
      - name: Build Docker Image
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          push: ${{ github.event_name == 'release' }}
 | 
			
		||||
          secrets:
 | 
			
		||||
          secrets: |
 | 
			
		||||
            GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
 | 
			
		||||
            GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
 | 
			
		||||
          tags: |
 | 
			
		||||
@ -75,7 +75,7 @@ jobs:
 | 
			
		||||
          registry: ghcr.io
 | 
			
		||||
          username: ${{ github.repository_owner }}
 | 
			
		||||
          password: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
      - name: Build Docker Image
 | 
			
		||||
        uses: docker/build-push-action@v3
 | 
			
		||||
        with:
 | 
			
		||||
          push: ${{ github.event_name == 'release' }}
 | 
			
		||||
@ -88,9 +88,6 @@ jobs:
 | 
			
		||||
            ghcr.io/goauthentik/${{ matrix.type }}:latest
 | 
			
		||||
          file: ${{ matrix.type }}.Dockerfile
 | 
			
		||||
          platforms: linux/amd64,linux/arm64
 | 
			
		||||
          secrets: |
 | 
			
		||||
            GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
 | 
			
		||||
            GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
 | 
			
		||||
          build-args: |
 | 
			
		||||
            VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
 | 
			
		||||
  build-outpost-binary:
 | 
			
		||||
@ -109,7 +106,7 @@ jobs:
 | 
			
		||||
      - uses: actions/setup-go@v3
 | 
			
		||||
        with:
 | 
			
		||||
          go-version: "^1.17"
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							@ -26,14 +26,14 @@ jobs:
 | 
			
		||||
        id: get_version
 | 
			
		||||
        uses: actions/github-script@v6
 | 
			
		||||
        with:
 | 
			
		||||
          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          github-token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
          script: |
 | 
			
		||||
            return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
 | 
			
		||||
      - name: Create Release
 | 
			
		||||
        id: create_release
 | 
			
		||||
        uses: actions/create-release@v1.1.4
 | 
			
		||||
        env:
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
        with:
 | 
			
		||||
          tag_name: ${{ github.ref }}
 | 
			
		||||
          release_name: Release ${{ steps.get_version.outputs.result }}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							@ -19,6 +19,8 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
      - name: Setup authentik env
 | 
			
		||||
        uses: ./.github/actions/setup
 | 
			
		||||
      - name: run compile
 | 
			
		||||
@ -27,7 +29,7 @@ jobs:
 | 
			
		||||
        uses: peter-evans/create-pull-request@v4
 | 
			
		||||
        id: cpr
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
          branch: compile-backend-translation
 | 
			
		||||
          commit-message: "core: compile backend translations"
 | 
			
		||||
          title: "core: compile backend translations"
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										16
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										16
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							@ -10,7 +10,9 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v3
 | 
			
		||||
      - uses: actions/setup-node@v3.5.1
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
      - uses: actions/setup-node@v3.6.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
          registry-url: 'https://registry.npmjs.org'
 | 
			
		||||
@ -28,14 +30,20 @@ jobs:
 | 
			
		||||
        run: |
 | 
			
		||||
          export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
 | 
			
		||||
          npm i @goauthentik/api@$VERSION
 | 
			
		||||
      - name: Create Pull Request
 | 
			
		||||
        uses: peter-evans/create-pull-request@v4
 | 
			
		||||
      - uses: peter-evans/create-pull-request@v4
 | 
			
		||||
        id: cpr
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
          branch: update-web-api-client
 | 
			
		||||
          commit-message: "web: bump API Client version"
 | 
			
		||||
          title: "web: bump API Client version"
 | 
			
		||||
          body: "web: bump API Client version"
 | 
			
		||||
          delete-branch: true
 | 
			
		||||
          signoff: true
 | 
			
		||||
          team-reviewers: "@goauthentik/core"
 | 
			
		||||
          author: authentik bot <github-bot@goauthentik.io>
 | 
			
		||||
      - uses: peter-evans/enable-pull-request-automerge@v2
 | 
			
		||||
        with:
 | 
			
		||||
          token: ${{ secrets.BOT_GITHUB_TOKEN }}
 | 
			
		||||
          pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
 | 
			
		||||
          merge-method: squash
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							@ -14,7 +14,9 @@
 | 
			
		||||
        "webauthn",
 | 
			
		||||
        "traefik",
 | 
			
		||||
        "passwordless",
 | 
			
		||||
        "kubernetes"
 | 
			
		||||
        "kubernetes",
 | 
			
		||||
        "sso",
 | 
			
		||||
        "slo"
 | 
			
		||||
    ],
 | 
			
		||||
    "python.linting.pylintEnabled": true,
 | 
			
		||||
    "todo-tree.tree.showCountsInTree": true,
 | 
			
		||||
 | 
			
		||||
@ -59,19 +59,18 @@ These are the current packages:
 | 
			
		||||
authentik
 | 
			
		||||
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
 | 
			
		||||
├── api - General API Configuration (Routes, Schema and general API utilities)
 | 
			
		||||
├── blueprints - Handle managed models and their state.
 | 
			
		||||
├── core - Core authentik functionality, central routes, core Models
 | 
			
		||||
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
 | 
			
		||||
├── events - Event Log, middleware and signals to generate signals
 | 
			
		||||
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
 | 
			
		||||
├── lib - Generic library of functions, few dependencies on other packages.
 | 
			
		||||
├── managed - Handle managed models and their state.
 | 
			
		||||
├── outposts - Configure and deploy outposts on kubernetes and docker.
 | 
			
		||||
├── policies - General PolicyEngine
 | 
			
		||||
│   ├── dummy - A Dummy policy used for testing
 | 
			
		||||
│   ├── event_matcher - Match events based on different criteria
 | 
			
		||||
│   ├── expiry - Check when a user's password was last set
 | 
			
		||||
│   ├── expression - Execute any arbitrary python code
 | 
			
		||||
│   ├── hibp - Check a password against HaveIBeenPwned
 | 
			
		||||
│   ├── password - Check a password against several rules
 | 
			
		||||
│   └── reputation - Check the user's/client's reputation
 | 
			
		||||
├── providers
 | 
			
		||||
 | 
			
		||||
@ -31,7 +31,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.4-bullseye AS go-builder
 | 
			
		||||
FROM docker.io/golang:1.19.5-bullseye AS go-builder
 | 
			
		||||
 | 
			
		||||
WORKDIR /work
 | 
			
		||||
 | 
			
		||||
@ -50,6 +50,7 @@ RUN go build -o /work/authentik ./cmd/server/
 | 
			
		||||
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
 | 
			
		||||
 | 
			
		||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
 | 
			
		||||
ENV GEOIPUPDATE_VERBOSE="true"
 | 
			
		||||
 | 
			
		||||
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
 | 
			
		||||
    --mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
 | 
			
		||||
@ -57,7 +58,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
 | 
			
		||||
    /bin/sh -c "\
 | 
			
		||||
        export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \
 | 
			
		||||
        export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \
 | 
			
		||||
        /usr/bin/entry.sh || exit 0 \
 | 
			
		||||
        /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0 \
 | 
			
		||||
    "
 | 
			
		||||
 | 
			
		||||
# Stage 6: Run
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										9
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										9
									
								
								Makefile
									
									
									
									
									
								
							@ -6,15 +6,6 @@ NPM_VERSION = $(shell python -m scripts.npm_version)
 | 
			
		||||
 | 
			
		||||
all: lint-fix lint test gen web
 | 
			
		||||
 | 
			
		||||
test-integration:
 | 
			
		||||
	coverage run manage.py test tests/integration
 | 
			
		||||
 | 
			
		||||
test-e2e-provider:
 | 
			
		||||
	coverage run manage.py test tests/e2e/test_provider*
 | 
			
		||||
 | 
			
		||||
test-e2e-rest:
 | 
			
		||||
	coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source*
 | 
			
		||||
 | 
			
		||||
test-go:
 | 
			
		||||
	go test -timeout 0 -v -race -cover ./...
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,8 +6,8 @@ Authentik takes security very seriously. We follow the rules of [responsible dis
 | 
			
		||||
 | 
			
		||||
| Version   | Supported          |
 | 
			
		||||
| --------- | ------------------ |
 | 
			
		||||
| 2022.11.x | :white_check_mark: |
 | 
			
		||||
| 2022.12.x | :white_check_mark: |
 | 
			
		||||
| 2023.1.x  | :white_check_mark: |
 | 
			
		||||
 | 
			
		||||
## Reporting a Vulnerability
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
from os import environ
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
__version__ = "2022.12.2"
 | 
			
		||||
__version__ = "2023.1.1"
 | 
			
		||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,7 @@
 | 
			
		||||
"""authentik administration metrics"""
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
 | 
			
		||||
from django.db.models.functions import ExtractHour
 | 
			
		||||
from drf_spectacular.utils import extend_schema, extend_schema_field
 | 
			
		||||
from guardian.shortcuts import get_objects_for_user
 | 
			
		||||
from rest_framework.fields import IntegerField, SerializerMethodField
 | 
			
		||||
@ -21,38 +24,44 @@ class CoordinateSerializer(PassiveSerializer):
 | 
			
		||||
class LoginMetricsSerializer(PassiveSerializer):
 | 
			
		||||
    """Login Metrics per 1h"""
 | 
			
		||||
 | 
			
		||||
    logins_per_1h = SerializerMethodField()
 | 
			
		||||
    logins_failed_per_1h = SerializerMethodField()
 | 
			
		||||
    authorizations_per_1h = SerializerMethodField()
 | 
			
		||||
    logins = SerializerMethodField()
 | 
			
		||||
    logins_failed = SerializerMethodField()
 | 
			
		||||
    authorizations = SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_logins_per_1h(self, _):
 | 
			
		||||
        """Get successful logins per hour for the last 24 hours"""
 | 
			
		||||
    def get_logins(self, _):
 | 
			
		||||
        """Get successful logins per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.LOGIN)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.LOGIN
 | 
			
		||||
            )
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_logins_failed_per_1h(self, _):
 | 
			
		||||
        """Get failed logins per hour for the last 24 hours"""
 | 
			
		||||
    def get_logins_failed(self, _):
 | 
			
		||||
        """Get failed logins per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.LOGIN_FAILED)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.LOGIN_FAILED
 | 
			
		||||
            )
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_authorizations_per_1h(self, _):
 | 
			
		||||
        """Get successful authorizations per hour for the last 24 hours"""
 | 
			
		||||
    def get_authorizations(self, _):
 | 
			
		||||
        """Get successful authorizations per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.AUTHORIZE_APPLICATION)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.AUTHORIZE_APPLICATION
 | 
			
		||||
            )
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,13 @@ from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField
 | 
			
		||||
from rest_framework.fields import (
 | 
			
		||||
    CharField,
 | 
			
		||||
    ChoiceField,
 | 
			
		||||
    DateTimeField,
 | 
			
		||||
    ListField,
 | 
			
		||||
    SerializerMethodField,
 | 
			
		||||
)
 | 
			
		||||
from rest_framework.permissions import IsAdminUser
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
@ -26,6 +32,7 @@ class TaskSerializer(PassiveSerializer):
 | 
			
		||||
    task_name = CharField()
 | 
			
		||||
    task_description = CharField()
 | 
			
		||||
    task_finish_timestamp = DateTimeField(source="finish_time")
 | 
			
		||||
    task_duration = SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    status = ChoiceField(
 | 
			
		||||
        source="result.status.name",
 | 
			
		||||
@ -33,7 +40,11 @@ class TaskSerializer(PassiveSerializer):
 | 
			
		||||
    )
 | 
			
		||||
    messages = ListField(source="result.messages")
 | 
			
		||||
 | 
			
		||||
    def to_representation(self, instance):
 | 
			
		||||
    def get_task_duration(self, instance: TaskInfo) -> int:
 | 
			
		||||
        """Get the duration a task took to run"""
 | 
			
		||||
        return max(instance.finish_timestamp - instance.start_timestamp, 0)
 | 
			
		||||
 | 
			
		||||
    def to_representation(self, instance: TaskInfo):
 | 
			
		||||
        """When a new version of authentik adds fields to TaskInfo,
 | 
			
		||||
        the API will fail with an AttributeError, as the classes
 | 
			
		||||
        are pickled in cache. In that case, just delete the info"""
 | 
			
		||||
@ -68,7 +79,6 @@ class TaskViewSet(ViewSet):
 | 
			
		||||
            ),
 | 
			
		||||
        ],
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    def retrieve(self, request: Request, pk=None) -> Response:
 | 
			
		||||
        """Get a single system task"""
 | 
			
		||||
        task = TaskInfo.by_name(pk)
 | 
			
		||||
@ -99,7 +109,6 @@ class TaskViewSet(ViewSet):
 | 
			
		||||
        ],
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, methods=["post"])
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    def retry(self, request: Request, pk=None) -> Response:
 | 
			
		||||
        """Retry task"""
 | 
			
		||||
        task = TaskInfo.by_name(pk)
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,6 @@ from authentik.root.monitoring import monitoring_set
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(monitoring_set)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def monitoring_set_workers(sender, **kwargs):
 | 
			
		||||
    """Set worker gauge"""
 | 
			
		||||
    count = len(CELERY_APP.control.ping(timeout=0.5))
 | 
			
		||||
@ -16,7 +15,6 @@ def monitoring_set_workers(sender, **kwargs):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(monitoring_set)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def monitoring_set_tasks(sender, **kwargs):
 | 
			
		||||
    """Set task gauges"""
 | 
			
		||||
    for task in TaskInfo.all().values():
 | 
			
		||||
 | 
			
		||||
@ -62,7 +62,7 @@ window.addEventListener('DOMContentLoaded', (event) => {
 | 
			
		||||
    allow-spec-url-load="false"
 | 
			
		||||
    allow-spec-file-load="false">
 | 
			
		||||
    <div slot="nav-logo">
 | 
			
		||||
        <img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
 | 
			
		||||
        <img  alt="authentik Logo" class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
 | 
			
		||||
    </div>
 | 
			
		||||
</rapi-doc>
 | 
			
		||||
<script>
 | 
			
		||||
 | 
			
		||||
@ -45,7 +45,6 @@ from authentik.policies.dummy.api import DummyPolicyViewSet
 | 
			
		||||
from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet
 | 
			
		||||
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
 | 
			
		||||
from authentik.policies.expression.api import ExpressionPolicyViewSet
 | 
			
		||||
from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
 | 
			
		||||
from authentik.policies.password.api import PasswordPolicyViewSet
 | 
			
		||||
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
 | 
			
		||||
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
 | 
			
		||||
@ -150,7 +149,6 @@ router.register("policies/all", PolicyViewSet)
 | 
			
		||||
router.register("policies/bindings", PolicyBindingViewSet)
 | 
			
		||||
router.register("policies/expression", ExpressionPolicyViewSet)
 | 
			
		||||
router.register("policies/event_matcher", EventMatcherPolicyViewSet)
 | 
			
		||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
 | 
			
		||||
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
 | 
			
		||||
router.register("policies/password", PasswordPolicyViewSet)
 | 
			
		||||
router.register("policies/reputation/scores", ReputationViewSet)
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
"""Serializer mixin for managed models"""
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from drf_spectacular.utils import extend_schema, inline_serializer
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
@ -11,6 +12,7 @@ from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.api.decorators import permission_required
 | 
			
		||||
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
 | 
			
		||||
from authentik.blueprints.v1.importer import Importer
 | 
			
		||||
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
@ -40,6 +42,21 @@ class BlueprintInstanceSerializer(ModelSerializer):
 | 
			
		||||
            raise ValidationError(exc) from exc
 | 
			
		||||
        return path
 | 
			
		||||
 | 
			
		||||
    def validate_content(self, content: str) -> str:
 | 
			
		||||
        """Ensure content (if set) is a valid blueprint"""
 | 
			
		||||
        if content == "":
 | 
			
		||||
            return content
 | 
			
		||||
        context = self.instance.context if self.instance else {}
 | 
			
		||||
        valid, logs = Importer(content, context).validate()
 | 
			
		||||
        if not valid:
 | 
			
		||||
            raise ValidationError(_("Failed to validate blueprint"), *[x["msg"] for x in logs])
 | 
			
		||||
        return content
 | 
			
		||||
 | 
			
		||||
    def validate(self, attrs: dict) -> dict:
 | 
			
		||||
        if attrs.get("path", "") == "" and attrs.get("content", "") == "":
 | 
			
		||||
            raise ValidationError(_("Either path or content must be set."))
 | 
			
		||||
        return super().validate(attrs)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        model = BlueprintInstance
 | 
			
		||||
@ -54,6 +71,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
 | 
			
		||||
            "enabled",
 | 
			
		||||
            "managed_models",
 | 
			
		||||
            "metadata",
 | 
			
		||||
            "content",
 | 
			
		||||
        ]
 | 
			
		||||
        extra_kwargs = {
 | 
			
		||||
            "status": {"read_only": True},
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-01-10 19:48
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_blueprints", "0001_initial"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="blueprintinstance",
 | 
			
		||||
            name="content",
 | 
			
		||||
            field=models.TextField(blank=True, default=""),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="blueprintinstance",
 | 
			
		||||
            name="path",
 | 
			
		||||
            field=models.TextField(blank=True, default=""),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -1,30 +1,18 @@
 | 
			
		||||
"""blueprint models"""
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from django.contrib.postgres.fields import ArrayField
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from opencontainers.distribution.reggie import (
 | 
			
		||||
    NewClient,
 | 
			
		||||
    WithDebug,
 | 
			
		||||
    WithDefaultName,
 | 
			
		||||
    WithDigest,
 | 
			
		||||
    WithReference,
 | 
			
		||||
    WithUserAgent,
 | 
			
		||||
    WithUsernamePassword,
 | 
			
		||||
)
 | 
			
		||||
from requests.exceptions import RequestException
 | 
			
		||||
from rest_framework.serializers import Serializer
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.blueprints.v1.oci import BlueprintOCIClient, OCIException
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
 | 
			
		||||
from authentik.lib.sentry import SentryIgnoredException
 | 
			
		||||
from authentik.lib.utils.http import authentik_user_agent
 | 
			
		||||
 | 
			
		||||
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -74,7 +62,8 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
 | 
			
		||||
 | 
			
		||||
    name = models.TextField()
 | 
			
		||||
    metadata = models.JSONField(default=dict)
 | 
			
		||||
    path = models.TextField()
 | 
			
		||||
    path = models.TextField(default="", blank=True)
 | 
			
		||||
    content = models.TextField(default="", blank=True)
 | 
			
		||||
    context = models.JSONField(default=dict)
 | 
			
		||||
    last_applied = models.DateTimeField(auto_now=True)
 | 
			
		||||
    last_applied_hash = models.TextField()
 | 
			
		||||
@ -86,60 +75,29 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
 | 
			
		||||
 | 
			
		||||
    def retrieve_oci(self) -> str:
 | 
			
		||||
        """Get blueprint from an OCI registry"""
 | 
			
		||||
        url = urlparse(self.path)
 | 
			
		||||
        ref = "latest"
 | 
			
		||||
        path = url.path[1:]
 | 
			
		||||
        if ":" in url.path:
 | 
			
		||||
            path, _, ref = path.partition(":")
 | 
			
		||||
        client = NewClient(
 | 
			
		||||
            f"https://{url.hostname}",
 | 
			
		||||
            WithUserAgent(authentik_user_agent()),
 | 
			
		||||
            WithUsernamePassword(url.username, url.password),
 | 
			
		||||
            WithDefaultName(path),
 | 
			
		||||
            WithDebug(True),
 | 
			
		||||
        )
 | 
			
		||||
        LOGGER.info("Fetching OCI manifests for blueprint", instance=self)
 | 
			
		||||
        manifest_request = client.NewRequest(
 | 
			
		||||
            "GET",
 | 
			
		||||
            "/v2/<name>/manifests/<reference>",
 | 
			
		||||
            WithReference(ref),
 | 
			
		||||
        ).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
 | 
			
		||||
        client = BlueprintOCIClient(self.path.replace("oci://", "https://"))
 | 
			
		||||
        try:
 | 
			
		||||
            manifest_response = client.Do(manifest_request)
 | 
			
		||||
            manifest_response.raise_for_status()
 | 
			
		||||
        except RequestException as exc:
 | 
			
		||||
            manifests = client.fetch_manifests()
 | 
			
		||||
            return client.fetch_blobs(manifests)
 | 
			
		||||
        except OCIException as exc:
 | 
			
		||||
            raise BlueprintRetrievalFailed(exc) from exc
 | 
			
		||||
        manifest = manifest_response.json()
 | 
			
		||||
        if "errors" in manifest:
 | 
			
		||||
            raise BlueprintRetrievalFailed(manifest["errors"])
 | 
			
		||||
 | 
			
		||||
        blob = None
 | 
			
		||||
        for layer in manifest.get("layers", []):
 | 
			
		||||
            if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
 | 
			
		||||
                blob = layer.get("digest")
 | 
			
		||||
                LOGGER.debug("Found layer with matching media type", instance=self, blob=blob)
 | 
			
		||||
        if not blob:
 | 
			
		||||
            raise BlueprintRetrievalFailed("Blob not found")
 | 
			
		||||
 | 
			
		||||
        blob_request = client.NewRequest(
 | 
			
		||||
            "GET",
 | 
			
		||||
            "/v2/<name>/blobs/<digest>",
 | 
			
		||||
            WithDigest(blob),
 | 
			
		||||
        )
 | 
			
		||||
    def retrieve_file(self) -> str:
 | 
			
		||||
        """Get blueprint from path"""
 | 
			
		||||
        try:
 | 
			
		||||
            blob_response = client.Do(blob_request)
 | 
			
		||||
            blob_response.raise_for_status()
 | 
			
		||||
            return blob_response.text
 | 
			
		||||
        except RequestException as exc:
 | 
			
		||||
            full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
 | 
			
		||||
            with full_path.open("r", encoding="utf-8") as _file:
 | 
			
		||||
                return _file.read()
 | 
			
		||||
        except (IOError, OSError) as exc:
 | 
			
		||||
            raise BlueprintRetrievalFailed(exc) from exc
 | 
			
		||||
 | 
			
		||||
    def retrieve(self) -> str:
 | 
			
		||||
        """Retrieve blueprint contents"""
 | 
			
		||||
        if self.path.startswith("oci://"):
 | 
			
		||||
            return self.retrieve_oci()
 | 
			
		||||
        full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
 | 
			
		||||
        with full_path.open("r", encoding="utf-8") as _file:
 | 
			
		||||
            return _file.read()
 | 
			
		||||
        if self.path != "":
 | 
			
		||||
            return self.retrieve_file()
 | 
			
		||||
        return self.content
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> Serializer:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										51
									
								
								authentik/blueprints/tests/fixtures/tags.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										51
									
								
								authentik/blueprints/tests/fixtures/tags.yaml
									
									
									
									
										vendored
									
									
								
							@ -3,6 +3,12 @@ context:
 | 
			
		||||
    foo: bar
 | 
			
		||||
    policy_property: name
 | 
			
		||||
    policy_property_value: foo-bar-baz-qux
 | 
			
		||||
    sequence:
 | 
			
		||||
    - foo
 | 
			
		||||
    - bar
 | 
			
		||||
    mapping:
 | 
			
		||||
      key1: value
 | 
			
		||||
      key2: 2
 | 
			
		||||
entries:
 | 
			
		||||
    - model: !Format ["%s", authentik_sources_oauth.oauthsource]
 | 
			
		||||
      state: !Format ["%s", present]
 | 
			
		||||
@ -19,7 +25,7 @@ entries:
 | 
			
		||||
                  [slug, default-source-authentication],
 | 
			
		||||
              ]
 | 
			
		||||
          enrollment_flow:
 | 
			
		||||
              !Find [authentik_flows.Flow, [slug, default-source-enrollment]]
 | 
			
		||||
              !Find [!Format  ["%s", authentik_flows.Flow], [slug, default-source-enrollment]]
 | 
			
		||||
    - attrs:
 | 
			
		||||
          expression: return True
 | 
			
		||||
      identifiers:
 | 
			
		||||
@ -92,6 +98,49 @@ entries:
 | 
			
		||||
                  ]
 | 
			
		||||
              if_true_simple: !If [!Context foo, true, text]
 | 
			
		||||
              if_false_simple: !If [null, false, 2]
 | 
			
		||||
              enumerate_mapping_to_mapping: !Enumerate [
 | 
			
		||||
                  !Context mapping,
 | 
			
		||||
                  MAP,
 | 
			
		||||
                  [!Format ["prefix-%s", !Index 0], !Format ["other-prefix-%s", !Value 0]]
 | 
			
		||||
              ]
 | 
			
		||||
              enumerate_mapping_to_sequence: !Enumerate [
 | 
			
		||||
                  !Context mapping,
 | 
			
		||||
                  SEQ,
 | 
			
		||||
                  !Format ["prefixed-pair-%s-%s", !Index 0, !Value 0]
 | 
			
		||||
              ]
 | 
			
		||||
              enumerate_sequence_to_sequence: !Enumerate [
 | 
			
		||||
                  !Context sequence,
 | 
			
		||||
                  SEQ,
 | 
			
		||||
                  !Format ["prefixed-items-%s-%s", !Index 0, !Value 0]
 | 
			
		||||
              ]
 | 
			
		||||
              enumerate_sequence_to_mapping: !Enumerate [
 | 
			
		||||
                  !Context sequence,
 | 
			
		||||
                  MAP,
 | 
			
		||||
                  [!Format ["index: %d", !Index 0], !Value 0]
 | 
			
		||||
              ]
 | 
			
		||||
              nested_complex_enumeration: !Enumerate [
 | 
			
		||||
                  !Context sequence,
 | 
			
		||||
                  MAP,
 | 
			
		||||
                  [
 | 
			
		||||
                      !Index 0,
 | 
			
		||||
                      !Enumerate [
 | 
			
		||||
                          !Context mapping,
 | 
			
		||||
                          MAP,
 | 
			
		||||
                          [
 | 
			
		||||
                              !Format ["%s", !Index 0],
 | 
			
		||||
                              [
 | 
			
		||||
                                  !Enumerate [!Value 2, SEQ, !Format ["prefixed-%s", !Value 0]],
 | 
			
		||||
                                  {
 | 
			
		||||
                                    outer_value: !Value 1,
 | 
			
		||||
                                    outer_index: !Index 1,
 | 
			
		||||
                                    middle_value: !Value 0,
 | 
			
		||||
                                    middle_index: !Index 0
 | 
			
		||||
                                  }
 | 
			
		||||
                              ]
 | 
			
		||||
                          ]
 | 
			
		||||
                      ]
 | 
			
		||||
                  ]
 | 
			
		||||
              ]
 | 
			
		||||
      identifiers:
 | 
			
		||||
          name: test
 | 
			
		||||
      conditions:
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,8 @@
 | 
			
		||||
from django.test import TransactionTestCase
 | 
			
		||||
from requests_mock import Mocker
 | 
			
		||||
 | 
			
		||||
from authentik.blueprints.models import OCI_MEDIA_TYPE, BlueprintInstance, BlueprintRetrievalFailed
 | 
			
		||||
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
 | 
			
		||||
from authentik.blueprints.v1.oci import OCI_MEDIA_TYPE
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestBlueprintOCI(TransactionTestCase):
 | 
			
		||||
 | 
			
		||||
@ -162,6 +162,61 @@ class TestBlueprintsV1(TransactionTestCase):
 | 
			
		||||
                    "if_false_complex": ["list", "with", "items", "foo-bar"],
 | 
			
		||||
                    "if_true_simple": True,
 | 
			
		||||
                    "if_false_simple": 2,
 | 
			
		||||
                    "enumerate_mapping_to_mapping": {
 | 
			
		||||
                        "prefix-key1": "other-prefix-value",
 | 
			
		||||
                        "prefix-key2": "other-prefix-2",
 | 
			
		||||
                    },
 | 
			
		||||
                    "enumerate_mapping_to_sequence": [
 | 
			
		||||
                        "prefixed-pair-key1-value",
 | 
			
		||||
                        "prefixed-pair-key2-2",
 | 
			
		||||
                    ],
 | 
			
		||||
                    "enumerate_sequence_to_sequence": [
 | 
			
		||||
                        "prefixed-items-0-foo",
 | 
			
		||||
                        "prefixed-items-1-bar",
 | 
			
		||||
                    ],
 | 
			
		||||
                    "enumerate_sequence_to_mapping": {"index: 0": "foo", "index: 1": "bar"},
 | 
			
		||||
                    "nested_complex_enumeration": {
 | 
			
		||||
                        "0": {
 | 
			
		||||
                            "key1": [
 | 
			
		||||
                                ["prefixed-f", "prefixed-o", "prefixed-o"],
 | 
			
		||||
                                {
 | 
			
		||||
                                    "outer_value": "foo",
 | 
			
		||||
                                    "outer_index": 0,
 | 
			
		||||
                                    "middle_value": "value",
 | 
			
		||||
                                    "middle_index": "key1",
 | 
			
		||||
                                },
 | 
			
		||||
                            ],
 | 
			
		||||
                            "key2": [
 | 
			
		||||
                                ["prefixed-f", "prefixed-o", "prefixed-o"],
 | 
			
		||||
                                {
 | 
			
		||||
                                    "outer_value": "foo",
 | 
			
		||||
                                    "outer_index": 0,
 | 
			
		||||
                                    "middle_value": 2,
 | 
			
		||||
                                    "middle_index": "key2",
 | 
			
		||||
                                },
 | 
			
		||||
                            ],
 | 
			
		||||
                        },
 | 
			
		||||
                        "1": {
 | 
			
		||||
                            "key1": [
 | 
			
		||||
                                ["prefixed-b", "prefixed-a", "prefixed-r"],
 | 
			
		||||
                                {
 | 
			
		||||
                                    "outer_value": "bar",
 | 
			
		||||
                                    "outer_index": 1,
 | 
			
		||||
                                    "middle_value": "value",
 | 
			
		||||
                                    "middle_index": "key1",
 | 
			
		||||
                                },
 | 
			
		||||
                            ],
 | 
			
		||||
                            "key2": [
 | 
			
		||||
                                ["prefixed-b", "prefixed-a", "prefixed-r"],
 | 
			
		||||
                                {
 | 
			
		||||
                                    "outer_value": "bar",
 | 
			
		||||
                                    "outer_index": 1,
 | 
			
		||||
                                    "middle_value": 2,
 | 
			
		||||
                                    "middle_index": "key2",
 | 
			
		||||
                                },
 | 
			
		||||
                            ],
 | 
			
		||||
                        },
 | 
			
		||||
                    },
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -43,3 +43,28 @@ class TestBlueprintsV1API(APITestCase):
 | 
			
		||||
                    "6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def test_api_blank(self):
 | 
			
		||||
        """Test blank"""
 | 
			
		||||
        res = self.client.post(
 | 
			
		||||
            reverse("authentik_api:blueprintinstance-list"),
 | 
			
		||||
            data={
 | 
			
		||||
                "name": "foo",
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(res.status_code, 400)
 | 
			
		||||
        self.assertJSONEqual(
 | 
			
		||||
            res.content.decode(), {"non_field_errors": ["Either path or content must be set."]}
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_api_content(self):
 | 
			
		||||
        """Test blank"""
 | 
			
		||||
        res = self.client.post(
 | 
			
		||||
            reverse("authentik_api:blueprintinstance-list"),
 | 
			
		||||
            data={
 | 
			
		||||
                "name": "foo",
 | 
			
		||||
                "content": '{"version": 3}',
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(res.status_code, 400)
 | 
			
		||||
        self.assertJSONEqual(res.content.decode(), {"content": ["Failed to validate blueprint"]})
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,15 @@
 | 
			
		||||
"""transfer common classes"""
 | 
			
		||||
from collections import OrderedDict
 | 
			
		||||
from copy import copy
 | 
			
		||||
from dataclasses import asdict, dataclass, field, is_dataclass
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from functools import reduce
 | 
			
		||||
from operator import ixor
 | 
			
		||||
from os import getenv
 | 
			
		||||
from typing import Any, Literal, Optional, Union
 | 
			
		||||
from typing import Any, Iterable, Literal, Mapping, Optional, Union
 | 
			
		||||
from uuid import UUID
 | 
			
		||||
 | 
			
		||||
from deepmerge import always_merger
 | 
			
		||||
from django.apps import apps
 | 
			
		||||
from django.db.models import Model, Q
 | 
			
		||||
from rest_framework.fields import Field
 | 
			
		||||
@ -64,11 +66,13 @@ class BlueprintEntry:
 | 
			
		||||
    identifiers: dict[str, Any] = field(default_factory=dict)
 | 
			
		||||
    attrs: Optional[dict[str, Any]] = field(default_factory=dict)
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    id: Optional[str] = None
 | 
			
		||||
 | 
			
		||||
    _state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
 | 
			
		||||
 | 
			
		||||
    def __post_init__(self, *args, **kwargs) -> None:
 | 
			
		||||
        self.__tag_contexts: list["YAMLTagContext"] = []
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
 | 
			
		||||
        """Convert a SerializerModel instance to a blueprint Entry"""
 | 
			
		||||
@ -85,17 +89,46 @@ class BlueprintEntry:
 | 
			
		||||
            attrs=all_attrs,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def _get_tag_context(
 | 
			
		||||
        self,
 | 
			
		||||
        depth: int = 0,
 | 
			
		||||
        context_tag_type: Optional[type["YAMLTagContext"] | tuple["YAMLTagContext", ...]] = None,
 | 
			
		||||
    ) -> "YAMLTagContext":
 | 
			
		||||
        """Get a YAMLTagContext object located at a certain depth in the tag tree"""
 | 
			
		||||
        if depth < 0:
 | 
			
		||||
            raise ValueError("depth must be a positive number or zero")
 | 
			
		||||
 | 
			
		||||
        if context_tag_type:
 | 
			
		||||
            contexts = [x for x in self.__tag_contexts if isinstance(x, context_tag_type)]
 | 
			
		||||
        else:
 | 
			
		||||
            contexts = self.__tag_contexts
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            return contexts[-(depth + 1)]
 | 
			
		||||
        except IndexError:
 | 
			
		||||
            raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}")
 | 
			
		||||
 | 
			
		||||
    def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
 | 
			
		||||
        """Check if we have any special tags that need handling"""
 | 
			
		||||
        val = copy(value)
 | 
			
		||||
 | 
			
		||||
        if isinstance(value, YAMLTagContext):
 | 
			
		||||
            self.__tag_contexts.append(value)
 | 
			
		||||
 | 
			
		||||
        if isinstance(value, YAMLTag):
 | 
			
		||||
            return value.resolve(self, blueprint)
 | 
			
		||||
            val = value.resolve(self, blueprint)
 | 
			
		||||
 | 
			
		||||
        if isinstance(value, dict):
 | 
			
		||||
            for key, inner_value in value.items():
 | 
			
		||||
                value[key] = self.tag_resolver(inner_value, blueprint)
 | 
			
		||||
                val[key] = self.tag_resolver(inner_value, blueprint)
 | 
			
		||||
        if isinstance(value, list):
 | 
			
		||||
            for idx, inner_value in enumerate(value):
 | 
			
		||||
                value[idx] = self.tag_resolver(inner_value, blueprint)
 | 
			
		||||
        return value
 | 
			
		||||
                val[idx] = self.tag_resolver(inner_value, blueprint)
 | 
			
		||||
 | 
			
		||||
        if isinstance(value, YAMLTagContext):
 | 
			
		||||
            self.__tag_contexts.pop()
 | 
			
		||||
 | 
			
		||||
        return val
 | 
			
		||||
 | 
			
		||||
    def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
 | 
			
		||||
        """Get attributes of this entry, with all yaml tags resolved"""
 | 
			
		||||
@ -145,12 +178,19 @@ class YAMLTag:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class YAMLTagContext:
 | 
			
		||||
    """Base class for all YAML Tag Contexts"""
 | 
			
		||||
 | 
			
		||||
    def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
 | 
			
		||||
        """Implement yaml tag context logic"""
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class KeyOf(YAMLTag):
 | 
			
		||||
    """Reference another object by their ID"""
 | 
			
		||||
 | 
			
		||||
    id_from: str
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.id_from = node.value
 | 
			
		||||
@ -177,7 +217,6 @@ class Env(YAMLTag):
 | 
			
		||||
    key: str
 | 
			
		||||
    default: Optional[Any]
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.default = None
 | 
			
		||||
@ -197,7 +236,6 @@ class Context(YAMLTag):
 | 
			
		||||
    key: str
 | 
			
		||||
    default: Optional[Any]
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.default = None
 | 
			
		||||
@ -220,7 +258,6 @@ class Format(YAMLTag):
 | 
			
		||||
    format_string: str
 | 
			
		||||
    args: list[Any]
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.format_string = node.value[0].value
 | 
			
		||||
@ -245,15 +282,12 @@ class Format(YAMLTag):
 | 
			
		||||
class Find(YAMLTag):
 | 
			
		||||
    """Find any object"""
 | 
			
		||||
 | 
			
		||||
    model_name: str
 | 
			
		||||
    model_name: str | YAMLTag
 | 
			
		||||
    conditions: list[list]
 | 
			
		||||
 | 
			
		||||
    model_class: type[Model]
 | 
			
		||||
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.model_name = node.value[0].value
 | 
			
		||||
        self.model_class = apps.get_model(*self.model_name.split("."))
 | 
			
		||||
        self.model_name = loader.construct_object(node.value[0])
 | 
			
		||||
        self.conditions = []
 | 
			
		||||
        for raw_node in node.value[1:]:
 | 
			
		||||
            values = []
 | 
			
		||||
@ -262,6 +296,13 @@ class Find(YAMLTag):
 | 
			
		||||
            self.conditions.append(values)
 | 
			
		||||
 | 
			
		||||
    def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
 | 
			
		||||
        if isinstance(self.model_name, YAMLTag):
 | 
			
		||||
            model_name = self.model_name.resolve(entry, blueprint)
 | 
			
		||||
        else:
 | 
			
		||||
            model_name = self.model_name
 | 
			
		||||
 | 
			
		||||
        model_class = apps.get_model(*model_name.split("."))
 | 
			
		||||
 | 
			
		||||
        query = Q()
 | 
			
		||||
        for cond in self.conditions:
 | 
			
		||||
            if isinstance(cond[0], YAMLTag):
 | 
			
		||||
@ -273,7 +314,7 @@ class Find(YAMLTag):
 | 
			
		||||
            else:
 | 
			
		||||
                query_value = cond[1]
 | 
			
		||||
            query &= Q(**{query_key: query_value})
 | 
			
		||||
        instance = self.model_class.objects.filter(query).first()
 | 
			
		||||
        instance = model_class.objects.filter(query).first()
 | 
			
		||||
        if instance:
 | 
			
		||||
            return instance.pk
 | 
			
		||||
        return None
 | 
			
		||||
@ -296,7 +337,6 @@ class Condition(YAMLTag):
 | 
			
		||||
        "XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.mode = node.value[0].value
 | 
			
		||||
@ -329,7 +369,6 @@ class If(YAMLTag):
 | 
			
		||||
    when_true: Any
 | 
			
		||||
    when_false: Any
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.condition = loader.construct_object(node.value[0])
 | 
			
		||||
@ -351,6 +390,133 @@ class If(YAMLTag):
 | 
			
		||||
            raise EntryInvalidError(exc)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Enumerate(YAMLTag, YAMLTagContext):
 | 
			
		||||
    """Iterate over an iterable."""
 | 
			
		||||
 | 
			
		||||
    iterable: YAMLTag | Iterable
 | 
			
		||||
    item_body: Any
 | 
			
		||||
    output_body: Literal["SEQ", "MAP"]
 | 
			
		||||
 | 
			
		||||
    _OUTPUT_BODIES = {
 | 
			
		||||
        "SEQ": (list, lambda a, b: [*a, b]),
 | 
			
		||||
        "MAP": (
 | 
			
		||||
            dict,
 | 
			
		||||
            lambda a, b: always_merger.merge(
 | 
			
		||||
                a, {b[0]: b[1]} if isinstance(b, (tuple, list)) else b
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.iterable = loader.construct_object(node.value[0])
 | 
			
		||||
        self.output_body = node.value[1].value
 | 
			
		||||
        self.item_body = loader.construct_object(node.value[2])
 | 
			
		||||
        self.__current_context: tuple[Any, Any] = tuple()
 | 
			
		||||
 | 
			
		||||
    def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
 | 
			
		||||
        return self.__current_context
 | 
			
		||||
 | 
			
		||||
    def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
 | 
			
		||||
        if isinstance(self.iterable, EnumeratedItem) and self.iterable.depth == 0:
 | 
			
		||||
            raise EntryInvalidError(
 | 
			
		||||
                f"{self.__class__.__name__} tag's iterable references this tag's context. "
 | 
			
		||||
                "This is a noop. Check you are setting depth bigger than 0."
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        if isinstance(self.iterable, YAMLTag):
 | 
			
		||||
            iterable = self.iterable.resolve(entry, blueprint)
 | 
			
		||||
        else:
 | 
			
		||||
            iterable = self.iterable
 | 
			
		||||
 | 
			
		||||
        if not isinstance(iterable, Iterable):
 | 
			
		||||
            raise EntryInvalidError(
 | 
			
		||||
                f"{self.__class__.__name__}'s iterable must be an iterable "
 | 
			
		||||
                "such as a sequence or a mapping"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        if isinstance(iterable, Mapping):
 | 
			
		||||
            iterable = tuple(iterable.items())
 | 
			
		||||
        else:
 | 
			
		||||
            iterable = tuple(enumerate(iterable))
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()]
 | 
			
		||||
        except KeyError as exc:
 | 
			
		||||
            raise EntryInvalidError(exc)
 | 
			
		||||
 | 
			
		||||
        result = output_class()
 | 
			
		||||
 | 
			
		||||
        self.__current_context = tuple()
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            for item in iterable:
 | 
			
		||||
                self.__current_context = item
 | 
			
		||||
                resolved_body = entry.tag_resolver(self.item_body, blueprint)
 | 
			
		||||
                result = add_fn(result, resolved_body)
 | 
			
		||||
                if not isinstance(result, output_class):
 | 
			
		||||
                    raise EntryInvalidError(
 | 
			
		||||
                        f"Invalid {self.__class__.__name__} item found: {resolved_body}"
 | 
			
		||||
                    )
 | 
			
		||||
        finally:
 | 
			
		||||
            self.__current_context = tuple()
 | 
			
		||||
 | 
			
		||||
        return result
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EnumeratedItem(YAMLTag):
 | 
			
		||||
    """Get the current item value and index provided by an Enumerate tag context"""
 | 
			
		||||
 | 
			
		||||
    depth: int
 | 
			
		||||
 | 
			
		||||
    _SUPPORTED_CONTEXT_TAGS = (Enumerate,)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.depth = int(node.value)
 | 
			
		||||
 | 
			
		||||
    def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
 | 
			
		||||
        try:
 | 
			
		||||
            context_tag: Enumerate = entry._get_tag_context(
 | 
			
		||||
                depth=self.depth,
 | 
			
		||||
                context_tag_type=EnumeratedItem._SUPPORTED_CONTEXT_TAGS,
 | 
			
		||||
            )
 | 
			
		||||
        except ValueError as exc:
 | 
			
		||||
            if self.depth == 0:
 | 
			
		||||
                raise EntryInvalidError(
 | 
			
		||||
                    f"{self.__class__.__name__} tags are only usable "
 | 
			
		||||
                    f"inside an {Enumerate.__name__} tag"
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
            raise EntryInvalidError(f"{self.__class__.__name__} tag: {exc}")
 | 
			
		||||
 | 
			
		||||
        return context_tag.get_context(entry, blueprint)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Index(EnumeratedItem):
 | 
			
		||||
    """Get the current item index provided by an Enumerate tag context"""
 | 
			
		||||
 | 
			
		||||
    def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
 | 
			
		||||
        context = super().resolve(entry, blueprint)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            return context[0]
 | 
			
		||||
        except IndexError:  # pragma: no cover
 | 
			
		||||
            raise EntryInvalidError(f"Empty/invalid context: {context}")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Value(EnumeratedItem):
 | 
			
		||||
    """Get the current item value provided by an Enumerate tag context"""
 | 
			
		||||
 | 
			
		||||
    def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
 | 
			
		||||
        context = super().resolve(entry, blueprint)
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            return context[1]
 | 
			
		||||
        except IndexError:  # pragma: no cover
 | 
			
		||||
            raise EntryInvalidError(f"Empty/invalid context: {context}")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BlueprintDumper(SafeDumper):
 | 
			
		||||
    """Dump dataclasses to yaml"""
 | 
			
		||||
 | 
			
		||||
@ -394,6 +560,9 @@ class BlueprintLoader(SafeLoader):
 | 
			
		||||
        self.add_constructor("!Condition", Condition)
 | 
			
		||||
        self.add_constructor("!If", If)
 | 
			
		||||
        self.add_constructor("!Env", Env)
 | 
			
		||||
        self.add_constructor("!Enumerate", Enumerate)
 | 
			
		||||
        self.add_constructor("!Value", Value)
 | 
			
		||||
        self.add_constructor("!Index", Index)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EntryInvalidError(SentryIgnoredException):
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										98
									
								
								authentik/blueprints/v1/oci.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								authentik/blueprints/v1/oci.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,98 @@
 | 
			
		||||
"""OCI Client"""
 | 
			
		||||
from typing import Any
 | 
			
		||||
from urllib.parse import ParseResult, urlparse
 | 
			
		||||
 | 
			
		||||
from opencontainers.distribution.reggie import (
 | 
			
		||||
    NewClient,
 | 
			
		||||
    WithDebug,
 | 
			
		||||
    WithDefaultName,
 | 
			
		||||
    WithDigest,
 | 
			
		||||
    WithReference,
 | 
			
		||||
    WithUserAgent,
 | 
			
		||||
    WithUsernamePassword,
 | 
			
		||||
)
 | 
			
		||||
from requests.exceptions import RequestException
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
from structlog.stdlib import BoundLogger
 | 
			
		||||
 | 
			
		||||
from authentik.lib.sentry import SentryIgnoredException
 | 
			
		||||
from authentik.lib.utils.http import authentik_user_agent
 | 
			
		||||
 | 
			
		||||
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OCIException(SentryIgnoredException):
 | 
			
		||||
    """OCI-related errors"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BlueprintOCIClient:
 | 
			
		||||
    """Blueprint OCI Client"""
 | 
			
		||||
 | 
			
		||||
    url: ParseResult
 | 
			
		||||
    sanitized_url: str
 | 
			
		||||
    logger: BoundLogger
 | 
			
		||||
    ref: str
 | 
			
		||||
    client: NewClient
 | 
			
		||||
 | 
			
		||||
    def __init__(self, url: str) -> None:
 | 
			
		||||
        self._parse_url(url)
 | 
			
		||||
        self.logger = get_logger().bind(url=self.sanitized_url)
 | 
			
		||||
 | 
			
		||||
        self.ref = "latest"
 | 
			
		||||
        path = self.url.path[1:]
 | 
			
		||||
        if ":" in self.url.path:
 | 
			
		||||
            path, _, self.ref = path.partition(":")
 | 
			
		||||
        self.client = NewClient(
 | 
			
		||||
            f"https://{self.url.hostname}",
 | 
			
		||||
            WithUserAgent(authentik_user_agent()),
 | 
			
		||||
            WithUsernamePassword(self.url.username, self.url.password),
 | 
			
		||||
            WithDefaultName(path),
 | 
			
		||||
            WithDebug(True),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def _parse_url(self, url: str):
 | 
			
		||||
        self.url = urlparse(url)
 | 
			
		||||
        netloc = self.url.netloc
 | 
			
		||||
        if "@" in netloc:
 | 
			
		||||
            netloc = netloc[netloc.index("@") + 1 :]
 | 
			
		||||
        self.sanitized_url = self.url._replace(netloc=netloc).geturl()
 | 
			
		||||
 | 
			
		||||
    def fetch_manifests(self) -> dict[str, Any]:
 | 
			
		||||
        """Fetch manifests for ref"""
 | 
			
		||||
        self.logger.info("Fetching OCI manifests for blueprint")
 | 
			
		||||
        manifest_request = self.client.NewRequest(
 | 
			
		||||
            "GET",
 | 
			
		||||
            "/v2/<name>/manifests/<reference>",
 | 
			
		||||
            WithReference(self.ref),
 | 
			
		||||
        ).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
 | 
			
		||||
        try:
 | 
			
		||||
            manifest_response = self.client.Do(manifest_request)
 | 
			
		||||
            manifest_response.raise_for_status()
 | 
			
		||||
        except RequestException as exc:
 | 
			
		||||
            raise OCIException(exc) from exc
 | 
			
		||||
        manifest = manifest_response.json()
 | 
			
		||||
        if "errors" in manifest:
 | 
			
		||||
            raise OCIException(manifest["errors"])
 | 
			
		||||
        return manifest
 | 
			
		||||
 | 
			
		||||
    def fetch_blobs(self, manifest: dict[str, Any]):
 | 
			
		||||
        """Fetch blob based on manifest info"""
 | 
			
		||||
        blob = None
 | 
			
		||||
        for layer in manifest.get("layers", []):
 | 
			
		||||
            if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
 | 
			
		||||
                blob = layer.get("digest")
 | 
			
		||||
                self.logger.debug("Found layer with matching media type", blob=blob)
 | 
			
		||||
        if not blob:
 | 
			
		||||
            raise OCIException("Blob not found")
 | 
			
		||||
 | 
			
		||||
        blob_request = self.client.NewRequest(
 | 
			
		||||
            "GET",
 | 
			
		||||
            "/v2/<name>/blobs/<digest>",
 | 
			
		||||
            WithDigest(blob),
 | 
			
		||||
        )
 | 
			
		||||
        try:
 | 
			
		||||
            blob_response = self.client.Do(blob_request)
 | 
			
		||||
            blob_response.raise_for_status()
 | 
			
		||||
            return blob_response.text
 | 
			
		||||
        except RequestException as exc:
 | 
			
		||||
            raise OCIException(exc) from exc
 | 
			
		||||
@ -1,8 +1,10 @@
 | 
			
		||||
"""Application API Views"""
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.db.models import QuerySet
 | 
			
		||||
from django.db.models.functions import ExtractHour
 | 
			
		||||
from django.http.response import HttpResponseBadRequest
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
@ -225,7 +227,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        methods=["POST"],
 | 
			
		||||
        parser_classes=(MultiPartParser,),
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_icon(self, request: Request, slug: str):
 | 
			
		||||
        """Set application icon"""
 | 
			
		||||
        app: Application = self.get_object()
 | 
			
		||||
@ -245,7 +246,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        filter_backends=[],
 | 
			
		||||
        methods=["POST"],
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_icon_url(self, request: Request, slug: str):
 | 
			
		||||
        """Set application icon (as URL)"""
 | 
			
		||||
        app: Application = self.get_object()
 | 
			
		||||
@ -254,15 +254,14 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
    @permission_required("authentik_core.view_application", ["authentik_events.view_event"])
 | 
			
		||||
    @extend_schema(responses={200: CoordinateSerializer(many=True)})
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def metrics(self, request: Request, slug: str):
 | 
			
		||||
        """Metrics for application logins"""
 | 
			
		||||
        app = self.get_object()
 | 
			
		||||
        return Response(
 | 
			
		||||
            get_objects_for_user(request.user, "authentik_events.view_event")
 | 
			
		||||
            .filter(
 | 
			
		||||
            get_objects_for_user(request.user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.AUTHORIZE_APPLICATION,
 | 
			
		||||
                context__authorized_application__pk=app.pk.hex,
 | 
			
		||||
            )
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -96,7 +96,6 @@ class GroupFilter(FilterSet):
 | 
			
		||||
        queryset=User.objects.all(),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def filter_attributes(self, queryset, name, value):
 | 
			
		||||
        """Filter attributes by query args"""
 | 
			
		||||
        try:
 | 
			
		||||
@ -157,7 +156,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=unused-argument, invalid-name
 | 
			
		||||
    def add_user(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Add user to group"""
 | 
			
		||||
        group: Group = self.get_object()
 | 
			
		||||
@ -182,7 +180,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=unused-argument, invalid-name
 | 
			
		||||
    def remove_user(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Add user to group"""
 | 
			
		||||
        group: Group = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -117,7 +117,6 @@ class PropertyMappingViewSet(
 | 
			
		||||
        ],
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
 | 
			
		||||
    # pylint: disable=unused-argument, invalid-name
 | 
			
		||||
    def test(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Test Property Mapping"""
 | 
			
		||||
        mapping: PropertyMapping = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -102,7 +102,6 @@ class SourceViewSet(
 | 
			
		||||
        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()
 | 
			
		||||
@ -122,7 +121,6 @@ class SourceViewSet(
 | 
			
		||||
        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()
 | 
			
		||||
 | 
			
		||||
@ -112,7 +112,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def view_key(self, request: Request, identifier: str) -> Response:
 | 
			
		||||
        """Return token key and log access"""
 | 
			
		||||
        token: Token = self.get_object()
 | 
			
		||||
@ -134,7 +133,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_key(self, request: Request, identifier: str) -> Response:
 | 
			
		||||
        """Return token key and log access"""
 | 
			
		||||
        token: Token = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -53,7 +53,7 @@ class UsedByMixin:
 | 
			
		||||
        responses={200: UsedBySerializer(many=True)},
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument, too-many-locals
 | 
			
		||||
    # pylint: disable=too-many-locals
 | 
			
		||||
    def used_by(self, request: Request, *args, **kwargs) -> Response:
 | 
			
		||||
        """Get a list of all objects that use this object"""
 | 
			
		||||
        # pyright: reportGeneralTypeIssues=false
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,7 @@ from json import loads
 | 
			
		||||
from typing import Any, Optional
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth import update_session_auth_hash
 | 
			
		||||
from django.db.models.functions import ExtractHour
 | 
			
		||||
from django.db.models.query import QuerySet
 | 
			
		||||
from django.db.transaction import atomic
 | 
			
		||||
from django.db.utils import IntegrityError
 | 
			
		||||
@ -199,38 +200,44 @@ class SessionUserSerializer(PassiveSerializer):
 | 
			
		||||
class UserMetricsSerializer(PassiveSerializer):
 | 
			
		||||
    """User Metrics"""
 | 
			
		||||
 | 
			
		||||
    logins_per_1h = SerializerMethodField()
 | 
			
		||||
    logins_failed_per_1h = SerializerMethodField()
 | 
			
		||||
    authorizations_per_1h = SerializerMethodField()
 | 
			
		||||
    logins = SerializerMethodField()
 | 
			
		||||
    logins_failed = SerializerMethodField()
 | 
			
		||||
    authorizations = SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_logins_per_1h(self, _):
 | 
			
		||||
        """Get successful logins per hour for the last 24 hours"""
 | 
			
		||||
    def get_logins(self, _):
 | 
			
		||||
        """Get successful logins per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.LOGIN, user__pk=user.pk)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.LOGIN, user__pk=user.pk
 | 
			
		||||
            )
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_logins_failed_per_1h(self, _):
 | 
			
		||||
        """Get failed logins per hour for the last 24 hours"""
 | 
			
		||||
    def get_logins_failed(self, _):
 | 
			
		||||
        """Get failed logins per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.LOGIN_FAILED, context__username=user.username)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.LOGIN_FAILED, context__username=user.username
 | 
			
		||||
            )
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @extend_schema_field(CoordinateSerializer(many=True))
 | 
			
		||||
    def get_authorizations_per_1h(self, _):
 | 
			
		||||
        """Get failed logins per hour for the last 24 hours"""
 | 
			
		||||
    def get_authorizations(self, _):
 | 
			
		||||
        """Get failed logins per 8 hours for the last 7 days"""
 | 
			
		||||
        user = self.context["user"]
 | 
			
		||||
        return (
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
 | 
			
		||||
            .get_events_per_hour()
 | 
			
		||||
            get_objects_for_user(user, "authentik_events.view_event").filter(
 | 
			
		||||
                action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
 | 
			
		||||
            )
 | 
			
		||||
            # 3 data points per day, so 8 hour spans
 | 
			
		||||
            .get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -262,7 +269,6 @@ class UsersFilter(FilterSet):
 | 
			
		||||
        queryset=Group.objects.all(),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def filter_attributes(self, queryset, name, value):
 | 
			
		||||
        """Filter attributes by query args"""
 | 
			
		||||
        try:
 | 
			
		||||
@ -397,9 +403,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
                return Response(data={"non_field_errors": [str(exc)]}, status=400)
 | 
			
		||||
 | 
			
		||||
    @extend_schema(responses={200: SessionUserSerializer(many=False)})
 | 
			
		||||
    @action(detail=False, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    def me(self, request: Request) -> Response:
 | 
			
		||||
    @action(url_path="me", url_name="me", detail=False, pagination_class=None, filter_backends=[])
 | 
			
		||||
    def user_me(self, request: Request) -> Response:
 | 
			
		||||
        """Get information about current user"""
 | 
			
		||||
        context = {"request": request}
 | 
			
		||||
        serializer = SessionUserSerializer(
 | 
			
		||||
@ -427,7 +432,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, methods=["POST"])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def set_password(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """Set password for user"""
 | 
			
		||||
        user: User = self.get_object()
 | 
			
		||||
@ -445,7 +449,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
    @permission_required("authentik_core.view_user", ["authentik_events.view_event"])
 | 
			
		||||
    @extend_schema(responses={200: UserMetricsSerializer(many=False)})
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def metrics(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """User metrics per 1h"""
 | 
			
		||||
        user: User = self.get_object()
 | 
			
		||||
@ -461,7 +464,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def recovery(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """Create a temporary link that a user can use to recover their accounts"""
 | 
			
		||||
        link, _ = self._create_recovery_link()
 | 
			
		||||
@ -486,7 +488,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def recovery_email(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """Create a temporary link that a user can use to recover their accounts"""
 | 
			
		||||
        for_user: User = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -49,7 +49,6 @@ class Command(BaseCommand):
 | 
			
		||||
        return namespace
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def post_save_handler(sender, instance: Model, created: bool, **_):
 | 
			
		||||
        """Signal handler for all object's post_save"""
 | 
			
		||||
        if not should_log_model(instance):
 | 
			
		||||
@ -65,7 +64,6 @@ class Command(BaseCommand):
 | 
			
		||||
        ).save()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def pre_delete_handler(sender, instance: Model, **_):
 | 
			
		||||
        """Signal handler for all object's pre_delete"""
 | 
			
		||||
        if not should_log_model(instance):  # pragma: no cover
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,6 @@ if TYPE_CHECKING:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(post_save)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
 | 
			
		||||
    """Clear user's application cache upon application creation"""
 | 
			
		||||
    from authentik.core.api.applications import user_app_cache_key
 | 
			
		||||
@ -36,7 +35,6 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(user_logged_in)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
 | 
			
		||||
    """Create an AuthenticatedSession from request"""
 | 
			
		||||
    from authentik.core.models import AuthenticatedSession
 | 
			
		||||
@ -47,7 +45,6 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(user_logged_out)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
 | 
			
		||||
    """Delete AuthenticatedSession if it exists"""
 | 
			
		||||
    from authentik.core.models import AuthenticatedSession
 | 
			
		||||
 | 
			
		||||
@ -48,7 +48,6 @@ class Action(Enum):
 | 
			
		||||
class MessageStage(StageView):
 | 
			
		||||
    """Show a pre-configured message after the flow is done"""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Show a pre-configured message after the flow is done"""
 | 
			
		||||
        message = getattr(self.executor.current_stage, "message", "")
 | 
			
		||||
@ -209,7 +208,6 @@ class SourceFlowManager:
 | 
			
		||||
            response.error_message = error.messages
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get_stages_to_append(self, flow: Flow) -> list[Stage]:
 | 
			
		||||
        """Hook to override stages which are appended to the flow"""
 | 
			
		||||
        if not self.source.enrollment_flow:
 | 
			
		||||
@ -264,7 +262,6 @@ class SourceFlowManager:
 | 
			
		||||
            flow_slug=flow.slug,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def handle_auth(
 | 
			
		||||
        self,
 | 
			
		||||
        connection: UserSourceConnection,
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,6 @@ class PostUserEnrollmentStage(StageView):
 | 
			
		||||
    """Dynamically injected stage which saves the Connection after
 | 
			
		||||
    the user has been enrolled."""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Stage used after the user has been enrolled"""
 | 
			
		||||
        connection: UserSourceConnection = self.executor.plan.context[
 | 
			
		||||
 | 
			
		||||
@ -60,7 +60,7 @@
 | 
			
		||||
    <div class="ak-login-container">
 | 
			
		||||
        <header class="pf-c-login__header">
 | 
			
		||||
            <div class="pf-c-brand ak-brand">
 | 
			
		||||
                <img src="{{ tenant.branding_logo }}" alt="authentik icon" />
 | 
			
		||||
                <img src="{{ tenant.branding_logo }}" alt="authentik Logo" />
 | 
			
		||||
            </div>
 | 
			
		||||
        </header>
 | 
			
		||||
        {% block main_container %}
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
"""Test Source flow_manager"""
 | 
			
		||||
from django.contrib.auth.models import AnonymousUser
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.test.client import RequestFactory
 | 
			
		||||
from guardian.utils import get_anonymous_user
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import SourceUserMatchingModes, User
 | 
			
		||||
@ -22,7 +21,6 @@ class TestSourceFlowManager(TestCase):
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        super().setUp()
 | 
			
		||||
        self.source: OAuthSource = OAuthSource.objects.create(name="test")
 | 
			
		||||
        self.factory = RequestFactory()
 | 
			
		||||
        self.identifier = generate_id()
 | 
			
		||||
 | 
			
		||||
    def test_unauthenticated_enroll(self):
 | 
			
		||||
 | 
			
		||||
@ -47,11 +47,11 @@ def create_test_tenant() -> Tenant:
 | 
			
		||||
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
 | 
			
		||||
    """Generate a certificate for testing"""
 | 
			
		||||
    builder = CertificateBuilder(
 | 
			
		||||
        name=f"{generate_id()}.self-signed.goauthentik.io",
 | 
			
		||||
        use_ec_private_key=use_ec_private_key,
 | 
			
		||||
    )
 | 
			
		||||
    builder.common_name = "goauthentik.io"
 | 
			
		||||
    builder.build(
 | 
			
		||||
        subject_alt_names=["goauthentik.io"],
 | 
			
		||||
        subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"],
 | 
			
		||||
        validity_days=360,
 | 
			
		||||
    )
 | 
			
		||||
    builder.common_name = generate_id()
 | 
			
		||||
 | 
			
		||||
@ -187,7 +187,6 @@ class CertificateKeyPairFilter(FilterSet):
 | 
			
		||||
        label="Only return certificate-key pairs with keys", method="filter_has_key"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def filter_has_key(self, queryset, name, value):  # pragma: no cover
 | 
			
		||||
        """Only return certificate-key pairs with keys"""
 | 
			
		||||
        return queryset.exclude(key_data__exact="")
 | 
			
		||||
@ -236,10 +235,11 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        data = CertificateGenerationSerializer(data=request.data)
 | 
			
		||||
        if not data.is_valid():
 | 
			
		||||
            return Response(data.errors, status=400)
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder.common_name = data.validated_data["common_name"]
 | 
			
		||||
        raw_san = data.validated_data.get("subject_alt_name", "")
 | 
			
		||||
        sans = raw_san.split(",") if raw_san != "" else []
 | 
			
		||||
        builder = CertificateBuilder(data.validated_data["common_name"])
 | 
			
		||||
        builder.build(
 | 
			
		||||
            subject_alt_names=data.validated_data.get("subject_alt_name", "").split(","),
 | 
			
		||||
            subject_alt_names=sans,
 | 
			
		||||
            validity_days=int(data.validated_data["validity_days"]),
 | 
			
		||||
        )
 | 
			
		||||
        instance = builder.save()
 | 
			
		||||
@ -257,7 +257,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        responses={200: CertificateDataSerializer(many=False)},
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def view_certificate(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Return certificate-key pairs certificate and log access"""
 | 
			
		||||
        certificate: CertificateKeyPair = self.get_object()
 | 
			
		||||
@ -288,7 +287,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        responses={200: CertificateDataSerializer(many=False)},
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def view_private_key(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Return certificate-key pairs private key and log access"""
 | 
			
		||||
        certificate: CertificateKeyPair = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -27,20 +27,16 @@ class AuthentikCryptoConfig(ManagedAppConfig):
 | 
			
		||||
        from authentik.crypto.builder import CertificateBuilder
 | 
			
		||||
        from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder.common_name = "goauthentik.io"
 | 
			
		||||
        builder = CertificateBuilder("authentik Internal JWT Certificate")
 | 
			
		||||
        builder.build(
 | 
			
		||||
            subject_alt_names=["goauthentik.io"],
 | 
			
		||||
            validity_days=360,
 | 
			
		||||
        )
 | 
			
		||||
        if not cert:
 | 
			
		||||
 | 
			
		||||
            cert = CertificateKeyPair()
 | 
			
		||||
        cert.certificate_data = builder.certificate
 | 
			
		||||
        cert.key_data = builder.private_key
 | 
			
		||||
        cert.name = "authentik Internal JWT Certificate"
 | 
			
		||||
        cert.managed = MANAGED_KEY
 | 
			
		||||
        cert.save()
 | 
			
		||||
        builder.cert = cert
 | 
			
		||||
        builder.cert.managed = MANAGED_KEY
 | 
			
		||||
        builder.save()
 | 
			
		||||
 | 
			
		||||
    def reconcile_managed_jwt_cert(self):
 | 
			
		||||
        """Ensure managed JWT certificate"""
 | 
			
		||||
@ -63,10 +59,6 @@ class AuthentikCryptoConfig(ManagedAppConfig):
 | 
			
		||||
        name = "authentik Self-signed Certificate"
 | 
			
		||||
        if CertificateKeyPair.objects.filter(name=name).exists():
 | 
			
		||||
            return
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder = CertificateBuilder(name)
 | 
			
		||||
        builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
 | 
			
		||||
        CertificateKeyPair.objects.create(
 | 
			
		||||
            name="authentik Self-signed Certificate",
 | 
			
		||||
            certificate_data=builder.certificate,
 | 
			
		||||
            key_data=builder.private_key,
 | 
			
		||||
        )
 | 
			
		||||
        builder.save()
 | 
			
		||||
 | 
			
		||||
@ -21,13 +21,13 @@ class CertificateBuilder:
 | 
			
		||||
 | 
			
		||||
    _use_ec_private_key: bool
 | 
			
		||||
 | 
			
		||||
    def __init__(self, use_ec_private_key=False):
 | 
			
		||||
    def __init__(self, name: str, use_ec_private_key=False):
 | 
			
		||||
        self._use_ec_private_key = use_ec_private_key
 | 
			
		||||
        self.__public_key = None
 | 
			
		||||
        self.__private_key = None
 | 
			
		||||
        self.__builder = None
 | 
			
		||||
        self.__certificate = None
 | 
			
		||||
        self.common_name = "authentik Self-signed Certificate"
 | 
			
		||||
        self.common_name = name
 | 
			
		||||
        self.cert = CertificateKeyPair()
 | 
			
		||||
 | 
			
		||||
    def save(self) -> CertificateKeyPair:
 | 
			
		||||
@ -57,7 +57,10 @@ class CertificateBuilder:
 | 
			
		||||
        one_day = datetime.timedelta(1, 0, 0)
 | 
			
		||||
        self.__private_key = self.generate_private_key()
 | 
			
		||||
        self.__public_key = self.__private_key.public_key()
 | 
			
		||||
        alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []]
 | 
			
		||||
        alt_names: list[x509.GeneralName] = []
 | 
			
		||||
        for alt_name in subject_alt_names or []:
 | 
			
		||||
            if alt_name.strip() != "":
 | 
			
		||||
                alt_names.append(x509.DNSName(alt_name))
 | 
			
		||||
        self.__builder = (
 | 
			
		||||
            x509.CertificateBuilder()
 | 
			
		||||
            .subject_name(
 | 
			
		||||
@ -76,12 +79,15 @@ class CertificateBuilder:
 | 
			
		||||
                    ]
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
            .add_extension(x509.SubjectAlternativeName(alt_names), critical=True)
 | 
			
		||||
            .not_valid_before(datetime.datetime.today() - one_day)
 | 
			
		||||
            .not_valid_after(datetime.datetime.today() + datetime.timedelta(days=validity_days))
 | 
			
		||||
            .serial_number(int(uuid.uuid4()))
 | 
			
		||||
            .public_key(self.__public_key)
 | 
			
		||||
        )
 | 
			
		||||
        if alt_names:
 | 
			
		||||
            self.__builder = self.__builder.add_extension(
 | 
			
		||||
                x509.SubjectAlternativeName(alt_names), critical=True
 | 
			
		||||
            )
 | 
			
		||||
        self.__certificate = self.__builder.sign(
 | 
			
		||||
            private_key=self.__private_key,
 | 
			
		||||
            algorithm=hashes.SHA256(),
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,8 @@ from json import loads
 | 
			
		||||
from os import makedirs
 | 
			
		||||
from tempfile import TemporaryDirectory
 | 
			
		||||
 | 
			
		||||
from cryptography.x509.extensions import SubjectAlternativeName
 | 
			
		||||
from cryptography.x509.general_name import DNSName
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from rest_framework.test import APITestCase
 | 
			
		||||
 | 
			
		||||
@ -14,7 +16,7 @@ from authentik.crypto.builder import CertificateBuilder
 | 
			
		||||
from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.lib.generators import generate_key
 | 
			
		||||
from authentik.lib.generators import generate_id, generate_key
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -54,8 +56,8 @@ class TestCrypto(APITestCase):
 | 
			
		||||
 | 
			
		||||
    def test_builder(self):
 | 
			
		||||
        """Test Builder"""
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder.common_name = "test-cert"
 | 
			
		||||
        name = generate_id()
 | 
			
		||||
        builder = CertificateBuilder(name)
 | 
			
		||||
        with self.assertRaises(ValueError):
 | 
			
		||||
            builder.save()
 | 
			
		||||
        builder.build(
 | 
			
		||||
@ -64,17 +66,49 @@ class TestCrypto(APITestCase):
 | 
			
		||||
        )
 | 
			
		||||
        instance = builder.save()
 | 
			
		||||
        now = datetime.datetime.today()
 | 
			
		||||
        self.assertEqual(instance.name, "test-cert")
 | 
			
		||||
        self.assertEqual(instance.name, name)
 | 
			
		||||
        self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
 | 
			
		||||
 | 
			
		||||
    def test_builder_api(self):
 | 
			
		||||
        """Test Builder (via API)"""
 | 
			
		||||
        self.client.force_login(create_test_admin_user())
 | 
			
		||||
        name = generate_id()
 | 
			
		||||
        self.client.post(
 | 
			
		||||
            reverse("authentik_api:certificatekeypair-generate"),
 | 
			
		||||
            data={"common_name": "foo", "subject_alt_name": "bar,baz", "validity_days": 3},
 | 
			
		||||
            data={"common_name": name, "subject_alt_name": "bar,baz", "validity_days": 3},
 | 
			
		||||
        )
 | 
			
		||||
        self.assertTrue(CertificateKeyPair.objects.filter(name="foo").exists())
 | 
			
		||||
        key = CertificateKeyPair.objects.filter(name=name).first()
 | 
			
		||||
        self.assertIsNotNone(key)
 | 
			
		||||
        ext: SubjectAlternativeName = key.certificate.extensions[0].value
 | 
			
		||||
        self.assertIsInstance(ext, SubjectAlternativeName)
 | 
			
		||||
        self.assertIsInstance(ext[0], DNSName)
 | 
			
		||||
        self.assertEqual(ext[0].value, "bar")
 | 
			
		||||
        self.assertIsInstance(ext[1], DNSName)
 | 
			
		||||
        self.assertEqual(ext[1].value, "baz")
 | 
			
		||||
 | 
			
		||||
    def test_builder_api_empty_san(self):
 | 
			
		||||
        """Test Builder (via API)"""
 | 
			
		||||
        self.client.force_login(create_test_admin_user())
 | 
			
		||||
        name = generate_id()
 | 
			
		||||
        self.client.post(
 | 
			
		||||
            reverse("authentik_api:certificatekeypair-generate"),
 | 
			
		||||
            data={"common_name": name, "subject_alt_name": "", "validity_days": 3},
 | 
			
		||||
        )
 | 
			
		||||
        key = CertificateKeyPair.objects.filter(name=name).first()
 | 
			
		||||
        self.assertIsNotNone(key)
 | 
			
		||||
        self.assertEqual(len(key.certificate.extensions), 0)
 | 
			
		||||
 | 
			
		||||
    def test_builder_api_empty_san_multiple(self):
 | 
			
		||||
        """Test Builder (via API)"""
 | 
			
		||||
        self.client.force_login(create_test_admin_user())
 | 
			
		||||
        name = generate_id()
 | 
			
		||||
        self.client.post(
 | 
			
		||||
            reverse("authentik_api:certificatekeypair-generate"),
 | 
			
		||||
            data={"common_name": name, "subject_alt_name": ", ", "validity_days": 3},
 | 
			
		||||
        )
 | 
			
		||||
        key = CertificateKeyPair.objects.filter(name=name).first()
 | 
			
		||||
        self.assertIsNotNone(key)
 | 
			
		||||
        self.assertEqual(len(key.certificate.extensions), 0)
 | 
			
		||||
 | 
			
		||||
    def test_builder_api_invalid(self):
 | 
			
		||||
        """Test Builder (via API) (invalid)"""
 | 
			
		||||
@ -193,8 +227,8 @@ class TestCrypto(APITestCase):
 | 
			
		||||
 | 
			
		||||
    def test_discovery(self):
 | 
			
		||||
        """Test certificate discovery"""
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder.common_name = "test-cert"
 | 
			
		||||
        name = generate_id()
 | 
			
		||||
        builder = CertificateBuilder(name)
 | 
			
		||||
        with self.assertRaises(ValueError):
 | 
			
		||||
            builder.save()
 | 
			
		||||
        builder.build(
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,11 @@
 | 
			
		||||
"""Events API Views"""
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from json import loads
 | 
			
		||||
 | 
			
		||||
import django_filters
 | 
			
		||||
from django.db.models.aggregates import Count
 | 
			
		||||
from django.db.models.fields.json import KeyTextTransform
 | 
			
		||||
from django.db.models.functions import ExtractDay
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
 | 
			
		||||
from guardian.shortcuts import get_objects_for_user
 | 
			
		||||
@ -81,7 +83,6 @@ class EventsFilter(django_filters.FilterSet):
 | 
			
		||||
        label="Tenant name",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def filter_context_model_pk(self, queryset, name, value):
 | 
			
		||||
        """Because we store the PK as UUID.hex,
 | 
			
		||||
        we need to remove the dashes that a client may send. We can't use a
 | 
			
		||||
@ -178,7 +179,7 @@ class EventViewSet(ModelViewSet):
 | 
			
		||||
            get_objects_for_user(request.user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=filtered_action)
 | 
			
		||||
            .filter(**query)
 | 
			
		||||
            .get_events_per_day()
 | 
			
		||||
            .get_events_per(timedelta(weeks=4), ExtractDay, 30)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @extend_schema(responses={200: TypeCreateSerializer(many=True)})
 | 
			
		||||
 | 
			
		||||
@ -80,7 +80,6 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        request=OpenApiTypes.NONE,
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def test(self, request: Request, pk=None) -> Response:
 | 
			
		||||
        """Send example notification using selected transport. Requires
 | 
			
		||||
        Modify permissions."""
 | 
			
		||||
 | 
			
		||||
@ -12,12 +12,21 @@ from django.http import HttpRequest, HttpResponse
 | 
			
		||||
from django_otp.plugins.otp_static.models import StaticToken
 | 
			
		||||
from guardian.models import UserObjectPermission
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import AuthenticatedSession, User
 | 
			
		||||
from authentik.core.models import (
 | 
			
		||||
    AuthenticatedSession,
 | 
			
		||||
    PropertyMapping,
 | 
			
		||||
    Provider,
 | 
			
		||||
    Source,
 | 
			
		||||
    User,
 | 
			
		||||
    UserSourceConnection,
 | 
			
		||||
)
 | 
			
		||||
from authentik.events.models import Event, EventAction, Notification
 | 
			
		||||
from authentik.events.utils import model_to_dict
 | 
			
		||||
from authentik.flows.models import FlowToken
 | 
			
		||||
from authentik.flows.models import FlowToken, Stage
 | 
			
		||||
from authentik.lib.sentry import before_send
 | 
			
		||||
from authentik.lib.utils.errors import exception_to_string
 | 
			
		||||
from authentik.outposts.models import OutpostServiceConnection
 | 
			
		||||
from authentik.policies.models import Policy, PolicyBindingModel
 | 
			
		||||
 | 
			
		||||
IGNORED_MODELS = (
 | 
			
		||||
    Event,
 | 
			
		||||
@ -27,6 +36,14 @@ IGNORED_MODELS = (
 | 
			
		||||
    StaticToken,
 | 
			
		||||
    Session,
 | 
			
		||||
    FlowToken,
 | 
			
		||||
    Provider,
 | 
			
		||||
    Source,
 | 
			
		||||
    PropertyMapping,
 | 
			
		||||
    UserSourceConnection,
 | 
			
		||||
    Stage,
 | 
			
		||||
    OutpostServiceConnection,
 | 
			
		||||
    Policy,
 | 
			
		||||
    PolicyBindingModel,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -34,7 +51,7 @@ def should_log_model(model: Model) -> bool:
 | 
			
		||||
    """Return true if operation on `model` should be logged"""
 | 
			
		||||
    if model.__module__.startswith("silk"):
 | 
			
		||||
        return False
 | 
			
		||||
    return not isinstance(model, IGNORED_MODELS)
 | 
			
		||||
    return model.__class__ not in IGNORED_MODELS
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EventNewThread(Thread):
 | 
			
		||||
@ -101,7 +118,6 @@ class AuditMiddleware:
 | 
			
		||||
        self.disconnect(request)
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def process_exception(self, request: HttpRequest, exception: Exception):
 | 
			
		||||
        """Disconnect handlers in case of exception"""
 | 
			
		||||
        self.disconnect(request)
 | 
			
		||||
@ -125,7 +141,6 @@ class AuditMiddleware:
 | 
			
		||||
            thread.run()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def post_save_handler(
 | 
			
		||||
        user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
 | 
			
		||||
    ):
 | 
			
		||||
@ -137,7 +152,6 @@ class AuditMiddleware:
 | 
			
		||||
        EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
 | 
			
		||||
        """Signal handler for all object's pre_delete"""
 | 
			
		||||
        if not should_log_model(instance):  # pragma: no cover
 | 
			
		||||
 | 
			
		||||
@ -11,8 +11,7 @@ from django.conf import settings
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.db.models import Count, ExpressionWrapper, F
 | 
			
		||||
from django.db.models.fields import DurationField
 | 
			
		||||
from django.db.models.functions import ExtractHour
 | 
			
		||||
from django.db.models.functions.datetime import ExtractDay
 | 
			
		||||
from django.db.models.functions import Extract
 | 
			
		||||
from django.db.models.manager import Manager
 | 
			
		||||
from django.db.models.query import QuerySet
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
@ -111,48 +110,35 @@ class EventAction(models.TextChoices):
 | 
			
		||||
class EventQuerySet(QuerySet):
 | 
			
		||||
    """Custom events query set with helper functions"""
 | 
			
		||||
 | 
			
		||||
    def get_events_per_hour(self) -> list[dict[str, int]]:
 | 
			
		||||
    def get_events_per(
 | 
			
		||||
        self,
 | 
			
		||||
        time_since: timedelta,
 | 
			
		||||
        extract: Extract,
 | 
			
		||||
        data_points: int,
 | 
			
		||||
    ) -> list[dict[str, int]]:
 | 
			
		||||
        """Get event count by hour in the last day, fill with zeros"""
 | 
			
		||||
        date_from = now() - timedelta(days=1)
 | 
			
		||||
        _now = now()
 | 
			
		||||
        max_since = timedelta(days=60)
 | 
			
		||||
        # Allow maximum of 60 days to limit load
 | 
			
		||||
        if time_since.total_seconds() > max_since.total_seconds():
 | 
			
		||||
            time_since = max_since
 | 
			
		||||
        date_from = _now - time_since
 | 
			
		||||
        result = (
 | 
			
		||||
            self.filter(created__gte=date_from)
 | 
			
		||||
            .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
 | 
			
		||||
            .annotate(age_hours=ExtractHour("age"))
 | 
			
		||||
            .values("age_hours")
 | 
			
		||||
            .annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField()))
 | 
			
		||||
            .annotate(age_interval=extract("age"))
 | 
			
		||||
            .values("age_interval")
 | 
			
		||||
            .annotate(count=Count("pk"))
 | 
			
		||||
            .order_by("age_hours")
 | 
			
		||||
            .order_by("age_interval")
 | 
			
		||||
        )
 | 
			
		||||
        data = Counter({int(d["age_hours"]): d["count"] for d in result})
 | 
			
		||||
        data = Counter({int(d["age_interval"]): d["count"] for d in result})
 | 
			
		||||
        results = []
 | 
			
		||||
        _now = now()
 | 
			
		||||
        for hour in range(0, -24, -1):
 | 
			
		||||
        interval_delta = time_since / data_points
 | 
			
		||||
        for interval in range(1, -data_points, -1):
 | 
			
		||||
            results.append(
 | 
			
		||||
                {
 | 
			
		||||
                    "x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
 | 
			
		||||
                    "y_cord": data[hour * -1],
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        return results
 | 
			
		||||
 | 
			
		||||
    def get_events_per_day(self) -> list[dict[str, int]]:
 | 
			
		||||
        """Get event count by hour in the last day, fill with zeros"""
 | 
			
		||||
        date_from = now() - timedelta(weeks=4)
 | 
			
		||||
        result = (
 | 
			
		||||
            self.filter(created__gte=date_from)
 | 
			
		||||
            .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
 | 
			
		||||
            .annotate(age_days=ExtractDay("age"))
 | 
			
		||||
            .values("age_days")
 | 
			
		||||
            .annotate(count=Count("pk"))
 | 
			
		||||
            .order_by("age_days")
 | 
			
		||||
        )
 | 
			
		||||
        data = Counter({int(d["age_days"]): d["count"] for d in result})
 | 
			
		||||
        results = []
 | 
			
		||||
        _now = now()
 | 
			
		||||
        for day in range(0, -30, -1):
 | 
			
		||||
            results.append(
 | 
			
		||||
                {
 | 
			
		||||
                    "x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000,
 | 
			
		||||
                    "y_cord": data[day * -1],
 | 
			
		||||
                    "x_cord": time.mktime((_now + (interval_delta * interval)).timetuple()) * 1000,
 | 
			
		||||
                    "y_cord": data[interval * -1],
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        return results
 | 
			
		||||
@ -165,13 +151,14 @@ class EventManager(Manager):
 | 
			
		||||
        """use custom queryset"""
 | 
			
		||||
        return EventQuerySet(self.model, using=self._db)
 | 
			
		||||
 | 
			
		||||
    def get_events_per_hour(self) -> list[dict[str, int]]:
 | 
			
		||||
    def get_events_per(
 | 
			
		||||
        self,
 | 
			
		||||
        time_since: timedelta,
 | 
			
		||||
        extract: Extract,
 | 
			
		||||
        data_points: int,
 | 
			
		||||
    ) -> list[dict[str, int]]:
 | 
			
		||||
        """Wrap method from queryset"""
 | 
			
		||||
        return self.get_queryset().get_events_per_hour()
 | 
			
		||||
 | 
			
		||||
    def get_events_per_day(self) -> list[dict[str, int]]:
 | 
			
		||||
        """Wrap method from queryset"""
 | 
			
		||||
        return self.get_queryset().get_events_per_day()
 | 
			
		||||
        return self.get_queryset().get_events_per(time_since, extract, data_points)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Event(SerializerModel, ExpiringModel):
 | 
			
		||||
@ -461,7 +448,7 @@ class NotificationTransport(SerializerModel):
 | 
			
		||||
            # pyright: reportGeneralTypeIssues=false
 | 
			
		||||
            return send_mail(mail.__dict__)  # pylint: disable=no-value-for-parameter
 | 
			
		||||
        except (SMTPException, ConnectionError, OSError) as exc:
 | 
			
		||||
            raise NotificationTransportError from exc
 | 
			
		||||
            raise NotificationTransportError(exc) from exc
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> "Serializer":
 | 
			
		||||
 | 
			
		||||
@ -102,7 +102,7 @@ class TaskInfo:
 | 
			
		||||
        key = CACHE_KEY_PREFIX + self.task_name
 | 
			
		||||
        if self.result.uid:
 | 
			
		||||
            key += f"/{self.result.uid}"
 | 
			
		||||
            self.task_name += f"_{self.result.uid}"
 | 
			
		||||
            self.task_name += f"/{self.result.uid}"
 | 
			
		||||
        self.set_prom_metrics()
 | 
			
		||||
        cache.set(key, self, timeout=timeout_hours * 60 * 60)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -22,7 +22,6 @@ SESSION_LOGIN_EVENT = "login_event"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(user_logged_in)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
 | 
			
		||||
    """Log successful login"""
 | 
			
		||||
    kwargs = {}
 | 
			
		||||
@ -45,14 +44,12 @@ def get_login_event(request: HttpRequest) -> Optional[Event]:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(user_logged_out)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
 | 
			
		||||
    """Log successfully logout"""
 | 
			
		||||
    Event.new(EventAction.LOGOUT).from_http(request, user=user)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(user_write)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any], **kwargs):
 | 
			
		||||
    """Log User write"""
 | 
			
		||||
    data["created"] = kwargs.get("created", False)
 | 
			
		||||
@ -60,7 +57,6 @@ def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(login_failed)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_login_failed(
 | 
			
		||||
    signal,
 | 
			
		||||
    sender,
 | 
			
		||||
@ -74,7 +70,6 @@ def on_login_failed(
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(invitation_used)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_):
 | 
			
		||||
    """Log Invitation usage"""
 | 
			
		||||
    Event.new(EventAction.INVITE_USED, invitation_uuid=invitation.invite_uuid.hex).from_http(
 | 
			
		||||
@ -83,21 +78,18 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(password_changed)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def on_password_changed(sender, user: User, password: str, **_):
 | 
			
		||||
    """Log password change"""
 | 
			
		||||
    Event.new(EventAction.PASSWORD_SET).from_http(None, user=user)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(post_save, sender=Event)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def event_post_save_notification(sender, instance: Event, **_):
 | 
			
		||||
    """Start task to check if any policies trigger an notification on this event"""
 | 
			
		||||
    event_notification_handler.delay(instance.event_uuid.hex)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(pre_delete, sender=User)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def event_user_pre_delete_cleanup(sender, instance: User, **_):
 | 
			
		||||
    """If gdpr_compliance is enabled, remove all the user's events"""
 | 
			
		||||
    gdpr_cleanup.delay(instance.pk)
 | 
			
		||||
 | 
			
		||||
@ -210,7 +210,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def export(self, request: Request, slug: str) -> Response:
 | 
			
		||||
        """Export flow to .yaml file"""
 | 
			
		||||
        flow = self.get_object()
 | 
			
		||||
@ -221,7 +220,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
 | 
			
		||||
    @extend_schema(responses={200: FlowDiagramSerializer()})
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["get"])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def diagram(self, request: Request, slug: str) -> Response:
 | 
			
		||||
        """Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
 | 
			
		||||
        diagram = FlowDiagram(self.get_object(), request.user)
 | 
			
		||||
@ -245,7 +243,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        methods=["POST"],
 | 
			
		||||
        parser_classes=(MultiPartParser,),
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_background(self, request: Request, slug: str):
 | 
			
		||||
        """Set Flow background"""
 | 
			
		||||
        flow: Flow = self.get_object()
 | 
			
		||||
@ -265,7 +262,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        filter_backends=[],
 | 
			
		||||
        methods=["POST"],
 | 
			
		||||
    )
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def set_background_url(self, request: Request, slug: str):
 | 
			
		||||
        """Set Flow background (as URL)"""
 | 
			
		||||
        flow: Flow = self.get_object()
 | 
			
		||||
@ -278,7 +274,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def execute(self, request: Request, slug: str):
 | 
			
		||||
        """Execute flow for current user"""
 | 
			
		||||
        # Because we pre-plan the flow here, and not in the planner, we need to manually clear
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,6 @@ LOGGER = get_logger()
 | 
			
		||||
class StageMarker:
 | 
			
		||||
    """Base stage marker class, no extra attributes, and has no special handler."""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def process(
 | 
			
		||||
        self,
 | 
			
		||||
        plan: "FlowPlan",
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,6 @@ def delete_cache_prefix(prefix: str) -> int:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(monitoring_set)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def monitoring_set_flows(sender, **kwargs):
 | 
			
		||||
    """set flow gauges"""
 | 
			
		||||
    GAUGE_FLOWS_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}*") or []))
 | 
			
		||||
@ -27,7 +26,6 @@ def monitoring_set_flows(sender, **kwargs):
 | 
			
		||||
 | 
			
		||||
@receiver(post_save)
 | 
			
		||||
@receiver(pre_delete)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def invalidate_flow_cache(sender, instance, **_):
 | 
			
		||||
    """Invalidate flow cache when flow is updated"""
 | 
			
		||||
    from authentik.flows.models import Flow, FlowStageBinding, Stage
 | 
			
		||||
 | 
			
		||||
@ -91,7 +91,6 @@ class ChallengeStageView(StageView):
 | 
			
		||||
            )
 | 
			
		||||
        return HttpChallengeResponse(challenge)
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def post(self, request: Request, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Handle challenge response"""
 | 
			
		||||
        challenge: ChallengeResponse = self.get_response_instance(data=request.data)
 | 
			
		||||
 | 
			
		||||
@ -113,7 +113,7 @@ class InvalidStageError(SentryIgnoredException):
 | 
			
		||||
 | 
			
		||||
@method_decorator(xframe_options_sameorigin, name="dispatch")
 | 
			
		||||
class FlowExecutorView(APIView):
 | 
			
		||||
    """Stage 1 Flow executor, passing requests to Stage Views"""
 | 
			
		||||
    """Flow executor, passing requests to Stage Views"""
 | 
			
		||||
 | 
			
		||||
    permission_classes = [AllowAny]
 | 
			
		||||
 | 
			
		||||
@ -166,7 +166,7 @@ class FlowExecutorView(APIView):
 | 
			
		||||
        self._logger.debug("f(exec): restored flow plan from token", plan=plan)
 | 
			
		||||
        return plan
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument, too-many-return-statements
 | 
			
		||||
    # pylint: disable=too-many-return-statements
 | 
			
		||||
    def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
 | 
			
		||||
        with Hub.current.start_span(
 | 
			
		||||
            op="authentik.flow.executor.dispatch", description=self.flow.slug
 | 
			
		||||
 | 
			
		||||
@ -47,7 +47,6 @@ class FlowInspectorPlanSerializer(PassiveSerializer):
 | 
			
		||||
        """Get the plan's context, sanitized"""
 | 
			
		||||
        return sanitize_dict(plan.context)
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get_session_id(self, plan: FlowPlan) -> str:
 | 
			
		||||
        """Get a unique session ID"""
 | 
			
		||||
        request: Request = self.context["request"]
 | 
			
		||||
 | 
			
		||||
@ -93,7 +93,7 @@ class ConfigLoader:
 | 
			
		||||
        if url.scheme == "file":
 | 
			
		||||
            try:
 | 
			
		||||
                with open(url.path, "r", encoding="utf8") as _file:
 | 
			
		||||
                    value = _file.read()
 | 
			
		||||
                    value = _file.read().strip()
 | 
			
		||||
            except OSError as exc:
 | 
			
		||||
                self.log("error", f"Failed to read config value from {url.path}: {exc}")
 | 
			
		||||
                value = url.query
 | 
			
		||||
 | 
			
		||||
@ -64,6 +64,7 @@ outposts:
 | 
			
		||||
  disable_embedded_outpost: false
 | 
			
		||||
 | 
			
		||||
ldap:
 | 
			
		||||
  task_timeout_hours: 2
 | 
			
		||||
  tls:
 | 
			
		||||
    ciphers: null
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -159,7 +159,6 @@ class BaseEvaluator:
 | 
			
		||||
                raise exc
 | 
			
		||||
            return result
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def handle_error(self, exc: Exception, expression_source: str):  # pragma: no cover
 | 
			
		||||
        """Exception Handler"""
 | 
			
		||||
        LOGGER.warning("Expression error", exc=exc)
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,6 @@ from logging import Logger
 | 
			
		||||
from os import getpid
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def add_process_id(logger: Logger, method_name: str, event_dict):
 | 
			
		||||
    """Add the current process ID"""
 | 
			
		||||
    event_dict["pid"] = getpid()
 | 
			
		||||
 | 
			
		||||
@ -148,7 +148,6 @@ class OutpostViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
 | 
			
		||||
    @extend_schema(responses={200: OutpostHealthSerializer(many=True)})
 | 
			
		||||
    @action(methods=["GET"], detail=True, pagination_class=None)
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def health(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """Get outposts current health"""
 | 
			
		||||
        outpost: Outpost = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -91,7 +91,6 @@ class ServiceConnectionViewSet(
 | 
			
		||||
 | 
			
		||||
    @extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)})
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[])
 | 
			
		||||
    # pylint: disable=unused-argument, invalid-name
 | 
			
		||||
    def state(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Get the service connection's state"""
 | 
			
		||||
        connection = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -69,7 +69,6 @@ class OutpostConsumer(AuthJsonConsumer):
 | 
			
		||||
        self.outpost = outpost
 | 
			
		||||
        self.last_uid = self.channel_name
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def disconnect(self, code):
 | 
			
		||||
        if self.outpost and self.last_uid:
 | 
			
		||||
            state = OutpostState.for_instance_uid(self.outpost, self.last_uid)
 | 
			
		||||
@ -127,7 +126,6 @@ class OutpostConsumer(AuthJsonConsumer):
 | 
			
		||||
        response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
 | 
			
		||||
        self.send_json(asdict(response))
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def event_update(self, event):  # pragma: no cover
 | 
			
		||||
        """Event handler which is called by post_save signals, Send update instruction"""
 | 
			
		||||
        self.send_json(
 | 
			
		||||
 | 
			
		||||
@ -23,7 +23,6 @@ UPDATE_TRIGGERING_MODELS = (
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(pre_save, sender=Outpost)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def pre_save_outpost(sender, instance: Outpost, **_):
 | 
			
		||||
    """Pre-save checks for an outpost, if the name or config.kubernetes_namespace changes,
 | 
			
		||||
    we call down and then wait for the up after save"""
 | 
			
		||||
@ -43,7 +42,6 @@ def pre_save_outpost(sender, instance: Outpost, **_):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(m2m_changed, sender=Outpost.providers.through)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def m2m_changed_update(sender, instance: Model, action: str, **_):
 | 
			
		||||
    """Update outpost on m2m change, when providers are added or removed"""
 | 
			
		||||
    if action in ["post_add", "post_remove", "post_clear"]:
 | 
			
		||||
@ -51,7 +49,6 @@ def m2m_changed_update(sender, instance: Model, action: str, **_):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(post_save)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def post_save_update(sender, instance: Model, created: bool, **_):
 | 
			
		||||
    """If an Outpost is saved, Ensure that token is created/updated
 | 
			
		||||
 | 
			
		||||
@ -70,7 +67,6 @@ def post_save_update(sender, instance: Model, created: bool, **_):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(pre_delete, sender=Outpost)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def pre_delete_cleanup(sender, instance: Outpost, **_):
 | 
			
		||||
    """Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
 | 
			
		||||
    instance.user.delete()
 | 
			
		||||
 | 
			
		||||
@ -144,7 +144,6 @@ class PolicyViewSet(
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
 | 
			
		||||
    # pylint: disable=unused-argument, invalid-name
 | 
			
		||||
    def test(self, request: Request, pk: str) -> Response:
 | 
			
		||||
        """Test policy"""
 | 
			
		||||
        policy = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -52,6 +52,8 @@ class PolicyEngine:
 | 
			
		||||
        self.empty_result = True
 | 
			
		||||
        if not isinstance(pbm, PolicyBindingModel):  # pragma: no cover
 | 
			
		||||
            raise ValueError(f"{pbm} is not instance of PolicyBindingModel")
 | 
			
		||||
        if not user:
 | 
			
		||||
            raise ValueError("User must be set")
 | 
			
		||||
        self.__pbm = pbm
 | 
			
		||||
        self.request = PolicyRequest(user)
 | 
			
		||||
        self.request.obj = pbm
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,25 @@
 | 
			
		||||
"""Event Matcher Policy API"""
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from rest_framework.fields import ChoiceField
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.policies.api.policies import PolicySerializer
 | 
			
		||||
from authentik.policies.event_matcher.models import EventMatcherPolicy
 | 
			
		||||
from authentik.policies.event_matcher.models import EventMatcherPolicy, app_choices
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EventMatcherPolicySerializer(PolicySerializer):
 | 
			
		||||
    """Event Matcher Policy Serializer"""
 | 
			
		||||
 | 
			
		||||
    app = ChoiceField(
 | 
			
		||||
        choices=app_choices(),
 | 
			
		||||
        required=False,
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "Match events created by selected application. When left empty, "
 | 
			
		||||
            "all applications are matched."
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = EventMatcherPolicy
 | 
			
		||||
        fields = PolicySerializer.Meta.fields + [
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,25 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-01-05 09:24
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        (
 | 
			
		||||
            "authentik_policies_event_matcher",
 | 
			
		||||
            "0020_eventmatcherpolicy_authentik_p_policy__e605cf_idx",
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="eventmatcherpolicy",
 | 
			
		||||
            name="app",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default="",
 | 
			
		||||
                help_text="Match events created by selected application. When left empty, all applications are matched.",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -33,7 +33,6 @@ class EventMatcherPolicy(Policy):
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    app = models.TextField(
 | 
			
		||||
        choices=app_choices(),
 | 
			
		||||
        blank=True,
 | 
			
		||||
        default="",
 | 
			
		||||
        help_text=_(
 | 
			
		||||
 | 
			
		||||
@ -1,24 +0,0 @@
 | 
			
		||||
"""Source API Views"""
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.policies.api.policies import PolicySerializer
 | 
			
		||||
from authentik.policies.hibp.models import HaveIBeenPwendPolicy
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HaveIBeenPwendPolicySerializer(PolicySerializer):
 | 
			
		||||
    """Have I Been Pwned Policy Serializer"""
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = HaveIBeenPwendPolicy
 | 
			
		||||
        fields = PolicySerializer.Meta.fields + ["password_field", "allowed_count"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HaveIBeenPwendPolicyViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
    """Source Viewset"""
 | 
			
		||||
 | 
			
		||||
    queryset = HaveIBeenPwendPolicy.objects.all()
 | 
			
		||||
    serializer_class = HaveIBeenPwendPolicySerializer
 | 
			
		||||
    filterset_fields = "__all__"
 | 
			
		||||
    search_fields = ["name", "password_field"]
 | 
			
		||||
    ordering = ["name"]
 | 
			
		||||
@ -1,11 +0,0 @@
 | 
			
		||||
"""Authentik hibp app config"""
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AuthentikPolicyHIBPConfig(AppConfig):
 | 
			
		||||
    """Authentik hibp app config"""
 | 
			
		||||
 | 
			
		||||
    name = "authentik.policies.hibp"
 | 
			
		||||
    label = "authentik_policies_hibp"
 | 
			
		||||
    verbose_name = "authentik Policies.HaveIBeenPwned"
 | 
			
		||||
@ -1,38 +0,0 @@
 | 
			
		||||
# Generated by Django 3.0.6 on 2020-05-19 22:08
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    initial = True
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_policies", "0001_initial"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="HaveIBeenPwendPolicy",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "policy_ptr",
 | 
			
		||||
                    models.OneToOneField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        parent_link=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        to="authentik_policies.Policy",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("allowed_count", models.IntegerField(default=0)),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                "verbose_name": "Have I Been Pwned Policy",
 | 
			
		||||
                "verbose_name_plural": "Have I Been Pwned Policies",
 | 
			
		||||
            },
 | 
			
		||||
            bases=("authentik_policies.policy",),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -1,21 +0,0 @@
 | 
			
		||||
# Generated by Django 3.0.8 on 2020-07-10 18:45
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_policies_hibp", "0001_initial"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="haveibeenpwendpolicy",
 | 
			
		||||
            name="password_field",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                default="password",
 | 
			
		||||
                help_text="Field key to check, field keys defined in Prompt stages are available.",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -1,17 +0,0 @@
 | 
			
		||||
# Generated by Django 4.1.2 on 2022-10-19 19:24
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_policies_hibp", "0002_haveibeenpwendpolicy_password_field"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddIndex(
 | 
			
		||||
            model_name="haveibeenpwendpolicy",
 | 
			
		||||
            index=models.Index(fields=["policy_ptr_id"], name="authentik_p_policy__6957d7_idx"),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -1,71 +0,0 @@
 | 
			
		||||
"""authentik HIBP Models"""
 | 
			
		||||
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 authentik.lib.utils.http import get_http_session
 | 
			
		||||
from authentik.policies.models import Policy, PolicyResult
 | 
			
		||||
from authentik.policies.types import PolicyRequest
 | 
			
		||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HaveIBeenPwendPolicy(Policy):
 | 
			
		||||
    """DEPRECATED. Check if password is on HaveIBeenPwned's list by uploading the first
 | 
			
		||||
    5 characters of the SHA1 Hash."""
 | 
			
		||||
 | 
			
		||||
    password_field = models.TextField(
 | 
			
		||||
        default="password",
 | 
			
		||||
        help_text=_("Field key to check, field keys defined in Prompt stages are available."),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    allowed_count = models.IntegerField(default=0)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> type[BaseSerializer]:
 | 
			
		||||
        from authentik.policies.hibp.api import HaveIBeenPwendPolicySerializer
 | 
			
		||||
 | 
			
		||||
        return HaveIBeenPwendPolicySerializer
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def component(self) -> str:
 | 
			
		||||
        return "ak-policy-hibp-form"
 | 
			
		||||
 | 
			
		||||
    def passes(self, 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."""
 | 
			
		||||
        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(),
 | 
			
		||||
            )
 | 
			
		||||
            return PolicyResult(False, _("Password not set in context"))
 | 
			
		||||
        password = str(password)
 | 
			
		||||
 | 
			
		||||
        pw_hash = sha1(password.encode("utf-8")).hexdigest()  # nosec
 | 
			
		||||
        url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}"
 | 
			
		||||
        result = get_http_session().get(url).text
 | 
			
		||||
        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.allowed_count:
 | 
			
		||||
            message = _("Password exists on %(count)d online lists." % {"count": final_count})
 | 
			
		||||
            return PolicyResult(False, message)
 | 
			
		||||
        return PolicyResult(True)
 | 
			
		||||
 | 
			
		||||
    class Meta(Policy.PolicyMeta):
 | 
			
		||||
 | 
			
		||||
        verbose_name = _("Have I Been Pwned Policy")
 | 
			
		||||
        verbose_name_plural = _("Have I Been Pwned Policies")
 | 
			
		||||
@ -1,44 +0,0 @@
 | 
			
		||||
"""HIBP Policy tests"""
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from guardian.shortcuts import get_anonymous_user
 | 
			
		||||
 | 
			
		||||
from authentik.lib.generators import generate_key
 | 
			
		||||
from authentik.policies.hibp.models import HaveIBeenPwendPolicy
 | 
			
		||||
from authentik.policies.types import PolicyRequest, PolicyResult
 | 
			
		||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestHIBPPolicy(TestCase):
 | 
			
		||||
    """Test HIBP Policy"""
 | 
			
		||||
 | 
			
		||||
    def test_invalid(self):
 | 
			
		||||
        """Test without password"""
 | 
			
		||||
        policy = HaveIBeenPwendPolicy.objects.create(
 | 
			
		||||
            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 = HaveIBeenPwendPolicy.objects.create(
 | 
			
		||||
            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 = HaveIBeenPwendPolicy.objects.create(
 | 
			
		||||
            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())
 | 
			
		||||
@ -1,34 +1,10 @@
 | 
			
		||||
# 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"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
@ -69,5 +45,4 @@ class Migration(migrations.Migration):
 | 
			
		||||
            name="error_message",
 | 
			
		||||
            field=models.TextField(blank=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(migrate_hibp_policy),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
@ -138,5 +138,5 @@ class PolicyProcess(PROCESS_CLASS):
 | 
			
		||||
        try:
 | 
			
		||||
            self.connection.send(self.profiling_wrapper())
 | 
			
		||||
        except Exception as exc:  # pylint: disable=broad-except
 | 
			
		||||
            LOGGER.warning("Policy failed to run", exc=exc)
 | 
			
		||||
            LOGGER.warning("Policy failed to run", exc=exception_to_string(exc))
 | 
			
		||||
            self.connection.send(PolicyResult(False, str(exc)))
 | 
			
		||||
 | 
			
		||||
@ -37,7 +37,6 @@ def update_score(request: HttpRequest, identifier: str, amount: int):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(login_failed)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def handle_failed_login(sender, request, credentials, **_):
 | 
			
		||||
    """Lower Score for failed login attempts"""
 | 
			
		||||
    if "username" in credentials:
 | 
			
		||||
@ -45,14 +44,12 @@ def handle_failed_login(sender, request, credentials, **_):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(identification_failed)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def handle_identification_failed(sender, request, uid_field: str, **_):
 | 
			
		||||
    """Lower Score for failed identification attempts"""
 | 
			
		||||
    update_score(request, uid_field, -1)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(user_logged_in)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def handle_successful_login(sender, request, user, **_):
 | 
			
		||||
    """Raise score for successful attempts"""
 | 
			
		||||
    update_score(request, user.username, 1)
 | 
			
		||||
 | 
			
		||||
@ -13,14 +13,12 @@ LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(monitoring_set)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def monitoring_set_policies(sender, **kwargs):
 | 
			
		||||
    """set policy gauges"""
 | 
			
		||||
    GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}_*") or []))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(post_save)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def invalidate_policy_cache(sender, instance, **_):
 | 
			
		||||
    """Invalidate Policy cache when policy is updated"""
 | 
			
		||||
    from authentik.policies.models import Policy, PolicyBinding
 | 
			
		||||
 | 
			
		||||
@ -83,7 +83,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    @action(methods=["GET"], detail=True)
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    def setup_urls(self, request: Request, pk: int) -> str:
 | 
			
		||||
        """Get Providers setup URLs"""
 | 
			
		||||
        provider = get_object_or_404(OAuth2Provider, pk=pk)
 | 
			
		||||
@ -140,7 +139,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, methods=["GET"])
 | 
			
		||||
    # pylint: disable=invalid-name, unused-argument
 | 
			
		||||
    def preview_user(self, request: Request, pk: int) -> Response:
 | 
			
		||||
        """Preview user data for provider"""
 | 
			
		||||
        provider: OAuth2Provider = self.get_object()
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,8 @@ from django_filters.filters import AllValuesMultipleFilter
 | 
			
		||||
from django_filters.filterset import FilterSet
 | 
			
		||||
from drf_spectacular.types import OpenApiTypes
 | 
			
		||||
from drf_spectacular.utils import extend_schema_field
 | 
			
		||||
from rest_framework.fields import CharField
 | 
			
		||||
from rest_framework.serializers import ValidationError
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.propertymappings import PropertyMappingSerializer
 | 
			
		||||
@ -10,9 +12,18 @@ from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.providers.oauth2.models import ScopeMapping
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def no_space(value: str) -> str:
 | 
			
		||||
    """Ensure value contains no spaces"""
 | 
			
		||||
    if " " in value:
 | 
			
		||||
        raise ValidationError("Value must not contain spaces.")
 | 
			
		||||
    return value
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ScopeMappingSerializer(PropertyMappingSerializer):
 | 
			
		||||
    """ScopeMapping Serializer"""
 | 
			
		||||
 | 
			
		||||
    scope_name = CharField(help_text="Scope name requested by the client", validators=[no_space])
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        model = ScopeMapping
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ class Migration(migrations.Migration):
 | 
			
		||||
            model_name="oauth2provider",
 | 
			
		||||
            name="verification_keys",
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.",
 | 
			
		||||
                help_text="JWTs created with the configured certificates can authenticate with this provider.",
 | 
			
		||||
                related_name="+",
 | 
			
		||||
                to="authentik_crypto.certificatekeypair",
 | 
			
		||||
                verbose_name="Allowed certificates for JWT-based client_credentials",
 | 
			
		||||
 | 
			
		||||
@ -32,7 +32,7 @@ class Migration(migrations.Migration):
 | 
			
		||||
            field=models.ManyToManyField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                default=None,
 | 
			
		||||
                help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.",
 | 
			
		||||
                help_text="JWTs created with the configured certificates can authenticate with this provider.",
 | 
			
		||||
                related_name="oauth2_providers",
 | 
			
		||||
                to="authentik_crypto.certificatekeypair",
 | 
			
		||||
                verbose_name="Allowed certificates for JWT-based client_credentials",
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,7 @@ from django.urls import reverse
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
 | 
			
		||||
from authentik.lib.generators import generate_id, generate_key
 | 
			
		||||
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
 | 
			
		||||
from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
@ -57,6 +58,8 @@ class TesOAuth2Introspection(OAuthTestCase):
 | 
			
		||||
        self.assertJSONEqual(
 | 
			
		||||
            res.content.decode(),
 | 
			
		||||
            {
 | 
			
		||||
                "acr": ACR_AUTHENTIK_DEFAULT,
 | 
			
		||||
                "auth_time": None,
 | 
			
		||||
                "aud": None,
 | 
			
		||||
                "sub": "bar",
 | 
			
		||||
                "exp": None,
 | 
			
		||||
@ -64,6 +67,7 @@ class TesOAuth2Introspection(OAuthTestCase):
 | 
			
		||||
                "iss": "foo",
 | 
			
		||||
                "active": True,
 | 
			
		||||
                "client_id": self.provider.client_id,
 | 
			
		||||
                "scope": " ".join(self.token.scope),
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,23 +1,46 @@
 | 
			
		||||
"""JWKS tests"""
 | 
			
		||||
import base64
 | 
			
		||||
import json
 | 
			
		||||
 | 
			
		||||
from django.test import RequestFactory
 | 
			
		||||
from cryptography.hazmat.backends import default_backend
 | 
			
		||||
from cryptography.x509 import load_der_x509_certificate
 | 
			
		||||
from django.urls.base import reverse
 | 
			
		||||
from jwt import PyJWKSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
 | 
			
		||||
from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
 | 
			
		||||
TEST_CORDS_CERT = """
 | 
			
		||||
-----BEGIN CERTIFICATE-----
 | 
			
		||||
MIIB6jCCAZCgAwIBAgIRAOsdE3N7zETzs+7shTXGj5wwCgYIKoZIzj0EAwIwHjEc
 | 
			
		||||
MBoGA1UEAwwTYXV0aGVudGlrIDIwMjIuMTIuMjAeFw0yMzAxMTYyMjU2MjVaFw0y
 | 
			
		||||
NDAxMTIyMjU2MjVaMHgxTDBKBgNVBAMMQ0NsbDR2TzFJSGxvdFFhTGwwMHpES2tM
 | 
			
		||||
WENYdzRPUFF2eEtZN1NrczAuc2VsZi1zaWduZWQuZ29hdXRoZW50aWsuaW8xEjAQ
 | 
			
		||||
BgNVBAoMCWF1dGhlbnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwWTATBgcqhkjO
 | 
			
		||||
PQIBBggqhkjOPQMBBwNCAAQAwOGam7AKOi5LKmb9lK1rAzA2JTppqrFiIaUdjqmH
 | 
			
		||||
ZICJP00Wt0dfqOtEjgMEv1Hhu1DmKZn2ehvpxwPSzBr5o1UwUzBRBgNVHREBAf8E
 | 
			
		||||
RzBFgkNCNkw4YlI0UldJRU42NUZLamdUTzV1YmRvNUZWdkpNS2lxdjFZeTRULnNl
 | 
			
		||||
bGYtc2lnbmVkLmdvYXV0aGVudGlrLmlvMAoGCCqGSM49BAMCA0gAMEUCIC/JAfnl
 | 
			
		||||
uC30ihqepbiMCaTaPMbL8Ka2Lk92IYfMhf46AiEAz9Kmv6HF2D4MK54iwhz2WqvF
 | 
			
		||||
8vo+OiGdTQ1Qoj7fgYU=
 | 
			
		||||
-----END CERTIFICATE-----
 | 
			
		||||
"""
 | 
			
		||||
TEST_CORDS_KEY = """
 | 
			
		||||
-----BEGIN EC PRIVATE KEY-----
 | 
			
		||||
MHcCAQEEIKy6mPLJc5v71InMMvYaxyXI3xXpwQTPLyAYWVFnZHVioAoGCCqGSM49
 | 
			
		||||
AwEHoUQDQgAEAMDhmpuwCjouSypm/ZStawMwNiU6aaqxYiGlHY6ph2SAiT9NFrdH
 | 
			
		||||
X6jrRI4DBL9R4btQ5imZ9nob6ccD0swa+Q==
 | 
			
		||||
-----END EC PRIVATE KEY-----
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestJWKS(OAuthTestCase):
 | 
			
		||||
    """Test JWKS view"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        super().setUp()
 | 
			
		||||
        self.factory = RequestFactory()
 | 
			
		||||
 | 
			
		||||
    def test_rs256(self):
 | 
			
		||||
        """Test JWKS request with RS256"""
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
@ -34,6 +57,8 @@ class TestJWKS(OAuthTestCase):
 | 
			
		||||
        body = json.loads(response.content.decode())
 | 
			
		||||
        self.assertEqual(len(body["keys"]), 1)
 | 
			
		||||
        PyJWKSet.from_dict(body)
 | 
			
		||||
        key = body["keys"][0]
 | 
			
		||||
        load_der_x509_certificate(base64.b64decode(key["x5c"][0]), default_backend()).public_key()
 | 
			
		||||
 | 
			
		||||
    def test_hs256(self):
 | 
			
		||||
        """Test JWKS request with HS256"""
 | 
			
		||||
@ -65,3 +90,25 @@ class TestJWKS(OAuthTestCase):
 | 
			
		||||
        body = json.loads(response.content.decode())
 | 
			
		||||
        self.assertEqual(len(body["keys"]), 1)
 | 
			
		||||
        PyJWKSet.from_dict(body)
 | 
			
		||||
 | 
			
		||||
    def test_ecdsa_coords_mismatched(self):
 | 
			
		||||
        """Test JWKS request with ES256"""
 | 
			
		||||
        cert = CertificateKeyPair.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            key_data=TEST_CORDS_KEY,
 | 
			
		||||
            certificate_data=TEST_CORDS_CERT,
 | 
			
		||||
        )
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name="test",
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            authorization_flow=create_test_flow(),
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
            signing_key=cert,
 | 
			
		||||
        )
 | 
			
		||||
        app = Application.objects.create(name="test", slug="test", provider=provider)
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug})
 | 
			
		||||
        )
 | 
			
		||||
        body = json.loads(response.content.decode())
 | 
			
		||||
        self.assertEqual(len(body["keys"]), 1)
 | 
			
		||||
        PyJWKSet.from_dict(body)
 | 
			
		||||
 | 
			
		||||
@ -318,7 +318,6 @@ class AuthorizationFlowInitView(PolicyAccessView):
 | 
			
		||||
        request.context["oauth_response_type"] = self.params.response_type
 | 
			
		||||
        return request
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Start FlowPLanner, return to flow executor shell"""
 | 
			
		||||
        # After we've checked permissions, and the user has access, check if we need
 | 
			
		||||
@ -429,7 +428,6 @@ class OAuthFulfillmentStage(StageView):
 | 
			
		||||
        """Wrapper when this stage gets hit with a post request"""
 | 
			
		||||
        return self.get(request, *args, **kwargs)
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """final Stage of an OAuth2 Flow"""
 | 
			
		||||
        if PLAN_CONTEXT_PARAMS not in self.executor.plan.context:
 | 
			
		||||
 | 
			
		||||
@ -73,7 +73,6 @@ class GitHubUserView(View):
 | 
			
		||||
class GitHubUserTeamsView(View):
 | 
			
		||||
    """Emulate GitHub's /user/teams API Endpoint"""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
 | 
			
		||||
        """Emulate GitHub's /user/teams API Endpoint"""
 | 
			
		||||
        user = token.user
 | 
			
		||||
 | 
			
		||||
@ -52,9 +52,8 @@ class TokenIntrospectionParams:
 | 
			
		||||
        if not provider:
 | 
			
		||||
            raise TokenIntrospectionError
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            token: RefreshToken = RefreshToken.objects.get(provider=provider, **token_filter)
 | 
			
		||||
        except RefreshToken.DoesNotExist:
 | 
			
		||||
        token: RefreshToken = RefreshToken.objects.filter(provider=provider, **token_filter).first()
 | 
			
		||||
        if not token:
 | 
			
		||||
            LOGGER.debug("Token does not exist", token=raw_token)
 | 
			
		||||
            raise TokenIntrospectionError()
 | 
			
		||||
 | 
			
		||||
@ -74,15 +73,12 @@ class TokenIntrospectionView(View):
 | 
			
		||||
        """Introspection handler"""
 | 
			
		||||
        try:
 | 
			
		||||
            self.params = TokenIntrospectionParams.from_request(request)
 | 
			
		||||
 | 
			
		||||
            response_dic = {}
 | 
			
		||||
            response = {}
 | 
			
		||||
            if self.params.id_token:
 | 
			
		||||
                token_dict = self.params.id_token.to_dict()
 | 
			
		||||
                for k in ("aud", "sub", "exp", "iat", "iss"):
 | 
			
		||||
                    response_dic[k] = token_dict[k]
 | 
			
		||||
            response_dic["active"] = True
 | 
			
		||||
            response_dic["client_id"] = self.params.token.provider.client_id
 | 
			
		||||
 | 
			
		||||
            return TokenResponse(response_dic)
 | 
			
		||||
                response.update(self.params.id_token.to_dict())
 | 
			
		||||
            response["active"] = True
 | 
			
		||||
            response["scope"] = " ".join(self.params.token.scope)
 | 
			
		||||
            response["client_id"] = self.params.token.provider.client_id
 | 
			
		||||
            return TokenResponse(response)
 | 
			
		||||
        except TokenIntrospectionError:
 | 
			
		||||
            return TokenResponse({"active": False})
 | 
			
		||||
 | 
			
		||||
@ -15,27 +15,49 @@ from cryptography.hazmat.primitives.serialization import Encoding
 | 
			
		||||
from django.http import HttpRequest, HttpResponse, JsonResponse
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from django.views import View
 | 
			
		||||
from jwt.utils import base64url_encode
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def b64_enc(number: int) -> str:
 | 
			
		||||
    """Convert number to base64-encoded octet-value"""
 | 
			
		||||
    length = ((number).bit_length() + 7) // 8
 | 
			
		||||
    number_bytes = number.to_bytes(length, "big")
 | 
			
		||||
    final = urlsafe_b64encode(number_bytes).rstrip(b"=")
 | 
			
		||||
    return final.decode("ascii")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# See https://notes.salrahman.com/generate-es256-es384-es512-private-keys/
 | 
			
		||||
# and _CURVE_TYPES in the same file as the below curve files
 | 
			
		||||
ec_crv_map = {
 | 
			
		||||
    SECP256R1: "P-256",
 | 
			
		||||
    SECP384R1: "P-384",
 | 
			
		||||
    SECP521R1: "P-512",
 | 
			
		||||
    SECP521R1: "P-521",
 | 
			
		||||
}
 | 
			
		||||
min_length_map = {
 | 
			
		||||
    SECP256R1: 32,
 | 
			
		||||
    SECP384R1: 48,
 | 
			
		||||
    SECP521R1: 66,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# https://github.com/jpadilla/pyjwt/issues/709
 | 
			
		||||
def bytes_from_int(val: int, min_length: int = 0) -> bytes:
 | 
			
		||||
    """Custom bytes_from_int that accepts a minimum length"""
 | 
			
		||||
    remaining = val
 | 
			
		||||
    byte_length = 0
 | 
			
		||||
 | 
			
		||||
    while remaining != 0:
 | 
			
		||||
        remaining >>= 8
 | 
			
		||||
        byte_length += 1
 | 
			
		||||
    length = max([byte_length, min_length])
 | 
			
		||||
    return val.to_bytes(length, "big", signed=False)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def to_base64url_uint(val: int, min_length: int = 0) -> bytes:
 | 
			
		||||
    """Custom to_base64url_uint that accepts a minimum length"""
 | 
			
		||||
    if val < 0:
 | 
			
		||||
        raise ValueError("Must be a positive integer")
 | 
			
		||||
 | 
			
		||||
    int_bytes = bytes_from_int(val, min_length)
 | 
			
		||||
 | 
			
		||||
    if len(int_bytes) == 0:
 | 
			
		||||
        int_bytes = b"\x00"
 | 
			
		||||
 | 
			
		||||
    return base64url_encode(int_bytes)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JWKSView(View):
 | 
			
		||||
@ -51,24 +73,25 @@ class JWKSView(View):
 | 
			
		||||
            public_key: RSAPublicKey = private_key.public_key()
 | 
			
		||||
            public_numbers = public_key.public_numbers()
 | 
			
		||||
            key_data = {
 | 
			
		||||
                "kid": key.kid,
 | 
			
		||||
                "kty": "RSA",
 | 
			
		||||
                "alg": JWTAlgorithms.RS256,
 | 
			
		||||
                "use": "sig",
 | 
			
		||||
                "kid": key.kid,
 | 
			
		||||
                "n": b64_enc(public_numbers.n),
 | 
			
		||||
                "e": b64_enc(public_numbers.e),
 | 
			
		||||
                "n": to_base64url_uint(public_numbers.n).decode(),
 | 
			
		||||
                "e": to_base64url_uint(public_numbers.e).decode(),
 | 
			
		||||
            }
 | 
			
		||||
        elif isinstance(private_key, EllipticCurvePrivateKey):
 | 
			
		||||
            public_key: EllipticCurvePublicKey = private_key.public_key()
 | 
			
		||||
            public_numbers = public_key.public_numbers()
 | 
			
		||||
            curve_type = type(public_key.curve)
 | 
			
		||||
            key_data = {
 | 
			
		||||
                "kid": key.kid,
 | 
			
		||||
                "kty": "EC",
 | 
			
		||||
                "alg": JWTAlgorithms.ES256,
 | 
			
		||||
                "use": "sig",
 | 
			
		||||
                "kid": key.kid,
 | 
			
		||||
                "x": b64_enc(public_numbers.x),
 | 
			
		||||
                "y": b64_enc(public_numbers.y),
 | 
			
		||||
                "crv": ec_crv_map.get(type(public_key.curve), public_key.curve.name),
 | 
			
		||||
                "x": to_base64url_uint(public_numbers.x, min_length_map[curve_type]).decode(),
 | 
			
		||||
                "y": to_base64url_uint(public_numbers.y, min_length_map[curve_type]).decode(),
 | 
			
		||||
                "crv": ec_crv_map.get(curve_type, public_key.curve.name),
 | 
			
		||||
            }
 | 
			
		||||
        else:
 | 
			
		||||
            return key_data
 | 
			
		||||
 | 
			
		||||
@ -4,8 +4,10 @@ from typing import Any
 | 
			
		||||
from django.http import HttpRequest, HttpResponse, JsonResponse
 | 
			
		||||
from django.shortcuts import get_object_or_404, reverse
 | 
			
		||||
from django.views import View
 | 
			
		||||
from guardian.shortcuts import get_anonymous_user
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.exceptions import PropertyMappingExpressionException
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
from authentik.providers.oauth2.constants import (
 | 
			
		||||
    ACR_AUTHENTIK_DEFAULT,
 | 
			
		||||
@ -108,21 +110,40 @@ class ProviderInfoView(View):
 | 
			
		||||
            "scopes_supported": scopes,
 | 
			
		||||
            # https://openid.net/specs/openid-connect-core-1_0.html#RequestObject
 | 
			
		||||
            "request_parameter_supported": False,
 | 
			
		||||
            # Because claims are dynamic and per-application, the only claims listed here
 | 
			
		||||
            # are ones that are always set by authentik itself on every token
 | 
			
		||||
            "claims_supported": [
 | 
			
		||||
                "sub",
 | 
			
		||||
                "iss",
 | 
			
		||||
                "aud",
 | 
			
		||||
                "exp",
 | 
			
		||||
                "iat",
 | 
			
		||||
                "auth_time",
 | 
			
		||||
                "acr",
 | 
			
		||||
            ],
 | 
			
		||||
            "claims_supported": self.get_claims(provider),
 | 
			
		||||
            "claims_parameter_supported": False,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def get_claims(self, provider: OAuth2Provider) -> list[str]:
 | 
			
		||||
        """Get a list of supported claims based on configured scope mappings"""
 | 
			
		||||
        default_claims = [
 | 
			
		||||
            "sub",
 | 
			
		||||
            "iss",
 | 
			
		||||
            "aud",
 | 
			
		||||
            "exp",
 | 
			
		||||
            "iat",
 | 
			
		||||
            "auth_time",
 | 
			
		||||
            "acr",
 | 
			
		||||
            "amr",
 | 
			
		||||
            "nonce",
 | 
			
		||||
        ]
 | 
			
		||||
        for scope in ScopeMapping.objects.filter(provider=provider).order_by("scope_name"):
 | 
			
		||||
            value = None
 | 
			
		||||
            try:
 | 
			
		||||
                value = scope.evaluate(
 | 
			
		||||
                    user=get_anonymous_user(),
 | 
			
		||||
                    request=self.request,
 | 
			
		||||
                    provider=provider,
 | 
			
		||||
                )
 | 
			
		||||
            except PropertyMappingExpressionException:
 | 
			
		||||
                continue
 | 
			
		||||
            if value is None:
 | 
			
		||||
                continue
 | 
			
		||||
            if not isinstance(value, dict):
 | 
			
		||||
                continue
 | 
			
		||||
            default_claims.extend(value.keys())
 | 
			
		||||
        return default_claims
 | 
			
		||||
 | 
			
		||||
    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
 | 
			
		||||
        """OpenID-compliant Provider Info"""
 | 
			
		||||
        return JsonResponse(self.get_info(self.provider), json_dumps_params={"indent": 2})
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ from django.utils.decorators import method_decorator
 | 
			
		||||
from django.utils.timezone import datetime, now
 | 
			
		||||
from django.views import View
 | 
			
		||||
from django.views.decorators.csrf import csrf_exempt
 | 
			
		||||
from guardian.shortcuts import get_anonymous_user
 | 
			
		||||
from jwt import PyJWK, PyJWTError, decode
 | 
			
		||||
from sentry_sdk.hub import Hub
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
@ -104,7 +105,8 @@ class TokenParams:
 | 
			
		||||
        with Hub.current.start_span(
 | 
			
		||||
            op="authentik.providers.oauth2.token.policy",
 | 
			
		||||
        ):
 | 
			
		||||
            engine = PolicyEngine(app, self.user, request)
 | 
			
		||||
            user = self.user if self.user else get_anonymous_user()
 | 
			
		||||
            engine = PolicyEngine(app, user, request)
 | 
			
		||||
            engine.request.context["oauth_scopes"] = self.scope
 | 
			
		||||
            engine.request.context["oauth_grant_type"] = self.grant_type
 | 
			
		||||
            engine.request.context["oauth_code_verifier"] = self.code_verifier
 | 
			
		||||
@ -112,7 +114,9 @@ class TokenParams:
 | 
			
		||||
            engine.build()
 | 
			
		||||
            result = engine.result
 | 
			
		||||
            if not result.passing:
 | 
			
		||||
                LOGGER.info("User not authenticated for application", user=self.user, app=app)
 | 
			
		||||
                LOGGER.info(
 | 
			
		||||
                    "User not authenticated for application", user=self.user, app_slug=app.slug
 | 
			
		||||
                )
 | 
			
		||||
                raise TokenError("invalid_grant")
 | 
			
		||||
 | 
			
		||||
    def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
 | 
			
		||||
@ -303,10 +307,10 @@ class TokenParams:
 | 
			
		||||
        source: Optional[OAuthSource] = None
 | 
			
		||||
        parsed_key: Optional[PyJWK] = None
 | 
			
		||||
        for source in self.provider.jwks_sources.all():
 | 
			
		||||
            LOGGER.debug("verifying jwt with source", source=source.name)
 | 
			
		||||
            LOGGER.debug("verifying jwt with source", source=source.slug)
 | 
			
		||||
            keys = source.oidc_jwks.get("keys", [])
 | 
			
		||||
            for key in keys:
 | 
			
		||||
                LOGGER.debug("verifying jwt with key", source=source.name, key=key.get("kid"))
 | 
			
		||||
                LOGGER.debug("verifying jwt with key", source=source.slug, key=key.get("kid"))
 | 
			
		||||
                try:
 | 
			
		||||
                    parsed_key = PyJWK.from_dict(key)
 | 
			
		||||
                    token = decode(
 | 
			
		||||
@ -320,12 +324,14 @@ class TokenParams:
 | 
			
		||||
                # AttributeError is raised when the configured JWK is a private key
 | 
			
		||||
                # and not a public key
 | 
			
		||||
                except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
 | 
			
		||||
                    LOGGER.warning("failed to validate jwt", exc=exc)
 | 
			
		||||
                    LOGGER.warning("failed to verify jwt", exc=exc, source=source.slug)
 | 
			
		||||
 | 
			
		||||
        if not token:
 | 
			
		||||
            LOGGER.warning("No token could be verified")
 | 
			
		||||
            raise TokenError("invalid_grant")
 | 
			
		||||
 | 
			
		||||
        LOGGER.debug("successfully verified jwt with source", source=source.slug)
 | 
			
		||||
 | 
			
		||||
        if "exp" in token:
 | 
			
		||||
            exp = datetime.fromtimestamp(token["exp"])
 | 
			
		||||
            # Non-timezone aware check since we assume `exp` is in UTC
 | 
			
		||||
 | 
			
		||||
@ -110,6 +110,8 @@ class UserInfoView(View):
 | 
			
		||||
            return HttpResponseBadRequest()
 | 
			
		||||
        claims = self.get_claims(self.token)
 | 
			
		||||
        claims["sub"] = self.token.id_token.sub
 | 
			
		||||
        if self.token.id_token.nonce:
 | 
			
		||||
            claims["nonce"] = self.token.id_token.nonce
 | 
			
		||||
        response = TokenResponse(claims)
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -37,6 +37,7 @@ class OpenIDConnectConfigurationSerializer(PassiveSerializer):
 | 
			
		||||
class ProxyProviderSerializer(ProviderSerializer):
 | 
			
		||||
    """ProxyProvider Serializer"""
 | 
			
		||||
 | 
			
		||||
    client_id = CharField(read_only=True)
 | 
			
		||||
    redirect_uris = CharField(read_only=True)
 | 
			
		||||
    outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
 | 
			
		||||
 | 
			
		||||
@ -77,6 +78,7 @@ class ProxyProviderSerializer(ProviderSerializer):
 | 
			
		||||
 | 
			
		||||
        model = ProxyProvider
 | 
			
		||||
        fields = ProviderSerializer.Meta.fields + [
 | 
			
		||||
            "client_id",
 | 
			
		||||
            "internal_host",
 | 
			
		||||
            "external_host",
 | 
			
		||||
            "internal_host_ssl_validation",
 | 
			
		||||
@ -86,8 +88,10 @@ class ProxyProviderSerializer(ProviderSerializer):
 | 
			
		||||
            "basic_auth_password_attribute",
 | 
			
		||||
            "basic_auth_user_attribute",
 | 
			
		||||
            "mode",
 | 
			
		||||
            "intercept_header_auth",
 | 
			
		||||
            "redirect_uris",
 | 
			
		||||
            "cookie_domain",
 | 
			
		||||
            "jwks_sources",
 | 
			
		||||
            "token_validity",
 | 
			
		||||
            "outpost_set",
 | 
			
		||||
        ]
 | 
			
		||||
@ -168,6 +172,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
 | 
			
		||||
            "mode",
 | 
			
		||||
            "cookie_domain",
 | 
			
		||||
            "token_validity",
 | 
			
		||||
            "intercept_header_auth",
 | 
			
		||||
            "scopes_to_request",
 | 
			
		||||
            "assigned_application_slug",
 | 
			
		||||
            "assigned_application_name",
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user