Compare commits
	
		
			304 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 73eb97ca6e | |||
| ebe90d8886 | |||
| a1a1b113b1 | |||
| 9adf8e88ba | |||
| 72d87ee51d | |||
| 9654285535 | |||
| 6e47e69c62 | |||
| 1ba89a02ee | |||
| 1fb3642701 | |||
| 847d97b813 | |||
| 253060def2 | |||
| 2e70ea799a | |||
| 7364914ae8 | |||
| 1f1d322958 | |||
| e4841ce1a4 | |||
| af30b781b6 | |||
| 5f490c563e | |||
| e33a5528f7 | |||
| d4de243e3b | |||
| 317117ee68 | |||
| 40d03a6124 | |||
| 9cfeeb35ba | |||
| b7d828702d | |||
| 19dfeec782 | |||
| 07eef2869f | |||
| f7fd31cc84 | |||
| 465d9c2b93 | |||
| 04aae8f584 | |||
| bbca90c93a | |||
| dda1d4e0fb | |||
| f072c600cc | |||
| 65b8a5bb8d | |||
| 92537a6c8d | |||
| 72836ecd9d | |||
| 251a97c77e | |||
| 7f7046f0e4 | |||
| 20e59158c2 | |||
| 9a9e55ae32 | |||
| 481260a5ca | |||
| 436adcce2e | |||
| cd3f02fd3b | |||
| 7abfd24150 | |||
| d3feab9463 | |||
| 189427609f | |||
| d76a9c211a | |||
| ef7d9c4d35 | |||
| 70c25692eb | |||
| 71b31a2812 | |||
| 3208358a03 | |||
| a6a8eddf7c | |||
| 8c0a87b710 | |||
| 2f88c435fb | |||
| 5cad59a9f8 | |||
| 5ac6a6910e | |||
| d751a7fc4c | |||
| f1fd223bc7 | |||
| e75712fa09 | |||
| 1b87375661 | |||
| 545a114450 | |||
| 02b06838e2 | |||
| 6868b7722c | |||
| 1e303b515b | |||
| 34a9a6a389 | |||
| 7a1935b4e2 | |||
| bf60b33d03 | |||
| 9bb50fd556 | |||
| 5e7521915a | |||
| 7b0cda3a6a | |||
| db5279f952 | |||
| 9fc072e4df | |||
| 55ea9afeec | |||
| 9485f0b8cc | |||
| fabdb6448f | |||
| e629079352 | |||
| e6dfa8294e | |||
| e5a5a5c603 | |||
| 4d07da5ffa | |||
| 5b4f34fd5f | |||
| 2e05047151 | |||
| 459a6ea437 | |||
| ea7f9f291f | |||
| 241d790e69 | |||
| 83e08f12ae | |||
| 6526659b51 | |||
| 6c3b7c8d3e | |||
| d51ecc4554 | |||
| ef63e35ad2 | |||
| 4e9176ed2e | |||
| d1296e9cc7 | |||
| d85e0593f1 | |||
| 20c1f15dc0 | |||
| c864f4e312 | |||
| 202ad1a3ac | |||
| 979a5f800e | |||
| c151faeff6 | |||
| b3a3852a54 | |||
| e401b4e74e | |||
| 9538ad5710 | |||
| 49bf82a0a4 | |||
| e6fdec4c8e | |||
| 73b87a5e3d | |||
| 303b847cdc | |||
| 0386c0dd7b | |||
| 7f1b9cdeb2 | |||
| 252bb04dd3 | |||
| 3fbcfb48fb | |||
| 69f7198976 | |||
| c74c8b2083 | |||
| 63d4f598e4 | |||
| ded6b6f937 | |||
| 225099b1a1 | |||
| 6b7a32548d | |||
| c71d415456 | |||
| c03f0d1d7c | |||
| ac9cac302c | |||
| 701c140cfd | |||
| ca5761652c | |||
| 553872e8dd | |||
| adc9b67a9c | |||
| fa2ff5fc2b | |||
| d5cab5d580 | |||
| 9e3b5d313b | |||
| be8b2bf6f6 | |||
| 3f8cd7ff13 | |||
| b266a2cdfb | |||
| 9a15a66d85 | |||
| 446f104c90 | |||
| 2cad9a3d07 | |||
| ee48b8c225 | |||
| a91649a7d4 | |||
| ca89201bd8 | |||
| e3a8fc0746 | |||
| 5e3a6b802b | |||
| e8d9f992b9 | |||
| 260b2c8ca8 | |||
| 751e77fa9e | |||
| 86c2a5d69d | |||
| 1a02049104 | |||
| 32934fcd38 | |||
| d84d7c26ca | |||
| 2f6e6a3123 | |||
| 36b674349a | |||
| 038ef67745 | |||
| 53831fa354 | |||
| be39673f29 | |||
| 0f8dbfcc9c | |||
| ba57bf4fa2 | |||
| b1c9126832 | |||
| e674f03064 | |||
| 08451c15f4 | |||
| 99d161e212 | |||
| 940ccf9ea8 | |||
| 08cce2ca4e | |||
| 4acbda2b77 | |||
| 83cfb5f8c2 | |||
| 0d370ef0a9 | |||
| a335ca0895 | |||
| 8a666535a8 | |||
| e6431593f7 | |||
| 928c2bf0d6 | |||
| 68388e9551 | |||
| 5d26fa0403 | |||
| 42f9ba8efe | |||
| 0440ad7c09 | |||
| 3ebc531ae2 | |||
| ca3b5fa2a2 | |||
| 0f0a5b0621 | |||
| 51835887ab | |||
| 09bcbcc2ac | |||
| 8a76d6a21b | |||
| 48ab436444 | |||
| 18a53a9e23 | |||
| 6725569ba8 | |||
| 812be495a5 | |||
| dbc3df1f63 | |||
| 07b001bc2b | |||
| c012bed379 | |||
| d330e9ee7f | |||
| be21a5d172 | |||
| ea2f623955 | |||
| 6fc38436f4 | |||
| 35faf269db | |||
| e56c3fc54c | |||
| 5891fb3ad6 | |||
| 1041718e27 | |||
| 2507c0eec9 | |||
| 5ea9601062 | |||
| c0e6a6c614 | |||
| 4523550422 | |||
| 988cf15b71 | |||
| 6ae660aea4 | |||
| f201ce8059 | |||
| 59624ed45c | |||
| 3e78baf2d7 | |||
| 08c67b2a2c | |||
| 01d29134b9 | |||
| 55250e88e5 | |||
| f1b100c8a5 | |||
| 19708bc67b | |||
| 40a885aaaa | |||
| c529340d6c | |||
| c317efa14c | |||
| 379fcf9c1f | |||
| e10a7b48b7 | |||
| 3e666de91d | |||
| 333758d91f | |||
| 50678a9e2e | |||
| eb8f52b870 | |||
| 3ee90712b2 | |||
| e4eadf8080 | |||
| 26ebaf16fc | |||
| d0ed372af0 | |||
| cc8b2d7dfe | |||
| 61a212371f | |||
| 9ce49c2089 | |||
| 34c45900c2 | |||
| bf7d110af3 | |||
| e7b498e8b4 | |||
| b55cb2b40c | |||
| 25c001f2cd | |||
| 2a409215d3 | |||
| ad8ee83697 | |||
| 1efd09fcd5 | |||
| 35f0e6b88d | |||
| bb2c4423b0 | |||
| ad9f29566b | |||
| e76bb6bc13 | |||
| a68642779d | |||
| 3c04fcaa9f | |||
| 5955d28073 | |||
| a6fb6161d7 | |||
| 6b0e0610c6 | |||
| d7631e8af0 | |||
| 6e625f7400 | |||
| f54ead2b45 | |||
| c4e4e17f93 | |||
| 43c87f87c3 | |||
| 4da0c81f44 | |||
| 9b70aaa717 | |||
| 5769eb277c | |||
| 26f60b3e85 | |||
| 7d8ed06539 | |||
| 4d858c64e0 | |||
| 6f0792ccfe | |||
| 04f06e00ff | |||
| 776c3128b8 | |||
| e9e0992dce | |||
| 69af788b0f | |||
| ceace0282b | |||
| ccef7b4233 | |||
| cad6c42fdd | |||
| d2abe6d455 | |||
| 68d120b3b4 | |||
| 48c0c0baca | |||
| 7b29a1e485 | |||
| fe28d216fe | |||
| e36fb6641e | |||
| 972471ce79 | |||
| 38edd76949 | |||
| cd07c12c1b | |||
| 3ce8b836dc | |||
| d27dfcc1e3 | |||
| 1d5958a78f | |||
| b6e0a1d8f4 | |||
| 2a122845d9 | |||
| 21c7787eed | |||
| fae4d34131 | |||
| 7ff7bfeb58 | |||
| 983604265b | |||
| f8d6daa928 | |||
| 6fc26aca72 | |||
| 29da7dd8d6 | |||
| 91ca90f700 | |||
| b3c8ffb96c | |||
| b35d9ae8b0 | |||
| 302b047f1a | |||
| dcd80c6d63 | |||
| d741ed430a | |||
| 8436738b0f | |||
| 5b150657f5 | |||
| f89479caf3 | |||
| 2f3bf5efe7 | |||
| 5fb07acf54 | |||
| 99d0d4e8de | |||
| afc5dc5543 | |||
| 9341787fe7 | |||
| 6c9b3ebd2b | |||
| a525d6c3a9 | |||
| b59b9314e4 | |||
| 7687b744cc | |||
| 9fb41b8d10 | |||
| 51ffdcb5cb | |||
| 4d6cd4c57d | |||
| 41c5f01422 | |||
| e567cd5580 | |||
| 5f81909bab | |||
| d03b43605e | |||
| ea187d4e81 | |||
| 502ac51fa7 | |||
| 4bc6fd28d4 | |||
| 820c9e7d06 | |||
| e5a8714e6a | |||
| d56d6ea3a9 | |||
| 4f5e1fb86b | 
@ -1,5 +1,5 @@
 | 
			
		||||
[bumpversion]
 | 
			
		||||
current_version = 2021.4.5
 | 
			
		||||
current_version = 2021.5.1-rc6
 | 
			
		||||
tag = True
 | 
			
		||||
commit = True
 | 
			
		||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
 | 
			
		||||
@ -19,20 +19,14 @@ values =
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:website/docs/installation/docker-compose.md]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:website/docs/installation/kubernetes.md]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:docker-compose.yml]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:helm/values.yaml]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:helm/README.md]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:helm/Chart.yaml]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:.github/workflows/release.yml]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:authentik/__init__.py]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:internal/constants/constants.go]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:outpost/pkg/version.go]
 | 
			
		||||
 | 
			
		||||
[bumpversion:file:web/src/constants.ts]
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										8
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							@ -1,5 +1,13 @@
 | 
			
		||||
version: 2
 | 
			
		||||
updates:
 | 
			
		||||
- package-ecosystem: "github-actions"
 | 
			
		||||
  directory: "/"
 | 
			
		||||
  schedule:
 | 
			
		||||
    interval: daily
 | 
			
		||||
    time: "04:00"
 | 
			
		||||
  open-pull-requests-limit: 10
 | 
			
		||||
  assignees:
 | 
			
		||||
  - BeryJu
 | 
			
		||||
- package-ecosystem: gomod
 | 
			
		||||
  directory: "/outpost"
 | 
			
		||||
  schedule:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										137
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										137
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							@ -3,32 +3,43 @@ name: authentik-on-release
 | 
			
		||||
on:
 | 
			
		||||
  release:
 | 
			
		||||
    types: [published, created]
 | 
			
		||||
  push:
 | 
			
		||||
    branches:
 | 
			
		||||
      - version-*
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  # Build
 | 
			
		||||
  build-server:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - name: Set up QEMU
 | 
			
		||||
        uses: docker/setup-qemu-action@v1.1.0
 | 
			
		||||
      - name: Set up Docker Buildx
 | 
			
		||||
        uses: docker/setup-buildx-action@v1
 | 
			
		||||
      - name: Docker Login Registry
 | 
			
		||||
        env:
 | 
			
		||||
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
        run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
 | 
			
		||||
        uses: docker/login-action@v1
 | 
			
		||||
        with:
 | 
			
		||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
      - name: prepare ts api client
 | 
			
		||||
        run: |
 | 
			
		||||
          docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
        run: docker build
 | 
			
		||||
          --no-cache
 | 
			
		||||
          -t beryju/authentik:2021.4.5
 | 
			
		||||
          -t beryju/authentik:latest
 | 
			
		||||
          -f Dockerfile .
 | 
			
		||||
      - name: Push Docker Container to Registry (versioned)
 | 
			
		||||
        run: docker push beryju/authentik:2021.4.5
 | 
			
		||||
      - name: Push Docker Container to Registry (latest)
 | 
			
		||||
        run: docker push beryju/authentik:latest
 | 
			
		||||
        uses: docker/build-push-action@v2
 | 
			
		||||
        with:
 | 
			
		||||
          push: ${{ github.event_name == 'release' }}
 | 
			
		||||
          tags: |
 | 
			
		||||
            beryju/authentik:2021.5.1-rc6,
 | 
			
		||||
            beryju/authentik:latest,
 | 
			
		||||
            ghcr.io/goauthentik/server:2021.5.1-rc6,
 | 
			
		||||
            ghcr.io/goauthentik/server:latest
 | 
			
		||||
          platforms: linux/amd64,linux/arm64
 | 
			
		||||
          context: .
 | 
			
		||||
  build-proxy:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - uses: actions/setup-go@v2
 | 
			
		||||
        with:
 | 
			
		||||
          go-version: "^1.15"
 | 
			
		||||
@ -37,56 +48,71 @@ jobs:
 | 
			
		||||
          cd outpost
 | 
			
		||||
          go get -u github.com/go-swagger/go-swagger/cmd/swagger
 | 
			
		||||
          swagger generate client -f ../swagger.yaml -A authentik -t pkg/
 | 
			
		||||
          go build -v .
 | 
			
		||||
          go build -v ./cmd/proxy/server.go
 | 
			
		||||
      - name: Set up QEMU
 | 
			
		||||
        uses: docker/setup-qemu-action@v1.1.0
 | 
			
		||||
      - name: Set up Docker Buildx
 | 
			
		||||
        uses: docker/setup-buildx-action@v1
 | 
			
		||||
      - name: Docker Login Registry
 | 
			
		||||
        env:
 | 
			
		||||
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
        run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
 | 
			
		||||
        uses: docker/login-action@v1
 | 
			
		||||
        with:
 | 
			
		||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
        run: |
 | 
			
		||||
          cd outpost/
 | 
			
		||||
          docker build \
 | 
			
		||||
          --no-cache \
 | 
			
		||||
          -t beryju/authentik-proxy:2021.4.5 \
 | 
			
		||||
          -t beryju/authentik-proxy:latest \
 | 
			
		||||
          -f proxy.Dockerfile .
 | 
			
		||||
      - name: Push Docker Container to Registry (versioned)
 | 
			
		||||
        run: docker push beryju/authentik-proxy:2021.4.5
 | 
			
		||||
      - name: Push Docker Container to Registry (latest)
 | 
			
		||||
        run: docker push beryju/authentik-proxy:latest
 | 
			
		||||
  build-static:
 | 
			
		||||
        uses: docker/build-push-action@v2
 | 
			
		||||
        with:
 | 
			
		||||
          push: ${{ github.event_name == 'release' }}
 | 
			
		||||
          tags: |
 | 
			
		||||
            beryju/authentik-proxy:2021.5.1-rc6,
 | 
			
		||||
            beryju/authentik-proxy:latest,
 | 
			
		||||
            ghcr.io/goauthentik/proxy:2021.5.1-rc6,
 | 
			
		||||
            ghcr.io/goauthentik/proxy:latest
 | 
			
		||||
          context: outpost/
 | 
			
		||||
          file: outpost/proxy.Dockerfile
 | 
			
		||||
          platforms: linux/amd64,linux/arm64
 | 
			
		||||
  build-ldap:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - name: prepare ts api client
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - uses: actions/setup-go@v2
 | 
			
		||||
        with:
 | 
			
		||||
          go-version: "^1.15"
 | 
			
		||||
      - name: prepare go api client
 | 
			
		||||
        run: |
 | 
			
		||||
          docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
 | 
			
		||||
          cd outpost
 | 
			
		||||
          go get -u github.com/go-swagger/go-swagger/cmd/swagger
 | 
			
		||||
          swagger generate client -f ../swagger.yaml -A authentik -t pkg/
 | 
			
		||||
          go build -v ./cmd/ldap/server.go
 | 
			
		||||
      - name: Set up QEMU
 | 
			
		||||
        uses: docker/setup-qemu-action@v1.1.0
 | 
			
		||||
      - name: Set up Docker Buildx
 | 
			
		||||
        uses: docker/setup-buildx-action@v1
 | 
			
		||||
      - name: Docker Login Registry
 | 
			
		||||
        env:
 | 
			
		||||
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
        run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
 | 
			
		||||
        uses: docker/login-action@v1
 | 
			
		||||
        with:
 | 
			
		||||
          username: ${{ secrets.DOCKER_USERNAME }}
 | 
			
		||||
          password: ${{ secrets.DOCKER_PASSWORD }}
 | 
			
		||||
      - name: Building Docker Image
 | 
			
		||||
        run: |
 | 
			
		||||
          cd web/
 | 
			
		||||
          docker build \
 | 
			
		||||
          --no-cache \
 | 
			
		||||
          -t beryju/authentik-static:2021.4.5 \
 | 
			
		||||
          -t beryju/authentik-static:latest \
 | 
			
		||||
          -f Dockerfile .
 | 
			
		||||
      - name: Push Docker Container to Registry (versioned)
 | 
			
		||||
        run: docker push beryju/authentik-static:2021.4.5
 | 
			
		||||
      - name: Push Docker Container to Registry (latest)
 | 
			
		||||
        run: docker push beryju/authentik-static:latest
 | 
			
		||||
        uses: docker/build-push-action@v2
 | 
			
		||||
        with:
 | 
			
		||||
          push: ${{ github.event_name == 'release' }}
 | 
			
		||||
          tags: |
 | 
			
		||||
            beryju/authentik-ldap:2021.5.1-rc6,
 | 
			
		||||
            beryju/authentik-ldap:latest,
 | 
			
		||||
            ghcr.io/goauthentik/ldap:2021.5.1-rc6,
 | 
			
		||||
            ghcr.io/goauthentik/ldap:latest
 | 
			
		||||
          context: outpost/
 | 
			
		||||
          file: outpost/ldap.Dockerfile
 | 
			
		||||
          platforms: linux/amd64,linux/arm64
 | 
			
		||||
  test-release:
 | 
			
		||||
    if: ${{ github.event_name == 'release' }}
 | 
			
		||||
    needs:
 | 
			
		||||
      - build-server
 | 
			
		||||
      - build-static
 | 
			
		||||
      - build-proxy
 | 
			
		||||
      - build-ldap
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - name: Run test suite in final docker images
 | 
			
		||||
        run: |
 | 
			
		||||
          sudo apt-get install -y pwgen
 | 
			
		||||
@ -97,18 +123,19 @@ jobs:
 | 
			
		||||
          docker-compose start postgresql redis
 | 
			
		||||
          docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
 | 
			
		||||
  sentry-release:
 | 
			
		||||
    if: ${{ github.event_name == 'release' }}
 | 
			
		||||
    needs:
 | 
			
		||||
      - test-release
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@v1
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - name: Create a Sentry.io release
 | 
			
		||||
        uses: tclindner/sentry-releases-action@v1.2.0
 | 
			
		||||
        uses: getsentry/action-release@v1
 | 
			
		||||
        env:
 | 
			
		||||
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
 | 
			
		||||
          SENTRY_ORG: beryjuorg
 | 
			
		||||
          SENTRY_PROJECT: authentik
 | 
			
		||||
          SENTRY_URL: https://sentry.beryju.org
 | 
			
		||||
        with:
 | 
			
		||||
          tagName: 2021.4.5
 | 
			
		||||
          version: authentik@2021.5.1-rc6
 | 
			
		||||
          environment: beryjuorg-prod
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										24
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										24
									
								
								.github/workflows/tag.yml
									
									
									
									
										vendored
									
									
								
							@ -10,7 +10,10 @@ jobs:
 | 
			
		||||
    name: Create Release from Tag
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: actions/checkout@master
 | 
			
		||||
      - uses: actions/checkout@v2
 | 
			
		||||
      - name: prepare ts api client
 | 
			
		||||
        run: |
 | 
			
		||||
          docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
 | 
			
		||||
      - name: Pre-release test
 | 
			
		||||
        run: |
 | 
			
		||||
          sudo apt-get install -y pwgen
 | 
			
		||||
@ -25,15 +28,6 @@ jobs:
 | 
			
		||||
          docker-compose up --no-start
 | 
			
		||||
          docker-compose start postgresql redis
 | 
			
		||||
          docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
 | 
			
		||||
      - name: Install Helm
 | 
			
		||||
        run: |
 | 
			
		||||
          apt update && apt install -y curl
 | 
			
		||||
          curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash
 | 
			
		||||
      - name: Helm package
 | 
			
		||||
        run: |
 | 
			
		||||
          helm dependency update helm/
 | 
			
		||||
          helm package helm/
 | 
			
		||||
          mv authentik-*.tgz authentik-chart.tgz
 | 
			
		||||
      - name: Extract version number
 | 
			
		||||
        id: get_version
 | 
			
		||||
        uses: actions/github-script@0.2.0
 | 
			
		||||
@ -51,13 +45,3 @@ jobs:
 | 
			
		||||
          release_name: Release ${{ steps.get_version.outputs.result }}
 | 
			
		||||
          draft: true
 | 
			
		||||
          prerelease: false
 | 
			
		||||
      - name: Upload packaged Helm Chart
 | 
			
		||||
        id: upload-release-asset
 | 
			
		||||
        uses: actions/upload-release-asset@v1.0.1
 | 
			
		||||
        env:
 | 
			
		||||
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
        with:
 | 
			
		||||
          upload_url: ${{ steps.create_release.outputs.upload_url }}
 | 
			
		||||
          asset_path: ./authentik-chart.tgz
 | 
			
		||||
          asset_name: authentik-chart.tgz
 | 
			
		||||
          asset_content_type: application/gzip
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -202,3 +202,5 @@ selenium_screenshots/
 | 
			
		||||
backups/
 | 
			
		||||
media/
 | 
			
		||||
*mmdb
 | 
			
		||||
 | 
			
		||||
.idea/
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										35
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								Dockerfile
									
									
									
									
									
								
							@ -1,3 +1,4 @@
 | 
			
		||||
# Stage 1: Lock python dependencies
 | 
			
		||||
FROM python:3.9-slim-buster as locker
 | 
			
		||||
 | 
			
		||||
COPY ./Pipfile /app/
 | 
			
		||||
@ -9,6 +10,34 @@ RUN pip install pipenv && \
 | 
			
		||||
    pipenv lock -r > requirements.txt && \
 | 
			
		||||
    pipenv lock -rd > requirements-dev.txt
 | 
			
		||||
 | 
			
		||||
# Stage 2: Build webui
 | 
			
		||||
FROM node as npm-builder
 | 
			
		||||
 | 
			
		||||
COPY ./web /static/
 | 
			
		||||
 | 
			
		||||
ENV NODE_ENV=production
 | 
			
		||||
RUN cd /static && npm i --production=false && npm run build
 | 
			
		||||
 | 
			
		||||
# Stage 3: Build go proxy
 | 
			
		||||
FROM golang:1.16.4 AS builder
 | 
			
		||||
 | 
			
		||||
WORKDIR /work
 | 
			
		||||
 | 
			
		||||
COPY --from=npm-builder /static/robots.txt /work/web/robots.txt
 | 
			
		||||
COPY --from=npm-builder /static/security.txt /work/web/security.txt
 | 
			
		||||
COPY --from=npm-builder /static/dist/ /work/web/dist/
 | 
			
		||||
COPY --from=npm-builder /static/authentik/ /work/web/authentik/
 | 
			
		||||
 | 
			
		||||
# RUN ls /work/web/static/authentik/ && exit 1
 | 
			
		||||
COPY ./cmd /work/cmd
 | 
			
		||||
COPY ./web/static.go /work/web/static.go
 | 
			
		||||
COPY ./internal /work/internal
 | 
			
		||||
COPY ./go.mod /work/go.mod
 | 
			
		||||
COPY ./go.sum /work/go.sum
 | 
			
		||||
 | 
			
		||||
RUN go build -o /work/authentik ./cmd/server/main.go
 | 
			
		||||
 | 
			
		||||
# Stage 4: Run
 | 
			
		||||
FROM python:3.9-slim-buster
 | 
			
		||||
 | 
			
		||||
WORKDIR /
 | 
			
		||||
@ -23,11 +52,12 @@ RUN apt-get update && \
 | 
			
		||||
    curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
 | 
			
		||||
    echo "deb http://apt.postgresql.org/pub/repos/apt buster-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
 | 
			
		||||
    apt-get update && \
 | 
			
		||||
    apt-get install -y --no-install-recommends postgresql-client-12 postgresql-client-11 build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
 | 
			
		||||
    apt-get clean && \
 | 
			
		||||
    apt-get install -y --no-install-recommends libpq-dev postgresql-client-12 postgresql-client-11 build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
 | 
			
		||||
    pip install -r /requirements.txt --no-cache-dir && \
 | 
			
		||||
    apt-get remove --purge -y build-essential && \
 | 
			
		||||
    apt-get autoremove --purge -y && \
 | 
			
		||||
    apt-get clean && \
 | 
			
		||||
    rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
 | 
			
		||||
    # This is quite hacky, but docker has no guaranteed Group ID
 | 
			
		||||
    # we could instead check for the GID of the socket and add the user dynamically,
 | 
			
		||||
    # but then we have to drop permmissions later
 | 
			
		||||
@ -44,6 +74,7 @@ COPY ./pyproject.toml /
 | 
			
		||||
COPY ./xml /xml
 | 
			
		||||
COPY ./manage.py /
 | 
			
		||||
COPY ./lifecycle/ /lifecycle
 | 
			
		||||
COPY --from=builder /work/authentik /authentik-proxy
 | 
			
		||||
 | 
			
		||||
USER authentik
 | 
			
		||||
STOPSIGNAL SIGINT
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										12
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								Makefile
									
									
									
									
									
								
							@ -1,4 +1,4 @@
 | 
			
		||||
all: lint-fix lint coverage gen
 | 
			
		||||
all: lint-fix lint test gen
 | 
			
		||||
 | 
			
		||||
test-integration:
 | 
			
		||||
	k3d cluster create || exit 0
 | 
			
		||||
@ -8,7 +8,7 @@ test-integration:
 | 
			
		||||
test-e2e:
 | 
			
		||||
	coverage run manage.py test --failfast -v 3 tests/e2e
 | 
			
		||||
 | 
			
		||||
coverage:
 | 
			
		||||
test:
 | 
			
		||||
	coverage run manage.py test -v 3 authentik
 | 
			
		||||
	coverage html
 | 
			
		||||
	coverage report
 | 
			
		||||
@ -22,7 +22,7 @@ lint:
 | 
			
		||||
	bandit -r authentik tests lifecycle -x node_modules
 | 
			
		||||
	pylint authentik tests lifecycle
 | 
			
		||||
 | 
			
		||||
gen: coverage
 | 
			
		||||
gen:
 | 
			
		||||
	./manage.py generate_swagger -o swagger.yaml -f yaml
 | 
			
		||||
 | 
			
		||||
local-stack:
 | 
			
		||||
@ -31,7 +31,5 @@ local-stack:
 | 
			
		||||
	docker-compose up -d
 | 
			
		||||
	docker-compose run --rm server migrate
 | 
			
		||||
 | 
			
		||||
build-static:
 | 
			
		||||
	docker-compose -f scripts/ci.docker-compose.yml up -d
 | 
			
		||||
	docker build -t beryju/authentik-static -f static.Dockerfile --network=scripts_default .
 | 
			
		||||
	docker-compose -f scripts/ci.docker-compose.yml down -v
 | 
			
		||||
run:
 | 
			
		||||
	go run -v cmd/server/main.go
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										3
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								Pipfile
									
									
									
									
									
								
							@ -32,7 +32,7 @@ lxml = ">=4.6.3"
 | 
			
		||||
packaging = "*"
 | 
			
		||||
psycopg2-binary = "*"
 | 
			
		||||
pycryptodome = "*"
 | 
			
		||||
pyjwkest = "*"
 | 
			
		||||
pyjwt = "*"
 | 
			
		||||
pyyaml = "*"
 | 
			
		||||
requests-oauthlib = "*"
 | 
			
		||||
sentry-sdk = "*"
 | 
			
		||||
@ -59,3 +59,4 @@ pylint-django = "*"
 | 
			
		||||
pytest = "*"
 | 
			
		||||
pytest-django = "*"
 | 
			
		||||
selenium = "*"
 | 
			
		||||
requests-mock = "*"
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										288
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										288
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							@ -1,7 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
    "_meta": {
 | 
			
		||||
        "hash": {
 | 
			
		||||
            "sha256": "a9d504f00ee8820017f26a4fda2938de456cb72b4bc2f8735fc8c6a6c615d46a"
 | 
			
		||||
            "sha256": "17be2923cf8d281e430ec1467aea723806ac6f7c58fc6553ede92317e43f4d14"
 | 
			
		||||
        },
 | 
			
		||||
        "pipfile-spec": 6,
 | 
			
		||||
        "requires": {
 | 
			
		||||
@ -88,10 +88,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "attrs": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
 | 
			
		||||
                "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
 | 
			
		||||
                "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
 | 
			
		||||
                "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==20.3.0"
 | 
			
		||||
            "version": "==21.2.0"
 | 
			
		||||
        },
 | 
			
		||||
        "autobahn": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -116,25 +116,25 @@
 | 
			
		||||
        },
 | 
			
		||||
        "boto3": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:1e55df93aa47a84e2a12a639c7f145e16e6e9ef959542d69d5526d50d2e92692",
 | 
			
		||||
                "sha256:eab42daaaf68cdad5b112d31dcb0684162098f6558ba7b64156be44f993525fa"
 | 
			
		||||
                "sha256:2f0d76660d484ff4c8c2efe9171c1281b38681e6806f87cf100e822432eda11e",
 | 
			
		||||
                "sha256:cbaa8df5faf81730f117bfa0e3fcda68ec3fa9449a05847aa6140a3f4c087765"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==1.17.54"
 | 
			
		||||
            "version": "==1.17.69"
 | 
			
		||||
        },
 | 
			
		||||
        "botocore": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:20a864fc6570ba11d52532c72c3ccabab5c71a9b4a9418601a313d56f1d2ce5b",
 | 
			
		||||
                "sha256:37ec76ea2df8609540ba6cb0fe360ae1c589d2e1ee91eb642fd767823f3fcedd"
 | 
			
		||||
                "sha256:7e94d3777763ece33d282b437e3b05b5567b9af816bd7819dbe4eb9bc6db6082",
 | 
			
		||||
                "sha256:f755b19ddebda0f8ab7afc75ebcb5412dd802eca0a7e670f5fff8c5e58bc88b1"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==1.20.54"
 | 
			
		||||
            "version": "==1.20.69"
 | 
			
		||||
        },
 | 
			
		||||
        "cachetools": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2",
 | 
			
		||||
                "sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9"
 | 
			
		||||
                "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001",
 | 
			
		||||
                "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==4.2.1"
 | 
			
		||||
            "version": "==4.2.2"
 | 
			
		||||
        },
 | 
			
		||||
        "cbor2": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -312,11 +312,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "django": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:0604e84c4fb698a5e53e5857b5aea945b2f19a18f25f10b8748dbdf935788927",
 | 
			
		||||
                "sha256:21f0f9643722675976004eb683c55d33c05486f94506672df3d6a141546f389d"
 | 
			
		||||
                "sha256:0a1d195ad65c52bf275b8277b3d49680bd1137a5f55039a806f25f6b9752ce3d",
 | 
			
		||||
                "sha256:18dd3145ddbd04bf189ff79b9954d08fda5171ea7b57bf705789fea766a07d50"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==3.2"
 | 
			
		||||
            "version": "==3.2.2"
 | 
			
		||||
        },
 | 
			
		||||
        "django-dbbackup": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -351,11 +351,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "django-otp": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:381a15e65293b8b06d47b7d6b306e0b7af2e104137ac92f6c566d3b9b90b6244",
 | 
			
		||||
                "sha256:f4ab096b424c33ffe69453620356e1b7517f30dfb9ba13bfeaa1d1f20faddc13"
 | 
			
		||||
                "sha256:75a815747a0542cc5442e3a6396dfd272c49a0866bee2149ac57ecc36ddd3961",
 | 
			
		||||
                "sha256:cc657a0e7266cda6ab42f861bdc3840ed24f7e441bc7f249916174dd1a6375a0"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==1.0.3"
 | 
			
		||||
            "version": "==1.0.5"
 | 
			
		||||
        },
 | 
			
		||||
        "django-prometheus": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -437,13 +437,14 @@
 | 
			
		||||
        },
 | 
			
		||||
        "google-auth": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:010f011c4e27d3d5eb01106fba6aac39d164842dfcd8709955c4638f5b11ccf8",
 | 
			
		||||
                "sha256:f30a672a64d91cc2e3137765d088c5deec26416246f7a9e956eaf69a8d7ed49c"
 | 
			
		||||
                "sha256:588bdb03a41ecb4978472b847881e5518b5d9ec6153d3d679aa127a55e13b39f",
 | 
			
		||||
                "sha256:9ad25fba07f46a628ad4d0ca09f38dcb262830df2ac95b217f9b0129c9e42206"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==1.29.0"
 | 
			
		||||
            "version": "==1.30.0"
 | 
			
		||||
        },
 | 
			
		||||
        "gunicorn": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e",
 | 
			
		||||
                "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
@ -504,20 +505,23 @@
 | 
			
		||||
        },
 | 
			
		||||
        "httptools": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be",
 | 
			
		||||
                "sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d",
 | 
			
		||||
                "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce",
 | 
			
		||||
                "sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2",
 | 
			
		||||
                "sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6",
 | 
			
		||||
                "sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f",
 | 
			
		||||
                "sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009",
 | 
			
		||||
                "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce",
 | 
			
		||||
                "sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a",
 | 
			
		||||
                "sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c",
 | 
			
		||||
                "sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4",
 | 
			
		||||
                "sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437"
 | 
			
		||||
                "sha256:07659649fe6b3948b6490825f89abe5eb1cec79ebfaaa0b4bf30f3f33f3c2ba8",
 | 
			
		||||
                "sha256:08b79e09114e6ab5c3dbf560bba2cb2257ea38cdaeaf99b7cb80d8f92622fcd9",
 | 
			
		||||
                "sha256:1e35aa179b67086cc600a984924a88589b90793c9c1b260152ca4908786e09df",
 | 
			
		||||
                "sha256:31629e1f1b89959f8c0927bad12184dc07977dcf71e24f4772934aa490aa199b",
 | 
			
		||||
                "sha256:851026bd63ec0af7e7592890d97d15c92b62d9e17094353f19a52c8e2b33710a",
 | 
			
		||||
                "sha256:8fcca4b7efe353b13a24017211334c57d055a6e132c7adffed13a10d28efca57",
 | 
			
		||||
                "sha256:9abd788465aa46a0f288bd3a99e53edd184177d6379e2098fd6097bb359ad9d6",
 | 
			
		||||
                "sha256:aebdf0bd7bf7c90ae6b3be458692bf6e9e5b610b501f9f74c7979015a51db4c4",
 | 
			
		||||
                "sha256:bda99a5723e7eab355ce57435c70853fc137a65aebf2f1cd4d15d96e2956da7b",
 | 
			
		||||
                "sha256:c1c63d860749841024951b0a78e4dec6f543d23751ef061d6ab60064c7b8b524",
 | 
			
		||||
                "sha256:c4111a0a8a00eff1e495d43ea5230aaf64968a48ddba8ea2d5f982efae827404",
 | 
			
		||||
                "sha256:dce59ee45dd6ee6c434346a5ac527c44014326f560866b4b2f414a692ee1aca8",
 | 
			
		||||
                "sha256:f759717ca1b2ef498c67ba4169c2b33eecf943a89f5329abcff8b89d153eb500",
 | 
			
		||||
                "sha256:fb7199b8fb0c50a22e77260bb59017e0c075fa80cb03bb2c8692de76e7bb7fe7",
 | 
			
		||||
                "sha256:fbf7ecd31c39728f251b1c095fd27c84e4d21f60a1d079a0333472ff3ae59d34"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==0.1.1"
 | 
			
		||||
            "version": "==0.1.2"
 | 
			
		||||
        },
 | 
			
		||||
        "hyperlink": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -603,18 +607,24 @@
 | 
			
		||||
                "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d",
 | 
			
		||||
                "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3",
 | 
			
		||||
                "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2",
 | 
			
		||||
                "sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae",
 | 
			
		||||
                "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f",
 | 
			
		||||
                "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927",
 | 
			
		||||
                "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3",
 | 
			
		||||
                "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7",
 | 
			
		||||
                "sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59",
 | 
			
		||||
                "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f",
 | 
			
		||||
                "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade",
 | 
			
		||||
                "sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96",
 | 
			
		||||
                "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468",
 | 
			
		||||
                "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b",
 | 
			
		||||
                "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4",
 | 
			
		||||
                "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354",
 | 
			
		||||
                "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83",
 | 
			
		||||
                "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04",
 | 
			
		||||
                "sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16",
 | 
			
		||||
                "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791",
 | 
			
		||||
                "sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a",
 | 
			
		||||
                "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51",
 | 
			
		||||
                "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1",
 | 
			
		||||
                "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a",
 | 
			
		||||
@ -627,10 +637,14 @@
 | 
			
		||||
                "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa",
 | 
			
		||||
                "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106",
 | 
			
		||||
                "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d",
 | 
			
		||||
                "sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617",
 | 
			
		||||
                "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4",
 | 
			
		||||
                "sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92",
 | 
			
		||||
                "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0",
 | 
			
		||||
                "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4",
 | 
			
		||||
                "sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24",
 | 
			
		||||
                "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2",
 | 
			
		||||
                "sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e",
 | 
			
		||||
                "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0",
 | 
			
		||||
                "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654",
 | 
			
		||||
                "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2",
 | 
			
		||||
@ -905,41 +919,6 @@
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==3.10.1"
 | 
			
		||||
        },
 | 
			
		||||
        "pycryptodomex": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:00a584ee52bf5e27d540129ca9bf7c4a7e7447f24ff4a220faa1304ad0c09bcd",
 | 
			
		||||
                "sha256:04265a7a84ae002001249bd1de2823bcf46832bd4b58f6965567cb8a07cf4f00",
 | 
			
		||||
                "sha256:0bd35af6a18b724c689e56f2dbbdd8e409288be71952d271ba3d9614b31d188c",
 | 
			
		||||
                "sha256:20c45a30f3389148f94edb77f3b216c677a277942f62a2b81a1cc0b6b2dde7fc",
 | 
			
		||||
                "sha256:2959304d1ce31ab303d9fb5db2b294814278b35154d9b30bf7facc52d6088d0a",
 | 
			
		||||
                "sha256:36dab7f506948056ceba2d57c1ade74e898401960de697cefc02f3519bd26c1b",
 | 
			
		||||
                "sha256:37ec1b407ec032c7a0c1fdd2da12813f560bad38ae61ad9c7ce3c0573b3e5e30",
 | 
			
		||||
                "sha256:3b8eb85b3cc7f083d87978c264d10ff9de3b4bfc46f1c6fdc2792e7d7ebc87bb",
 | 
			
		||||
                "sha256:3dfce70c4e425607ae87b8eae67c9c7dbba59a33b62d70f79417aef0bc5c735b",
 | 
			
		||||
                "sha256:418f51c61eab52d9920f4ef468d22c89dab1be5ac796f71cf3802f6a6e667df0",
 | 
			
		||||
                "sha256:4195604f75cdc1db9bccdb9e44d783add3c817319c30aaff011670c9ed167690",
 | 
			
		||||
                "sha256:4344ab16faf6c2d9df2b6772995623698fb2d5f114dace4ab2ff335550cf71d5",
 | 
			
		||||
                "sha256:541cd3e3e252fb19a7b48f420b798b53483302b7fe4d9954c947605d0a263d62",
 | 
			
		||||
                "sha256:564063e3782474c92cbb333effd06e6eb718471783c6e67f28c63f0fc3ac7b23",
 | 
			
		||||
                "sha256:72f44b5be46faef2a1bf2a85902511b31f4dd7b01ce0c3978e92edb2cc812a82",
 | 
			
		||||
                "sha256:8a98e02cbf8f624add45deff444539bf26345b479fc04fa0937b23cd84078d91",
 | 
			
		||||
                "sha256:940db96449d7b2ebb2c7bf190be1514f3d67914bd37e54e8d30a182bd375a1a9",
 | 
			
		||||
                "sha256:961333e7ee896651f02d4692242aa36b787b8e8e0baa2256717b2b9d55ae0a3c",
 | 
			
		||||
                "sha256:9f713ffb4e27b5575bd917c70bbc3f7b348241a351015dbbc514c01b7061ff7e",
 | 
			
		||||
                "sha256:a6584ae58001d17bb4dc0faa8a426919c2c028ef4d90ceb4191802ca6edb8204",
 | 
			
		||||
                "sha256:c2b680987f418858e89dbb4f09c8c919ece62811780a27051ace72b2f69fb1be",
 | 
			
		||||
                "sha256:d8fae5ba3d34c868ae43614e0bd6fb61114b2687ac3255798791ce075d95aece",
 | 
			
		||||
                "sha256:dbd2c361db939a4252589baa94da4404d45e3fc70da1a31e541644cdf354336e",
 | 
			
		||||
                "sha256:e090a8609e2095aa86978559b140cf8968af99ee54b8791b29ff804838f29f10",
 | 
			
		||||
                "sha256:e4a1245e7b846e88ba63e7543483bda61b9acbaee61eadbead5a1ce479d94740",
 | 
			
		||||
                "sha256:ec9901d19cadb80d9235ee41cc58983f18660314a0eb3fc7b11b0522ac3b6c4a",
 | 
			
		||||
                "sha256:f2abeb4c4ce7584912f4d637b2c57f23720d35dd2892bfeb1b2c84b6fb7a8c88",
 | 
			
		||||
                "sha256:f3bb267df679f70a9f40f17d62d22fe12e8b75e490f41807e7560de4d3e6bf9f",
 | 
			
		||||
                "sha256:f933ecf4cb736c7af60a6a533db2bf569717f2318b265f92907acff1db43bc34",
 | 
			
		||||
                "sha256:fc9c55dc1ed57db76595f2d19a479fc1c3a1be2c9da8de798a93d286c5f65f38"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==3.10.1"
 | 
			
		||||
        },
 | 
			
		||||
        "pyhamcrest": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:412e00137858f04bde0729913874a48485665f2d36fe9ee449f26be864af9316",
 | 
			
		||||
@ -947,12 +926,13 @@
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==2.0.2"
 | 
			
		||||
        },
 | 
			
		||||
        "pyjwkest": {
 | 
			
		||||
        "pyjwt": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:5560fd5ba08655f29ff6ad1df1e15dc05abc9d976fcbcec8d2b5167f49b70222"
 | 
			
		||||
                "sha256:934d73fbba91b0483d3857d1aff50e96b2a892384ee2c17417ed3203f173fca1",
 | 
			
		||||
                "sha256:fba44e7898bbca160a2b2b501f492824fc8382485d3a6f11ba5d0c1937ce6130"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==1.4.2"
 | 
			
		||||
            "version": "==2.1.0"
 | 
			
		||||
        },
 | 
			
		||||
        "pyopenssl": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -983,10 +963,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "python-dotenv": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:471b782da0af10da1a80341e8438fca5fadeba2881c54360d5fd8d03d03a4f4a",
 | 
			
		||||
                "sha256:49782a97c9d641e8a09ae1d9af0856cc587c8d2474919342d5104d85be9890b2"
 | 
			
		||||
                "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544",
 | 
			
		||||
                "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==0.17.0"
 | 
			
		||||
            "version": "==0.17.1"
 | 
			
		||||
        },
 | 
			
		||||
        "pytz": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1106,33 +1086,33 @@
 | 
			
		||||
        },
 | 
			
		||||
        "s3transfer": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:af1af6384bd7fb8208b06480f9be73d0295d965c4c073a5c95ea5b6661dccc18",
 | 
			
		||||
                "sha256:f3dfd791cad2799403e3c8051810a7ca6ee1d2e630e5d2a8f9649d892bdb3db6"
 | 
			
		||||
                "sha256:9b3752887a2880690ce628bc263d6d13a3864083aeacff4890c1c9839a5eb0bc",
 | 
			
		||||
                "sha256:cb022f4b16551edebbb31a377d3f09600dbada7363d8c5db7976e7f47732e1b2"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==0.4.0"
 | 
			
		||||
            "version": "==0.4.2"
 | 
			
		||||
        },
 | 
			
		||||
        "sentry-sdk": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:71de00c9711926816f750bc0f57ef2abbcb1bfbdf5378c601df7ec978f44857a",
 | 
			
		||||
                "sha256:9221e985f425913204989d0e0e1cbb719e8b7fa10540f1bc509f660c06a34e66"
 | 
			
		||||
                "sha256:c1227d38dca315ba35182373f129c3e2722e8ed999e52584e6aca7d287870739",
 | 
			
		||||
                "sha256:c7d380a21281e15be3d9f67a3c4fbb4f800c481d88ff8d8931f39486dd7b4ada"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==1.0.0"
 | 
			
		||||
            "version": "==1.1.0"
 | 
			
		||||
        },
 | 
			
		||||
        "service-identity": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:001c0707759cb3de7e49c078a7c0c9cd12594161d3bf06b9c254fdcb1a60dc36",
 | 
			
		||||
                "sha256:0858a54aabc5b459d1aafa8a518ed2081a285087f349fe3e55197989232e2e2d"
 | 
			
		||||
                "sha256:6e6c6086ca271dc11b033d17c3a8bea9f24ebff920c587da090afc9519419d34",
 | 
			
		||||
                "sha256:f0b0caac3d40627c3c04d7a51b6e06721857a0e10a8775f2d1d7e72901b3a7db"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==18.1.0"
 | 
			
		||||
            "version": "==21.1.0"
 | 
			
		||||
        },
 | 
			
		||||
        "six": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
 | 
			
		||||
                "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
 | 
			
		||||
                "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
 | 
			
		||||
                "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==1.15.0"
 | 
			
		||||
            "version": "==1.16.0"
 | 
			
		||||
        },
 | 
			
		||||
        "sqlparse": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1198,11 +1178,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "typing-extensions": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
 | 
			
		||||
                "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
 | 
			
		||||
                "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
 | 
			
		||||
                "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
 | 
			
		||||
                "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
 | 
			
		||||
                "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==3.7.4.3"
 | 
			
		||||
            "version": "==3.10.0.0"
 | 
			
		||||
        },
 | 
			
		||||
        "uritemplate": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1279,10 +1259,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "websocket-client": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:44b5df8f08c74c3d82d28100fdc81f4536809ce98a17f0757557813275fbb663",
 | 
			
		||||
                "sha256:63509b41d158ae5b7f67eb4ad20fecbb4eee99434e73e140354dc3ff8e09716f"
 | 
			
		||||
                "sha256:2e50d26ca593f70aba7b13a489435ef88b8fc3b5c5643c1ce8808ff9b40f0b32",
 | 
			
		||||
                "sha256:d376bd60eace9d437ab6d7ee16f4ab4e821c9dae591e1b783c58ebd8aaf80c5c"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==0.58.0"
 | 
			
		||||
            "version": "==0.59.0"
 | 
			
		||||
        },
 | 
			
		||||
        "websockets": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1313,22 +1293,20 @@
 | 
			
		||||
        },
 | 
			
		||||
        "xmlsec": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:252f79ed4482d6eefcca62c3bfc99b8d95c07abd846262d854a207ec4d67fac5",
 | 
			
		||||
                "sha256:31884dc97cc34cf1681a0f239f613969e61f9a01f4c2d2a62e53d68216fe42d6",
 | 
			
		||||
                "sha256:32a669dfe447bccecdb4ef79221c0452ce6dad919f3a75daf512792141a54dac",
 | 
			
		||||
                "sha256:3d13d7b6cb921dbc4d60d00ad00081a038df73a1e69f5bcc3695deb1bf2093b0",
 | 
			
		||||
                "sha256:5e2f263a21fd146859911479ec35e40a57f519e650f56c775f91367d2a1b6e15",
 | 
			
		||||
                "sha256:61076be98da4c7cf842a78aa3f129a5039f2ba4992e02480eefe78028d317698",
 | 
			
		||||
                "sha256:69d7f965d6b74b3266f7baa99a0377d9c76acbf26c615b4ee8d2cbe17bf85528",
 | 
			
		||||
                "sha256:6d8bb24c3a4db398011f394e29b58cd34c9c26d76b772c5d418d8579df127234",
 | 
			
		||||
                "sha256:6d9d46d1f6b4985023469a1e334cb35c7c8fc6bd9d8b65ca52b923a7a6869c2a",
 | 
			
		||||
                "sha256:8a7ffdc4f7f760253aa4dd8d2037358eb33915ca1dcf1c2422b19fcf0ab68506",
 | 
			
		||||
                "sha256:927fc5755bb93dc09275bd5d818811e016290c194012d63f8e6f86b7ece3e468",
 | 
			
		||||
                "sha256:dcaa084c3700f775eba09d81a1432444f82d9ad6270320c56c1a733d71cceb3a",
 | 
			
		||||
                "sha256:f59698cc0366395ca79b48b080674973541aae290670c57d88f05d939a4c00da"
 | 
			
		||||
                "sha256:17d2e66d4e3e601d210eed936b53c3eb44cddaef62f60b5c6ad5c18e948d926c",
 | 
			
		||||
                "sha256:2bc1b871b49d6580779805a4a1c2d835e834a2fa614fe40cf71931d11a8279cf",
 | 
			
		||||
                "sha256:52eded125c0d1ab72125105ef061370c6b06ab9bd37e29a61bc2f8a61205bae4",
 | 
			
		||||
                "sha256:72af9a5a747a5fe6e425d2be10daa43d18307dbe03498df3820fc3cd93daa148",
 | 
			
		||||
                "sha256:806855d505da24aeb77758a6f373b1473e5ed63bdbe346af90cc6d2b053e4716",
 | 
			
		||||
                "sha256:8746dd992aaec06ed8ff1615f4a8e2a32258e8af38f9a9f8acf3ee1fb34a5da6",
 | 
			
		||||
                "sha256:9d52b2b15d42292725e4f9d8a5b040e39cba0a9cd58059ac951e7310d6340bb9",
 | 
			
		||||
                "sha256:b380f3ebc042f71afab057632481d06e06f1ba4f90047d91ca92612a7d3d487b",
 | 
			
		||||
                "sha256:be0f475edd8e9c98f57449c97839f6a81946e79e4cccb81e4b5196a2cc40e044",
 | 
			
		||||
                "sha256:bf3c62d154f2222caf56d897ddfd53fd0aef560d5a2202447d90e015301a0a10",
 | 
			
		||||
                "sha256:fe6a5f05aba3ff47e105a308482b68f8b0fd80656eb1456a9c1e4de47d2c580f"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==1.3.9"
 | 
			
		||||
            "version": "==1.3.10"
 | 
			
		||||
        },
 | 
			
		||||
        "yarl": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1439,17 +1417,17 @@
 | 
			
		||||
        },
 | 
			
		||||
        "astroid": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:ad63b8552c70939568966811a088ef0bc880f99a24a00834abd0e3681b514f91",
 | 
			
		||||
                "sha256:bea3f32799fbb8581f58431c12591bc20ce11cbc90ad82e2ea5717d94f2080d5"
 | 
			
		||||
                "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e",
 | 
			
		||||
                "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==2.5.3"
 | 
			
		||||
            "version": "==2.5.6"
 | 
			
		||||
        },
 | 
			
		||||
        "attrs": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
 | 
			
		||||
                "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
 | 
			
		||||
                "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
 | 
			
		||||
                "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==20.3.0"
 | 
			
		||||
            "version": "==21.2.0"
 | 
			
		||||
        },
 | 
			
		||||
        "bandit": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1474,6 +1452,20 @@
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==1.0.1"
 | 
			
		||||
        },
 | 
			
		||||
        "certifi": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
 | 
			
		||||
                "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==2020.12.5"
 | 
			
		||||
        },
 | 
			
		||||
        "chardet": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
 | 
			
		||||
                "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==4.0.0"
 | 
			
		||||
        },
 | 
			
		||||
        "click": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
 | 
			
		||||
@ -1556,10 +1548,17 @@
 | 
			
		||||
        },
 | 
			
		||||
        "gitpython": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:3283ae2fba31c913d857e12e5ba5f9a7772bbc064ae2bb09efafa71b0dd4939b",
 | 
			
		||||
                "sha256:be27633e7509e58391f10207cd32b2a6cf5b908f92d9cd30da2e514e1137af61"
 | 
			
		||||
                "sha256:05af150f47a5cca3f4b0af289b73aef8cf3c4fe2385015b06220cbcdee48bb6e",
 | 
			
		||||
                "sha256:a77824e516d3298b04fb36ec7845e92747df8fcfee9cacc32dd6239f9652f867"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==3.1.14"
 | 
			
		||||
            "version": "==3.1.15"
 | 
			
		||||
        },
 | 
			
		||||
        "idna": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
 | 
			
		||||
                "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==2.10"
 | 
			
		||||
        },
 | 
			
		||||
        "iniconfig": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1633,10 +1632,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "pbr": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9",
 | 
			
		||||
                "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00"
 | 
			
		||||
                "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd",
 | 
			
		||||
                "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==5.5.1"
 | 
			
		||||
            "version": "==5.6.0"
 | 
			
		||||
        },
 | 
			
		||||
        "pluggy": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1654,19 +1653,19 @@
 | 
			
		||||
        },
 | 
			
		||||
        "pylint": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:209d712ec870a0182df034ae19f347e725c1e615b2269519ab58a35b3fcbbe7a",
 | 
			
		||||
                "sha256:bd38914c7731cdc518634a8d3c5585951302b6e2b6de60fbb3f7a0220e21eeee"
 | 
			
		||||
                "sha256:586d8fa9b1891f4b725f587ef267abe2a1bad89d6b184520c7f07a253dd6e217",
 | 
			
		||||
                "sha256:f7e2072654a6b6afdf5e2fb38147d3e2d2d43c89f648637baab63e026481279b"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==2.7.4"
 | 
			
		||||
            "version": "==2.8.2"
 | 
			
		||||
        },
 | 
			
		||||
        "pylint-django": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:a5a4515209a6237d1d390a4a307d53f53baaf4f058ecf4bb556c775d208f6b0d",
 | 
			
		||||
                "sha256:dc5ed27bb7662d73444ccd15a0b3964ed6ced6cc2712b85db616102062d2ec35"
 | 
			
		||||
                "sha256:aff49d9602a39c027b4ed7521a041438893205918f405800063b7ff692b7371b",
 | 
			
		||||
                "sha256:f63f717169b0c2e4e19c28f1c32c28290647330184fcb7427805ae9b6994f3fc"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==2.4.3"
 | 
			
		||||
            "version": "==2.4.4"
 | 
			
		||||
        },
 | 
			
		||||
        "pylint-plugin-utils": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1684,11 +1683,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "pytest": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634",
 | 
			
		||||
                "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"
 | 
			
		||||
                "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b",
 | 
			
		||||
                "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==6.2.3"
 | 
			
		||||
            "version": "==6.2.4"
 | 
			
		||||
        },
 | 
			
		||||
        "pytest-django": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1779,6 +1778,21 @@
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==2021.4.4"
 | 
			
		||||
        },
 | 
			
		||||
        "requests": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
 | 
			
		||||
                "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==2.25.1"
 | 
			
		||||
        },
 | 
			
		||||
        "requests-mock": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:33296f228d8c5df11a7988b741325422480baddfdf5dd9318fd0eb40c3ed8595",
 | 
			
		||||
                "sha256:5c8ef0254c14a84744be146e9799dc13ebc4f6186058112d9aeed96b131b58e2"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "version": "==1.9.2"
 | 
			
		||||
        },
 | 
			
		||||
        "selenium": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c",
 | 
			
		||||
@ -1789,10 +1803,10 @@
 | 
			
		||||
        },
 | 
			
		||||
        "six": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
 | 
			
		||||
                "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
 | 
			
		||||
                "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
 | 
			
		||||
                "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==1.15.0"
 | 
			
		||||
            "version": "==1.16.0"
 | 
			
		||||
        },
 | 
			
		||||
        "smmap": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
@ -1852,11 +1866,11 @@
 | 
			
		||||
        },
 | 
			
		||||
        "typing-extensions": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
 | 
			
		||||
                "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
 | 
			
		||||
                "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
 | 
			
		||||
                "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
 | 
			
		||||
                "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
 | 
			
		||||
                "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
 | 
			
		||||
            ],
 | 
			
		||||
            "version": "==3.7.4.3"
 | 
			
		||||
            "version": "==3.10.0.0"
 | 
			
		||||
        },
 | 
			
		||||
        "urllib3": {
 | 
			
		||||
            "extras": [
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@
 | 
			
		||||

 | 
			
		||||

 | 
			
		||||

 | 
			
		||||
[Transifex](https://www.transifex.com/beryjuorg/authentik/)
 | 
			
		||||
 | 
			
		||||
## What is authentik?
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -4,8 +4,8 @@
 | 
			
		||||
 | 
			
		||||
| Version    | Supported          |
 | 
			
		||||
| ---------- | ------------------ |
 | 
			
		||||
| 2021.3.x   | :white_check_mark: |
 | 
			
		||||
| 2021.4.x   | :white_check_mark: |
 | 
			
		||||
| 2021.5.x   | :white_check_mark: |
 | 
			
		||||
 | 
			
		||||
## Reporting a Vulnerability
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,3 @@
 | 
			
		||||
"""authentik"""
 | 
			
		||||
__version__ = "2021.4.5"
 | 
			
		||||
__version__ = "2021.5.1-rc6"
 | 
			
		||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
"""API Authentication"""
 | 
			
		||||
from base64 import b64decode, b64encode
 | 
			
		||||
from base64 import b64decode
 | 
			
		||||
from binascii import Error
 | 
			
		||||
from typing import Any, Optional, Union
 | 
			
		||||
 | 
			
		||||
@ -19,14 +19,6 @@ def token_from_header(raw_header: bytes) -> Optional[Token]:
 | 
			
		||||
    auth_credentials = raw_header.decode()
 | 
			
		||||
    if auth_credentials == "":
 | 
			
		||||
        return None
 | 
			
		||||
    # Legacy, accept basic auth thats fully encoded (2021.3 outposts)
 | 
			
		||||
    if " " not in auth_credentials:
 | 
			
		||||
        try:
 | 
			
		||||
            plain = b64decode(auth_credentials.encode()).decode()
 | 
			
		||||
            auth_type, body = plain.split()
 | 
			
		||||
            auth_credentials = f"{auth_type} {b64encode(body.encode()).decode()}"
 | 
			
		||||
        except (UnicodeDecodeError, Error):
 | 
			
		||||
            raise AuthenticationFailed("Malformed header")
 | 
			
		||||
    auth_type, auth_credentials = auth_credentials.split()
 | 
			
		||||
    if auth_type.lower() not in ["basic", "bearer"]:
 | 
			
		||||
        LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@
 | 
			
		||||
{% load static %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
authentik API Browser
 | 
			
		||||
API Browser - {{ config.authentik.branding.title }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block head %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										16
									
								
								authentik/api/tests/test_config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								authentik/api/tests/test_config.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
			
		||||
"""Test config API"""
 | 
			
		||||
from json import loads
 | 
			
		||||
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from rest_framework.test import APITestCase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestConfig(APITestCase):
 | 
			
		||||
    """Test config API"""
 | 
			
		||||
 | 
			
		||||
    def test_config(self):
 | 
			
		||||
        """Test YAML generation"""
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse("authentik_api:configs-list"),
 | 
			
		||||
        )
 | 
			
		||||
        self.assertTrue(loads(response.content.decode()))
 | 
			
		||||
							
								
								
									
										33
									
								
								authentik/api/tests/test_decorators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								authentik/api/tests/test_decorators.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,33 @@
 | 
			
		||||
"""test decorators api"""
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from guardian.shortcuts import assign_perm
 | 
			
		||||
from rest_framework.test import APITestCase
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Application, User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestAPIDecorators(APITestCase):
 | 
			
		||||
    """test decorators api"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        super().setUp()
 | 
			
		||||
        self.user = User.objects.create(username="test-user")
 | 
			
		||||
 | 
			
		||||
    def test_obj_perm_denied(self):
 | 
			
		||||
        """Test object perm denied"""
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        app = Application.objects.create(name="denied", slug="denied")
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_other_perm_denied(self):
 | 
			
		||||
        """Test other perm denied"""
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        app = Application.objects.create(name="denied", slug="denied")
 | 
			
		||||
        assign_perm("authentik_core.view_application", self.user, app)
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
@ -47,6 +47,7 @@ from authentik.policies.reputation.api import (
 | 
			
		||||
    ReputationPolicyViewSet,
 | 
			
		||||
    UserReputationViewSet,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
 | 
			
		||||
from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet
 | 
			
		||||
from authentik.providers.oauth2.api.scope import ScopeMappingViewSet
 | 
			
		||||
from authentik.providers.oauth2.api.tokens import (
 | 
			
		||||
@ -63,6 +64,7 @@ from authentik.sources.oauth.api.source import OAuthSourceViewSet
 | 
			
		||||
from authentik.sources.oauth.api.source_connection import (
 | 
			
		||||
    UserOAuthSourceConnectionViewSet,
 | 
			
		||||
)
 | 
			
		||||
from authentik.sources.plex.api import PlexSourceViewSet
 | 
			
		||||
from authentik.sources.saml.api import SAMLSourceViewSet
 | 
			
		||||
from authentik.stages.authenticator_static.api import (
 | 
			
		||||
    AuthenticatorStaticStageViewSet,
 | 
			
		||||
@ -120,6 +122,7 @@ router.register(
 | 
			
		||||
    "outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet
 | 
			
		||||
)
 | 
			
		||||
router.register("outposts/proxy", ProxyOutpostConfigViewSet)
 | 
			
		||||
router.register("outposts/ldap", LDAPOutpostConfigViewSet)
 | 
			
		||||
 | 
			
		||||
router.register("flows/instances", FlowViewSet)
 | 
			
		||||
router.register("flows/bindings", FlowStageBindingViewSet)
 | 
			
		||||
@ -136,6 +139,7 @@ router.register("sources/oauth_user_connections", UserOAuthSourceConnectionViewS
 | 
			
		||||
router.register("sources/ldap", LDAPSourceViewSet)
 | 
			
		||||
router.register("sources/saml", SAMLSourceViewSet)
 | 
			
		||||
router.register("sources/oauth", OAuthSourceViewSet)
 | 
			
		||||
router.register("sources/plex", PlexSourceViewSet)
 | 
			
		||||
 | 
			
		||||
router.register("policies/all", PolicyViewSet)
 | 
			
		||||
router.register("policies/bindings", PolicyBindingViewSet)
 | 
			
		||||
@ -149,6 +153,7 @@ router.register("policies/reputation/ips", IPReputationViewSet)
 | 
			
		||||
router.register("policies/reputation", ReputationPolicyViewSet)
 | 
			
		||||
 | 
			
		||||
router.register("providers/all", ProviderViewSet)
 | 
			
		||||
router.register("providers/ldap", LDAPProviderViewSet)
 | 
			
		||||
router.register("providers/proxy", ProxyProviderViewSet)
 | 
			
		||||
router.register("providers/oauth2", OAuth2ProviderViewSet)
 | 
			
		||||
router.register("providers/saml", SAMLProviderViewSet)
 | 
			
		||||
 | 
			
		||||
@ -91,6 +91,23 @@ class ApplicationViewSet(ModelViewSet):
 | 
			
		||||
                applications.append(application)
 | 
			
		||||
        return applications
 | 
			
		||||
 | 
			
		||||
    @swagger_auto_schema(
 | 
			
		||||
        responses={
 | 
			
		||||
            204: "Access granted",
 | 
			
		||||
            403: "Access denied",
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    @action(detail=True, methods=["GET"])
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def check_access(self, request: Request, slug: str) -> Response:
 | 
			
		||||
        """Check access to a single application by slug"""
 | 
			
		||||
        application = self.get_object()
 | 
			
		||||
        engine = PolicyEngine(application, self.request.user, self.request)
 | 
			
		||||
        engine.build()
 | 
			
		||||
        if engine.passing:
 | 
			
		||||
            return Response(status=204)
 | 
			
		||||
        return Response(status=403)
 | 
			
		||||
 | 
			
		||||
    @swagger_auto_schema(
 | 
			
		||||
        manual_parameters=[
 | 
			
		||||
            openapi.Parameter(
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,9 @@
 | 
			
		||||
"""Groups API Viewset"""
 | 
			
		||||
from django.db.models.query import QuerySet
 | 
			
		||||
from rest_framework.fields import JSONField
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.utils import is_dict
 | 
			
		||||
from authentik.core.models import Group
 | 
			
		||||
@ -26,3 +28,16 @@ class GroupViewSet(ModelViewSet):
 | 
			
		||||
    search_fields = ["name", "is_superuser"]
 | 
			
		||||
    filterset_fields = ["name", "is_superuser"]
 | 
			
		||||
    ordering = ["name"]
 | 
			
		||||
 | 
			
		||||
    def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
 | 
			
		||||
        """Custom filter_queryset method which ignores guardian, but still supports sorting"""
 | 
			
		||||
        for backend in list(self.filter_backends):
 | 
			
		||||
            if backend == ObjectPermissionsFilter:
 | 
			
		||||
                continue
 | 
			
		||||
            queryset = backend().filter_queryset(self.request, queryset, self)
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
    def filter_queryset(self, queryset):
 | 
			
		||||
        if self.request.user.has_perm("authentik_core.view_group"):
 | 
			
		||||
            return self._filter_queryset_for_list(queryset)
 | 
			
		||||
        return super().filter_queryset(queryset)
 | 
			
		||||
 | 
			
		||||
@ -45,6 +45,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
 | 
			
		||||
            "verbose_name",
 | 
			
		||||
            "verbose_name_plural",
 | 
			
		||||
            "policy_engine_mode",
 | 
			
		||||
            "user_matching_mode",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,18 +1,30 @@
 | 
			
		||||
"""User API Views"""
 | 
			
		||||
from json import loads
 | 
			
		||||
 | 
			
		||||
from django.db.models.query import QuerySet
 | 
			
		||||
from django.http.response import Http404
 | 
			
		||||
from django.urls import reverse_lazy
 | 
			
		||||
from django.utils.http import urlencode
 | 
			
		||||
from django_filters.filters import BooleanFilter, CharFilter
 | 
			
		||||
from django_filters.filterset import FilterSet
 | 
			
		||||
from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method
 | 
			
		||||
from guardian.utils import get_anonymous_user
 | 
			
		||||
from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import CharField, JSONField, SerializerMethodField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import BooleanField, ModelSerializer
 | 
			
		||||
from rest_framework.serializers import (
 | 
			
		||||
    BooleanField,
 | 
			
		||||
    ListSerializer,
 | 
			
		||||
    ModelSerializer,
 | 
			
		||||
    ValidationError,
 | 
			
		||||
)
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
 | 
			
		||||
 | 
			
		||||
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
 | 
			
		||||
from authentik.api.decorators import permission_required
 | 
			
		||||
from authentik.core.api.groups import GroupSerializer
 | 
			
		||||
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
 | 
			
		||||
from authentik.core.middleware import (
 | 
			
		||||
    SESSION_IMPERSONATE_ORIGINAL_USER,
 | 
			
		||||
@ -29,6 +41,8 @@ class UserSerializer(ModelSerializer):
 | 
			
		||||
    is_superuser = BooleanField(read_only=True)
 | 
			
		||||
    avatar = CharField(read_only=True)
 | 
			
		||||
    attributes = JSONField(validators=[is_dict], required=False)
 | 
			
		||||
    groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
 | 
			
		||||
    uid = CharField(read_only=True)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
@ -40,9 +54,11 @@ class UserSerializer(ModelSerializer):
 | 
			
		||||
            "is_active",
 | 
			
		||||
            "last_login",
 | 
			
		||||
            "is_superuser",
 | 
			
		||||
            "groups",
 | 
			
		||||
            "email",
 | 
			
		||||
            "avatar",
 | 
			
		||||
            "attributes",
 | 
			
		||||
            "uid",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -84,13 +100,44 @@ class UserMetricsSerializer(PassiveSerializer):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UsersFilter(FilterSet):
 | 
			
		||||
    """Filter for users"""
 | 
			
		||||
 | 
			
		||||
    attributes = CharFilter(
 | 
			
		||||
        field_name="attributes",
 | 
			
		||||
        lookup_expr="",
 | 
			
		||||
        label="Attributes",
 | 
			
		||||
        method="filter_attributes",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def filter_attributes(self, queryset, name, value):
 | 
			
		||||
        """Filter attributes by query args"""
 | 
			
		||||
        try:
 | 
			
		||||
            value = loads(value)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            raise ValidationError(detail="filter: failed to parse JSON")
 | 
			
		||||
        if not isinstance(value, dict):
 | 
			
		||||
            raise ValidationError(detail="filter: value must be key:value mapping")
 | 
			
		||||
        qs = {}
 | 
			
		||||
        for key, _value in value.items():
 | 
			
		||||
            qs[f"attributes__{key}"] = _value
 | 
			
		||||
        return queryset.filter(**qs)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = User
 | 
			
		||||
        fields = ["username", "name", "is_active", "is_superuser", "attributes"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserViewSet(ModelViewSet):
 | 
			
		||||
    """User Viewset"""
 | 
			
		||||
 | 
			
		||||
    queryset = User.objects.none()
 | 
			
		||||
    serializer_class = UserSerializer
 | 
			
		||||
    search_fields = ["username", "name", "is_active"]
 | 
			
		||||
    filterset_fields = ["username", "name", "is_active"]
 | 
			
		||||
    filterset_class = UsersFilter
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        return User.objects.all().exclude(pk=get_anonymous_user().pk)
 | 
			
		||||
@ -144,3 +191,16 @@ class UserViewSet(ModelViewSet):
 | 
			
		||||
            reverse_lazy("authentik_flows:default-recovery") + f"?{querystring}"
 | 
			
		||||
        )
 | 
			
		||||
        return Response({"link": link})
 | 
			
		||||
 | 
			
		||||
    def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
 | 
			
		||||
        """Custom filter_queryset method which ignores guardian, but still supports sorting"""
 | 
			
		||||
        for backend in list(self.filter_backends):
 | 
			
		||||
            if backend == ObjectPermissionsFilter:
 | 
			
		||||
                continue
 | 
			
		||||
            queryset = backend().filter_queryset(self.request, queryset, self)
 | 
			
		||||
        return queryset
 | 
			
		||||
 | 
			
		||||
    def filter_queryset(self, queryset):
 | 
			
		||||
        if self.request.user.has_perm("authentik_core.view_group"):
 | 
			
		||||
            return self._filter_queryset_for_list(queryset)
 | 
			
		||||
        return super().filter_queryset(queryset)
 | 
			
		||||
 | 
			
		||||
@ -20,10 +20,12 @@ def is_dict(value: Any):
 | 
			
		||||
class PassiveSerializer(Serializer):
 | 
			
		||||
    """Base serializer class which doesn't implement create/update methods"""
 | 
			
		||||
 | 
			
		||||
    def create(self, validated_data: dict) -> Model:
 | 
			
		||||
    def create(self, validated_data: dict) -> Model:  # pragma: no cover
 | 
			
		||||
        return Model()
 | 
			
		||||
 | 
			
		||||
    def update(self, instance: Model, validated_data: dict) -> Model:
 | 
			
		||||
    def update(
 | 
			
		||||
        self, instance: Model, validated_data: dict
 | 
			
		||||
    ) -> Model:  # pragma: no cover
 | 
			
		||||
        return Model()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										40
									
								
								authentik/core/migrations/0020_source_user_matching_mode.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								authentik/core/migrations/0020_source_user_matching_mode.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,40 @@
 | 
			
		||||
# Generated by Django 3.2 on 2021-05-03 17:06
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_core", "0019_source_managed"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="source",
 | 
			
		||||
            name="user_matching_mode",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                choices=[
 | 
			
		||||
                    ("identifier", "Use the source-specific identifier"),
 | 
			
		||||
                    (
 | 
			
		||||
                        "email_link",
 | 
			
		||||
                        "Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.",
 | 
			
		||||
                    ),
 | 
			
		||||
                    (
 | 
			
		||||
                        "email_deny",
 | 
			
		||||
                        "Use the user's email address, but deny enrollment when the email address already exists.",
 | 
			
		||||
                    ),
 | 
			
		||||
                    (
 | 
			
		||||
                        "username_link",
 | 
			
		||||
                        "Link to a user with identical username address. Can have security implications when a username is used with another source.",
 | 
			
		||||
                    ),
 | 
			
		||||
                    (
 | 
			
		||||
                        "username_deny",
 | 
			
		||||
                        "Use the user's username, but deny enrollment when the username already exists.",
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
                default="identifier",
 | 
			
		||||
                help_text="How the source determines if an existing user should be authenticated or a new user enrolled.",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -34,6 +34,7 @@ from authentik.policies.models import PolicyBindingModel
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
 | 
			
		||||
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
 | 
			
		||||
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
 | 
			
		||||
 | 
			
		||||
GRAVATAR_URL = "https://secure.gravatar.com"
 | 
			
		||||
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
 | 
			
		||||
@ -240,6 +241,30 @@ class Application(PolicyBindingModel):
 | 
			
		||||
        verbose_name_plural = _("Applications")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SourceUserMatchingModes(models.TextChoices):
 | 
			
		||||
    """Different modes a source can handle new/returning users"""
 | 
			
		||||
 | 
			
		||||
    IDENTIFIER = "identifier", _("Use the source-specific identifier")
 | 
			
		||||
    EMAIL_LINK = "email_link", _(
 | 
			
		||||
        (
 | 
			
		||||
            "Link to a user with identical email address. Can have security implications "
 | 
			
		||||
            "when a source doesn't validate email addresses."
 | 
			
		||||
        )
 | 
			
		||||
    )
 | 
			
		||||
    EMAIL_DENY = "email_deny", _(
 | 
			
		||||
        "Use the user's email address, but deny enrollment when the email address already exists."
 | 
			
		||||
    )
 | 
			
		||||
    USERNAME_LINK = "username_link", _(
 | 
			
		||||
        (
 | 
			
		||||
            "Link to a user with identical username address. Can have security implications "
 | 
			
		||||
            "when a username is used with another source."
 | 
			
		||||
        )
 | 
			
		||||
    )
 | 
			
		||||
    USERNAME_DENY = "username_deny", _(
 | 
			
		||||
        "Use the user's username, but deny enrollment when the username already exists."
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
 | 
			
		||||
    """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
 | 
			
		||||
 | 
			
		||||
@ -272,6 +297,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
 | 
			
		||||
        related_name="source_enrollment",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    user_matching_mode = models.TextField(
 | 
			
		||||
        choices=SourceUserMatchingModes.choices,
 | 
			
		||||
        default=SourceUserMatchingModes.IDENTIFIER,
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            (
 | 
			
		||||
                "How the source determines if an existing user should be authenticated or "
 | 
			
		||||
                "a new user enrolled."
 | 
			
		||||
            )
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    objects = InheritanceManager()
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
@ -301,6 +337,8 @@ class UserSourceConnection(CreatedUpdatedModel):
 | 
			
		||||
    user = models.ForeignKey(User, on_delete=models.CASCADE)
 | 
			
		||||
    source = models.ForeignKey(Source, on_delete=models.CASCADE)
 | 
			
		||||
 | 
			
		||||
    objects = InheritanceManager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        unique_together = (("user", "source"),)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								authentik/core/sources/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/core/sources/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										286
									
								
								authentik/core/sources/flow_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										286
									
								
								authentik/core/sources/flow_manager.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,286 @@
 | 
			
		||||
"""Source decision helper"""
 | 
			
		||||
from enum import Enum
 | 
			
		||||
from typing import Any, Optional, Type
 | 
			
		||||
 | 
			
		||||
from django.contrib import messages
 | 
			
		||||
from django.db import IntegrityError
 | 
			
		||||
from django.db.models.query_utils import Q
 | 
			
		||||
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
 | 
			
		||||
from django.shortcuts import redirect
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import (
 | 
			
		||||
    Source,
 | 
			
		||||
    SourceUserMatchingModes,
 | 
			
		||||
    User,
 | 
			
		||||
    UserSourceConnection,
 | 
			
		||||
)
 | 
			
		||||
from authentik.core.sources.stage import (
 | 
			
		||||
    PLAN_CONTEXT_SOURCES_CONNECTION,
 | 
			
		||||
    PostUserEnrollmentStage,
 | 
			
		||||
)
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
from authentik.flows.models import Flow, Stage, in_memory_stage
 | 
			
		||||
from authentik.flows.planner import (
 | 
			
		||||
    PLAN_CONTEXT_PENDING_USER,
 | 
			
		||||
    PLAN_CONTEXT_REDIRECT,
 | 
			
		||||
    PLAN_CONTEXT_SOURCE,
 | 
			
		||||
    PLAN_CONTEXT_SSO,
 | 
			
		||||
    FlowPlanner,
 | 
			
		||||
)
 | 
			
		||||
from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
 | 
			
		||||
from authentik.lib.utils.urls import redirect_with_qs
 | 
			
		||||
from authentik.policies.utils import delete_none_keys
 | 
			
		||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
 | 
			
		||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Action(Enum):
 | 
			
		||||
    """Actions that can be decided based on the request
 | 
			
		||||
    and source settings"""
 | 
			
		||||
 | 
			
		||||
    LINK = "link"
 | 
			
		||||
    AUTH = "auth"
 | 
			
		||||
    ENROLL = "enroll"
 | 
			
		||||
    DENY = "deny"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SourceFlowManager:
 | 
			
		||||
    """Help sources decide what they should do after authorization. Based on source settings and
 | 
			
		||||
    previous connections, authenticate the user, enroll a new user, link to an existing user
 | 
			
		||||
    or deny the request."""
 | 
			
		||||
 | 
			
		||||
    source: Source
 | 
			
		||||
    request: HttpRequest
 | 
			
		||||
 | 
			
		||||
    identifier: str
 | 
			
		||||
 | 
			
		||||
    connection_type: Type[UserSourceConnection] = UserSourceConnection
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        source: Source,
 | 
			
		||||
        request: HttpRequest,
 | 
			
		||||
        identifier: str,
 | 
			
		||||
        enroll_info: dict[str, Any],
 | 
			
		||||
    ) -> None:
 | 
			
		||||
        self.source = source
 | 
			
		||||
        self.request = request
 | 
			
		||||
        self.identifier = identifier
 | 
			
		||||
        self.enroll_info = enroll_info
 | 
			
		||||
        self._logger = get_logger().bind(source=source, identifier=identifier)
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=too-many-return-statements
 | 
			
		||||
    def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
 | 
			
		||||
        """decide which action should be taken"""
 | 
			
		||||
        new_connection = self.connection_type(
 | 
			
		||||
            source=self.source, identifier=self.identifier
 | 
			
		||||
        )
 | 
			
		||||
        # When request is authenticated, always link
 | 
			
		||||
        if self.request.user.is_authenticated:
 | 
			
		||||
            new_connection.user = self.request.user
 | 
			
		||||
            new_connection = self.update_connection(new_connection, **kwargs)
 | 
			
		||||
            new_connection.save()
 | 
			
		||||
            return Action.LINK, new_connection
 | 
			
		||||
 | 
			
		||||
        existing_connections = self.connection_type.objects.filter(
 | 
			
		||||
            source=self.source, identifier=self.identifier
 | 
			
		||||
        )
 | 
			
		||||
        if existing_connections.exists():
 | 
			
		||||
            connection = existing_connections.first()
 | 
			
		||||
            return Action.AUTH, self.update_connection(connection, **kwargs)
 | 
			
		||||
        # No connection exists, but we match on identifier, so enroll
 | 
			
		||||
        if self.source.user_matching_mode == SourceUserMatchingModes.IDENTIFIER:
 | 
			
		||||
            # We don't save the connection here cause it doesn't have a user assigned yet
 | 
			
		||||
            return Action.ENROLL, self.update_connection(new_connection, **kwargs)
 | 
			
		||||
 | 
			
		||||
        # Check for existing users with matching attributes
 | 
			
		||||
        query = Q()
 | 
			
		||||
        # Either query existing user based on email or username
 | 
			
		||||
        if self.source.user_matching_mode in [
 | 
			
		||||
            SourceUserMatchingModes.EMAIL_LINK,
 | 
			
		||||
            SourceUserMatchingModes.EMAIL_DENY,
 | 
			
		||||
        ]:
 | 
			
		||||
            if not self.enroll_info.get("email", None):
 | 
			
		||||
                self._logger.warning("Refusing to use none email", source=self.source)
 | 
			
		||||
                return Action.DENY, None
 | 
			
		||||
            query = Q(email__exact=self.enroll_info.get("email", None))
 | 
			
		||||
        if self.source.user_matching_mode in [
 | 
			
		||||
            SourceUserMatchingModes.USERNAME_LINK,
 | 
			
		||||
            SourceUserMatchingModes.USERNAME_DENY,
 | 
			
		||||
        ]:
 | 
			
		||||
            if not self.enroll_info.get("username", None):
 | 
			
		||||
                self._logger.warning(
 | 
			
		||||
                    "Refusing to use none username", source=self.source
 | 
			
		||||
                )
 | 
			
		||||
                return Action.DENY, None
 | 
			
		||||
            query = Q(username__exact=self.enroll_info.get("username", None))
 | 
			
		||||
        self._logger.debug("trying to link with existing user", query=query)
 | 
			
		||||
        matching_users = User.objects.filter(query)
 | 
			
		||||
        # No matching users, always enroll
 | 
			
		||||
        if not matching_users.exists():
 | 
			
		||||
            self._logger.debug("no matching users found, enrolling")
 | 
			
		||||
            return Action.ENROLL, self.update_connection(new_connection, **kwargs)
 | 
			
		||||
 | 
			
		||||
        user = matching_users.first()
 | 
			
		||||
        if self.source.user_matching_mode in [
 | 
			
		||||
            SourceUserMatchingModes.EMAIL_LINK,
 | 
			
		||||
            SourceUserMatchingModes.USERNAME_LINK,
 | 
			
		||||
        ]:
 | 
			
		||||
            new_connection.user = user
 | 
			
		||||
            new_connection = self.update_connection(new_connection, **kwargs)
 | 
			
		||||
            new_connection.save()
 | 
			
		||||
            return Action.LINK, new_connection
 | 
			
		||||
        if self.source.user_matching_mode in [
 | 
			
		||||
            SourceUserMatchingModes.EMAIL_DENY,
 | 
			
		||||
            SourceUserMatchingModes.USERNAME_DENY,
 | 
			
		||||
        ]:
 | 
			
		||||
            self._logger.info("denying source because user exists", user=user)
 | 
			
		||||
            return Action.DENY, None
 | 
			
		||||
        # Should never get here as default enroll case is returned above.
 | 
			
		||||
        return Action.DENY, None
 | 
			
		||||
 | 
			
		||||
    def update_connection(
 | 
			
		||||
        self, connection: UserSourceConnection, **kwargs
 | 
			
		||||
    ) -> UserSourceConnection:
 | 
			
		||||
        """Optionally make changes to the connection after it is looked up/created."""
 | 
			
		||||
        return connection
 | 
			
		||||
 | 
			
		||||
    def get_flow(self, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Get the flow response based on user_matching_mode"""
 | 
			
		||||
        try:
 | 
			
		||||
            action, connection = self.get_action(**kwargs)
 | 
			
		||||
        except IntegrityError as exc:
 | 
			
		||||
            self._logger.warning("failed to get action", exc=exc)
 | 
			
		||||
            return redirect("/")
 | 
			
		||||
        self._logger.debug("get_action() says", action=action, connection=connection)
 | 
			
		||||
        if connection:
 | 
			
		||||
            if action == Action.LINK:
 | 
			
		||||
                self._logger.debug("Linking existing user")
 | 
			
		||||
                return self.handle_existing_user_link(connection)
 | 
			
		||||
            if action == Action.AUTH:
 | 
			
		||||
                self._logger.debug("Handling auth user")
 | 
			
		||||
                return self.handle_auth_user(connection)
 | 
			
		||||
            if action == Action.ENROLL:
 | 
			
		||||
                self._logger.debug("Handling enrollment of new user")
 | 
			
		||||
                return self.handle_enroll(connection)
 | 
			
		||||
        # Default case, assume deny
 | 
			
		||||
        messages.error(
 | 
			
		||||
            self.request,
 | 
			
		||||
            _(
 | 
			
		||||
                (
 | 
			
		||||
                    "Request to authenticate with %(source)s has been denied. Please authenticate "
 | 
			
		||||
                    "with the source you've previously signed up with."
 | 
			
		||||
                )
 | 
			
		||||
                % {"source": self.source.name}
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        return redirect("/")
 | 
			
		||||
 | 
			
		||||
    # 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 flow.slug == self.source.enrollment_flow.slug:
 | 
			
		||||
            return [
 | 
			
		||||
                in_memory_stage(PostUserEnrollmentStage),
 | 
			
		||||
            ]
 | 
			
		||||
        return []
 | 
			
		||||
 | 
			
		||||
    def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse:
 | 
			
		||||
        """Prepare Authentication Plan, redirect user FlowExecutor"""
 | 
			
		||||
        # Ensure redirect is carried through when user was trying to
 | 
			
		||||
        # authorize application
 | 
			
		||||
        final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
 | 
			
		||||
            NEXT_ARG_NAME, "authentik_core:if-admin"
 | 
			
		||||
        )
 | 
			
		||||
        kwargs.update(
 | 
			
		||||
            {
 | 
			
		||||
                # Since we authenticate the user by their token, they have no backend set
 | 
			
		||||
                PLAN_CONTEXT_AUTHENTICATION_BACKEND: "django.contrib.auth.backends.ModelBackend",
 | 
			
		||||
                PLAN_CONTEXT_SSO: True,
 | 
			
		||||
                PLAN_CONTEXT_SOURCE: self.source,
 | 
			
		||||
                PLAN_CONTEXT_REDIRECT: final_redirect,
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        if not flow:
 | 
			
		||||
            return HttpResponseBadRequest()
 | 
			
		||||
        # We run the Flow planner here so we can pass the Pending user in the context
 | 
			
		||||
        planner = FlowPlanner(flow)
 | 
			
		||||
        plan = planner.plan(self.request, kwargs)
 | 
			
		||||
        for stage in self.get_stages_to_append(flow):
 | 
			
		||||
            plan.append(stage)
 | 
			
		||||
        self.request.session[SESSION_KEY_PLAN] = plan
 | 
			
		||||
        return redirect_with_qs(
 | 
			
		||||
            "authentik_core:if-flow",
 | 
			
		||||
            self.request.GET,
 | 
			
		||||
            flow_slug=flow.slug,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=unused-argument
 | 
			
		||||
    def handle_auth_user(
 | 
			
		||||
        self,
 | 
			
		||||
        connection: UserSourceConnection,
 | 
			
		||||
    ) -> HttpResponse:
 | 
			
		||||
        """Login user and redirect."""
 | 
			
		||||
        messages.success(
 | 
			
		||||
            self.request,
 | 
			
		||||
            _(
 | 
			
		||||
                "Successfully authenticated with %(source)s!"
 | 
			
		||||
                % {"source": self.source.name}
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
        flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
 | 
			
		||||
        return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs)
 | 
			
		||||
 | 
			
		||||
    def handle_existing_user_link(
 | 
			
		||||
        self,
 | 
			
		||||
        connection: UserSourceConnection,
 | 
			
		||||
    ) -> HttpResponse:
 | 
			
		||||
        """Handler when the user was already authenticated and linked an external source
 | 
			
		||||
        to their account."""
 | 
			
		||||
        # Connection has already been saved
 | 
			
		||||
        Event.new(
 | 
			
		||||
            EventAction.SOURCE_LINKED,
 | 
			
		||||
            message="Linked Source",
 | 
			
		||||
            source=self.source,
 | 
			
		||||
        ).from_http(self.request)
 | 
			
		||||
        messages.success(
 | 
			
		||||
            self.request,
 | 
			
		||||
            _("Successfully linked %(source)s!" % {"source": self.source.name}),
 | 
			
		||||
        )
 | 
			
		||||
        # When request isn't authenticated we jump straight to auth
 | 
			
		||||
        if not self.request.user.is_authenticated:
 | 
			
		||||
            return self.handle_auth_user(connection)
 | 
			
		||||
        return redirect(
 | 
			
		||||
            reverse(
 | 
			
		||||
                "authentik_core:if-admin",
 | 
			
		||||
            )
 | 
			
		||||
            + f"#/user;page-{self.source.slug}"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def handle_enroll(
 | 
			
		||||
        self,
 | 
			
		||||
        connection: UserSourceConnection,
 | 
			
		||||
    ) -> HttpResponse:
 | 
			
		||||
        """User was not authenticated and previous request was not authenticated."""
 | 
			
		||||
        messages.success(
 | 
			
		||||
            self.request,
 | 
			
		||||
            _(
 | 
			
		||||
                "Successfully authenticated with %(source)s!"
 | 
			
		||||
                % {"source": self.source.name}
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # We run the Flow planner here so we can pass the Pending user in the context
 | 
			
		||||
        if not self.source.enrollment_flow:
 | 
			
		||||
            self._logger.warning("source has no enrollment flow")
 | 
			
		||||
            return HttpResponseBadRequest()
 | 
			
		||||
        return self._handle_login_flow(
 | 
			
		||||
            self.source.enrollment_flow,
 | 
			
		||||
            **{
 | 
			
		||||
                PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
 | 
			
		||||
                PLAN_CONTEXT_SOURCES_CONNECTION: connection,
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
@ -1,32 +1,30 @@
 | 
			
		||||
"""OAuth Stages"""
 | 
			
		||||
"""Source flow manager stages"""
 | 
			
		||||
from django.http import HttpRequest, HttpResponse
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.core.models import User, UserSourceConnection
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
 | 
			
		||||
from authentik.flows.stage import StageView
 | 
			
		||||
from authentik.sources.oauth.models import UserOAuthSourceConnection
 | 
			
		||||
 | 
			
		||||
PLAN_CONTEXT_SOURCES_OAUTH_ACCESS = "sources_oauth_access"
 | 
			
		||||
PLAN_CONTEXT_SOURCES_CONNECTION = "goauthentik.io/sources/connection"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PostUserEnrollmentStage(StageView):
 | 
			
		||||
    """Dynamically injected stage which saves the OAuth Connection after
 | 
			
		||||
    """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"""
 | 
			
		||||
        access: UserOAuthSourceConnection = self.executor.plan.context[
 | 
			
		||||
            PLAN_CONTEXT_SOURCES_OAUTH_ACCESS
 | 
			
		||||
        connection: UserSourceConnection = self.executor.plan.context[
 | 
			
		||||
            PLAN_CONTEXT_SOURCES_CONNECTION
 | 
			
		||||
        ]
 | 
			
		||||
        user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
 | 
			
		||||
        access.user = user
 | 
			
		||||
        access.save()
 | 
			
		||||
        UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
 | 
			
		||||
        connection.user = user
 | 
			
		||||
        connection.save()
 | 
			
		||||
        Event.new(
 | 
			
		||||
            EventAction.SOURCE_LINKED,
 | 
			
		||||
            message="Linked OAuth Source",
 | 
			
		||||
            source=access.source,
 | 
			
		||||
            message="Linked Source",
 | 
			
		||||
            source=connection.source,
 | 
			
		||||
        ).from_http(self.request)
 | 
			
		||||
        return self.executor.stage_ok()
 | 
			
		||||
@ -14,9 +14,9 @@
 | 
			
		||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}?v={{ ak_version }}">
 | 
			
		||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}?v={{ ak_version }}">
 | 
			
		||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}?v={{ ak_version }}">
 | 
			
		||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}">
 | 
			
		||||
        {% block head_before %}
 | 
			
		||||
        {% endblock %}
 | 
			
		||||
        <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}">
 | 
			
		||||
        <script src="{% static 'dist/poly.js' %}?v={{ ak_version }}" type="module"></script>
 | 
			
		||||
        <script>window["polymerSkipLoadingFontRoboto"] = true;</script>
 | 
			
		||||
        {% block head %}
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,10 @@
 | 
			
		||||
{% load static %}
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
{% block head_before %}
 | 
			
		||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}?v={{ ak_version }}">
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block body %}
 | 
			
		||||
<div class="pf-c-background-image">
 | 
			
		||||
    <svg xmlns="http://www.w3.org/2000/svg" class="pf-c-background-image__filter" width="0" height="0">
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										125
									
								
								authentik/core/tests/test_applications_api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								authentik/core/tests/test_applications_api.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,125 @@
 | 
			
		||||
"""Test Applications API"""
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.encoding import force_str
 | 
			
		||||
from rest_framework.test import APITestCase
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Application, User
 | 
			
		||||
from authentik.policies.dummy.models import DummyPolicy
 | 
			
		||||
from authentik.policies.models import PolicyBinding
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestApplicationsAPI(APITestCase):
 | 
			
		||||
    """Test applications API"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        self.user = User.objects.get(username="akadmin")
 | 
			
		||||
        self.allowed = Application.objects.create(name="allowed", slug="allowed")
 | 
			
		||||
        self.denied = Application.objects.create(name="denied", slug="denied")
 | 
			
		||||
        PolicyBinding.objects.create(
 | 
			
		||||
            target=self.denied,
 | 
			
		||||
            policy=DummyPolicy.objects.create(
 | 
			
		||||
                name="deny", result=False, wait_min=1, wait_max=2
 | 
			
		||||
            ),
 | 
			
		||||
            order=0,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_check_access(self):
 | 
			
		||||
        """Test check_access operation """
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse(
 | 
			
		||||
                "authentik_api:application-check-access",
 | 
			
		||||
                kwargs={"slug": self.allowed.slug},
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 204)
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse(
 | 
			
		||||
                "authentik_api:application-check-access",
 | 
			
		||||
                kwargs={"slug": self.denied.slug},
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_list(self):
 | 
			
		||||
        """Test list operation without superuser_full_list"""
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        response = self.client.get(reverse("authentik_api:application-list"))
 | 
			
		||||
        self.assertJSONEqual(
 | 
			
		||||
            force_str(response.content),
 | 
			
		||||
            {
 | 
			
		||||
                "pagination": {
 | 
			
		||||
                    "next": 0,
 | 
			
		||||
                    "previous": 0,
 | 
			
		||||
                    "count": 2,
 | 
			
		||||
                    "current": 1,
 | 
			
		||||
                    "total_pages": 1,
 | 
			
		||||
                    "start_index": 1,
 | 
			
		||||
                    "end_index": 2,
 | 
			
		||||
                },
 | 
			
		||||
                "results": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "pk": str(self.allowed.pk),
 | 
			
		||||
                        "name": "allowed",
 | 
			
		||||
                        "slug": "allowed",
 | 
			
		||||
                        "provider": None,
 | 
			
		||||
                        "provider_obj": None,
 | 
			
		||||
                        "launch_url": None,
 | 
			
		||||
                        "meta_launch_url": "",
 | 
			
		||||
                        "meta_icon": None,
 | 
			
		||||
                        "meta_description": "",
 | 
			
		||||
                        "meta_publisher": "",
 | 
			
		||||
                        "policy_engine_mode": "any",
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_list_superuser_full_list(self):
 | 
			
		||||
        """Test list operation with superuser_full_list"""
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            reverse("authentik_api:application-list") + "?superuser_full_list=true"
 | 
			
		||||
        )
 | 
			
		||||
        self.assertJSONEqual(
 | 
			
		||||
            force_str(response.content),
 | 
			
		||||
            {
 | 
			
		||||
                "pagination": {
 | 
			
		||||
                    "next": 0,
 | 
			
		||||
                    "previous": 0,
 | 
			
		||||
                    "count": 2,
 | 
			
		||||
                    "current": 1,
 | 
			
		||||
                    "total_pages": 1,
 | 
			
		||||
                    "start_index": 1,
 | 
			
		||||
                    "end_index": 2,
 | 
			
		||||
                },
 | 
			
		||||
                "results": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "pk": str(self.allowed.pk),
 | 
			
		||||
                        "name": "allowed",
 | 
			
		||||
                        "slug": "allowed",
 | 
			
		||||
                        "provider": None,
 | 
			
		||||
                        "provider_obj": None,
 | 
			
		||||
                        "launch_url": None,
 | 
			
		||||
                        "meta_launch_url": "",
 | 
			
		||||
                        "meta_icon": None,
 | 
			
		||||
                        "meta_description": "",
 | 
			
		||||
                        "meta_publisher": "",
 | 
			
		||||
                        "policy_engine_mode": "any",
 | 
			
		||||
                    },
 | 
			
		||||
                    {
 | 
			
		||||
                        "launch_url": None,
 | 
			
		||||
                        "meta_description": "",
 | 
			
		||||
                        "meta_icon": None,
 | 
			
		||||
                        "meta_launch_url": "",
 | 
			
		||||
                        "meta_publisher": "",
 | 
			
		||||
                        "name": "denied",
 | 
			
		||||
                        "pk": str(self.denied.pk),
 | 
			
		||||
                        "policy_engine_mode": "any",
 | 
			
		||||
                        "provider": None,
 | 
			
		||||
                        "provider_obj": None,
 | 
			
		||||
                        "slug": "denied",
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
@ -1,11 +1,14 @@
 | 
			
		||||
"""authentik core models tests"""
 | 
			
		||||
from time import sleep
 | 
			
		||||
from typing import Callable, Type
 | 
			
		||||
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from django.utils.timezone import now
 | 
			
		||||
from guardian.shortcuts import get_anonymous_user
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Token
 | 
			
		||||
from authentik.core.models import Provider, Source, Token
 | 
			
		||||
from authentik.flows.models import Stage
 | 
			
		||||
from authentik.lib.utils.reflection import all_subclasses
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestModels(TestCase):
 | 
			
		||||
@ -24,3 +27,40 @@ class TestModels(TestCase):
 | 
			
		||||
        )
 | 
			
		||||
        sleep(0.5)
 | 
			
		||||
        self.assertFalse(token.is_expired)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def source_tester_factory(test_model: Type[Stage]) -> Callable:
 | 
			
		||||
    """Test source"""
 | 
			
		||||
 | 
			
		||||
    def tester(self: TestModels):
 | 
			
		||||
        model_class = None
 | 
			
		||||
        if test_model._meta.abstract:
 | 
			
		||||
            model_class = test_model.__bases__[0]()
 | 
			
		||||
        else:
 | 
			
		||||
            model_class = test_model()
 | 
			
		||||
        model_class.slug = "test"
 | 
			
		||||
        self.assertIsNotNone(model_class.component)
 | 
			
		||||
        _ = model_class.ui_login_button
 | 
			
		||||
        _ = model_class.ui_user_settings
 | 
			
		||||
 | 
			
		||||
    return tester
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def provider_tester_factory(test_model: Type[Stage]) -> Callable:
 | 
			
		||||
    """Test provider"""
 | 
			
		||||
 | 
			
		||||
    def tester(self: TestModels):
 | 
			
		||||
        model_class = None
 | 
			
		||||
        if test_model._meta.abstract:
 | 
			
		||||
            model_class = test_model.__bases__[0]()
 | 
			
		||||
        else:
 | 
			
		||||
            model_class = test_model()
 | 
			
		||||
        self.assertIsNotNone(model_class.component)
 | 
			
		||||
 | 
			
		||||
    return tester
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
for model in all_subclasses(Source):
 | 
			
		||||
    setattr(TestModels, f"test_model_{model.__name__}", source_tester_factory(model))
 | 
			
		||||
for model in all_subclasses(Provider):
 | 
			
		||||
    setattr(TestModels, f"test_model_{model.__name__}", provider_tester_factory(model))
 | 
			
		||||
 | 
			
		||||
@ -2,9 +2,10 @@
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from rest_framework.fields import CharField
 | 
			
		||||
from rest_framework.fields import CharField, DictField
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer
 | 
			
		||||
from authentik.flows.challenge import Challenge
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
@ -14,8 +15,8 @@ class UILoginButton:
 | 
			
		||||
    # Name, ran through i18n
 | 
			
		||||
    name: str
 | 
			
		||||
 | 
			
		||||
    # URL Which Button points to
 | 
			
		||||
    url: str
 | 
			
		||||
    # Challenge which is presented to the user when they click the button
 | 
			
		||||
    challenge: Challenge
 | 
			
		||||
 | 
			
		||||
    # Icon URL, used as-is
 | 
			
		||||
    icon_url: Optional[str] = None
 | 
			
		||||
@ -25,7 +26,7 @@ class UILoginButtonSerializer(PassiveSerializer):
 | 
			
		||||
    """Serializer for Login buttons of sources"""
 | 
			
		||||
 | 
			
		||||
    name = CharField()
 | 
			
		||||
    url = CharField()
 | 
			
		||||
    challenge = DictField()
 | 
			
		||||
    icon_url = CharField(required=False, allow_null=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -39,7 +39,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
			
		||||
        """Show if this keypair has a private key configured or not"""
 | 
			
		||||
        return instance.key_data != "" and instance.key_data is not None
 | 
			
		||||
 | 
			
		||||
    def validate_certificate_data(self, value):
 | 
			
		||||
    def validate_certificate_data(self, value: str) -> str:
 | 
			
		||||
        """Verify that input is a valid PEM x509 Certificate"""
 | 
			
		||||
        try:
 | 
			
		||||
            load_pem_x509_certificate(value.encode("utf-8"), default_backend())
 | 
			
		||||
@ -47,7 +47,7 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
			
		||||
            raise ValidationError("Unable to load certificate.")
 | 
			
		||||
        return value
 | 
			
		||||
 | 
			
		||||
    def validate_key_data(self, value):
 | 
			
		||||
    def validate_key_data(self, value: str) -> str:
 | 
			
		||||
        """Verify that input is a valid PEM RSA Key"""
 | 
			
		||||
        # Since this field is optional, data can be empty.
 | 
			
		||||
        if value != "":
 | 
			
		||||
@ -57,8 +57,10 @@ class CertificateKeyPairSerializer(ModelSerializer):
 | 
			
		||||
                    password=None,
 | 
			
		||||
                    backend=default_backend(),
 | 
			
		||||
                )
 | 
			
		||||
            except ValueError:
 | 
			
		||||
                raise ValidationError("Unable to load private key.")
 | 
			
		||||
            except (ValueError, TypeError):
 | 
			
		||||
                raise ValidationError(
 | 
			
		||||
                    "Unable to load private key (possibly encrypted?)."
 | 
			
		||||
                )
 | 
			
		||||
        return value
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
@ -33,7 +33,7 @@ class CertificateBuilder:
 | 
			
		||||
    def save(self) -> Optional[CertificateKeyPair]:
 | 
			
		||||
        """Save generated certificate as model"""
 | 
			
		||||
        if not self.__certificate:
 | 
			
		||||
            return None
 | 
			
		||||
            raise ValueError("Certificated hasn't been built yet")
 | 
			
		||||
        return CertificateKeyPair.objects.create(
 | 
			
		||||
            name=self.common_name,
 | 
			
		||||
            certificate_data=self.certificate,
 | 
			
		||||
 | 
			
		||||
@ -37,6 +37,8 @@ class TestCrypto(TestCase):
 | 
			
		||||
        """Test Builder"""
 | 
			
		||||
        builder = CertificateBuilder()
 | 
			
		||||
        builder.common_name = "test-cert"
 | 
			
		||||
        with self.assertRaises(ValueError):
 | 
			
		||||
            builder.save()
 | 
			
		||||
        builder.build(
 | 
			
		||||
            subject_alt_names=[],
 | 
			
		||||
            validity_days=3,
 | 
			
		||||
 | 
			
		||||
@ -8,10 +8,10 @@ from rest_framework.decorators import action
 | 
			
		||||
from rest_framework.fields import CharField, DictField, IntegerField
 | 
			
		||||
from rest_framework.request import Request
 | 
			
		||||
from rest_framework.response import Response
 | 
			
		||||
from rest_framework.serializers import ModelSerializer, Serializer
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ReadOnlyModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.utils import TypeCreateSerializer
 | 
			
		||||
from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -38,31 +38,19 @@ class EventSerializer(ModelSerializer):
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EventTopPerUserParams(Serializer):
 | 
			
		||||
class EventTopPerUserParams(PassiveSerializer):
 | 
			
		||||
    """Query params for top_per_user"""
 | 
			
		||||
 | 
			
		||||
    top_n = IntegerField(default=15)
 | 
			
		||||
 | 
			
		||||
    def create(self, request: Request) -> Response:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def update(self, request: Request) -> Response:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EventTopPerUserSerializer(Serializer):
 | 
			
		||||
class EventTopPerUserSerializer(PassiveSerializer):
 | 
			
		||||
    """Response object of Event's top_per_user"""
 | 
			
		||||
 | 
			
		||||
    application = DictField()
 | 
			
		||||
    counted_events = IntegerField()
 | 
			
		||||
    unique_users = IntegerField()
 | 
			
		||||
 | 
			
		||||
    def create(self, request: Request) -> Response:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def update(self, request: Request) -> Response:
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EventsFilter(django_filters.FilterSet):
 | 
			
		||||
    """Filter for events"""
 | 
			
		||||
@ -132,7 +120,7 @@ class EventViewSet(ReadOnlyModelViewSet):
 | 
			
		||||
    def top_per_user(self, request: Request):
 | 
			
		||||
        """Get the top_n events grouped by user count"""
 | 
			
		||||
        filtered_action = request.query_params.get("action", EventAction.LOGIN)
 | 
			
		||||
        top_n = request.query_params.get("top_n", 15)
 | 
			
		||||
        top_n = int(request.query_params.get("top_n", "15"))
 | 
			
		||||
        return Response(
 | 
			
		||||
            get_objects_for_user(request.user, "authentik_events.view_event")
 | 
			
		||||
            .filter(action=filtered_action)
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,9 @@
 | 
			
		||||
"""Notification API Views"""
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from guardian.utils import get_anonymous_user
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.fields import ReadOnlyField
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
 | 
			
		||||
@ -46,8 +49,12 @@ class NotificationViewSet(
 | 
			
		||||
        "event",
 | 
			
		||||
        "seen",
 | 
			
		||||
    ]
 | 
			
		||||
    filter_backends = [
 | 
			
		||||
        DjangoFilterBackend,
 | 
			
		||||
        OrderingFilter,
 | 
			
		||||
        SearchFilter,
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        if not self.request:
 | 
			
		||||
            return super().get_queryset()
 | 
			
		||||
        return Notification.objects.filter(user=self.request.user)
 | 
			
		||||
        user = self.request.user if self.request else get_anonymous_user()
 | 
			
		||||
        return Notification.objects.filter(user=user.pk)
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
"""Event notification tasks"""
 | 
			
		||||
from guardian.shortcuts import get_anonymous_user
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.events.models import (
 | 
			
		||||
@ -35,7 +35,10 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
 | 
			
		||||
        LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid)
 | 
			
		||||
        return
 | 
			
		||||
    event: Event = events.first()
 | 
			
		||||
    trigger: NotificationRule = NotificationRule.objects.get(name=trigger_name)
 | 
			
		||||
    triggers: NotificationRule = NotificationRule.objects.filter(name=trigger_name)
 | 
			
		||||
    if not triggers.exists():
 | 
			
		||||
        return
 | 
			
		||||
    trigger = triggers.first()
 | 
			
		||||
 | 
			
		||||
    if "policy_uuid" in event.context:
 | 
			
		||||
        policy_uuid = event.context["policy_uuid"]
 | 
			
		||||
@ -58,7 +61,13 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    LOGGER.debug("e(trigger): checking if trigger applies", trigger=trigger)
 | 
			
		||||
    user = User.objects.filter(pk=event.user.get("pk")).first() or get_anonymous_user()
 | 
			
		||||
    try:
 | 
			
		||||
        user = (
 | 
			
		||||
            User.objects.filter(pk=event.user.get("pk")).first() or get_anonymous_user()
 | 
			
		||||
        )
 | 
			
		||||
    except User.DoesNotExist:
 | 
			
		||||
        LOGGER.warning("e(trigger): failed to get user", trigger=trigger)
 | 
			
		||||
        return
 | 
			
		||||
    policy_engine = PolicyEngine(trigger, user)
 | 
			
		||||
    policy_engine.mode = PolicyEngineMode.MODE_ANY
 | 
			
		||||
    policy_engine.empty_result = False
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										32
									
								
								authentik/flows/tests/test_stage_model.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								authentik/flows/tests/test_stage_model.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,32 @@
 | 
			
		||||
"""base model tests"""
 | 
			
		||||
from typing import Callable, Type
 | 
			
		||||
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
from authentik.flows.models import Stage
 | 
			
		||||
from authentik.flows.stage import StageView
 | 
			
		||||
from authentik.lib.utils.reflection import all_subclasses
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestModels(TestCase):
 | 
			
		||||
    """Generic model properties tests"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def model_tester_factory(test_model: Type[Stage]) -> Callable:
 | 
			
		||||
    """Test a form"""
 | 
			
		||||
 | 
			
		||||
    def tester(self: TestModels):
 | 
			
		||||
        model_class = None
 | 
			
		||||
        if test_model._meta.abstract:
 | 
			
		||||
            model_class = test_model.__bases__[0]()
 | 
			
		||||
        else:
 | 
			
		||||
            model_class = test_model()
 | 
			
		||||
        self.assertTrue(issubclass(model_class.type, StageView))
 | 
			
		||||
        self.assertIsNotNone(test_model.component)
 | 
			
		||||
        _ = test_model.ui_user_settings
 | 
			
		||||
 | 
			
		||||
    return tester
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
for model in all_subclasses(Stage):
 | 
			
		||||
    setattr(TestModels, f"test_model_{model.__name__}", model_tester_factory(model))
 | 
			
		||||
@ -13,7 +13,7 @@ from django.db.models.query_utils import Q
 | 
			
		||||
from django.db.utils import IntegrityError
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.serializers import BaseSerializer, Serializer
 | 
			
		||||
from structlog import BoundLogger, get_logger
 | 
			
		||||
from structlog.stdlib import BoundLogger, get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.flows.models import Flow, FlowStageBinding, Stage
 | 
			
		||||
from authentik.flows.transfer.common import (
 | 
			
		||||
 | 
			
		||||
@ -86,6 +86,13 @@ class ConfigLoader:
 | 
			
		||||
        url = urlparse(value)
 | 
			
		||||
        if url.scheme == "env":
 | 
			
		||||
            value = os.getenv(url.netloc, url.query)
 | 
			
		||||
        if url.scheme == "file":
 | 
			
		||||
            try:
 | 
			
		||||
                with open(url.netloc, "r") as _file:
 | 
			
		||||
                    value = _file.read()
 | 
			
		||||
            except OSError:
 | 
			
		||||
                self._log("error", f"Failed to read config value from {url.netloc}")
 | 
			
		||||
                value = url.query
 | 
			
		||||
        return value
 | 
			
		||||
 | 
			
		||||
    def update_from_file(self, path: str):
 | 
			
		||||
@ -163,6 +170,7 @@ class ConfigLoader:
 | 
			
		||||
        # Walk each component of the path
 | 
			
		||||
        path_parts = path.split(sep)
 | 
			
		||||
        for comp in path_parts[:-1]:
 | 
			
		||||
            # pyright: reportGeneralTypeIssues=false
 | 
			
		||||
            if comp not in root:
 | 
			
		||||
                root[comp] = {}
 | 
			
		||||
            root = root.get(comp)
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,10 @@ postgresql:
 | 
			
		||||
  user: authentik
 | 
			
		||||
  password: 'env://POSTGRES_PASSWORD'
 | 
			
		||||
 | 
			
		||||
web:
 | 
			
		||||
  listen: 0.0.0.0:9000
 | 
			
		||||
  listen_tls: 0.0.0.0:9443
 | 
			
		||||
 | 
			
		||||
redis:
 | 
			
		||||
  host: localhost
 | 
			
		||||
  password: ''
 | 
			
		||||
@ -34,7 +38,10 @@ email:
 | 
			
		||||
  from: authentik@localhost
 | 
			
		||||
 | 
			
		||||
outposts:
 | 
			
		||||
  docker_image_base: "beryju/authentik" # this is prepended to -proxy:version
 | 
			
		||||
  # Placeholders:
 | 
			
		||||
  # %(type)s: Outpost type; proxy, ldap, etc
 | 
			
		||||
  # %(version)s: Current version; 2021.4.1
 | 
			
		||||
  docker_image_base: "beryju/authentik-%(type)s:%(version)s"
 | 
			
		||||
 | 
			
		||||
authentik:
 | 
			
		||||
  avatars: gravatar  # gravatar or none
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										16
									
								
								authentik/lib/tests/test_utils_reflection.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								authentik/lib/tests/test_utils_reflection.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
			
		||||
"""Test Reflection utils"""
 | 
			
		||||
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
from authentik.lib.utils.reflection import path_to_class
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestReflectionUtils(TestCase):
 | 
			
		||||
    """Test Reflection-utils"""
 | 
			
		||||
 | 
			
		||||
    def test_path_to_class(self):
 | 
			
		||||
        """Test path_to_class"""
 | 
			
		||||
        self.assertIsNone(path_to_class(None))
 | 
			
		||||
        self.assertEqual(path_to_class("datetime.datetime"), datetime)
 | 
			
		||||
@ -3,6 +3,9 @@ from typing import Any, Optional
 | 
			
		||||
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
 | 
			
		||||
OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP"
 | 
			
		||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_client_ip_from_meta(meta: dict[str, Any]) -> Optional[str]:
 | 
			
		||||
    """Attempt to get the client's IP by checking common HTTP Headers.
 | 
			
		||||
@ -18,9 +21,27 @@ def _get_client_ip_from_meta(meta: dict[str, Any]) -> Optional[str]:
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]:
 | 
			
		||||
    """Get the actual remote IP when set by an outpost. Only
 | 
			
		||||
    allowed when the request is authenticated, by a user with USER_ATTRIBUTE_CAN_OVERRIDE_IP set
 | 
			
		||||
    to outpost"""
 | 
			
		||||
    if not hasattr(request, "user"):
 | 
			
		||||
        return None
 | 
			
		||||
    if not request.user.is_authenticated:
 | 
			
		||||
        return None
 | 
			
		||||
    if OUTPOST_REMOTE_IP_HEADER not in request.META:
 | 
			
		||||
        return None
 | 
			
		||||
    if request.user.attributes.get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False):
 | 
			
		||||
        return None
 | 
			
		||||
    return request.META[OUTPOST_REMOTE_IP_HEADER]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_client_ip(request: Optional[HttpRequest]) -> Optional[str]:
 | 
			
		||||
    """Attempt to get the client's IP by checking common HTTP Headers.
 | 
			
		||||
    Returns none if no IP Could be found"""
 | 
			
		||||
    if request:
 | 
			
		||||
        override = _get_outpost_override_ip(request)
 | 
			
		||||
        if override:
 | 
			
		||||
            return override
 | 
			
		||||
        return _get_client_ip_from_meta(request.META)
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
@ -24,6 +24,7 @@ class OutpostSerializer(ModelSerializer):
 | 
			
		||||
        fields = [
 | 
			
		||||
            "pk",
 | 
			
		||||
            "name",
 | 
			
		||||
            "type",
 | 
			
		||||
            "providers",
 | 
			
		||||
            "providers_obj",
 | 
			
		||||
            "service_connection",
 | 
			
		||||
 | 
			
		||||
@ -82,7 +82,8 @@ class OutpostConsumer(AuthJsonConsumer):
 | 
			
		||||
            state.version = msg.args.get("version", None)
 | 
			
		||||
        elif msg.instruction == WebsocketMessageInstruction.ACK:
 | 
			
		||||
            return
 | 
			
		||||
        state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5)
 | 
			
		||||
        if state.version:
 | 
			
		||||
            state.save(timeout=OUTPOST_HELLO_INTERVAL * 1.5)
 | 
			
		||||
 | 
			
		||||
        response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
 | 
			
		||||
        self.send_json(asdict(response))
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,12 @@
 | 
			
		||||
"""Base Controller"""
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
from structlog.testing import capture_logs
 | 
			
		||||
 | 
			
		||||
from authentik import __version__
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.lib.sentry import SentryIgnoredException
 | 
			
		||||
from authentik.outposts.models import Outpost, OutpostServiceConnection
 | 
			
		||||
 | 
			
		||||
@ -21,6 +24,7 @@ class DeploymentPort:
 | 
			
		||||
    port: int
 | 
			
		||||
    name: str
 | 
			
		||||
    protocol: str
 | 
			
		||||
    inner_port: Optional[int] = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseController:
 | 
			
		||||
@ -55,3 +59,8 @@ class BaseController:
 | 
			
		||||
    def get_static_deployment(self) -> str:
 | 
			
		||||
        """Return a static deployment configuration"""
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def get_container_image(self) -> str:
 | 
			
		||||
        """Get container image to use for this outpost"""
 | 
			
		||||
        image_name_template: str = CONFIG.y("outposts.docker_image_base")
 | 
			
		||||
        return image_name_template % {"type": self.outpost.type, "version": __version__}
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,6 @@ from docker.models.containers import Container
 | 
			
		||||
from yaml import safe_dump
 | 
			
		||||
 | 
			
		||||
from authentik import __version__
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.outposts.controllers.base import BaseController, ControllerException
 | 
			
		||||
from authentik.outposts.models import (
 | 
			
		||||
    DockerServiceConnection,
 | 
			
		||||
@ -60,15 +59,14 @@ class DockerController(BaseController):
 | 
			
		||||
            return self.client.containers.get(container_name), False
 | 
			
		||||
        except NotFound:
 | 
			
		||||
            self.logger.info("Container does not exist, creating")
 | 
			
		||||
            image_prefix = CONFIG.y("outposts.docker_image_base")
 | 
			
		||||
            image_name = f"{image_prefix}-{self.outpost.type}:{__version__}"
 | 
			
		||||
            image_name = self.get_container_image()
 | 
			
		||||
            self.client.images.pull(image_name)
 | 
			
		||||
            container_args = {
 | 
			
		||||
                "image": image_name,
 | 
			
		||||
                "name": f"authentik-proxy-{self.outpost.uuid.hex}",
 | 
			
		||||
                "detach": True,
 | 
			
		||||
                "ports": {
 | 
			
		||||
                    f"{port.port}/{port.protocol.lower()}": port.port
 | 
			
		||||
                    f"{port.port}/{port.protocol.lower()}": port.inner_port or port.port
 | 
			
		||||
                    for port in self.deployment_ports
 | 
			
		||||
                },
 | 
			
		||||
                "environment": self._get_env(),
 | 
			
		||||
@ -143,15 +141,15 @@ class DockerController(BaseController):
 | 
			
		||||
    def get_static_deployment(self) -> str:
 | 
			
		||||
        """Generate docker-compose yaml for proxy, version 3.5"""
 | 
			
		||||
        ports = [
 | 
			
		||||
            f"{port.port}:{port.port}/{port.protocol.lower()}"
 | 
			
		||||
            f"{port.port}:{port.inner_port or port.port}/{port.protocol.lower()}"
 | 
			
		||||
            for port in self.deployment_ports
 | 
			
		||||
        ]
 | 
			
		||||
        image_prefix = CONFIG.y("outposts.docker_image_base")
 | 
			
		||||
        image_name = self.get_container_image()
 | 
			
		||||
        compose = {
 | 
			
		||||
            "version": "3.5",
 | 
			
		||||
            "services": {
 | 
			
		||||
                f"authentik_{self.outpost.type}": {
 | 
			
		||||
                    "image": f"{image_prefix}-{self.outpost.type}:{__version__}",
 | 
			
		||||
                    "image": image_name,
 | 
			
		||||
                    "ports": ports,
 | 
			
		||||
                    "environment": {
 | 
			
		||||
                        "AUTHENTIK_HOST": self.outpost.config.authentik_host,
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
"""Base Kubernetes Reconciler"""
 | 
			
		||||
from typing import TYPE_CHECKING, Generic, TypeVar
 | 
			
		||||
 | 
			
		||||
from django.utils.text import slugify
 | 
			
		||||
from kubernetes.client import V1ObjectMeta
 | 
			
		||||
from kubernetes.client.models.v1_deployment import V1Deployment
 | 
			
		||||
from kubernetes.client.models.v1_pod import V1Pod
 | 
			
		||||
@ -29,6 +30,11 @@ class NeedsUpdate(ReconcileTrigger):
 | 
			
		||||
    """Exception to trigger an update to the Kubernetes Object"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Disabled(SentryIgnoredException):
 | 
			
		||||
    """Exception which can be thrown in a reconciler to signal than an
 | 
			
		||||
    object should not be created."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class KubernetesObjectReconciler(Generic[T]):
 | 
			
		||||
    """Base Kubernetes Reconciler, handles the basic logic."""
 | 
			
		||||
 | 
			
		||||
@ -37,17 +43,27 @@ class KubernetesObjectReconciler(Generic[T]):
 | 
			
		||||
    def __init__(self, controller: "KubernetesController"):
 | 
			
		||||
        self.controller = controller
 | 
			
		||||
        self.namespace = controller.outpost.config.kubernetes_namespace
 | 
			
		||||
        self.logger = get_logger()
 | 
			
		||||
        self.logger = get_logger().bind(type=self.__class__.__name__)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        """Get the name of the object this reconciler manages"""
 | 
			
		||||
        raise NotImplementedError
 | 
			
		||||
        return (
 | 
			
		||||
            self.controller.outpost.config.object_naming_template
 | 
			
		||||
            % {
 | 
			
		||||
                "name": slugify(self.controller.outpost.name),
 | 
			
		||||
                "uuid": self.controller.outpost.uuid.hex,
 | 
			
		||||
            }
 | 
			
		||||
        ).lower()
 | 
			
		||||
 | 
			
		||||
    def up(self):
 | 
			
		||||
        """Create object if it doesn't exist, update if needed or recreate if needed."""
 | 
			
		||||
        current = None
 | 
			
		||||
        reference = self.get_reference_object()
 | 
			
		||||
        try:
 | 
			
		||||
            reference = self.get_reference_object()
 | 
			
		||||
        except Disabled:
 | 
			
		||||
            self.logger.debug("Object not required")
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            try:
 | 
			
		||||
                current = self.retrieve()
 | 
			
		||||
@ -58,7 +74,6 @@ class KubernetesObjectReconciler(Generic[T]):
 | 
			
		||||
                self.logger.debug("Other unhandled error", exc=exc)
 | 
			
		||||
                raise exc
 | 
			
		||||
            else:
 | 
			
		||||
                self.logger.debug("Got current, running reconcile")
 | 
			
		||||
                self.reconcile(current, reference)
 | 
			
		||||
        except NeedsRecreate:
 | 
			
		||||
            self.logger.debug("Recreate requested")
 | 
			
		||||
@ -67,16 +82,22 @@ class KubernetesObjectReconciler(Generic[T]):
 | 
			
		||||
                self.delete(current)
 | 
			
		||||
            else:
 | 
			
		||||
                self.logger.debug("No old found, creating")
 | 
			
		||||
            self.logger.debug("Created")
 | 
			
		||||
            self.logger.debug("Creating")
 | 
			
		||||
            self.create(reference)
 | 
			
		||||
        except NeedsUpdate:
 | 
			
		||||
            self.logger.debug("Updating")
 | 
			
		||||
            self.update(current, reference)
 | 
			
		||||
        else:
 | 
			
		||||
            self.logger.debug("Nothing to do...")
 | 
			
		||||
            self.logger.debug("Object is up-to-date.")
 | 
			
		||||
 | 
			
		||||
    def down(self):
 | 
			
		||||
        """Delete object if found"""
 | 
			
		||||
        # Call self.get_reference_object to check if we even need to do anything
 | 
			
		||||
        try:
 | 
			
		||||
            self.get_reference_object()
 | 
			
		||||
        except Disabled:
 | 
			
		||||
            self.logger.debug("Object not required")
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            current = self.retrieve()
 | 
			
		||||
            self.delete(current)
 | 
			
		||||
@ -120,7 +141,7 @@ class KubernetesObjectReconciler(Generic[T]):
 | 
			
		||||
            namespace=self.namespace,
 | 
			
		||||
            labels={
 | 
			
		||||
                "app.kubernetes.io/name": f"authentik-{self.controller.outpost.type.lower()}",
 | 
			
		||||
                "app.kubernetes.io/instance": self.controller.outpost.name,
 | 
			
		||||
                "app.kubernetes.io/instance": slugify(self.controller.outpost.name),
 | 
			
		||||
                "app.kubernetes.io/version": __version__,
 | 
			
		||||
                "app.kubernetes.io/managed-by": "goauthentik.io",
 | 
			
		||||
                "goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
 | 
			
		||||
 | 
			
		||||
@ -16,8 +16,6 @@ from kubernetes.client import (
 | 
			
		||||
    V1SecretKeySelector,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
from authentik import __version__
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.outposts.controllers.base import FIELD_MANAGER
 | 
			
		||||
from authentik.outposts.controllers.k8s.base import (
 | 
			
		||||
    KubernetesObjectReconciler,
 | 
			
		||||
@ -39,10 +37,6 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
 | 
			
		||||
        self.api = AppsV1Api(controller.client)
 | 
			
		||||
        self.outpost = self.controller.outpost
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
 | 
			
		||||
 | 
			
		||||
    def reconcile(self, current: V1Deployment, reference: V1Deployment):
 | 
			
		||||
        super().reconcile(current, reference)
 | 
			
		||||
        if current.spec.replicas != reference.spec.replicas:
 | 
			
		||||
@ -68,14 +62,13 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
 | 
			
		||||
        for port in self.controller.deployment_ports:
 | 
			
		||||
            container_ports.append(
 | 
			
		||||
                V1ContainerPort(
 | 
			
		||||
                    container_port=port.port,
 | 
			
		||||
                    container_port=port.inner_port or port.port,
 | 
			
		||||
                    name=port.name,
 | 
			
		||||
                    protocol=port.protocol.upper(),
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        meta = self.get_object_meta(name=self.name)
 | 
			
		||||
        secret_name = f"authentik-outpost-{self.controller.outpost.uuid.hex}-api"
 | 
			
		||||
        image_prefix = CONFIG.y("outposts.docker_image_base")
 | 
			
		||||
        image_name = self.controller.get_container_image()
 | 
			
		||||
        return V1Deployment(
 | 
			
		||||
            metadata=meta,
 | 
			
		||||
            spec=V1DeploymentSpec(
 | 
			
		||||
@ -87,14 +80,14 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
 | 
			
		||||
                        containers=[
 | 
			
		||||
                            V1Container(
 | 
			
		||||
                                name=str(self.outpost.type),
 | 
			
		||||
                                image=f"{image_prefix}-{self.outpost.type}:{__version__}",
 | 
			
		||||
                                image=image_name,
 | 
			
		||||
                                ports=container_ports,
 | 
			
		||||
                                env=[
 | 
			
		||||
                                    V1EnvVar(
 | 
			
		||||
                                        name="AUTHENTIK_HOST",
 | 
			
		||||
                                        value_from=V1EnvVarSource(
 | 
			
		||||
                                            secret_key_ref=V1SecretKeySelector(
 | 
			
		||||
                                                name=secret_name,
 | 
			
		||||
                                                name=self.name,
 | 
			
		||||
                                                key="authentik_host",
 | 
			
		||||
                                            )
 | 
			
		||||
                                        ),
 | 
			
		||||
@ -103,7 +96,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
 | 
			
		||||
                                        name="AUTHENTIK_TOKEN",
 | 
			
		||||
                                        value_from=V1EnvVarSource(
 | 
			
		||||
                                            secret_key_ref=V1SecretKeySelector(
 | 
			
		||||
                                                name=secret_name,
 | 
			
		||||
                                                name=self.name,
 | 
			
		||||
                                                key="token",
 | 
			
		||||
                                            )
 | 
			
		||||
                                        ),
 | 
			
		||||
@ -112,7 +105,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
 | 
			
		||||
                                        name="AUTHENTIK_INSECURE",
 | 
			
		||||
                                        value_from=V1EnvVarSource(
 | 
			
		||||
                                            secret_key_ref=V1SecretKeySelector(
 | 
			
		||||
                                                name=secret_name,
 | 
			
		||||
                                                name=self.name,
 | 
			
		||||
                                                key="authentik_host_insecure",
 | 
			
		||||
                                            )
 | 
			
		||||
                                        ),
 | 
			
		||||
 | 
			
		||||
@ -26,10 +26,6 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]):
 | 
			
		||||
        super().__init__(controller)
 | 
			
		||||
        self.api = CoreV1Api(controller.client)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        return f"authentik-outpost-{self.controller.outpost.uuid.hex}-api"
 | 
			
		||||
 | 
			
		||||
    def reconcile(self, current: V1Secret, reference: V1Secret):
 | 
			
		||||
        super().reconcile(current, reference)
 | 
			
		||||
        for key in reference.data.keys():
 | 
			
		||||
 | 
			
		||||
@ -21,10 +21,6 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
 | 
			
		||||
        super().__init__(controller)
 | 
			
		||||
        self.api = CoreV1Api(controller.client)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
 | 
			
		||||
 | 
			
		||||
    def reconcile(self, current: V1Service, reference: V1Service):
 | 
			
		||||
        super().reconcile(current, reference)
 | 
			
		||||
        if len(current.spec.ports) != len(reference.spec.ports):
 | 
			
		||||
@ -43,13 +39,17 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
 | 
			
		||||
                    name=port.name,
 | 
			
		||||
                    port=port.port,
 | 
			
		||||
                    protocol=port.protocol.upper(),
 | 
			
		||||
                    target_port=port.port,
 | 
			
		||||
                    target_port=port.inner_port or port.port,
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
        selector_labels = DeploymentReconciler(self.controller).get_pod_meta()
 | 
			
		||||
        return V1Service(
 | 
			
		||||
            metadata=meta,
 | 
			
		||||
            spec=V1ServiceSpec(ports=ports, selector=selector_labels, type="ClusterIP"),
 | 
			
		||||
            spec=V1ServiceSpec(
 | 
			
		||||
                ports=ports,
 | 
			
		||||
                selector=selector_labels,
 | 
			
		||||
                type=self.controller.outpost.config.kubernetes_service_type,
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def create(self, reference: V1Service):
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										11
									
								
								authentik/outposts/controllers/k8s/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								authentik/outposts/controllers/k8s/utils.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
			
		||||
"""k8s utils"""
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_namespace() -> str:
 | 
			
		||||
    """Get the namespace if we're running in a pod, otherwise default to default"""
 | 
			
		||||
    path = Path("/var/run/secrets/kubernetes.io/serviceaccount/namespace")
 | 
			
		||||
    if path.exists():
 | 
			
		||||
        with open(path, "r") as _namespace_file:
 | 
			
		||||
            return _namespace_file.read()
 | 
			
		||||
    return "default"
 | 
			
		||||
@ -2,14 +2,13 @@
 | 
			
		||||
from io import StringIO
 | 
			
		||||
from typing import Type
 | 
			
		||||
 | 
			
		||||
from kubernetes.client import OpenApiException
 | 
			
		||||
from kubernetes.client.api_client import ApiClient
 | 
			
		||||
from kubernetes.client.exceptions import ApiException
 | 
			
		||||
from structlog.testing import capture_logs
 | 
			
		||||
from urllib3.exceptions import HTTPError
 | 
			
		||||
from yaml import dump_all
 | 
			
		||||
 | 
			
		||||
from authentik.outposts.controllers.base import BaseController, ControllerException
 | 
			
		||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
 | 
			
		||||
from authentik.outposts.controllers.k8s.base import Disabled, KubernetesObjectReconciler
 | 
			
		||||
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
 | 
			
		||||
from authentik.outposts.controllers.k8s.secret import SecretReconciler
 | 
			
		||||
from authentik.outposts.controllers.k8s.service import ServiceReconciler
 | 
			
		||||
@ -43,8 +42,8 @@ class KubernetesController(BaseController):
 | 
			
		||||
                reconciler = self.reconcilers[reconcile_key](self)
 | 
			
		||||
                reconciler.up()
 | 
			
		||||
 | 
			
		||||
        except (OpenApiException, HTTPError) as exc:
 | 
			
		||||
            raise ControllerException from exc
 | 
			
		||||
        except ApiException as exc:
 | 
			
		||||
            raise ControllerException(str(exc)) from exc
 | 
			
		||||
 | 
			
		||||
    def up_with_logs(self) -> list[str]:
 | 
			
		||||
        try:
 | 
			
		||||
@ -55,23 +54,27 @@ class KubernetesController(BaseController):
 | 
			
		||||
                    reconciler.up()
 | 
			
		||||
                all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs]
 | 
			
		||||
            return all_logs
 | 
			
		||||
        except (OpenApiException, HTTPError) as exc:
 | 
			
		||||
            raise ControllerException from exc
 | 
			
		||||
        except ApiException as exc:
 | 
			
		||||
            raise ControllerException(str(exc)) from exc
 | 
			
		||||
 | 
			
		||||
    def down(self):
 | 
			
		||||
        try:
 | 
			
		||||
            for reconcile_key in self.reconcile_order:
 | 
			
		||||
                reconciler = self.reconcilers[reconcile_key](self)
 | 
			
		||||
                self.logger.debug("Tearing down object", name=reconcile_key)
 | 
			
		||||
                reconciler.down()
 | 
			
		||||
 | 
			
		||||
        except OpenApiException as exc:
 | 
			
		||||
            raise ControllerException from exc
 | 
			
		||||
        except ApiException as exc:
 | 
			
		||||
            raise ControllerException(str(exc)) from exc
 | 
			
		||||
 | 
			
		||||
    def get_static_deployment(self) -> str:
 | 
			
		||||
        documents = []
 | 
			
		||||
        for reconcile_key in self.reconcile_order:
 | 
			
		||||
            reconciler = self.reconcilers[reconcile_key](self)
 | 
			
		||||
            documents.append(reconciler.get_reference_object().to_dict())
 | 
			
		||||
            try:
 | 
			
		||||
                documents.append(reconciler.get_reference_object().to_dict())
 | 
			
		||||
            except Disabled:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
        with StringIO() as _str:
 | 
			
		||||
            dump_all(
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										20
									
								
								authentik/outposts/migrations/0016_alter_outpost_type.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								authentik/outposts/migrations/0016_alter_outpost_type.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
# Generated by Django 3.2 on 2021-04-26 09:27
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_outposts", "0015_auto_20201224_1206"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="outpost",
 | 
			
		||||
            name="type",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                choices=[("proxy", "Proxy"), ("ldap", "Ldap")], default="proxy"
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -5,6 +5,7 @@ from typing import Iterable, Optional, Union
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from dacite import from_dict
 | 
			
		||||
from django.contrib.auth.models import Permission
 | 
			
		||||
from django.core.cache import cache
 | 
			
		||||
from django.db import models, transaction
 | 
			
		||||
from django.db.models.base import Model
 | 
			
		||||
@ -31,6 +32,8 @@ from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
from authentik.lib.config import CONFIG
 | 
			
		||||
from authentik.lib.models import InheritanceForeignKey
 | 
			
		||||
from authentik.lib.sentry import SentryIgnoredException
 | 
			
		||||
from authentik.lib.utils.http import USER_ATTRIBUTE_CAN_OVERRIDE_IP
 | 
			
		||||
from authentik.outposts.controllers.k8s.utils import get_namespace
 | 
			
		||||
from authentik.outposts.docker_tls import DockerInlineTLS
 | 
			
		||||
 | 
			
		||||
OUR_VERSION = parse(__version__)
 | 
			
		||||
@ -55,16 +58,18 @@ class OutpostConfig:
 | 
			
		||||
        "error_reporting.environment", "customer"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    object_naming_template: str = field(default="ak-outpost-%(name)s")
 | 
			
		||||
    kubernetes_replicas: int = field(default=1)
 | 
			
		||||
    kubernetes_namespace: str = field(default="default")
 | 
			
		||||
    kubernetes_namespace: str = field(default_factory=get_namespace)
 | 
			
		||||
    kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict)
 | 
			
		||||
    kubernetes_ingress_secret_name: str = field(default="authentik-outpost")
 | 
			
		||||
    kubernetes_ingress_secret_name: str = field(default="authentik-outpost-tls")
 | 
			
		||||
    kubernetes_service_type: str = field(default="ClusterIP")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OutpostModel(Model):
 | 
			
		||||
    """Base model for providers that need more objects than just themselves"""
 | 
			
		||||
 | 
			
		||||
    def get_required_objects(self) -> Iterable[models.Model]:
 | 
			
		||||
    def get_required_objects(self) -> Iterable[Union[models.Model, str]]:
 | 
			
		||||
        """Return a list of all required objects"""
 | 
			
		||||
        return [self]
 | 
			
		||||
 | 
			
		||||
@ -77,6 +82,7 @@ class OutpostType(models.TextChoices):
 | 
			
		||||
    """Outpost types, currently only the reverse proxy is available"""
 | 
			
		||||
 | 
			
		||||
    PROXY = "proxy"
 | 
			
		||||
    LDAP = "ldap"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def default_outpost_config(host: Optional[str] = None):
 | 
			
		||||
@ -326,6 +332,7 @@ class Outpost(models.Model):
 | 
			
		||||
        if not users.exists():
 | 
			
		||||
            user: User = User.objects.create(username=self.user_identifier)
 | 
			
		||||
            user.attributes[USER_ATTRIBUTE_SA] = True
 | 
			
		||||
            user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True
 | 
			
		||||
            user.set_unusable_password()
 | 
			
		||||
            user.save()
 | 
			
		||||
        else:
 | 
			
		||||
@ -334,9 +341,29 @@ class Outpost(models.Model):
 | 
			
		||||
        # the ones the user needs
 | 
			
		||||
        with transaction.atomic():
 | 
			
		||||
            UserObjectPermission.objects.filter(user=user).delete()
 | 
			
		||||
            for model in self.get_required_objects():
 | 
			
		||||
                code_name = f"{model._meta.app_label}.view_{model._meta.model_name}"
 | 
			
		||||
                assign_perm(code_name, user, model)
 | 
			
		||||
            user.user_permissions.clear()
 | 
			
		||||
            for model_or_perm in self.get_required_objects():
 | 
			
		||||
                if isinstance(model_or_perm, models.Model):
 | 
			
		||||
                    model_or_perm: models.Model
 | 
			
		||||
                    code_name = (
 | 
			
		||||
                        f"{model_or_perm._meta.app_label}."
 | 
			
		||||
                        f"view_{model_or_perm._meta.model_name}"
 | 
			
		||||
                    )
 | 
			
		||||
                    assign_perm(code_name, user, model_or_perm)
 | 
			
		||||
                else:
 | 
			
		||||
                    app_label, perm = model_or_perm.split(".")
 | 
			
		||||
                    permission = Permission.objects.filter(
 | 
			
		||||
                        codename=perm,
 | 
			
		||||
                        content_type__app_label=app_label,
 | 
			
		||||
                    )
 | 
			
		||||
                    if not permission.exists():
 | 
			
		||||
                        LOGGER.warning("permission doesn't exist", perm=model_or_perm)
 | 
			
		||||
                        continue
 | 
			
		||||
                    user.user_permissions.add(permission.first())
 | 
			
		||||
        LOGGER.debug(
 | 
			
		||||
            "Updated service account's permissions",
 | 
			
		||||
            perms=UserObjectPermission.objects.filter(user=user),
 | 
			
		||||
        )
 | 
			
		||||
        return user
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
@ -359,9 +386,9 @@ class Outpost(models.Model):
 | 
			
		||||
            managed=f"goauthentik.io/outpost/{self.token_identifier}",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get_required_objects(self) -> Iterable[models.Model]:
 | 
			
		||||
    def get_required_objects(self) -> Iterable[Union[models.Model, str]]:
 | 
			
		||||
        """Get an iterator of all objects the user needs read access to"""
 | 
			
		||||
        objects = [self]
 | 
			
		||||
        objects: list[Union[models.Model, str]] = [self]
 | 
			
		||||
        for provider in (
 | 
			
		||||
            Provider.objects.filter(outpost=self).select_related().select_subclasses()
 | 
			
		||||
        ):
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,16 @@
 | 
			
		||||
"""authentik outpost signals"""
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db.models import Model
 | 
			
		||||
from django.db.models.signals import post_save, pre_delete
 | 
			
		||||
from django.db.models.signals import post_save, pre_delete, pre_save
 | 
			
		||||
from django.dispatch import receiver
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Provider
 | 
			
		||||
from authentik.crypto.models import CertificateKeyPair
 | 
			
		||||
from authentik.lib.utils.reflection import class_to_path
 | 
			
		||||
from authentik.outposts.controllers.base import ControllerException
 | 
			
		||||
from authentik.outposts.models import Outpost, OutpostServiceConnection
 | 
			
		||||
from authentik.outposts.tasks import outpost_post_save, outpost_pre_delete
 | 
			
		||||
from authentik.outposts.tasks import outpost_controller_down, outpost_post_save
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
UPDATE_TRIGGERING_MODELS = (
 | 
			
		||||
@ -20,6 +21,27 @@ 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"""
 | 
			
		||||
    old_instances = Outpost.objects.filter(pk=instance.pk)
 | 
			
		||||
    if not old_instances.exists():
 | 
			
		||||
        return
 | 
			
		||||
    old_instance = old_instances.first()
 | 
			
		||||
    dirty = False
 | 
			
		||||
    # Name changes the deployment name, need to recreate
 | 
			
		||||
    dirty += old_instance.name != instance.name
 | 
			
		||||
    # namespace requires re-create
 | 
			
		||||
    dirty += (
 | 
			
		||||
        old_instance.config.kubernetes_namespace != instance.config.kubernetes_namespace
 | 
			
		||||
    )
 | 
			
		||||
    if bool(dirty):
 | 
			
		||||
        LOGGER.info("Outpost needs re-deployment due to changes", instance=instance)
 | 
			
		||||
        outpost_controller_down_wrapper(old_instance)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(post_save)
 | 
			
		||||
# pylint: disable=unused-argument
 | 
			
		||||
def post_save_update(sender, instance: Model, **_):
 | 
			
		||||
@ -41,15 +63,23 @@ def post_save_update(sender, instance: Model, **_):
 | 
			
		||||
def pre_delete_cleanup(sender, instance: Outpost, **_):
 | 
			
		||||
    """Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
 | 
			
		||||
    instance.user.delete()
 | 
			
		||||
    # To ensure that deployment is cleaned up *consistently* we call the controller, and wait
 | 
			
		||||
    # for it to finish. We don't want to call it in this thread, as we don't have the Outpost
 | 
			
		||||
    # Service connection here
 | 
			
		||||
    outpost_controller_down_wrapper(instance)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def outpost_controller_down_wrapper(instance: Outpost):
 | 
			
		||||
    """To ensure that deployment is cleaned up *consistently* we call the controller, and wait
 | 
			
		||||
    for it to finish. We don't want to call it in this thread, as we don't have the Outpost
 | 
			
		||||
    Service connection here"""
 | 
			
		||||
    try:
 | 
			
		||||
        outpost_pre_delete.delay(instance.pk.hex).get()
 | 
			
		||||
    except RuntimeError:
 | 
			
		||||
        outpost_controller_down.delay(instance.pk.hex).get()
 | 
			
		||||
    except RuntimeError:  # pragma: no cover
 | 
			
		||||
        # In e2e/integration tests, this might run inside a thread/process and
 | 
			
		||||
        # trigger the celery `Never call result.get() within a task` detection
 | 
			
		||||
        if settings.TEST:
 | 
			
		||||
            pass
 | 
			
		||||
        else:
 | 
			
		||||
            raise
 | 
			
		||||
    except ControllerException as exc:
 | 
			
		||||
        LOGGER.warning(
 | 
			
		||||
            "failed to cleanup outpost deployment", exc=exc, instance=instance
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ from os import R_OK, access
 | 
			
		||||
from os.path import expanduser
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from socket import gethostname
 | 
			
		||||
from typing import Any
 | 
			
		||||
from typing import Any, Optional
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
 | 
			
		||||
import yaml
 | 
			
		||||
@ -19,7 +19,7 @@ from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
 | 
			
		||||
from authentik.lib.utils.reflection import path_to_class
 | 
			
		||||
from authentik.outposts.controllers.base import ControllerException
 | 
			
		||||
from authentik.outposts.controllers.base import BaseController, ControllerException
 | 
			
		||||
from authentik.outposts.models import (
 | 
			
		||||
    DockerServiceConnection,
 | 
			
		||||
    KubernetesServiceConnection,
 | 
			
		||||
@ -29,6 +29,8 @@ from authentik.outposts.models import (
 | 
			
		||||
    OutpostState,
 | 
			
		||||
    OutpostType,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.ldap.controllers.docker import LDAPDockerController
 | 
			
		||||
from authentik.providers.ldap.controllers.kubernetes import LDAPKubernetesController
 | 
			
		||||
from authentik.providers.proxy.controllers.docker import ProxyDockerController
 | 
			
		||||
from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
 | 
			
		||||
from authentik.root.celery import CELERY_APP
 | 
			
		||||
@ -36,6 +38,24 @@ from authentik.root.celery import CELERY_APP
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def controller_for_outpost(outpost: Outpost) -> Optional[BaseController]:
 | 
			
		||||
    """Get a controller for the outpost, when a service connection is defined"""
 | 
			
		||||
    if not outpost.service_connection:
 | 
			
		||||
        return None
 | 
			
		||||
    service_connection = outpost.service_connection
 | 
			
		||||
    if outpost.type == OutpostType.PROXY:
 | 
			
		||||
        if isinstance(service_connection, DockerServiceConnection):
 | 
			
		||||
            return ProxyDockerController(outpost, service_connection)
 | 
			
		||||
        if isinstance(service_connection, KubernetesServiceConnection):
 | 
			
		||||
            return ProxyKubernetesController(outpost, service_connection)
 | 
			
		||||
    if outpost.type == OutpostType.LDAP:
 | 
			
		||||
        if isinstance(service_connection, DockerServiceConnection):
 | 
			
		||||
            return LDAPDockerController(outpost, service_connection)
 | 
			
		||||
        if isinstance(service_connection, KubernetesServiceConnection):
 | 
			
		||||
            return LDAPKubernetesController(outpost, service_connection)
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@CELERY_APP.task()
 | 
			
		||||
def outpost_controller_all():
 | 
			
		||||
    """Launch Controller for all Outposts which support it"""
 | 
			
		||||
@ -76,16 +96,10 @@ def outpost_controller(self: MonitoredTask, outpost_pk: str):
 | 
			
		||||
    outpost: Outpost = Outpost.objects.get(pk=outpost_pk)
 | 
			
		||||
    self.set_uid(slugify(outpost.name))
 | 
			
		||||
    try:
 | 
			
		||||
        if not outpost.service_connection:
 | 
			
		||||
        controller = controller_for_outpost(outpost)
 | 
			
		||||
        if not controller:
 | 
			
		||||
            return
 | 
			
		||||
        if outpost.type == OutpostType.PROXY:
 | 
			
		||||
            service_connection = outpost.service_connection
 | 
			
		||||
            if isinstance(service_connection, DockerServiceConnection):
 | 
			
		||||
                logs = ProxyDockerController(outpost, service_connection).up_with_logs()
 | 
			
		||||
            if isinstance(service_connection, KubernetesServiceConnection):
 | 
			
		||||
                logs = ProxyKubernetesController(
 | 
			
		||||
                    outpost, service_connection
 | 
			
		||||
                ).up_with_logs()
 | 
			
		||||
        logs = controller.up_with_logs()
 | 
			
		||||
        LOGGER.debug("---------------Outpost Controller logs starting----------------")
 | 
			
		||||
        for log in logs:
 | 
			
		||||
            LOGGER.debug(log)
 | 
			
		||||
@ -97,15 +111,13 @@ def outpost_controller(self: MonitoredTask, outpost_pk: str):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@CELERY_APP.task()
 | 
			
		||||
def outpost_pre_delete(outpost_pk: str):
 | 
			
		||||
def outpost_controller_down(outpost_pk: str):
 | 
			
		||||
    """Delete outpost objects before deleting the DB Object"""
 | 
			
		||||
    outpost = Outpost.objects.get(pk=outpost_pk)
 | 
			
		||||
    if outpost.type == OutpostType.PROXY:
 | 
			
		||||
        service_connection = outpost.service_connection
 | 
			
		||||
        if isinstance(service_connection, DockerServiceConnection):
 | 
			
		||||
            ProxyDockerController(outpost, service_connection).down()
 | 
			
		||||
        if isinstance(service_connection, KubernetesServiceConnection):
 | 
			
		||||
            ProxyKubernetesController(outpost, service_connection).down()
 | 
			
		||||
    controller = controller_for_outpost(outpost)
 | 
			
		||||
    if not controller:
 | 
			
		||||
        return
 | 
			
		||||
    controller.down()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,84 @@
 | 
			
		||||
# Generated by Django 3.2 on 2021-05-02 17:06
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_policies_event_matcher", "0012_auto_20210323_1339"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="eventmatcherpolicy",
 | 
			
		||||
            name="app",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                choices=[
 | 
			
		||||
                    ("authentik.admin", "authentik Admin"),
 | 
			
		||||
                    ("authentik.api", "authentik API"),
 | 
			
		||||
                    ("authentik.events", "authentik Events"),
 | 
			
		||||
                    ("authentik.crypto", "authentik Crypto"),
 | 
			
		||||
                    ("authentik.flows", "authentik Flows"),
 | 
			
		||||
                    ("authentik.outposts", "authentik Outpost"),
 | 
			
		||||
                    ("authentik.lib", "authentik lib"),
 | 
			
		||||
                    ("authentik.policies", "authentik Policies"),
 | 
			
		||||
                    ("authentik.policies.dummy", "authentik Policies.Dummy"),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.policies.event_matcher",
 | 
			
		||||
                        "authentik Policies.Event Matcher",
 | 
			
		||||
                    ),
 | 
			
		||||
                    ("authentik.policies.expiry", "authentik Policies.Expiry"),
 | 
			
		||||
                    ("authentik.policies.expression", "authentik Policies.Expression"),
 | 
			
		||||
                    ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"),
 | 
			
		||||
                    ("authentik.policies.password", "authentik Policies.Password"),
 | 
			
		||||
                    ("authentik.policies.reputation", "authentik Policies.Reputation"),
 | 
			
		||||
                    ("authentik.providers.proxy", "authentik Providers.Proxy"),
 | 
			
		||||
                    ("authentik.providers.oauth2", "authentik Providers.OAuth2"),
 | 
			
		||||
                    ("authentik.providers.saml", "authentik Providers.SAML"),
 | 
			
		||||
                    ("authentik.recovery", "authentik Recovery"),
 | 
			
		||||
                    ("authentik.sources.ldap", "authentik Sources.LDAP"),
 | 
			
		||||
                    ("authentik.sources.oauth", "authentik Sources.OAuth"),
 | 
			
		||||
                    ("authentik.sources.plex", "authentik Sources.Plex"),
 | 
			
		||||
                    ("authentik.sources.saml", "authentik Sources.SAML"),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.stages.authenticator_static",
 | 
			
		||||
                        "authentik Stages.Authenticator.Static",
 | 
			
		||||
                    ),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.stages.authenticator_totp",
 | 
			
		||||
                        "authentik Stages.Authenticator.TOTP",
 | 
			
		||||
                    ),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.stages.authenticator_validate",
 | 
			
		||||
                        "authentik Stages.Authenticator.Validate",
 | 
			
		||||
                    ),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.stages.authenticator_webauthn",
 | 
			
		||||
                        "authentik Stages.Authenticator.WebAuthn",
 | 
			
		||||
                    ),
 | 
			
		||||
                    ("authentik.stages.captcha", "authentik Stages.Captcha"),
 | 
			
		||||
                    ("authentik.stages.consent", "authentik Stages.Consent"),
 | 
			
		||||
                    ("authentik.stages.deny", "authentik Stages.Deny"),
 | 
			
		||||
                    ("authentik.stages.dummy", "authentik Stages.Dummy"),
 | 
			
		||||
                    ("authentik.stages.email", "authentik Stages.Email"),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.stages.identification",
 | 
			
		||||
                        "authentik Stages.Identification",
 | 
			
		||||
                    ),
 | 
			
		||||
                    ("authentik.stages.invitation", "authentik Stages.User Invitation"),
 | 
			
		||||
                    ("authentik.stages.password", "authentik Stages.Password"),
 | 
			
		||||
                    ("authentik.stages.prompt", "authentik Stages.Prompt"),
 | 
			
		||||
                    ("authentik.stages.user_delete", "authentik Stages.User Delete"),
 | 
			
		||||
                    ("authentik.stages.user_login", "authentik Stages.User Login"),
 | 
			
		||||
                    ("authentik.stages.user_logout", "authentik Stages.User Logout"),
 | 
			
		||||
                    ("authentik.stages.user_write", "authentik Stages.User Write"),
 | 
			
		||||
                    ("authentik.core", "authentik Core"),
 | 
			
		||||
                    ("authentik.managed", "authentik Managed"),
 | 
			
		||||
                ],
 | 
			
		||||
                default="",
 | 
			
		||||
                help_text="Match events created by selected application. When left empty, all applications are matched.",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,85 @@
 | 
			
		||||
# Generated by Django 3.2.1 on 2021-05-05 17:17
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_policies_event_matcher", "0013_alter_eventmatcherpolicy_app"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="eventmatcherpolicy",
 | 
			
		||||
            name="app",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                choices=[
 | 
			
		||||
                    ("authentik.admin", "authentik Admin"),
 | 
			
		||||
                    ("authentik.api", "authentik API"),
 | 
			
		||||
                    ("authentik.events", "authentik Events"),
 | 
			
		||||
                    ("authentik.crypto", "authentik Crypto"),
 | 
			
		||||
                    ("authentik.flows", "authentik Flows"),
 | 
			
		||||
                    ("authentik.outposts", "authentik Outpost"),
 | 
			
		||||
                    ("authentik.lib", "authentik lib"),
 | 
			
		||||
                    ("authentik.policies", "authentik Policies"),
 | 
			
		||||
                    ("authentik.policies.dummy", "authentik Policies.Dummy"),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.policies.event_matcher",
 | 
			
		||||
                        "authentik Policies.Event Matcher",
 | 
			
		||||
                    ),
 | 
			
		||||
                    ("authentik.policies.expiry", "authentik Policies.Expiry"),
 | 
			
		||||
                    ("authentik.policies.expression", "authentik Policies.Expression"),
 | 
			
		||||
                    ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"),
 | 
			
		||||
                    ("authentik.policies.password", "authentik Policies.Password"),
 | 
			
		||||
                    ("authentik.policies.reputation", "authentik Policies.Reputation"),
 | 
			
		||||
                    ("authentik.providers.proxy", "authentik Providers.Proxy"),
 | 
			
		||||
                    ("authentik.providers.ldap", "authentik Providers.LDAP"),
 | 
			
		||||
                    ("authentik.providers.oauth2", "authentik Providers.OAuth2"),
 | 
			
		||||
                    ("authentik.providers.saml", "authentik Providers.SAML"),
 | 
			
		||||
                    ("authentik.recovery", "authentik Recovery"),
 | 
			
		||||
                    ("authentik.sources.ldap", "authentik Sources.LDAP"),
 | 
			
		||||
                    ("authentik.sources.oauth", "authentik Sources.OAuth"),
 | 
			
		||||
                    ("authentik.sources.plex", "authentik Sources.Plex"),
 | 
			
		||||
                    ("authentik.sources.saml", "authentik Sources.SAML"),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.stages.authenticator_static",
 | 
			
		||||
                        "authentik Stages.Authenticator.Static",
 | 
			
		||||
                    ),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.stages.authenticator_totp",
 | 
			
		||||
                        "authentik Stages.Authenticator.TOTP",
 | 
			
		||||
                    ),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.stages.authenticator_validate",
 | 
			
		||||
                        "authentik Stages.Authenticator.Validate",
 | 
			
		||||
                    ),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.stages.authenticator_webauthn",
 | 
			
		||||
                        "authentik Stages.Authenticator.WebAuthn",
 | 
			
		||||
                    ),
 | 
			
		||||
                    ("authentik.stages.captcha", "authentik Stages.Captcha"),
 | 
			
		||||
                    ("authentik.stages.consent", "authentik Stages.Consent"),
 | 
			
		||||
                    ("authentik.stages.deny", "authentik Stages.Deny"),
 | 
			
		||||
                    ("authentik.stages.dummy", "authentik Stages.Dummy"),
 | 
			
		||||
                    ("authentik.stages.email", "authentik Stages.Email"),
 | 
			
		||||
                    (
 | 
			
		||||
                        "authentik.stages.identification",
 | 
			
		||||
                        "authentik Stages.Identification",
 | 
			
		||||
                    ),
 | 
			
		||||
                    ("authentik.stages.invitation", "authentik Stages.User Invitation"),
 | 
			
		||||
                    ("authentik.stages.password", "authentik Stages.Password"),
 | 
			
		||||
                    ("authentik.stages.prompt", "authentik Stages.Prompt"),
 | 
			
		||||
                    ("authentik.stages.user_delete", "authentik Stages.User Delete"),
 | 
			
		||||
                    ("authentik.stages.user_login", "authentik Stages.User Login"),
 | 
			
		||||
                    ("authentik.stages.user_logout", "authentik Stages.User Logout"),
 | 
			
		||||
                    ("authentik.stages.user_write", "authentik Stages.User Write"),
 | 
			
		||||
                    ("authentik.core", "authentik Core"),
 | 
			
		||||
                    ("authentik.managed", "authentik Managed"),
 | 
			
		||||
                ],
 | 
			
		||||
                default="",
 | 
			
		||||
                help_text="Match events created by selected application. When left empty, all applications are matched.",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -4,7 +4,7 @@
 | 
			
		||||
{% load i18n %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
{% trans 'Permission denied - authentik' %}
 | 
			
		||||
{% trans 'Permission denied' %} - {{ config.authentik.branding.title }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block card_title %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								authentik/providers/ldap/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/providers/ldap/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										54
									
								
								authentik/providers/ldap/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								authentik/providers/ldap/api.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
			
		||||
"""LDAPProvider API Views"""
 | 
			
		||||
from rest_framework.fields import CharField
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.providers import ProviderSerializer
 | 
			
		||||
from authentik.providers.ldap.models import LDAPProvider
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LDAPProviderSerializer(ProviderSerializer):
 | 
			
		||||
    """LDAPProvider Serializer"""
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        model = LDAPProvider
 | 
			
		||||
        fields = ProviderSerializer.Meta.fields + [
 | 
			
		||||
            "base_dn",
 | 
			
		||||
            "search_group",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LDAPProviderViewSet(ModelViewSet):
 | 
			
		||||
    """LDAPProvider Viewset"""
 | 
			
		||||
 | 
			
		||||
    queryset = LDAPProvider.objects.all()
 | 
			
		||||
    serializer_class = LDAPProviderSerializer
 | 
			
		||||
    ordering = ["name"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LDAPOutpostConfigSerializer(ModelSerializer):
 | 
			
		||||
    """LDAPProvider Serializer"""
 | 
			
		||||
 | 
			
		||||
    application_slug = CharField(source="application.slug")
 | 
			
		||||
    bind_flow_slug = CharField(source="authorization_flow.slug")
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        model = LDAPProvider
 | 
			
		||||
        fields = [
 | 
			
		||||
            "pk",
 | 
			
		||||
            "name",
 | 
			
		||||
            "base_dn",
 | 
			
		||||
            "bind_flow_slug",
 | 
			
		||||
            "application_slug",
 | 
			
		||||
            "search_group",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LDAPOutpostConfigViewSet(ReadOnlyModelViewSet):
 | 
			
		||||
    """LDAPProvider Viewset"""
 | 
			
		||||
 | 
			
		||||
    queryset = LDAPProvider.objects.filter(application__isnull=False)
 | 
			
		||||
    serializer_class = LDAPOutpostConfigSerializer
 | 
			
		||||
    ordering = ["name"]
 | 
			
		||||
							
								
								
									
										10
									
								
								authentik/providers/ldap/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								authentik/providers/ldap/apps.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
"""authentik ldap provider app config"""
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AuthentikProviderLDAPConfig(AppConfig):
 | 
			
		||||
    """authentik ldap provider app config"""
 | 
			
		||||
 | 
			
		||||
    name = "authentik.providers.ldap"
 | 
			
		||||
    label = "authentik_providers_ldap"
 | 
			
		||||
    verbose_name = "authentik Providers.LDAP"
 | 
			
		||||
							
								
								
									
										0
									
								
								authentik/providers/ldap/controllers/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/providers/ldap/controllers/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										14
									
								
								authentik/providers/ldap/controllers/docker.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								authentik/providers/ldap/controllers/docker.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
"""LDAP Provider Docker Contoller"""
 | 
			
		||||
from authentik.outposts.controllers.base import DeploymentPort
 | 
			
		||||
from authentik.outposts.controllers.docker import DockerController
 | 
			
		||||
from authentik.outposts.models import DockerServiceConnection, Outpost
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LDAPDockerController(DockerController):
 | 
			
		||||
    """LDAP Provider Docker Contoller"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, outpost: Outpost, connection: DockerServiceConnection):
 | 
			
		||||
        super().__init__(outpost, connection)
 | 
			
		||||
        self.deployment_ports = [
 | 
			
		||||
            DeploymentPort(389, "ldap", "tcp", 3389),
 | 
			
		||||
        ]
 | 
			
		||||
							
								
								
									
										14
									
								
								authentik/providers/ldap/controllers/kubernetes.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								authentik/providers/ldap/controllers/kubernetes.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
"""LDAP Provider Kubernetes Contoller"""
 | 
			
		||||
from authentik.outposts.controllers.base import DeploymentPort
 | 
			
		||||
from authentik.outposts.controllers.kubernetes import KubernetesController
 | 
			
		||||
from authentik.outposts.models import KubernetesServiceConnection, Outpost
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LDAPKubernetesController(KubernetesController):
 | 
			
		||||
    """LDAP Provider Kubernetes Contoller"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection):
 | 
			
		||||
        super().__init__(outpost, connection)
 | 
			
		||||
        self.deployment_ports = [
 | 
			
		||||
            DeploymentPort(389, "ldap", "tcp", 3389),
 | 
			
		||||
        ]
 | 
			
		||||
							
								
								
									
										44
									
								
								authentik/providers/ldap/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								authentik/providers/ldap/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,44 @@
 | 
			
		||||
# Generated by Django 3.2 on 2021-04-26 12:45
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    initial = True
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_core", "0019_source_managed"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="LDAPProvider",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "provider_ptr",
 | 
			
		||||
                    models.OneToOneField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        parent_link=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        to="authentik_core.provider",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "base_dn",
 | 
			
		||||
                    models.TextField(
 | 
			
		||||
                        default="DC=ldap,DC=goauthentik,DC=io",
 | 
			
		||||
                        help_text="DN under which objects are accessible.",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                "verbose_name": "LDAP Provider",
 | 
			
		||||
                "verbose_name_plural": "LDAP Providers",
 | 
			
		||||
            },
 | 
			
		||||
            bases=("authentik_core.provider", models.Model),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -0,0 +1,26 @@
 | 
			
		||||
# Generated by Django 3.2 on 2021-04-26 19:57
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_core", "0019_source_managed"),
 | 
			
		||||
        ("authentik_providers_ldap", "0001_initial"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="ldapprovider",
 | 
			
		||||
            name="search_group",
 | 
			
		||||
            field=models.ForeignKey(
 | 
			
		||||
                default=None,
 | 
			
		||||
                help_text="Users in this group can do search queries. If not set, every user can execute search queries.",
 | 
			
		||||
                null=True,
 | 
			
		||||
                on_delete=django.db.models.deletion.SET_DEFAULT,
 | 
			
		||||
                to="authentik_core.group",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										0
									
								
								authentik/providers/ldap/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/providers/ldap/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										55
									
								
								authentik/providers/ldap/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								authentik/providers/ldap/models.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,55 @@
 | 
			
		||||
"""LDAP Provider"""
 | 
			
		||||
from typing import Iterable, Optional, Type, Union
 | 
			
		||||
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from rest_framework.serializers import Serializer
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Group, Provider
 | 
			
		||||
from authentik.outposts.models import OutpostModel
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LDAPProvider(OutpostModel, Provider):
 | 
			
		||||
    """Allow applications to authenticate against authentik's users using LDAP."""
 | 
			
		||||
 | 
			
		||||
    base_dn = models.TextField(
 | 
			
		||||
        default="DC=ldap,DC=goauthentik,DC=io",
 | 
			
		||||
        help_text=_("DN under which objects are accessible."),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    search_group = models.ForeignKey(
 | 
			
		||||
        Group,
 | 
			
		||||
        null=True,
 | 
			
		||||
        default=None,
 | 
			
		||||
        on_delete=models.SET_DEFAULT,
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "Users in this group can do search queries. "
 | 
			
		||||
            "If not set, every user can execute search queries."
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def launch_url(self) -> Optional[str]:
 | 
			
		||||
        """LDAP never has a launch URL"""
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def component(self) -> str:
 | 
			
		||||
        return "ak-provider-ldap-form"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> Type[Serializer]:
 | 
			
		||||
        from authentik.providers.ldap.api import LDAPProviderSerializer
 | 
			
		||||
 | 
			
		||||
        return LDAPProviderSerializer
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"LDAP Provider {self.name}"
 | 
			
		||||
 | 
			
		||||
    def get_required_objects(self) -> Iterable[Union[models.Model, str]]:
 | 
			
		||||
        return [self, "authentik_core.view_user", "authentik_core.view_group"]
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        verbose_name = _("LDAP Provider")
 | 
			
		||||
        verbose_name_plural = _("LDAP Providers")
 | 
			
		||||
@ -38,6 +38,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
 | 
			
		||||
            "client_type",
 | 
			
		||||
            "client_id",
 | 
			
		||||
            "client_secret",
 | 
			
		||||
            "access_code_validity",
 | 
			
		||||
            "token_validity",
 | 
			
		||||
            "include_claims_in_id_token",
 | 
			
		||||
            "jwt_alg",
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,9 @@
 | 
			
		||||
"""OAuth2Provider API Views"""
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from guardian.utils import get_anonymous_user
 | 
			
		||||
from rest_framework import mixins
 | 
			
		||||
from rest_framework.fields import CharField, ListField
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import GenericViewSet
 | 
			
		||||
 | 
			
		||||
@ -36,13 +39,17 @@ class AuthorizationCodeViewSet(
 | 
			
		||||
    serializer_class = ExpiringBaseGrantModelSerializer
 | 
			
		||||
    filterset_fields = ["user", "provider"]
 | 
			
		||||
    ordering = ["provider", "expires"]
 | 
			
		||||
    filter_backends = [
 | 
			
		||||
        DjangoFilterBackend,
 | 
			
		||||
        OrderingFilter,
 | 
			
		||||
        SearchFilter,
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        if not self.request:
 | 
			
		||||
        user = self.request.user if self.request else get_anonymous_user()
 | 
			
		||||
        if user.is_superuser:
 | 
			
		||||
            return super().get_queryset()
 | 
			
		||||
        if self.request.user.is_superuser:
 | 
			
		||||
            return super().get_queryset()
 | 
			
		||||
        return super().get_queryset().filter(user=self.request.user)
 | 
			
		||||
        return super().get_queryset().filter(user=user.pk)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class RefreshTokenViewSet(
 | 
			
		||||
@ -57,10 +64,14 @@ class RefreshTokenViewSet(
 | 
			
		||||
    serializer_class = ExpiringBaseGrantModelSerializer
 | 
			
		||||
    filterset_fields = ["user", "provider"]
 | 
			
		||||
    ordering = ["provider", "expires"]
 | 
			
		||||
    filter_backends = [
 | 
			
		||||
        DjangoFilterBackend,
 | 
			
		||||
        OrderingFilter,
 | 
			
		||||
        SearchFilter,
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        if not self.request:
 | 
			
		||||
        user = self.request.user if self.request else get_anonymous_user()
 | 
			
		||||
        if user.is_superuser:
 | 
			
		||||
            return super().get_queryset()
 | 
			
		||||
        if self.request.user.is_superuser:
 | 
			
		||||
            return super().get_queryset()
 | 
			
		||||
        return super().get_queryset().filter(user=self.request.user)
 | 
			
		||||
        return super().get_queryset().filter(user=user.pk)
 | 
			
		||||
 | 
			
		||||
@ -1,11 +1,11 @@
 | 
			
		||||
"""authentik auth oauth provider app config"""
 | 
			
		||||
"""authentik oauth provider app config"""
 | 
			
		||||
from importlib import import_module
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AuthentikProviderOAuth2Config(AppConfig):
 | 
			
		||||
    """authentik auth oauth provider app config"""
 | 
			
		||||
    """authentik oauth provider app config"""
 | 
			
		||||
 | 
			
		||||
    name = "authentik.providers.oauth2"
 | 
			
		||||
    label = "authentik_providers_oauth2"
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,24 @@
 | 
			
		||||
# Generated by Django 3.2 on 2021-04-28 18:17
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
import authentik.lib.utils.time
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_providers_oauth2", "0011_managed"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="oauth2provider",
 | 
			
		||||
            name="access_code_validity",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                default="minutes=1",
 | 
			
		||||
                help_text="Access codes not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
 | 
			
		||||
                validators=[authentik.lib.utils.time.timedelta_string_validator],
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -6,18 +6,18 @@ import time
 | 
			
		||||
from dataclasses import asdict, dataclass, field
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from hashlib import sha256
 | 
			
		||||
from typing import Any, Optional, Type
 | 
			
		||||
from typing import Any, Optional, Type, Union
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
 | 
			
		||||
from dacite import from_dict
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
from django.utils import dateformat, timezone
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key
 | 
			
		||||
from jwkest.jws import JWS
 | 
			
		||||
from jwt import encode
 | 
			
		||||
from rest_framework.serializers import Serializer
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
 | 
			
		||||
@ -175,6 +175,16 @@ class OAuth2Provider(Provider):
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    access_code_validity = models.TextField(
 | 
			
		||||
        default="minutes=1",
 | 
			
		||||
        validators=[timedelta_string_validator],
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            (
 | 
			
		||||
                "Access codes not valid on or after current time + this value "
 | 
			
		||||
                "(Format: hours=1;minutes=2;seconds=3)."
 | 
			
		||||
            )
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
    token_validity = models.TextField(
 | 
			
		||||
        default="minutes=10",
 | 
			
		||||
        validators=[timedelta_string_validator],
 | 
			
		||||
@ -229,7 +239,7 @@ class OAuth2Provider(Provider):
 | 
			
		||||
        token.access_token = token.create_access_token(user, request)
 | 
			
		||||
        return token
 | 
			
		||||
 | 
			
		||||
    def get_jwt_keys(self) -> list[Key]:
 | 
			
		||||
    def get_jwt_keys(self) -> Union[RSAPrivateKey, str]:
 | 
			
		||||
        """
 | 
			
		||||
        Takes a provider and returns the set of keys associated with it.
 | 
			
		||||
        Returns a list of keys.
 | 
			
		||||
@ -246,17 +256,10 @@ class OAuth2Provider(Provider):
 | 
			
		||||
                self.jwt_alg = JWTAlgorithms.HS256
 | 
			
		||||
                self.save()
 | 
			
		||||
            else:
 | 
			
		||||
                # Because the JWT Library uses python cryptodome,
 | 
			
		||||
                # we can't directly pass the RSAPublicKey
 | 
			
		||||
                # object, but have to load it ourselves
 | 
			
		||||
                key = import_rsa_key(self.rsa_key.key_data)
 | 
			
		||||
                keys = [RSAKey(key=key, kid=self.rsa_key.kid)]
 | 
			
		||||
                if not keys:
 | 
			
		||||
                    raise Exception("You must add at least one RSA Key.")
 | 
			
		||||
                return keys
 | 
			
		||||
                return self.rsa_key.private_key
 | 
			
		||||
 | 
			
		||||
        if self.jwt_alg == JWTAlgorithms.HS256:
 | 
			
		||||
            return [SYMKey(key=self.client_secret, alg=self.jwt_alg)]
 | 
			
		||||
            return self.client_secret
 | 
			
		||||
 | 
			
		||||
        raise Exception("Unsupported key algorithm.")
 | 
			
		||||
 | 
			
		||||
@ -297,11 +300,11 @@ class OAuth2Provider(Provider):
 | 
			
		||||
 | 
			
		||||
    def encode(self, payload: dict[str, Any]) -> str:
 | 
			
		||||
        """Represent the ID Token as a JSON Web Token (JWT)."""
 | 
			
		||||
        keys = self.get_jwt_keys()
 | 
			
		||||
        key = self.get_jwt_keys()
 | 
			
		||||
        # If the provider does not have an RSA Key assigned, it was switched to Symmetric
 | 
			
		||||
        self.refresh_from_db()
 | 
			
		||||
        jws = JWS(payload, alg=self.jwt_alg)
 | 
			
		||||
        return jws.sign_compact(keys)
 | 
			
		||||
        # pyright: reportGeneralTypeIssues=false
 | 
			
		||||
        return encode(payload, key, algorithm=self.jwt_alg)
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -14,7 +14,7 @@
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block title %}
 | 
			
		||||
{% trans 'End session' %}
 | 
			
		||||
{% trans 'End session' %} - {{ config.authentik.branding.title }}
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block card_title %}
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
"""Test authorize view"""
 | 
			
		||||
from django.test import RequestFactory, TestCase
 | 
			
		||||
from django.test import RequestFactory
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.encoding import force_str
 | 
			
		||||
 | 
			
		||||
@ -11,17 +11,21 @@ from authentik.providers.oauth2.errors import (
 | 
			
		||||
    ClientIdError,
 | 
			
		||||
    RedirectUriError,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.generators import generate_client_id
 | 
			
		||||
from authentik.providers.oauth2.generators import (
 | 
			
		||||
    generate_client_id,
 | 
			
		||||
    generate_client_secret,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    AuthorizationCode,
 | 
			
		||||
    GrantTypes,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RefreshToken,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestViewsAuthorize(TestCase):
 | 
			
		||||
class TestAuthorize(OAuthTestCase):
 | 
			
		||||
    """Test authorize view"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
@ -200,6 +204,7 @@ class TestViewsAuthorize(TestCase):
 | 
			
		||||
        provider = OAuth2Provider.objects.create(
 | 
			
		||||
            name="test",
 | 
			
		||||
            client_id="test",
 | 
			
		||||
            client_secret=generate_client_secret(),
 | 
			
		||||
            authorization_flow=flow,
 | 
			
		||||
            redirect_uris="http://localhost",
 | 
			
		||||
        )
 | 
			
		||||
@ -233,3 +238,4 @@ class TestViewsAuthorize(TestCase):
 | 
			
		||||
                ),
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.validate_jwt(token, provider)
 | 
			
		||||
@ -1,11 +1,11 @@
 | 
			
		||||
"""Test token view"""
 | 
			
		||||
from base64 import b64encode
 | 
			
		||||
 | 
			
		||||
from django.test import RequestFactory, TestCase
 | 
			
		||||
from django.test import RequestFactory
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
from django.utils.encoding import force_str
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.core.models import Application, User
 | 
			
		||||
from authentik.flows.models import Flow
 | 
			
		||||
from authentik.providers.oauth2.constants import (
 | 
			
		||||
    GRANT_TYPE_AUTHORIZATION_CODE,
 | 
			
		||||
@ -20,15 +20,17 @@ from authentik.providers.oauth2.models import (
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RefreshToken,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
 | 
			
		||||
from authentik.providers.oauth2.views.token import TokenParams
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestViewsToken(TestCase):
 | 
			
		||||
class TestToken(OAuthTestCase):
 | 
			
		||||
    """Test token view"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        super().setUp()
 | 
			
		||||
        self.factory = RequestFactory()
 | 
			
		||||
        self.app = Application.objects.create(name="test", slug="test")
 | 
			
		||||
 | 
			
		||||
    def test_request_auth_code(self):
 | 
			
		||||
        """test request param"""
 | 
			
		||||
@ -97,12 +99,15 @@ class TestViewsToken(TestCase):
 | 
			
		||||
            authorization_flow=Flow.objects.first(),
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
        )
 | 
			
		||||
        # Needs to be assigned to an application for iss to be set
 | 
			
		||||
        self.app.provider = provider
 | 
			
		||||
        self.app.save()
 | 
			
		||||
        header = b64encode(
 | 
			
		||||
            f"{provider.client_id}:{provider.client_secret}".encode()
 | 
			
		||||
        ).decode()
 | 
			
		||||
        user = User.objects.get(username="akadmin")
 | 
			
		||||
        code = AuthorizationCode.objects.create(
 | 
			
		||||
            code="foobar", provider=provider, user=user
 | 
			
		||||
            code="foobar", provider=provider, user=user, is_open_id=True
 | 
			
		||||
        )
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("authentik_providers_oauth2:token"),
 | 
			
		||||
@ -126,6 +131,7 @@ class TestViewsToken(TestCase):
 | 
			
		||||
                ),
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.validate_jwt(new_token, provider)
 | 
			
		||||
 | 
			
		||||
    def test_refresh_token_view(self):
 | 
			
		||||
        """test request param"""
 | 
			
		||||
@ -136,6 +142,9 @@ class TestViewsToken(TestCase):
 | 
			
		||||
            authorization_flow=Flow.objects.first(),
 | 
			
		||||
            redirect_uris="http://local.invalid",
 | 
			
		||||
        )
 | 
			
		||||
        # Needs to be assigned to an application for iss to be set
 | 
			
		||||
        self.app.provider = provider
 | 
			
		||||
        self.app.save()
 | 
			
		||||
        header = b64encode(
 | 
			
		||||
            f"{provider.client_id}:{provider.client_secret}".encode()
 | 
			
		||||
        ).decode()
 | 
			
		||||
@ -174,6 +183,7 @@ class TestViewsToken(TestCase):
 | 
			
		||||
                ),
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
        self.validate_jwt(new_token, provider)
 | 
			
		||||
 | 
			
		||||
    def test_refresh_token_view_invalid_origin(self):
 | 
			
		||||
        """test request param"""
 | 
			
		||||
							
								
								
									
										31
									
								
								authentik/providers/oauth2/tests/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								authentik/providers/oauth2/tests/utils.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,31 @@
 | 
			
		||||
"""OAuth test helpers"""
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from jwt import decode
 | 
			
		||||
 | 
			
		||||
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OAuthTestCase(TestCase):
 | 
			
		||||
    """OAuth test helpers"""
 | 
			
		||||
 | 
			
		||||
    required_jwt_keys = [
 | 
			
		||||
        "exp",
 | 
			
		||||
        "iat",
 | 
			
		||||
        "auth_time",
 | 
			
		||||
        "acr",
 | 
			
		||||
        "sub",
 | 
			
		||||
        "iss",
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider):
 | 
			
		||||
        """Validate that all required fields are set"""
 | 
			
		||||
        jwt = decode(
 | 
			
		||||
            token.access_token,
 | 
			
		||||
            provider.client_secret,
 | 
			
		||||
            algorithms=[provider.jwt_alg],
 | 
			
		||||
            audience=provider.client_id,
 | 
			
		||||
        )
 | 
			
		||||
        id_token = token.id_token.to_dict()
 | 
			
		||||
        for key in self.required_jwt_keys:
 | 
			
		||||
            self.assertIsNotNone(jwt[key], f"Key {key} is missing in access_token")
 | 
			
		||||
            self.assertIsNotNone(id_token[key], f"Key {key} is missing in id_token")
 | 
			
		||||
@ -219,7 +219,7 @@ class OAuthAuthorizationParams:
 | 
			
		||||
            code.code_challenge_method = self.code_challenge_method
 | 
			
		||||
 | 
			
		||||
        code.expires_at = timezone.now() + timedelta_from_string(
 | 
			
		||||
            self.provider.token_validity
 | 
			
		||||
            self.provider.access_code_validity
 | 
			
		||||
        )
 | 
			
		||||
        code.scope = self.scope
 | 
			
		||||
        code.nonce = self.nonce
 | 
			
		||||
@ -291,7 +291,7 @@ class OAuthFulfillmentStage(StageView):
 | 
			
		||||
                GrantTypes.HYBRID,
 | 
			
		||||
            ]:
 | 
			
		||||
                code = self.params.create_code(self.request)
 | 
			
		||||
                code.save()
 | 
			
		||||
                code.save(force_insert=True)
 | 
			
		||||
 | 
			
		||||
            if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
 | 
			
		||||
                query_params["code"] = code.code
 | 
			
		||||
 | 
			
		||||
@ -104,7 +104,6 @@ class TokenIntrospectionView(View):
 | 
			
		||||
    token: RefreshToken
 | 
			
		||||
    params: TokenIntrospectionParams
 | 
			
		||||
    provider: OAuth2Provider
 | 
			
		||||
    id_token: IDToken
 | 
			
		||||
 | 
			
		||||
    def post(self, request: HttpRequest) -> HttpResponse:
 | 
			
		||||
        """Introspection handler"""
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,23 @@
 | 
			
		||||
"""authentik OAuth2 JWKS Views"""
 | 
			
		||||
from base64 import urlsafe_b64encode
 | 
			
		||||
 | 
			
		||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
 | 
			
		||||
from django.http import HttpRequest, HttpResponse, JsonResponse
 | 
			
		||||
from django.shortcuts import get_object_or_404
 | 
			
		||||
from django.views import View
 | 
			
		||||
from jwkest import long_to_base64
 | 
			
		||||
from jwkest.jwk import import_rsa_key
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Application
 | 
			
		||||
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")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JWKSView(View):
 | 
			
		||||
    """Show RSA Key data for Provider"""
 | 
			
		||||
 | 
			
		||||
@ -22,15 +31,16 @@ class JWKSView(View):
 | 
			
		||||
        response_data = {}
 | 
			
		||||
 | 
			
		||||
        if provider.jwt_alg == JWTAlgorithms.RS256:
 | 
			
		||||
            public_key = import_rsa_key(provider.rsa_key.key_data).publickey()
 | 
			
		||||
            public_key: RSAPublicKey = provider.rsa_key.private_key.public_key()
 | 
			
		||||
            public_numbers = public_key.public_numbers()
 | 
			
		||||
            response_data["keys"] = [
 | 
			
		||||
                {
 | 
			
		||||
                    "kty": "RSA",
 | 
			
		||||
                    "alg": "RS256",
 | 
			
		||||
                    "use": "sig",
 | 
			
		||||
                    "kid": provider.rsa_key.kid,
 | 
			
		||||
                    "n": long_to_base64(public_key.n),
 | 
			
		||||
                    "e": long_to_base64(public_key.e),
 | 
			
		||||
                    "n": b64_enc(public_numbers.n),
 | 
			
		||||
                    "e": b64_enc(public_numbers.e),
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -16,6 +16,7 @@ from authentik.providers.oauth2.constants import (
 | 
			
		||||
from authentik.providers.oauth2.errors import TokenError, UserAuthError
 | 
			
		||||
from authentik.providers.oauth2.models import (
 | 
			
		||||
    AuthorizationCode,
 | 
			
		||||
    ClientTypes,
 | 
			
		||||
    OAuth2Provider,
 | 
			
		||||
    RefreshToken,
 | 
			
		||||
)
 | 
			
		||||
@ -75,7 +76,7 @@ class TokenParams:
 | 
			
		||||
            LOGGER.warning("OAuth2Provider does not exist", client_id=self.client_id)
 | 
			
		||||
            raise TokenError("invalid_client")
 | 
			
		||||
 | 
			
		||||
        if self.provider.client_type == "confidential":
 | 
			
		||||
        if self.provider.client_type == ClientTypes.CONFIDENTIAL:
 | 
			
		||||
            if self.provider.client_secret != self.client_secret:
 | 
			
		||||
                LOGGER.warning(
 | 
			
		||||
                    "Invalid client secret: client does not have secret",
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,8 @@
 | 
			
		||||
"""ProxyProvider API Views"""
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from drf_yasg.utils import swagger_serializer_method
 | 
			
		||||
from rest_framework.exceptions import ValidationError
 | 
			
		||||
from rest_framework.fields import CharField, ListField, SerializerMethodField
 | 
			
		||||
from rest_framework.serializers import ModelSerializer
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 | 
			
		||||
@ -30,6 +33,17 @@ class OpenIDConnectConfigurationSerializer(PassiveSerializer):
 | 
			
		||||
class ProxyProviderSerializer(ProviderSerializer):
 | 
			
		||||
    """ProxyProvider Serializer"""
 | 
			
		||||
 | 
			
		||||
    def validate(self, attrs) -> dict[Any, str]:
 | 
			
		||||
        """Check that internal_host is set when forward_auth_mode is disabled"""
 | 
			
		||||
        if (
 | 
			
		||||
            not attrs.get("forward_auth_mode", False)
 | 
			
		||||
            and attrs.get("internal_host", "") == ""
 | 
			
		||||
        ):
 | 
			
		||||
            raise ValidationError(
 | 
			
		||||
                "Internal host cannot be empty when forward auth is disabled."
 | 
			
		||||
            )
 | 
			
		||||
        return attrs
 | 
			
		||||
 | 
			
		||||
    def create(self, validated_data):
 | 
			
		||||
        instance: ProxyProvider = super().create(validated_data)
 | 
			
		||||
        instance.set_oauth_defaults()
 | 
			
		||||
@ -52,6 +66,7 @@ class ProxyProviderSerializer(ProviderSerializer):
 | 
			
		||||
            "basic_auth_enabled",
 | 
			
		||||
            "basic_auth_password_attribute",
 | 
			
		||||
            "basic_auth_user_attribute",
 | 
			
		||||
            "forward_auth_mode",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -86,6 +101,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
 | 
			
		||||
            "basic_auth_enabled",
 | 
			
		||||
            "basic_auth_password_attribute",
 | 
			
		||||
            "basic_auth_user_attribute",
 | 
			
		||||
            "forward_auth_mode",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    @swagger_serializer_method(serializer_or_field=OpenIDConnectConfigurationSerializer)
 | 
			
		||||
 | 
			
		||||
@ -17,6 +17,7 @@ from kubernetes.client.models.networking_v1beta1_ingress_rule import (
 | 
			
		||||
 | 
			
		||||
from authentik.outposts.controllers.base import FIELD_MANAGER
 | 
			
		||||
from authentik.outposts.controllers.k8s.base import (
 | 
			
		||||
    Disabled,
 | 
			
		||||
    KubernetesObjectReconciler,
 | 
			
		||||
    NeedsUpdate,
 | 
			
		||||
)
 | 
			
		||||
@ -33,10 +34,6 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]):
 | 
			
		||||
        super().__init__(controller)
 | 
			
		||||
        self.api = NetworkingV1beta1Api(controller.client)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
 | 
			
		||||
 | 
			
		||||
    def _check_annotations(self, reference: NetworkingV1beta1Ingress):
 | 
			
		||||
        """Check that all annotations *we* set are correct"""
 | 
			
		||||
        for key, value in self.get_ingress_annotations().items():
 | 
			
		||||
@ -54,7 +51,8 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]):
 | 
			
		||||
        expected_hosts = []
 | 
			
		||||
        expected_hosts_tls = []
 | 
			
		||||
        for proxy_provider in ProxyProvider.objects.filter(
 | 
			
		||||
            outpost__in=[self.controller.outpost]
 | 
			
		||||
            outpost__in=[self.controller.outpost],
 | 
			
		||||
            forward_auth_mode=False,
 | 
			
		||||
        ):
 | 
			
		||||
            proxy_provider: ProxyProvider
 | 
			
		||||
            external_host_name = urlparse(proxy_provider.external_host)
 | 
			
		||||
@ -102,27 +100,46 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]):
 | 
			
		||||
        rules = []
 | 
			
		||||
        tls_hosts = []
 | 
			
		||||
        for proxy_provider in ProxyProvider.objects.filter(
 | 
			
		||||
            outpost__in=[self.controller.outpost]
 | 
			
		||||
            outpost__in=[self.controller.outpost],
 | 
			
		||||
        ):
 | 
			
		||||
            proxy_provider: ProxyProvider
 | 
			
		||||
            external_host_name = urlparse(proxy_provider.external_host)
 | 
			
		||||
            if external_host_name.scheme == "https":
 | 
			
		||||
                tls_hosts.append(external_host_name.hostname)
 | 
			
		||||
            rule = NetworkingV1beta1IngressRule(
 | 
			
		||||
                host=external_host_name.hostname,
 | 
			
		||||
                http=NetworkingV1beta1HTTPIngressRuleValue(
 | 
			
		||||
                    paths=[
 | 
			
		||||
                        NetworkingV1beta1HTTPIngressPath(
 | 
			
		||||
                            backend=NetworkingV1beta1IngressBackend(
 | 
			
		||||
                                service_name=self.name,
 | 
			
		||||
                                service_port="http",
 | 
			
		||||
                            ),
 | 
			
		||||
                            path="/",
 | 
			
		||||
                        )
 | 
			
		||||
                    ]
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
            if proxy_provider.forward_auth_mode:
 | 
			
		||||
                rule = NetworkingV1beta1IngressRule(
 | 
			
		||||
                    host=external_host_name.hostname,
 | 
			
		||||
                    http=NetworkingV1beta1HTTPIngressRuleValue(
 | 
			
		||||
                        paths=[
 | 
			
		||||
                            NetworkingV1beta1HTTPIngressPath(
 | 
			
		||||
                                backend=NetworkingV1beta1IngressBackend(
 | 
			
		||||
                                    service_name=self.name,
 | 
			
		||||
                                    service_port="http",
 | 
			
		||||
                                ),
 | 
			
		||||
                                path="/akprox",
 | 
			
		||||
                            )
 | 
			
		||||
                        ]
 | 
			
		||||
                    ),
 | 
			
		||||
                )
 | 
			
		||||
            else:
 | 
			
		||||
                rule = NetworkingV1beta1IngressRule(
 | 
			
		||||
                    host=external_host_name.hostname,
 | 
			
		||||
                    http=NetworkingV1beta1HTTPIngressRuleValue(
 | 
			
		||||
                        paths=[
 | 
			
		||||
                            NetworkingV1beta1HTTPIngressPath(
 | 
			
		||||
                                backend=NetworkingV1beta1IngressBackend(
 | 
			
		||||
                                    service_name=self.name,
 | 
			
		||||
                                    service_port="http",
 | 
			
		||||
                                ),
 | 
			
		||||
                                path="/",
 | 
			
		||||
                            )
 | 
			
		||||
                        ]
 | 
			
		||||
                    ),
 | 
			
		||||
                )
 | 
			
		||||
            rules.append(rule)
 | 
			
		||||
        if not rules:
 | 
			
		||||
            self.logger.debug("No providers use proxying, no ingress needed")
 | 
			
		||||
            raise Disabled()
 | 
			
		||||
        tls_config = None
 | 
			
		||||
        if tls_hosts:
 | 
			
		||||
            tls_config = NetworkingV1beta1IngressTLS(
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										162
									
								
								authentik/providers/proxy/controllers/k8s/traefik.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								authentik/providers/proxy/controllers/k8s/traefik.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,162 @@
 | 
			
		||||
"""Kubernetes Traefik Middleware Reconciler"""
 | 
			
		||||
from dataclasses import asdict, dataclass, field
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
from dacite import from_dict
 | 
			
		||||
from kubernetes.client import ApiextensionsV1Api, CustomObjectsApi
 | 
			
		||||
 | 
			
		||||
from authentik.outposts.controllers.base import FIELD_MANAGER
 | 
			
		||||
from authentik.outposts.controllers.k8s.base import (
 | 
			
		||||
    Disabled,
 | 
			
		||||
    KubernetesObjectReconciler,
 | 
			
		||||
    NeedsUpdate,
 | 
			
		||||
)
 | 
			
		||||
from authentik.providers.proxy.models import ProxyProvider
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from authentik.outposts.controllers.kubernetes import KubernetesController
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class TraefikMiddlewareSpecForwardAuth:
 | 
			
		||||
    """traefik middleware forwardAuth spec"""
 | 
			
		||||
 | 
			
		||||
    address: str
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    authResponseHeaders: list[str]
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    trustForwardHeader: bool
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class TraefikMiddlewareSpec:
 | 
			
		||||
    """Traefik middleware spec"""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    forwardAuth: TraefikMiddlewareSpecForwardAuth
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class TraefikMiddlewareMetadata:
 | 
			
		||||
    """Traefik Middleware metadata"""
 | 
			
		||||
 | 
			
		||||
    name: str
 | 
			
		||||
    namespace: str
 | 
			
		||||
    labels: dict = field(default_factory=dict)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class TraefikMiddleware:
 | 
			
		||||
    """Traefik Middleware"""
 | 
			
		||||
 | 
			
		||||
    # pylint: disable=invalid-name
 | 
			
		||||
    apiVersion: str
 | 
			
		||||
    kind: str
 | 
			
		||||
    metadata: TraefikMiddlewareMetadata
 | 
			
		||||
    spec: TraefikMiddlewareSpec
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
CRD_NAME = "middlewares.traefik.containo.us"
 | 
			
		||||
CRD_GROUP = "traefik.containo.us"
 | 
			
		||||
CRD_VERSION = "v1alpha1"
 | 
			
		||||
CRD_PLURAL = "middlewares"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TraefikMiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware]):
 | 
			
		||||
    """Kubernetes Traefik Middleware Reconciler"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, controller: "KubernetesController") -> None:
 | 
			
		||||
        super().__init__(controller)
 | 
			
		||||
        self.api_ex = ApiextensionsV1Api(controller.client)
 | 
			
		||||
        self.api = CustomObjectsApi(controller.client)
 | 
			
		||||
 | 
			
		||||
    def _crd_exists(self) -> bool:
 | 
			
		||||
        """Check if the traefik middleware exists"""
 | 
			
		||||
        return bool(
 | 
			
		||||
            len(
 | 
			
		||||
                self.api_ex.list_custom_resource_definition(
 | 
			
		||||
                    field_selector=f"metadata.name={CRD_NAME}"
 | 
			
		||||
                ).items
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def reconcile(self, current: TraefikMiddleware, reference: TraefikMiddleware):
 | 
			
		||||
        super().reconcile(current, reference)
 | 
			
		||||
        if current.spec.forwardAuth.address != reference.spec.forwardAuth.address:
 | 
			
		||||
            raise NeedsUpdate()
 | 
			
		||||
 | 
			
		||||
    def get_reference_object(self) -> TraefikMiddleware:
 | 
			
		||||
        """Get deployment object for outpost"""
 | 
			
		||||
        if not ProxyProvider.objects.filter(
 | 
			
		||||
            outpost__in=[self.controller.outpost],
 | 
			
		||||
            forward_auth_mode=True,
 | 
			
		||||
        ).exists():
 | 
			
		||||
            self.logger.debug("No providers with forward auth enabled.")
 | 
			
		||||
            raise Disabled()
 | 
			
		||||
        if not self._crd_exists():
 | 
			
		||||
            self.logger.debug("CRD doesn't exist")
 | 
			
		||||
            raise Disabled()
 | 
			
		||||
        return TraefikMiddleware(
 | 
			
		||||
            apiVersion=f"{CRD_GROUP}/{CRD_VERSION}",
 | 
			
		||||
            kind="Middleware",
 | 
			
		||||
            metadata=TraefikMiddlewareMetadata(
 | 
			
		||||
                name=self.name,
 | 
			
		||||
                namespace=self.namespace,
 | 
			
		||||
                labels=self.get_object_meta().labels,
 | 
			
		||||
            ),
 | 
			
		||||
            spec=TraefikMiddlewareSpec(
 | 
			
		||||
                forwardAuth=TraefikMiddlewareSpecForwardAuth(
 | 
			
		||||
                    address=f"http://{self.name}.{self.namespace}:4180/akprox/auth?traefik",
 | 
			
		||||
                    authResponseHeaders=[
 | 
			
		||||
                        "Set-Cookie",
 | 
			
		||||
                        "X-Auth-Username",
 | 
			
		||||
                        "X-Forwarded-Email",
 | 
			
		||||
                        "X-Forwarded-Preferred-Username",
 | 
			
		||||
                        "X-Forwarded-User",
 | 
			
		||||
                    ],
 | 
			
		||||
                    trustForwardHeader=True,
 | 
			
		||||
                )
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def create(self, reference: TraefikMiddleware):
 | 
			
		||||
        return self.api.create_namespaced_custom_object(
 | 
			
		||||
            group=CRD_GROUP,
 | 
			
		||||
            version=CRD_VERSION,
 | 
			
		||||
            plural=CRD_PLURAL,
 | 
			
		||||
            namespace=self.namespace,
 | 
			
		||||
            body=asdict(reference),
 | 
			
		||||
            field_manager=FIELD_MANAGER,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def delete(self, reference: TraefikMiddleware):
 | 
			
		||||
        return self.api.delete_namespaced_custom_object(
 | 
			
		||||
            group=CRD_GROUP,
 | 
			
		||||
            version=CRD_VERSION,
 | 
			
		||||
            namespace=self.namespace,
 | 
			
		||||
            plural=CRD_PLURAL,
 | 
			
		||||
            name=self.name,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def retrieve(self) -> TraefikMiddleware:
 | 
			
		||||
        return from_dict(
 | 
			
		||||
            TraefikMiddleware,
 | 
			
		||||
            self.api.get_namespaced_custom_object(
 | 
			
		||||
                group=CRD_GROUP,
 | 
			
		||||
                version=CRD_VERSION,
 | 
			
		||||
                namespace=self.namespace,
 | 
			
		||||
                plural=CRD_PLURAL,
 | 
			
		||||
                name=self.name,
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def update(self, current: TraefikMiddleware, reference: TraefikMiddleware):
 | 
			
		||||
        return self.api.patch_namespaced_custom_object(
 | 
			
		||||
            group=CRD_GROUP,
 | 
			
		||||
            version=CRD_VERSION,
 | 
			
		||||
            namespace=self.namespace,
 | 
			
		||||
            plural=CRD_PLURAL,
 | 
			
		||||
            name=self.name,
 | 
			
		||||
            body=asdict(reference),
 | 
			
		||||
            field_manager=FIELD_MANAGER,
 | 
			
		||||
        )
 | 
			
		||||
@ -3,6 +3,9 @@ from authentik.outposts.controllers.base import DeploymentPort
 | 
			
		||||
from authentik.outposts.controllers.kubernetes import KubernetesController
 | 
			
		||||
from authentik.outposts.models import KubernetesServiceConnection, Outpost
 | 
			
		||||
from authentik.providers.proxy.controllers.k8s.ingress import IngressReconciler
 | 
			
		||||
from authentik.providers.proxy.controllers.k8s.traefik import (
 | 
			
		||||
    TraefikMiddlewareReconciler,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ProxyKubernetesController(KubernetesController):
 | 
			
		||||
@ -15,4 +18,6 @@ class ProxyKubernetesController(KubernetesController):
 | 
			
		||||
            DeploymentPort(4443, "https", "tcp"),
 | 
			
		||||
        ]
 | 
			
		||||
        self.reconcilers["ingress"] = IngressReconciler
 | 
			
		||||
        self.reconcilers["traefik middleware"] = TraefikMiddlewareReconciler
 | 
			
		||||
        self.reconcile_order.append("ingress")
 | 
			
		||||
        self.reconcile_order.append("traefik middleware")
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,35 @@
 | 
			
		||||
# Generated by Django 3.2 on 2021-04-27 18:47
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
import authentik.lib.models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_providers_proxy", "0010_auto_20201214_0942"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="proxyprovider",
 | 
			
		||||
            name="forward_auth_mode",
 | 
			
		||||
            field=models.BooleanField(
 | 
			
		||||
                default=False,
 | 
			
		||||
                help_text="Enable support for forwardAuth in traefik and nginx auth_request. Exclusive with internal_host.",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="proxyprovider",
 | 
			
		||||
            name="internal_host",
 | 
			
		||||
            field=models.TextField(
 | 
			
		||||
                blank=True,
 | 
			
		||||
                validators=[
 | 
			
		||||
                    authentik.lib.models.DomainlessURLValidator(
 | 
			
		||||
                        schemes=("http", "https")
 | 
			
		||||
                    )
 | 
			
		||||
                ],
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
"""authentik proxy models"""
 | 
			
		||||
import string
 | 
			
		||||
from random import SystemRandom
 | 
			
		||||
from typing import Iterable, Optional, Type
 | 
			
		||||
from typing import Iterable, Optional, Type, Union
 | 
			
		||||
from urllib.parse import urljoin
 | 
			
		||||
 | 
			
		||||
from django.db import models
 | 
			
		||||
@ -42,7 +42,8 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
 | 
			
		||||
    Protocols by using a Reverse-Proxy."""
 | 
			
		||||
 | 
			
		||||
    internal_host = models.TextField(
 | 
			
		||||
        validators=[DomainlessURLValidator(schemes=("http", "https"))]
 | 
			
		||||
        validators=[DomainlessURLValidator(schemes=("http", "https"))],
 | 
			
		||||
        blank=True,
 | 
			
		||||
    )
 | 
			
		||||
    external_host = models.TextField(
 | 
			
		||||
        validators=[DomainlessURLValidator(schemes=("http", "https"))]
 | 
			
		||||
@ -52,6 +53,13 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
 | 
			
		||||
        help_text=_("Validate SSL Certificates of upstream servers"),
 | 
			
		||||
        verbose_name=_("Internal host SSL Validation"),
 | 
			
		||||
    )
 | 
			
		||||
    forward_auth_mode = models.BooleanField(
 | 
			
		||||
        default=False,
 | 
			
		||||
        help_text=_(
 | 
			
		||||
            "Enable support for forwardAuth in traefik and nginx auth_request. Exclusive with "
 | 
			
		||||
            "internal_host."
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    skip_path_regex = models.TextField(
 | 
			
		||||
        default="",
 | 
			
		||||
@ -139,7 +147,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"Proxy Provider {self.name}"
 | 
			
		||||
 | 
			
		||||
    def get_required_objects(self) -> Iterable[models.Model]:
 | 
			
		||||
    def get_required_objects(self) -> Iterable[Union[models.Model, str]]:
 | 
			
		||||
        required_models = [self]
 | 
			
		||||
        if self.certificate is not None:
 | 
			
		||||
            required_models.append(self.certificate)
 | 
			
		||||
 | 
			
		||||
@ -102,11 +102,13 @@ INSTALLED_APPS = [
 | 
			
		||||
    "authentik.policies.password",
 | 
			
		||||
    "authentik.policies.reputation",
 | 
			
		||||
    "authentik.providers.proxy",
 | 
			
		||||
    "authentik.providers.ldap",
 | 
			
		||||
    "authentik.providers.oauth2",
 | 
			
		||||
    "authentik.providers.saml",
 | 
			
		||||
    "authentik.recovery",
 | 
			
		||||
    "authentik.sources.ldap",
 | 
			
		||||
    "authentik.sources.oauth",
 | 
			
		||||
    "authentik.sources.plex",
 | 
			
		||||
    "authentik.sources.saml",
 | 
			
		||||
    "authentik.stages.authenticator_static",
 | 
			
		||||
    "authentik.stages.authenticator_totp",
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,4 @@
 | 
			
		||||
"""authentik URL Configuration"""
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.conf.urls.static import static
 | 
			
		||||
from django.urls import include, path
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
@ -49,11 +47,3 @@ urlpatterns += [
 | 
			
		||||
    path("-/health/live/", LiveView.as_view(), name="health-live"),
 | 
			
		||||
    path("-/health/ready/", ReadyView.as_view(), name="health-ready"),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
if settings.DEBUG:  # pragma: no cover
 | 
			
		||||
 | 
			
		||||
    urlpatterns = (
 | 
			
		||||
        static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
 | 
			
		||||
        + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
 | 
			
		||||
        + urlpatterns
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@ -19,7 +19,7 @@ class BaseLDAPSynchronizer:
 | 
			
		||||
 | 
			
		||||
    def __init__(self, source: LDAPSource):
 | 
			
		||||
        self._source = source
 | 
			
		||||
        self._logger = get_logger().bind(source=source)
 | 
			
		||||
        self._logger = get_logger().bind(source=source, syncer=self.__class__.__name__)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def base_dn_users(self) -> str:
 | 
			
		||||
 | 
			
		||||
@ -75,6 +75,7 @@ class OAuthSourceSerializer(SourceSerializer):
 | 
			
		||||
            "callback_url",
 | 
			
		||||
            "type",
 | 
			
		||||
        ]
 | 
			
		||||
        extra_kwargs = {"consumer_secret": {"write_only": True}}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OAuthSourceViewSet(ModelViewSet):
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,7 @@
 | 
			
		||||
"""OAuth Source Serializer"""
 | 
			
		||||
from django_filters.rest_framework import DjangoFilterBackend
 | 
			
		||||
from guardian.utils import get_anonymous_user
 | 
			
		||||
from rest_framework.filters import OrderingFilter, SearchFilter
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.sources import SourceSerializer
 | 
			
		||||
@ -24,10 +27,14 @@ class UserOAuthSourceConnectionViewSet(ModelViewSet):
 | 
			
		||||
    queryset = UserOAuthSourceConnection.objects.all()
 | 
			
		||||
    serializer_class = UserOAuthSourceConnectionSerializer
 | 
			
		||||
    filterset_fields = ["source__slug"]
 | 
			
		||||
    filter_backends = [
 | 
			
		||||
        DjangoFilterBackend,
 | 
			
		||||
        OrderingFilter,
 | 
			
		||||
        SearchFilter,
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        if not self.request:
 | 
			
		||||
        user = self.request.user if self.request else get_anonymous_user()
 | 
			
		||||
        if user.is_superuser:
 | 
			
		||||
            return super().get_queryset()
 | 
			
		||||
        if self.request.user.is_superuser:
 | 
			
		||||
            return super().get_queryset()
 | 
			
		||||
        return super().get_queryset().filter(user=self.request.user)
 | 
			
		||||
        return super().get_queryset().filter(user=user.pk)
 | 
			
		||||
 | 
			
		||||
@ -2,11 +2,21 @@
 | 
			
		||||
from importlib import import_module
 | 
			
		||||
 | 
			
		||||
from django.apps import AppConfig
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
AUTHENTIK_SOURCES_OAUTH_TYPES = [
 | 
			
		||||
    "authentik.sources.oauth.types.discord",
 | 
			
		||||
    "authentik.sources.oauth.types.facebook",
 | 
			
		||||
    "authentik.sources.oauth.types.github",
 | 
			
		||||
    "authentik.sources.oauth.types.google",
 | 
			
		||||
    "authentik.sources.oauth.types.reddit",
 | 
			
		||||
    "authentik.sources.oauth.types.twitter",
 | 
			
		||||
    "authentik.sources.oauth.types.azure_ad",
 | 
			
		||||
    "authentik.sources.oauth.types.oidc",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AuthentikSourceOAuthConfig(AppConfig):
 | 
			
		||||
    """authentik source.oauth config"""
 | 
			
		||||
@ -18,7 +28,7 @@ class AuthentikSourceOAuthConfig(AppConfig):
 | 
			
		||||
 | 
			
		||||
    def ready(self):
 | 
			
		||||
        """Load source_types from config file"""
 | 
			
		||||
        for source_type in settings.AUTHENTIK_SOURCES_OAUTH_TYPES:
 | 
			
		||||
        for source_type in AUTHENTIK_SOURCES_OAUTH_TYPES:
 | 
			
		||||
            try:
 | 
			
		||||
                import_module(source_type)
 | 
			
		||||
                LOGGER.debug("Loaded OAuth Source Type", type=source_type)
 | 
			
		||||
 | 
			
		||||
@ -1,23 +0,0 @@
 | 
			
		||||
"""authentik oauth_client Authorization backend"""
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.backends import ModelBackend
 | 
			
		||||
from django.http import HttpRequest
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AuthorizedServiceBackend(ModelBackend):
 | 
			
		||||
    "Authentication backend for users registered with remote OAuth provider."
 | 
			
		||||
 | 
			
		||||
    def authenticate(
 | 
			
		||||
        self, request: HttpRequest, source: OAuthSource, identifier: str
 | 
			
		||||
    ) -> Optional[User]:
 | 
			
		||||
        "Fetch user for a given source by id."
 | 
			
		||||
        access = UserOAuthSourceConnection.objects.filter(
 | 
			
		||||
            source=source, identifier=identifier
 | 
			
		||||
        ).select_related("user")
 | 
			
		||||
        if not access.exists():
 | 
			
		||||
            return None
 | 
			
		||||
        return access.first().user
 | 
			
		||||
@ -9,6 +9,7 @@ from rest_framework.serializers import Serializer
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Source, UserSourceConnection
 | 
			
		||||
from authentik.core.types import UILoginButton, UserSettingSerializer
 | 
			
		||||
from authentik.flows.challenge import ChallengeTypes, RedirectChallenge
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from authentik.sources.oauth.types.manager import SourceType
 | 
			
		||||
@ -67,9 +68,14 @@ class OAuthSource(Source):
 | 
			
		||||
    @property
 | 
			
		||||
    def ui_login_button(self) -> UILoginButton:
 | 
			
		||||
        return UILoginButton(
 | 
			
		||||
            url=reverse(
 | 
			
		||||
                "authentik_sources_oauth:oauth-client-login",
 | 
			
		||||
                kwargs={"source_slug": self.slug},
 | 
			
		||||
            challenge=RedirectChallenge(
 | 
			
		||||
                instance={
 | 
			
		||||
                    "type": ChallengeTypes.REDIRECT.value,
 | 
			
		||||
                    "to": reverse(
 | 
			
		||||
                        "authentik_sources_oauth:oauth-client-login",
 | 
			
		||||
                        kwargs={"source_slug": self.slug},
 | 
			
		||||
                    ),
 | 
			
		||||
                }
 | 
			
		||||
            ),
 | 
			
		||||
            icon_url=static(f"authentik/sources/{self.provider_type}.svg"),
 | 
			
		||||
            name=self.name,
 | 
			
		||||
@ -163,16 +169,6 @@ class OpenIDOAuthSource(OAuthSource):
 | 
			
		||||
        verbose_name_plural = _("OpenID OAuth Sources")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PlexOAuthSource(OAuthSource):
 | 
			
		||||
    """Login using plex.tv."""
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 | 
			
		||||
        abstract = True
 | 
			
		||||
        verbose_name = _("Plex OAuth Source")
 | 
			
		||||
        verbose_name_plural = _("Plex OAuth Sources")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserOAuthSourceConnection(UserSourceConnection):
 | 
			
		||||
    """Authorized remote OAuth provider."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,13 +0,0 @@
 | 
			
		||||
"""Oauth2 Client Settings"""
 | 
			
		||||
 | 
			
		||||
AUTHENTIK_SOURCES_OAUTH_TYPES = [
 | 
			
		||||
    "authentik.sources.oauth.types.discord",
 | 
			
		||||
    "authentik.sources.oauth.types.facebook",
 | 
			
		||||
    "authentik.sources.oauth.types.github",
 | 
			
		||||
    "authentik.sources.oauth.types.google",
 | 
			
		||||
    "authentik.sources.oauth.types.reddit",
 | 
			
		||||
    "authentik.sources.oauth.types.twitter",
 | 
			
		||||
    "authentik.sources.oauth.types.azure_ad",
 | 
			
		||||
    "authentik.sources.oauth.types.oidc",
 | 
			
		||||
    "authentik.sources.oauth.types.plex",
 | 
			
		||||
]
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
"""Discord Type tests"""
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
 | 
			
		||||
from authentik.sources.oauth.models import OAuthSource
 | 
			
		||||
from authentik.sources.oauth.types.discord import DiscordOAuth2Callback
 | 
			
		||||
 | 
			
		||||
# https://discord.com/developers/docs/resources/user#user-object
 | 
			
		||||
@ -18,7 +18,7 @@ DISCORD_USER = {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestTypeGitHub(TestCase):
 | 
			
		||||
class TestTypeDiscord(TestCase):
 | 
			
		||||
    """OAuth Source tests"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
@ -32,10 +32,8 @@ class TestTypeGitHub(TestCase):
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_enroll_context(self):
 | 
			
		||||
        """Test GitHub Enrollment context"""
 | 
			
		||||
        ak_context = DiscordOAuth2Callback().get_user_enroll_context(
 | 
			
		||||
            self.source, UserOAuthSourceConnection(), DISCORD_USER
 | 
			
		||||
        )
 | 
			
		||||
        """Test discord Enrollment context"""
 | 
			
		||||
        ak_context = DiscordOAuth2Callback().get_user_enroll_context(DISCORD_USER)
 | 
			
		||||
        self.assertEqual(ak_context["username"], DISCORD_USER["username"])
 | 
			
		||||
        self.assertEqual(ak_context["email"], DISCORD_USER["email"])
 | 
			
		||||
        self.assertEqual(ak_context["name"], DISCORD_USER["username"])
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user