Compare commits
	
		
			141 Commits
		
	
	
		
			monorepo-v
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| b34665fabd | |||
| 0e07414e97 | |||
| dcbf5f323c | |||
| c3f1d6587d | |||
| 7254c11cb9 | |||
| ca4e6a10f5 | |||
| bda30c5ad5 | |||
| 588a7ff2e1 | |||
| 599d0f701f | |||
| 967e4cce9d | |||
| f1c5f43419 | |||
| b5b68fc829 | |||
| 1d7be5e770 | |||
| 489ef7a0a1 | |||
| 668f35cd5b | |||
| 42f0528a1d | |||
| ae47624761 | |||
| 14a6430e21 | |||
| ed0a9d6a0a | |||
| 53143e0c40 | |||
| 178e010ed4 | |||
| 49b666fbde | |||
| c343e3a7f4 | |||
| 5febf3ce5b | |||
| b8c5bd678b | |||
| 4dd5eccbaa | |||
| 2410884006 | |||
| 3cb921b0f9 | |||
| 535f92981f | |||
| 955d69d5b7 | |||
| fb01d8e96a | |||
| 6d39efd3e3 | |||
| 3020c31bcd | |||
| 22412729e2 | |||
| a02868a27d | |||
| bfbb4a8ebc | |||
| 6c0e827677 | |||
| 29884cbf81 | |||
| 0f02985b0c | |||
| 2244e026c2 | |||
| 429c03021c | |||
| f47e8d9d72 | |||
| 3e7d2587c4 | |||
| 55a38d4a36 | |||
| 6021bb932d | |||
| 54a5d95717 | |||
| a0a1275452 | |||
| 919aa5df59 | |||
| cedf7cf683 | |||
| cbc5a1c39d | |||
| 5f6b69c998 | |||
| cf065db3d5 | |||
| 86c65325ce | |||
| 2b8e10e979 | |||
| 9298807275 | |||
| ed56d6ac50 | |||
| 8c07b385ad | |||
| 880db7a86c | |||
| 99c1250ba5 | |||
| 5ce126ac83 | |||
| dfa21d0725 | |||
| e7e4af3894 | |||
| 931d6ec579 | |||
| ff45acb25c | |||
| c96557ff2d | |||
| 734feac4ae | |||
| b17a9ed145 | |||
| 2bef7695db | |||
| df472dd842 | |||
| 98d201d34c | |||
| 47e89602ab | |||
| ceb0851452 | |||
| cac2593658 | |||
| 1c9705bfaa | |||
| 9e2566cec4 | |||
| 5bdef1c4f6 | |||
| ae41ccd862 | |||
| 337956672f | |||
| cf160f800d | |||
| e9822cd937 | |||
| 5244f64be4 | |||
| 0df4824fd4 | |||
| ea22abc75d | |||
| b09bab7543 | |||
| 5aedc8a5f2 | |||
| 2f3ae0f607 | |||
| e3674426b7 | |||
| df915d3a5e | |||
| 4949c31860 | |||
| 4580dec06b | |||
| 56de969640 | |||
| 413902508d | |||
| 64af0ccba6 | |||
| 673db53777 | |||
| 8df7716d90 | |||
| 19bb2de13f | |||
| a218fd7628 | |||
| 78cfb50a90 | |||
| 2033d52dc2 | |||
| be00f47ddc | |||
| 2cc5f4b273 | |||
| 4e8f3407a4 | |||
| 7f861cc2a1 | |||
| 7bf58d0ba2 | |||
| fffcb00f39 | |||
| 77ee868573 | |||
| 6aaec08496 | |||
| cc15584650 | |||
| e55e446b89 | |||
| 76088e48b5 | |||
| 4165a0a6b2 | |||
| 647fefe5ce | |||
| 723dccdae3 | |||
| c82f747e5e | |||
| 43406e2464 | |||
| a0ff0bef85 | |||
| bedf548a5f | |||
| 976e81c1dd | |||
| ad733033d7 | |||
| ba686f6a93 | |||
| dc50be1e13 | |||
| 205686d252 | |||
| 6d589013e6 | |||
| 2d6433ca9a | |||
| b5f07acb26 | |||
| ea8702077c | |||
| 6593357115 | |||
| 6daed865c1 | |||
| c48a21707a | |||
| e857770c0a | |||
| add74c8799 | |||
| 12d854035d | |||
| 57dd4ae91d | |||
| 37fbc98177 | |||
| 14f216eb40 | |||
| 1209dd022e | |||
| c96f13ac66 | |||
| 5e6874cc1f | |||
| fb5053ec83 | |||
| 6f7dc2c543 | |||
| 542b69b224 | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 2025.2.4 | ||||
| current_version = 2025.4.3 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | ||||
|  | ||||
| @ -10,9 +10,6 @@ insert_final_newline = true | ||||
| [*.html] | ||||
| indent_size = 2 | ||||
|  | ||||
| [schemas/*.json] | ||||
| indent_size = 2 | ||||
|  | ||||
| [*.{yaml,yml}] | ||||
| indent_size = 2 | ||||
|  | ||||
|  | ||||
							
								
								
									
										8
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							| @ -28,15 +28,15 @@ runs: | ||||
|     - name: Setup node | ||||
|       uses: actions/setup-node@v4 | ||||
|       with: | ||||
|         node-version-file: package.json | ||||
|         node-version-file: web/package.json | ||||
|         cache: "npm" | ||||
|         cache-dependency-path: package-lock.json | ||||
|         cache-dependency-path: web/package-lock.json | ||||
|     - name: Setup go | ||||
|       uses: actions/setup-go@v5 | ||||
|       with: | ||||
|         go-version-file: "go.mod" | ||||
|     - name: Setup docker cache | ||||
|       uses: ScribeMD/docker-cache@0.5.0 | ||||
|       uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7 | ||||
|       with: | ||||
|         key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }} | ||||
|     - name: Setup dependencies | ||||
| @ -44,7 +44,7 @@ runs: | ||||
|       run: | | ||||
|         export PSQL_TAG=${{ inputs.postgresql_version }} | ||||
|         docker compose -f .github/actions/setup/docker-compose.yml up -d | ||||
|         npm ci | ||||
|         cd web && npm ci | ||||
|     - name: Generate config | ||||
|       shell: uv run python {0} | ||||
|       run: | | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| # Re-usable workflow for a single-architecture build | ||||
| name: "Single-arch Container build" | ||||
| name: Single-arch Container build | ||||
|  | ||||
| on: | ||||
|   workflow_call: | ||||
| @ -42,7 +42,7 @@ jobs: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: docker/setup-qemu-action@v3.6.0 | ||||
|       - uses: docker/setup-buildx-action@v3 | ||||
|       - name: Prepare variables | ||||
|       - name: prepare variables | ||||
|         uses: ./.github/actions/docker-push-variables | ||||
|         id: ev | ||||
|         env: | ||||
| @ -64,12 +64,12 @@ jobs: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.repository_owner }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: Make empty clients | ||||
|       - name: make empty clients | ||||
|         if: ${{ inputs.release }} | ||||
|         run: | | ||||
|           mkdir -p ./gen-ts-api | ||||
|           mkdir -p ./gen-go-api | ||||
|       - name: Generate TypeScript API Client | ||||
|       - name: generate ts client | ||||
|         if: ${{ !inputs.release }} | ||||
|         run: make gen-client-ts | ||||
|       - name: Build Docker Image | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| # Re-usable workflow for a multi-architecture build | ||||
| name: "Multi-arch container build" | ||||
| name: Multi-arch container build | ||||
|  | ||||
| on: | ||||
|   workflow_call: | ||||
| @ -49,7 +49,7 @@ jobs: | ||||
|       shouldPush: ${{ steps.ev.outputs.shouldPush }} | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - name: Prepare variables | ||||
|       - name: prepare variables | ||||
|         uses: ./.github/actions/docker-push-variables | ||||
|         id: ev | ||||
|         env: | ||||
| @ -69,7 +69,7 @@ jobs: | ||||
|         tag: ${{ fromJson(needs.get-tags.outputs.tags) }} | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - name: Prepare variables | ||||
|       - name: prepare variables | ||||
|         uses: ./.github/actions/docker-push-variables | ||||
|         id: ev | ||||
|         env: | ||||
|  | ||||
							
								
								
									
										6
									
								
								.github/workflows/api-py-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/api-py-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,4 @@ | ||||
| name: "Python API Publish" | ||||
|  | ||||
| name: authentik-api-py-publish | ||||
| on: | ||||
|   push: | ||||
|     branches: [main] | ||||
| @ -8,7 +7,6 @@ on: | ||||
|   workflow_dispatch: | ||||
| jobs: | ||||
|   build: | ||||
|     name: "Build and Publish" | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
| @ -32,7 +30,7 @@ jobs: | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version-file: "pyproject.toml" | ||||
|       - name: Generate Python API Client | ||||
|       - name: Generate API Client | ||||
|         run: make gen-client-py | ||||
|       - name: Publish package | ||||
|         working-directory: gen-py-api/ | ||||
|  | ||||
							
								
								
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| name: "TypeScript API Publish" | ||||
| name: authentik-api-ts-publish | ||||
| on: | ||||
|   push: | ||||
|     branches: [main] | ||||
| @ -7,7 +7,6 @@ on: | ||||
|   workflow_dispatch: | ||||
| jobs: | ||||
|   build: | ||||
|     name: "Build and Publish" | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
| @ -21,9 +20,9 @@ jobs: | ||||
|           token: ${{ steps.generate_token.outputs.token }} | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: package.json | ||||
|           node-version-file: web/package.json | ||||
|           registry-url: "https://registry.npmjs.org" | ||||
|       - name: Generate TypeScript API Client | ||||
|       - name: Generate API Client | ||||
|         run: make gen-client-ts | ||||
|       - name: Publish package | ||||
|         working-directory: gen-ts-api/ | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/ci-aws-cfn.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ci-aws-cfn.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| name: "authentik CI AWS CloudFormation" | ||||
| name: authentik-ci-aws-cfn | ||||
|  | ||||
| on: | ||||
|   push: | ||||
| @ -18,7 +18,6 @@ env: | ||||
|  | ||||
| jobs: | ||||
|   check-changes-applied: | ||||
|     name: "Check changes applied" | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
| @ -37,7 +36,6 @@ jobs: | ||||
|           uv run make aws-cfn | ||||
|           git diff --exit-code | ||||
|   ci-aws-cfn-mark: | ||||
|     name: "CI AWS CloudFormation Mark" | ||||
|     if: always() | ||||
|     needs: | ||||
|       - check-changes-applied | ||||
|  | ||||
							
								
								
									
										3
									
								
								.github/workflows/ci-main-daily.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/ci-main-daily.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,5 @@ | ||||
| --- | ||||
| name: "authentik CI Main Daily" | ||||
| name: authentik-ci-main-daily | ||||
|  | ||||
| on: | ||||
|   workflow_dispatch: | ||||
| @ -9,7 +9,6 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   test-container: | ||||
|     name: "Test Container ${{ matrix.version }}" | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|  | ||||
							
								
								
									
										74
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										74
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,5 @@ | ||||
| --- | ||||
| name: "authentik CI Main" | ||||
| name: authentik-ci-main | ||||
|  | ||||
| on: | ||||
|   push: | ||||
| @ -19,7 +19,6 @@ env: | ||||
|  | ||||
| jobs: | ||||
|   lint: | ||||
|     name: "Lint" | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
| @ -34,10 +33,9 @@ jobs: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - name: Run job ${{ matrix.job }} | ||||
|       - name: run job | ||||
|         run: uv run make ci-${{ matrix.job }} | ||||
|   test-migrations: | ||||
|     name: "Test Migrations" | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
| @ -46,7 +44,6 @@ jobs: | ||||
|       - name: run migrations | ||||
|         run: uv run python -m lifecycle.migrate | ||||
|   test-make-seed: | ||||
|     name: "Test Make Seed" | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - id: seed | ||||
| @ -55,7 +52,7 @@ jobs: | ||||
|     outputs: | ||||
|       seed: ${{ steps.seed.outputs.seed }} | ||||
|   test-migrations-from-stable: | ||||
|     name: "Test Migrations From Stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5" | ||||
|     name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5 | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 20 | ||||
|     needs: test-make-seed | ||||
| @ -70,26 +67,22 @@ jobs: | ||||
|       - uses: actions/checkout@v4 | ||||
|         with: | ||||
|           fetch-depth: 0 | ||||
|       - name: Checkout Stable | ||||
|       - name: checkout stable | ||||
|         run: | | ||||
|           # Copy current, latest config to local | ||||
|           # Temporarly comment the .github backup while migrating to uv | ||||
|           cp authentik/lib/default.yml local.env.yml | ||||
|           # cp -R .github .. | ||||
|           cp -R .github .. | ||||
|           cp -R scripts .. | ||||
|           git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1) | ||||
|           # rm -rf .github/ scripts/ | ||||
|           # mv ../.github ../scripts . | ||||
|           rm -rf scripts/ | ||||
|           mv ../scripts . | ||||
|           rm -rf .github/ scripts/ | ||||
|           mv ../.github ../scripts . | ||||
|       - name: Setup authentik env (stable) | ||||
|         uses: ./.github/actions/setup | ||||
|         with: | ||||
|           postgresql_version: ${{ matrix.psql }} | ||||
|         continue-on-error: true | ||||
|       - name: Run migrations to stable | ||||
|         run: poetry run python -m lifecycle.migrate | ||||
|       - name: Checkout current code | ||||
|       - name: run migrations to stable | ||||
|         run: uv run python -m lifecycle.migrate | ||||
|       - name: checkout current code | ||||
|         run: | | ||||
|           set -x | ||||
|           git fetch | ||||
| @ -100,10 +93,10 @@ jobs: | ||||
|         uses: ./.github/actions/setup | ||||
|         with: | ||||
|           postgresql_version: ${{ matrix.psql }} | ||||
|       - name: Migrate to latest | ||||
|       - name: migrate to latest | ||||
|         run: | | ||||
|           uv run python -m lifecycle.migrate | ||||
|       - name: Run tests | ||||
|       - name: run tests | ||||
|         env: | ||||
|           # Test in the main database that we just migrated from the previous stable version | ||||
|           AUTHENTIK_POSTGRESQL__TEST__NAME: authentik | ||||
| @ -113,7 +106,7 @@ jobs: | ||||
|         run: | | ||||
|           uv run make ci-test | ||||
|   test-unittest: | ||||
|     name: "Unit tests - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5" | ||||
|     name: test-unittest - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5 | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 20 | ||||
|     needs: test-make-seed | ||||
| @ -149,7 +142,6 @@ jobs: | ||||
|           file: unittest.xml | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|   test-integration: | ||||
|     name: "Integration tests" | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 30 | ||||
|     steps: | ||||
| @ -158,7 +150,7 @@ jobs: | ||||
|         uses: ./.github/actions/setup | ||||
|       - name: Create k8s Kind Cluster | ||||
|         uses: helm/kind-action@v1.12.0 | ||||
|       - name: Run integration | ||||
|       - name: run integration | ||||
|         run: | | ||||
|           uv run coverage run manage.py test tests/integration | ||||
|           uv run coverage xml | ||||
| @ -174,50 +166,49 @@ jobs: | ||||
|           file: unittest.xml | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|   test-e2e: | ||||
|     name: "Test E2E (${{ matrix.job.name }})" | ||||
|     name: test-e2e (${{ matrix.job.name }}) | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 30 | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         job: | ||||
|           - name: Proxy Provider | ||||
|           - name: proxy | ||||
|             glob: tests/e2e/test_provider_proxy* | ||||
|           - name: OAuth2 Provider | ||||
|           - name: oauth | ||||
|             glob: tests/e2e/test_provider_oauth2* tests/e2e/test_source_oauth* | ||||
|           - name: OIDC Provider | ||||
|           - name: oauth-oidc | ||||
|             glob: tests/e2e/test_provider_oidc* | ||||
|           - name: SAML Provider | ||||
|           - name: saml | ||||
|             glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml* | ||||
|           - name: LDAP Provider | ||||
|           - name: ldap | ||||
|             glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap* | ||||
|           - name: RADIUS Provider | ||||
|           - name: radius | ||||
|             glob: tests/e2e/test_provider_radius* | ||||
|           - name: SCIM Source | ||||
|           - name: scim | ||||
|             glob: tests/e2e/test_source_scim* | ||||
|           - name: Flows | ||||
|           - name: flows | ||||
|             glob: tests/e2e/test_flows* | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - name: Setup E2E env (chrome, etc) | ||||
|       - name: Setup e2e env (chrome, etc) | ||||
|         run: | | ||||
|           docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull | ||||
|       - id: cache-web | ||||
|         uses: actions/cache@v4 | ||||
|         with: | ||||
|           path: web/dist | ||||
|           key: ${{ runner.os }}-web-${{ hashFiles('./package-lock.json', 'web/src/**') }} | ||||
|       - name: Prepare Web UI | ||||
|           key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }} | ||||
|       - name: prepare web ui | ||||
|         if: steps.cache-web.outputs.cache-hit != 'true' | ||||
|         working-directory: web | ||||
|         run: | | ||||
|           npm ci | ||||
|           make gen-client-ts | ||||
|           npm run build -w @goauthentik/web | ||||
|  | ||||
|           npm run typecheck | ||||
|       - name: Run E2E tests | ||||
|           make -C .. gen-client-ts | ||||
|           npm run build | ||||
|       - name: run e2e | ||||
|         run: | | ||||
|           uv run coverage run manage.py test ${{ matrix.job.glob }} | ||||
|           uv run coverage xml | ||||
| @ -233,7 +224,6 @@ jobs: | ||||
|           file: unittest.xml | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|   ci-core-mark: | ||||
|     name: "CI Core Mark" | ||||
|     if: always() | ||||
|     needs: | ||||
|       - lint | ||||
| @ -248,7 +238,6 @@ jobs: | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|   build: | ||||
|     name: "Build" | ||||
|     permissions: | ||||
|       # Needed to upload container images to ghcr.io | ||||
|       packages: write | ||||
| @ -262,7 +251,6 @@ jobs: | ||||
|       image_name: ghcr.io/goauthentik/dev-server | ||||
|       release: false | ||||
|   pr-comment: | ||||
|     name: "PR Comment" | ||||
|     needs: | ||||
|       - build | ||||
|     runs-on: ubuntu-latest | ||||
| @ -275,7 +263,7 @@ jobs: | ||||
|       - uses: actions/checkout@v4 | ||||
|         with: | ||||
|           ref: ${{ github.event.pull_request.head.sha }} | ||||
|       - name: Prepare variables | ||||
|       - name: prepare variables | ||||
|         uses: ./.github/actions/docker-push-variables | ||||
|         id: ev | ||||
|         env: | ||||
|  | ||||
							
								
								
									
										32
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										32
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,5 @@ | ||||
| --- | ||||
| name: "authentik CI Outpost" | ||||
| name: authentik-ci-outpost | ||||
|  | ||||
| on: | ||||
|   push: | ||||
| @ -14,7 +14,6 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   lint-golint: | ||||
|     name: "Lint Go" | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
| @ -27,7 +26,7 @@ jobs: | ||||
|           mkdir -p web/dist | ||||
|           mkdir -p website/help | ||||
|           touch web/dist/test website/help/test | ||||
|       - name: Generate Go API Client | ||||
|       - name: Generate API | ||||
|         run: make gen-client-go | ||||
|       - name: golangci-lint | ||||
|         uses: golangci/golangci-lint-action@v7 | ||||
| @ -36,7 +35,6 @@ jobs: | ||||
|           args: --timeout 5000s --verbose | ||||
|           skip-cache: true | ||||
|   test-unittest: | ||||
|     name: "Unit Test Go" | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
| @ -45,13 +43,12 @@ jobs: | ||||
|           go-version-file: "go.mod" | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - name: Generate Go API Client | ||||
|       - name: Generate API | ||||
|         run: make gen-client-go | ||||
|       - name: Go unittests | ||||
|         run: | | ||||
|           go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./... | ||||
|   ci-outpost-mark: | ||||
|     name: "CI Outpost Mark" | ||||
|     if: always() | ||||
|     needs: | ||||
|       - lint-golint | ||||
| @ -62,7 +59,6 @@ jobs: | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|   build-container: | ||||
|     name: "Build Container" | ||||
|     timeout-minutes: 120 | ||||
|     needs: | ||||
|       - ci-outpost-mark | ||||
| @ -89,7 +85,7 @@ jobs: | ||||
|         uses: docker/setup-qemu-action@v3.6.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v3 | ||||
|       - name: Prepare variables | ||||
|       - name: prepare variables | ||||
|         uses: ./.github/actions/docker-push-variables | ||||
|         id: ev | ||||
|         env: | ||||
| @ -103,7 +99,7 @@ jobs: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.repository_owner }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: Generate Go API Client | ||||
|       - name: Generate API | ||||
|         run: make gen-client-go | ||||
|       - name: Build Docker Image | ||||
|         id: push | ||||
| @ -126,7 +122,6 @@ jobs: | ||||
|           subject-digest: ${{ steps.push.outputs.digest }} | ||||
|           push-to-registry: true | ||||
|   build-binary: | ||||
|     name: "Build Binary" | ||||
|     timeout-minutes: 120 | ||||
|     needs: | ||||
|       - ci-outpost-mark | ||||
| @ -145,22 +140,21 @@ jobs: | ||||
|       - uses: actions/checkout@v4 | ||||
|         with: | ||||
|           ref: ${{ github.event.pull_request.head.sha }} | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: package-lock.json | ||||
|       - name: Install Node.js dependencies | ||||
|         run: npm ci | ||||
|       - uses: actions/setup-go@v5 | ||||
|         with: | ||||
|           go-version-file: "go.mod" | ||||
|       - name: Generate Go API Client | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: web/package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: web/package-lock.json | ||||
|       - name: Generate API | ||||
|         run: make gen-client-go | ||||
|       - name: Build web | ||||
|         working-directory: web/ | ||||
|         run: | | ||||
|           npm ci | ||||
|           npm run build-proxy -w @goauthentik/web | ||||
|           npm run build-proxy | ||||
|       - name: Build outpost | ||||
|         run: | | ||||
|           set -x | ||||
|  | ||||
							
								
								
									
										70
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										70
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| name: CI Web UI | ||||
| name: authentik-ci-web | ||||
|  | ||||
| on: | ||||
|   push: | ||||
| @ -13,50 +13,54 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   lint: | ||||
|     name: Lint | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         command: | ||||
|           - lint | ||||
|           - lint:lockfile | ||||
|           - tsc | ||||
|           - prettier-check | ||||
|         project: | ||||
|           - web | ||||
|         include: | ||||
|           - command: tsc | ||||
|             project: web | ||||
|           - command: lit-analyse | ||||
|             project: web | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: package.json | ||||
|           node-version-file: ${{ matrix.project }}/package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: package-lock.json | ||||
|       - name: Install Node.js dependencies | ||||
|         run: npm ci | ||||
|       - name: Generate TypeScript API | ||||
|           cache-dependency-path: ${{ matrix.project }}/package-lock.json | ||||
|       - working-directory: ${{ matrix.project }}/ | ||||
|         run: | | ||||
|           npm ci | ||||
|       - name: Generate API | ||||
|         run: make gen-client-ts | ||||
|       - name: Build | ||||
|         run: | | ||||
|           npm run build -w @goauthentik/web | ||||
|       - name: Type check | ||||
|         run: | | ||||
|           npm run typecheck | ||||
|       - name: Lint | ||||
|         run: | | ||||
|           npm run lint -w @goauthentik/web | ||||
|           npm run lint:lockfile -w @goauthentik/web | ||||
|           npm run lit-analyse -w @goauthentik/web | ||||
|         working-directory: ${{ matrix.project }}/ | ||||
|         run: npm run ${{ matrix.command }} | ||||
|   build: | ||||
|     name: Build | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: package.json | ||||
|           node-version-file: web/package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: package-lock.json | ||||
|       - name: Install Node.js dependencies | ||||
|           cache-dependency-path: web/package-lock.json | ||||
|       - working-directory: web/ | ||||
|         run: npm ci | ||||
|       - name: Generate TypeScript API | ||||
|       - name: Generate API | ||||
|         run: make gen-client-ts | ||||
|       - name: build | ||||
|         run: | | ||||
|           npm run build -w @goauthentik/web | ||||
|           npm run typecheck | ||||
|         working-directory: web/ | ||||
|         run: npm run build | ||||
|   ci-web-mark: | ||||
|     name: CI Web Mark | ||||
|     if: always() | ||||
|     needs: | ||||
|       - build | ||||
| @ -67,7 +71,6 @@ jobs: | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|   test: | ||||
|     name: Test | ||||
|     needs: | ||||
|       - ci-web-mark | ||||
|     runs-on: ubuntu-latest | ||||
| @ -75,12 +78,13 @@ jobs: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: package.json | ||||
|           node-version-file: web/package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: package-lock.json | ||||
|       - name: Install Node.js dependencies | ||||
|           cache-dependency-path: web/package-lock.json | ||||
|       - working-directory: web/ | ||||
|         run: npm ci | ||||
|       - name: Generate TypeScript API | ||||
|       - name: Generate API | ||||
|         run: make gen-client-ts | ||||
|       - name: Test Web UI | ||||
|         run: npm run test -w @goauthentik/web || exit 0 | ||||
|       - name: test | ||||
|         working-directory: web/ | ||||
|         run: npm run test || exit 0 | ||||
|  | ||||
							
								
								
									
										94
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										94
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| name: CI Docs Website | ||||
| name: authentik-ci-website | ||||
|  | ||||
| on: | ||||
|   push: | ||||
| @ -13,59 +13,55 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   lint: | ||||
|     name: "Lint" | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         command: | ||||
|           - lint:lockfile | ||||
|           - prettier-check | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: package-lock.json | ||||
|       - name: Install Node.js dependencies | ||||
|         run: | | ||||
|           npm ci | ||||
|       - name: Generate TypeScript API | ||||
|         run: make gen-client-ts | ||||
|       - name: Lint Docs | ||||
|         run: | | ||||
|           npm run lint:prettier:check | ||||
|           npm run lint:lockfile -w @goauthentik/docs | ||||
|   test: | ||||
|     name: "Test Docs" | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: package-lock.json | ||||
|       - name: Install Node.js dependencies | ||||
|         run: | | ||||
|           npm ci | ||||
|       - name: Generate TypeScript API | ||||
|         run: make gen-client-ts | ||||
|       - name: Test Docs | ||||
|         run: | | ||||
|           npm run test -w @goauthentik/docs | ||||
|   build: | ||||
|     name: "Build Docs" | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: package-lock.json | ||||
|       - name: Install Node.js dependencies | ||||
|       - working-directory: website/ | ||||
|         run: npm ci | ||||
|       - name: Build | ||||
|         run: | | ||||
|           npm run build -w @goauthentik/docs | ||||
|       - name: Lint | ||||
|         working-directory: website/ | ||||
|         run: npm run ${{ matrix.command }} | ||||
|   test: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: website/package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: website/package-lock.json | ||||
|       - working-directory: website/ | ||||
|         run: npm ci | ||||
|       - name: test | ||||
|         working-directory: website/ | ||||
|         run: npm test | ||||
|   build: | ||||
|     runs-on: ubuntu-latest | ||||
|     name: ${{ matrix.job }} | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         job: | ||||
|           - build | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: website/package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: website/package-lock.json | ||||
|       - working-directory: website/ | ||||
|         run: npm ci | ||||
|       - name: build | ||||
|         working-directory: website/ | ||||
|         run: npm run ${{ matrix.job }} | ||||
|   ci-website-mark: | ||||
|     name: "CI Website Mark" | ||||
|     if: always() | ||||
|     needs: | ||||
|       - lint | ||||
|  | ||||
							
								
								
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @ -10,7 +10,7 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   analyze: | ||||
|     name: "Analyze" | ||||
|     name: Analyze | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       actions: read | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| name: "authentik CI Update WebAuthn MDS" | ||||
| name: authentik-gen-update-webauthn-mds | ||||
| on: | ||||
|   workflow_dispatch: | ||||
|   schedule: | ||||
| @ -11,7 +11,6 @@ env: | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     name: "Update WebAuthn MDS" | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|  | ||||
							
								
								
									
										3
									
								
								.github/workflows/gha-cache-cleanup.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/gha-cache-cleanup.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,6 +1,6 @@ | ||||
| --- | ||||
| # See https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries | ||||
| name: "Post-PR Closed Cache Cleanup" | ||||
| name: Cleanup cache after PR is closed | ||||
| on: | ||||
|   pull_request: | ||||
|     types: | ||||
| @ -12,7 +12,6 @@ permissions: | ||||
|  | ||||
| jobs: | ||||
|   cleanup: | ||||
|     name: "Cleanup Cache" | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Check out code | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| name: "authentik GHCR Retention Policy" | ||||
| name: ghcr-retention | ||||
|  | ||||
| on: | ||||
|   # schedule: | ||||
| @ -8,7 +8,7 @@ on: | ||||
| jobs: | ||||
|   clean-ghcr: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     name: "Delete old unused container images" | ||||
|     name: Delete old unused container images | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - id: generate_token | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/image-compress.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/image-compress.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,5 @@ | ||||
| --- | ||||
| name: "authentik CI Image Compression" | ||||
| name: authentik-compress-images | ||||
|  | ||||
| on: | ||||
|   push: | ||||
| @ -20,7 +20,7 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   compress: | ||||
|     name: "Compress Docker images" | ||||
|     name: compress | ||||
|     runs-on: ubuntu-latest | ||||
|     # Don't run on forks. Token will not be available. Will run on main and open a PR anyway | ||||
|     if: | | ||||
|  | ||||
							
								
								
									
										8
									
								
								.github/workflows/packages-npm-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/packages-npm-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -3,10 +3,10 @@ on: | ||||
|   push: | ||||
|     branches: [main] | ||||
|     paths: | ||||
|       - packages/docusaurus-config | ||||
|       - packages/eslint-config | ||||
|       - packages/prettier-config | ||||
|       - packages/tsconfig | ||||
|       - packages/docusaurus-config/** | ||||
|       - packages/eslint-config/** | ||||
|       - packages/prettier-config/** | ||||
|       - packages/tsconfig/** | ||||
|   workflow_dispatch: | ||||
| jobs: | ||||
|   publish: | ||||
|  | ||||
							
								
								
									
										7
									
								
								.github/workflows/publish-source-docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/publish-source-docs.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| name: "authentik Publish Source Docs" | ||||
| name: authentik-publish-source-docs | ||||
|  | ||||
| on: | ||||
|   push: | ||||
| @ -12,7 +12,6 @@ env: | ||||
|  | ||||
| jobs: | ||||
|   publish-source-docs: | ||||
|     name: "Publish" | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 120 | ||||
| @ -20,11 +19,11 @@ jobs: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - name: Generate docs | ||||
|       - name: generate docs | ||||
|         run: | | ||||
|           uv run make migrate | ||||
|           uv run ak build_source_docs | ||||
|       - name: Deploy to Netlify | ||||
|       - name: Publish | ||||
|         uses: netlify/actions/cli@master | ||||
|         with: | ||||
|           args: deploy --dir=source_docs --prod | ||||
|  | ||||
							
								
								
									
										3
									
								
								.github/workflows/release-next-branch.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/release-next-branch.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| name: "authentik on Release Next Branch" | ||||
| name: authentik-on-release-next-branch | ||||
|  | ||||
| on: | ||||
|   schedule: | ||||
| @ -11,7 +11,6 @@ permissions: | ||||
|  | ||||
| jobs: | ||||
|   update-next: | ||||
|     name: "Update Next Branch" | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     environment: internal-production | ||||
|  | ||||
							
								
								
									
										21
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										21
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,5 @@ | ||||
| --- | ||||
| name: "Release publish" | ||||
| name: authentik-on-release | ||||
|  | ||||
| on: | ||||
|   release: | ||||
| @ -7,7 +7,6 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   build-server: | ||||
|     name: "Build server" | ||||
|     uses: ./.github/workflows/_reusable-docker-build.yaml | ||||
|     secrets: inherit | ||||
|     permissions: | ||||
| @ -22,7 +21,6 @@ jobs: | ||||
|       registry_dockerhub: true | ||||
|       registry_ghcr: true | ||||
|   build-outpost: | ||||
|     name: "Build outpost" | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       # Needed to upload container images to ghcr.io | ||||
| @ -47,14 +45,14 @@ jobs: | ||||
|         uses: docker/setup-qemu-action@v3.6.0 | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v3 | ||||
|       - name: Prepare variables | ||||
|       - name: prepare variables | ||||
|         uses: ./.github/actions/docker-push-variables | ||||
|         id: ev | ||||
|         env: | ||||
|           DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} | ||||
|         with: | ||||
|           image-name: ghcr.io/goauthentik/${{ matrix.type }},beryju/authentik-${{ matrix.type }} | ||||
|       - name: Make empty clients | ||||
|       - name: make empty clients | ||||
|         run: | | ||||
|           mkdir -p ./gen-ts-api | ||||
|           mkdir -p ./gen-go-api | ||||
| @ -87,7 +85,6 @@ jobs: | ||||
|           subject-digest: ${{ steps.push.outputs.digest }} | ||||
|           push-to-registry: true | ||||
|   build-outpost-binary: | ||||
|     name: "Build outpost binary" | ||||
|     timeout-minutes: 120 | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
| @ -109,13 +106,14 @@ jobs: | ||||
|           go-version-file: "go.mod" | ||||
|       - uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version-file: package.json | ||||
|           node-version-file: web/package.json | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: package-lock.json | ||||
|           cache-dependency-path: web/package-lock.json | ||||
|       - name: Build web | ||||
|         working-directory: web/ | ||||
|         run: | | ||||
|           npm ci | ||||
|           npm run build-proxy -w @goauthentik/web | ||||
|           npm run build-proxy | ||||
|       - name: Build outpost | ||||
|         run: | | ||||
|           set -x | ||||
| @ -131,7 +129,6 @@ jobs: | ||||
|           asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} | ||||
|           tag: ${{ github.ref }} | ||||
|   upload-aws-cfn-template: | ||||
|     name: "Upload AWS CloudFormation template" | ||||
|     permissions: | ||||
|       # Needed for AWS login | ||||
|       id-token: write | ||||
| @ -153,7 +150,6 @@ jobs: | ||||
|           aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml | ||||
|           aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml | ||||
|   test-release: | ||||
|     name: "Test release" | ||||
|     needs: | ||||
|       - build-server | ||||
|       - build-outpost | ||||
| @ -170,7 +166,6 @@ jobs: | ||||
|           docker compose start postgresql redis | ||||
|           docker compose run -u root server test-all | ||||
|   sentry-release: | ||||
|     name: "Sentry release" | ||||
|     needs: | ||||
|       - build-server | ||||
|       - build-outpost | ||||
| @ -178,7 +173,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - name: Prepare variables | ||||
|       - name: prepare variables | ||||
|         uses: ./.github/actions/docker-push-variables | ||||
|         id: ev | ||||
|         env: | ||||
|  | ||||
							
								
								
									
										6
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,5 @@ | ||||
| --- | ||||
| name: "authentik on Tag Release" | ||||
| name: authentik-on-tag | ||||
|  | ||||
| on: | ||||
|   push: | ||||
| @ -8,7 +8,7 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     name: "Create Release from Tag" | ||||
|     name: Create Release from Tag | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
| @ -20,7 +20,7 @@ jobs: | ||||
|         with: | ||||
|           app_id: ${{ secrets.GH_APP_ID }} | ||||
|           private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} | ||||
|       - name: Prepare variables | ||||
|       - name: prepare variables | ||||
|         uses: ./.github/actions/docker-push-variables | ||||
|         id: ev | ||||
|         env: | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/repo-mirror.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/repo-mirror.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,15 +1,13 @@ | ||||
| name: "authentik Repository Mirror" | ||||
| name: "authentik-repo-mirror" | ||||
|  | ||||
| on: [push, delete] | ||||
|  | ||||
| jobs: | ||||
|   to_internal: | ||||
|     name: "Mirror to internal repository" | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|         name: "Checkout repository" | ||||
|         with: | ||||
|           fetch-depth: 0 | ||||
|       - if: ${{ env.MIRROR_KEY != '' }} | ||||
|  | ||||
							
								
								
									
										3
									
								
								.github/workflows/repo-stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/repo-stale.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| name: "authentik Repository Stale Issues" | ||||
| name: "authentik-repo-stale" | ||||
|  | ||||
| on: | ||||
|   schedule: | ||||
| @ -11,7 +11,6 @@ permissions: | ||||
|  | ||||
| jobs: | ||||
|   stale: | ||||
|     name: "Stale Issues" | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/semgrep.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/semgrep.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| name: "authentik CI Semgrep" | ||||
| name: authentik-semgrep | ||||
| on: | ||||
|   workflow_dispatch: {} | ||||
|   pull_request: {} | ||||
| @ -13,7 +13,7 @@ on: | ||||
|     - cron: '12 15 * * *' | ||||
| jobs: | ||||
|   semgrep: | ||||
|     name: "semgrep/ci" | ||||
|     name: semgrep/ci | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       contents: read | ||||
|  | ||||
							
								
								
									
										3
									
								
								.github/workflows/translation-advice.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/translation-advice.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,4 +1,4 @@ | ||||
| name: "authentik Translations Advice" | ||||
| name: authentik-translation-advice | ||||
|  | ||||
| on: | ||||
|   pull_request: | ||||
| @ -16,7 +16,6 @@ permissions: | ||||
|  | ||||
| jobs: | ||||
|   post-comment: | ||||
|     name: "Post Comment" | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Find Comment | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| --- | ||||
| name: "authentik Extract & Compile Translations" | ||||
| name: authentik-translate-extract-compile | ||||
| on: | ||||
|   schedule: | ||||
|     - cron: "0 0 * * *" # every day at midnight | ||||
| @ -16,7 +16,6 @@ env: | ||||
|  | ||||
| jobs: | ||||
|   compile: | ||||
|     name: "Compile Translations" | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - id: generate_token | ||||
| @ -33,20 +32,15 @@ jobs: | ||||
|         if: ${{ github.event_name == 'pull_request' }} | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - name: Generate TypeScript API | ||||
|       - name: Generate API | ||||
|         run: make gen-client-ts | ||||
|       - name: Extract Translations | ||||
|       - name: run extract | ||||
|         run: | | ||||
|           uv run make i18n-extract | ||||
|       - name: Build Docs Site | ||||
|         run: npm run build-bundled -w @goauthentik/docs | ||||
|       - name: Build Web UI | ||||
|         run: npm run build -w @goauthentik/web | ||||
|       - name: Type check | ||||
|         run: npm run typecheck | ||||
|       - name: Compile Messages | ||||
|       - name: run compile | ||||
|         run: | | ||||
|           uv run ak compilemessages | ||||
|           make web-check-compile | ||||
|       - name: Create Pull Request | ||||
|         if: ${{ github.event_name != 'pull_request' }} | ||||
|         uses: peter-evans/create-pull-request@v7 | ||||
|  | ||||
							
								
								
									
										3
									
								
								.github/workflows/translation-rename.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/translation-rename.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,6 +1,6 @@ | ||||
| # Rename transifex pull requests to have a correct naming | ||||
| # Also enables auto squash-merge | ||||
| name: "authentik Translations Transifex PR Rename" | ||||
| name: authentik-translation-transifex-rename | ||||
|  | ||||
| on: | ||||
|   pull_request: | ||||
| @ -12,7 +12,6 @@ permissions: | ||||
|  | ||||
| jobs: | ||||
|   rename_pr: | ||||
|     name: "Rename PR" | ||||
|     runs-on: ubuntu-latest | ||||
|     if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}} | ||||
|     steps: | ||||
|  | ||||
							
								
								
									
										23
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -217,26 +217,3 @@ source_docs/ | ||||
|  | ||||
| ### Docker ### | ||||
| docker-compose.override.yml | ||||
|  | ||||
|  | ||||
| ### Node ### | ||||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| lerna-debug.log* | ||||
|  | ||||
| node_modules/ | ||||
|  | ||||
| tsconfig.tsbuildinfo | ||||
|  | ||||
| # Wireit's cache | ||||
| .wireit | ||||
|  | ||||
| custom-elements.json | ||||
|  | ||||
|  | ||||
| ### Development ### | ||||
| .drafts | ||||
|  | ||||
| @ -4,16 +4,12 @@ | ||||
| **/LICENSE | ||||
|  | ||||
| authentik/stages/**/* | ||||
| authentik/sources/**/* | ||||
| schemas/**/* | ||||
| blueprints/**/* | ||||
|  | ||||
| ## Build asset directories | ||||
| coverage | ||||
| dist | ||||
| out | ||||
| .docusaurus | ||||
| .wireit | ||||
| website/docs/developer-docs/api/**/* | ||||
|  | ||||
| ## Environment | ||||
| @ -36,15 +32,14 @@ coverage | ||||
|  | ||||
| # Templates | ||||
| # TODO: Rename affected files to *.template.* or similar. | ||||
| authentik/**/*.html | ||||
| *.html | ||||
| *.mdx | ||||
| *.md | ||||
|  | ||||
| ## Import order matters | ||||
| web/src/poly.ts | ||||
| web/src/locale-codes.ts | ||||
| web/src/locales/ | ||||
| poly.ts | ||||
| src/locale-codes.ts | ||||
| src/locales/ | ||||
|  | ||||
| # Storybook | ||||
| storybook-static/ | ||||
|  | ||||
							
								
								
									
										2
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							| @ -17,6 +17,6 @@ | ||||
|         "ms-python.vscode-pylance", | ||||
|         "redhat.vscode-yaml", | ||||
|         "Tobermory.es6-string-html", | ||||
|         "unifiedjs.vscode-mdx" | ||||
|         "unifiedjs.vscode-mdx", | ||||
|     ] | ||||
| } | ||||
|  | ||||
							
								
								
									
										72
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										72
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -16,7 +16,7 @@ | ||||
|     ], | ||||
|     "typescript.preferences.importModuleSpecifier": "non-relative", | ||||
|     "typescript.preferences.importModuleSpecifierEnding": "index", | ||||
|     "typescript.tsdk": "./node_modules/typescript/lib", | ||||
|     "typescript.tsdk": "./web/node_modules/typescript/lib", | ||||
|     "typescript.enablePromptUseWorkspaceTsdk": true, | ||||
|     "yaml.schemas": { | ||||
|         "./blueprints/schema.json": "blueprints/**/*.yaml" | ||||
| @ -30,71 +30,7 @@ | ||||
|         } | ||||
|     ], | ||||
|     "go.testFlags": ["-count=1"], | ||||
|     "github-actions.workflows.pinned.workflows": [".github/workflows/ci-main.yml"], | ||||
|  | ||||
|     "eslint.useFlatConfig": true, | ||||
|  | ||||
|     "explorer.fileNesting.enabled": true, | ||||
|     "explorer.fileNesting.patterns": { | ||||
|         "*.mjs": "*.d.mts", | ||||
|         "*.cjs": "*.d.cts", | ||||
|         "package.json": "package-lock.json, yarn.lock, .yarnrc, .yarnrc.yml, .yarn, .nvmrc, .node-version", | ||||
|         "tsconfig.json": "tsconfig.*.json, jsconfig.json", | ||||
|         "Dockerfile": "*.Dockerfile" | ||||
|     }, | ||||
|  | ||||
|     "search.exclude": { | ||||
|         "**/node_modules": true, | ||||
|         "**/*.code-search": true, | ||||
|         "**/dist": true, | ||||
|         "**/out": true, | ||||
|         "**/package-lock.json": true | ||||
|     }, | ||||
|  | ||||
|     "[css]": { | ||||
|         "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|     }, | ||||
|     "[javascript]": { | ||||
|         "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|     }, | ||||
|     "[javascriptreact]": { | ||||
|         "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|     }, | ||||
|     "[json]": { | ||||
|         "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|     }, | ||||
|     "[markdown]": { | ||||
|         "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|     }, | ||||
|     "[shellscript]": { | ||||
|         "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|     }, | ||||
|     "[typescript]": { | ||||
|         "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|     }, | ||||
|     "[typescriptreact]": { | ||||
|         "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|     }, | ||||
|     "[django-html]": { | ||||
|         "editor.defaultFormatter": "esbenp.prettier-vscode" | ||||
|     }, | ||||
|  | ||||
|     "editor.codeActionsOnSave": { | ||||
|         "source.removeUnusedImports": "explicit" | ||||
|     }, | ||||
|     // We use Prettier for formatting, but specifying these settings | ||||
|     // will ensure that VS Code's IntelliSense doesn't autocomplete unformatted code. | ||||
|     "javascript.format.semicolons": "insert", | ||||
|     "typescript.format.semicolons": "insert", | ||||
|     "javascript.preferences.quoteStyle": "double", | ||||
|     "typescript.preferences.quoteStyle": "double", | ||||
|     "github.copilot.enable": { | ||||
|         "*": true, | ||||
|         "plaintext": true, | ||||
|         "markdown": true, | ||||
|         "scminput": false, | ||||
|         "csv": false, | ||||
|         "json": true, | ||||
|         "yaml": true | ||||
|     } | ||||
|     "github-actions.workflows.pinned.workflows": [ | ||||
|         ".github/workflows/ci-main.yml" | ||||
|     ] | ||||
| } | ||||
|  | ||||
							
								
								
									
										40
									
								
								.vscode/tasks.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										40
									
								
								.vscode/tasks.json
									
									
									
									
										vendored
									
									
								
							| @ -4,7 +4,12 @@ | ||||
|         { | ||||
|             "label": "authentik/core: make", | ||||
|             "command": "uv", | ||||
|             "args": ["run", "make", "lint-fix", "lint"], | ||||
|             "args": [ | ||||
|                 "run", | ||||
|                 "make", | ||||
|                 "lint-fix", | ||||
|                 "lint" | ||||
|             ], | ||||
|             "presentation": { | ||||
|                 "panel": "new" | ||||
|             }, | ||||
| @ -13,7 +18,11 @@ | ||||
|         { | ||||
|             "label": "authentik/core: run", | ||||
|             "command": "uv", | ||||
|             "args": ["run", "ak", "server"], | ||||
|             "args": [ | ||||
|                 "run", | ||||
|                 "ak", | ||||
|                 "server" | ||||
|             ], | ||||
|             "group": "build", | ||||
|             "presentation": { | ||||
|                 "panel": "dedicated", | ||||
| @ -23,13 +32,17 @@ | ||||
|         { | ||||
|             "label": "authentik/web: make", | ||||
|             "command": "make", | ||||
|             "args": ["web"], | ||||
|             "args": [ | ||||
|                 "web" | ||||
|             ], | ||||
|             "group": "build" | ||||
|         }, | ||||
|         { | ||||
|             "label": "authentik/web: watch", | ||||
|             "command": "make", | ||||
|             "args": ["web-watch"], | ||||
|             "args": [ | ||||
|                 "web-watch" | ||||
|             ], | ||||
|             "group": "build", | ||||
|             "presentation": { | ||||
|                 "panel": "dedicated", | ||||
| @ -39,19 +52,26 @@ | ||||
|         { | ||||
|             "label": "authentik: install", | ||||
|             "command": "make", | ||||
|             "args": ["install", "-j4"], | ||||
|             "args": [ | ||||
|                 "install", | ||||
|                 "-j4" | ||||
|             ], | ||||
|             "group": "build" | ||||
|         }, | ||||
|         { | ||||
|             "label": "authentik/website: make", | ||||
|             "command": "make", | ||||
|             "args": ["website"], | ||||
|             "args": [ | ||||
|                 "website" | ||||
|             ], | ||||
|             "group": "build" | ||||
|         }, | ||||
|         { | ||||
|             "label": "authentik/website: watch", | ||||
|             "command": "make", | ||||
|             "args": ["website-watch"], | ||||
|             "args": [ | ||||
|                 "website-watch" | ||||
|             ], | ||||
|             "group": "build", | ||||
|             "presentation": { | ||||
|                 "panel": "dedicated", | ||||
| @ -61,7 +81,11 @@ | ||||
|         { | ||||
|             "label": "authentik/api: generate", | ||||
|             "command": "uv", | ||||
|             "args": ["run", "make", "gen"], | ||||
|             "args": [ | ||||
|                 "run", | ||||
|                 "make", | ||||
|                 "gen" | ||||
|             ], | ||||
|             "group": "build" | ||||
|         } | ||||
|     ] | ||||
|  | ||||
							
								
								
									
										68
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										68
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,31 +1,49 @@ | ||||
| # syntax=docker/dockerfile:1 | ||||
|  | ||||
| # Stage 1 Web UI and Documentation build | ||||
|  | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS web-builder | ||||
| # Stage 1: Build website | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS website-builder | ||||
|  | ||||
| ENV NODE_ENV=production | ||||
|  | ||||
| WORKDIR /work | ||||
| WORKDIR /work/website | ||||
|  | ||||
| COPY ./package.json ./package.json | ||||
| COPY ./package-lock.json ./package-lock.json | ||||
| COPY ./packages ./packages | ||||
| COPY ./web ./web | ||||
| COPY ./website ./website | ||||
| RUN --mount=type=bind,target=/work/website/package.json,src=./website/package.json \ | ||||
|     --mount=type=bind,target=/work/website/package-lock.json,src=./website/package-lock.json \ | ||||
|     --mount=type=cache,id=npm-website,sharing=shared,target=/root/.npm \ | ||||
|     npm ci --include=dev | ||||
|  | ||||
| COPY ./gen-ts-api ./gen-ts-api | ||||
| COPY ./blueprints ./blueprints | ||||
| COPY ./schema.yml ./schema.yml | ||||
| COPY ./SECURITY.md ./SECURITY.md | ||||
| COPY ./website /work/website/ | ||||
| COPY ./blueprints /work/blueprints/ | ||||
| COPY ./schema.yml /work/ | ||||
| COPY ./SECURITY.md /work/ | ||||
|  | ||||
| RUN --mount=type=cache,target=/root/.npm npm ci --include=dev | ||||
| RUN npm run build-bundled | ||||
|  | ||||
| RUN npm run build-bundled -w @goauthentik/docs | ||||
| RUN npm run build -w @goauthentik/web | ||||
| # Stage 2: Build webui | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS web-builder | ||||
|  | ||||
| # Stage 2: Build go proxy | ||||
| ARG GIT_BUILD_HASH | ||||
| ENV GIT_BUILD_HASH=$GIT_BUILD_HASH | ||||
| ENV NODE_ENV=production | ||||
|  | ||||
| WORKDIR /work/web | ||||
|  | ||||
| RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ | ||||
|     --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ | ||||
|     --mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \ | ||||
|     --mount=type=bind,target=/work/web/scripts,src=./web/scripts \ | ||||
|     --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ | ||||
|     npm ci --include=dev | ||||
|  | ||||
| COPY ./package.json /work | ||||
| COPY ./web /work/web/ | ||||
| COPY ./website /work/website/ | ||||
| COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | ||||
|  | ||||
| RUN npm run build && \ | ||||
|     npm run build:sfe | ||||
|  | ||||
| # Stage 3: Build go proxy | ||||
| FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder | ||||
|  | ||||
| ARG TARGETOS | ||||
| @ -62,8 +80,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ | ||||
|     CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ | ||||
|     go build -o /go/authentik ./cmd/server | ||||
|  | ||||
| # Stage 3: MaxMind GeoIP | ||||
|  | ||||
| # Stage 4: MaxMind GeoIP | ||||
| FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip | ||||
|  | ||||
| ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" | ||||
| @ -77,10 +94,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ | ||||
|     mkdir -p /usr/share/GeoIP && \ | ||||
|     /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" | ||||
|  | ||||
| # Stage 4: Download uv | ||||
| FROM ghcr.io/astral-sh/uv:0.6.14 AS uv | ||||
|  | ||||
| # Stage 5: Base python image | ||||
| # Stage 5: Download uv | ||||
| FROM ghcr.io/astral-sh/uv:0.6.16 AS uv | ||||
| # Stage 6: Base python image | ||||
| FROM ghcr.io/goauthentik/fips-python:3.12.10-slim-bookworm-fips AS python-base | ||||
|  | ||||
| ENV VENV_PATH="/ak-root/.venv" \ | ||||
| @ -94,7 +110,7 @@ WORKDIR /ak-root/ | ||||
|  | ||||
| COPY --from=uv /uv /uvx /bin/ | ||||
|  | ||||
| # Stage 6: Python dependencies | ||||
| # Stage 7: Python dependencies | ||||
| FROM python-base AS python-deps | ||||
|  | ||||
| ARG TARGETARCH | ||||
| @ -129,7 +145,7 @@ RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \ | ||||
|     --mount=type=cache,target=/root/.cache/uv \ | ||||
|     uv sync --frozen --no-install-project --no-dev | ||||
|  | ||||
| # Stage 7: Run | ||||
| # Stage 8: Run | ||||
| FROM python-base AS final-image | ||||
|  | ||||
| ARG VERSION | ||||
| @ -174,7 +190,7 @@ COPY --from=go-builder /go/authentik /bin/authentik | ||||
| COPY --from=python-deps /ak-root/.venv /ak-root/.venv | ||||
| COPY --from=web-builder /work/web/dist/ /web/dist/ | ||||
| COPY --from=web-builder /work/web/authentik/ /web/authentik/ | ||||
| COPY --from=web-builder /work/website/build/ /website/help/ | ||||
| COPY --from=website-builder /work/website/build/ /website/help/ | ||||
| COPY --from=geoip /usr/share/GeoIP /geoip | ||||
|  | ||||
| USER 1000 | ||||
|  | ||||
							
								
								
									
										107
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										107
									
								
								Makefile
									
									
									
									
									
								
							| @ -36,13 +36,6 @@ test: ## Run the server tests and produce a coverage report (locally) | ||||
| 	uv run coverage html | ||||
| 	uv run coverage report | ||||
|  | ||||
| node-check-compile: ## Check and compile the TypeScript source code | ||||
| 	npm run typecheck | ||||
|  | ||||
| node-lint-fix: ## Lint and automatically fix errors in the javascript source code | ||||
| 	lint-codespell | ||||
| 	npm run lint:fix | ||||
|  | ||||
| lint-fix: lint-codespell  ## Lint and automatically fix errors in the python source code. Reports spelling errors. | ||||
| 	uv run black $(PY_SOURCES) | ||||
| 	uv run ruff check --fix $(PY_SOURCES) | ||||
| @ -54,6 +47,9 @@ lint: ## Lint the python and golang sources | ||||
| 	uv run bandit -c pyproject.toml -r $(PY_SOURCES) | ||||
| 	golangci-lint run -v | ||||
|  | ||||
| core-install: | ||||
| 	uv sync --frozen | ||||
|  | ||||
| migrate: ## Run the Authentik Django server's migrations | ||||
| 	uv run python -m lifecycle.migrate | ||||
|  | ||||
| @ -76,9 +72,7 @@ core-i18n-extract: | ||||
| 		--ignore website \ | ||||
| 		-l en | ||||
|  | ||||
| install:  ## Install all requires dependencies for `web`, `website` and `core` | ||||
| 	npm ci | ||||
| 	uv sync --frozen | ||||
| install: web-install website-install core-install  ## Install all requires dependencies for `web`, `website` and `core` | ||||
|  | ||||
| dev-drop-db: | ||||
| 	dropdb -U ${pg_user} -h ${pg_host} ${pg_name} | ||||
| @ -100,7 +94,6 @@ gen-build:  ## Extract the schema from the database | ||||
| 		AUTHENTIK_TENANTS__ENABLED=true \ | ||||
| 		AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ | ||||
| 		uv run ak make_blueprint_schema > blueprints/schema.json | ||||
|  | ||||
| 	AUTHENTIK_DEBUG=true \ | ||||
| 		AUTHENTIK_TENANTS__ENABLED=true \ | ||||
| 		AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ | ||||
| @ -108,24 +101,19 @@ gen-build:  ## Extract the schema from the database | ||||
|  | ||||
| gen-changelog:  ## (Release) generate the changelog based from the commits since the last tag | ||||
| 	git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md | ||||
|  | ||||
| 	npx prettier --write changelog.md | ||||
|  | ||||
| gen-diff:  ## (Release) generate the changelog diff between the current schema and the last tag | ||||
| 	git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > old_schema.yml | ||||
|  | ||||
| 	docker run \ | ||||
| 		--rm -v ${PWD}:/local \ | ||||
| 		--user ${UID}:${GID} \ | ||||
| 		docker.io/openapitools/openapi-diff:2.1.0-beta.8 \ | ||||
| 		--markdown /local/diff.md \ | ||||
| 		/local/old_schema.yml /local/schema.yml | ||||
|  | ||||
| 	rm old_schema.yml | ||||
|  | ||||
| 	sed -i 's/{/{/g' diff.md | ||||
| 	sed -i 's/}/}/g' diff.md | ||||
|  | ||||
| 	npx prettier --write diff.md | ||||
|  | ||||
| gen-clean-ts:  ## Remove generated API client for Typescript | ||||
| @ -145,57 +133,46 @@ gen-client-ts: gen-clean-ts  ## Build and install the authentik API for Typescri | ||||
| 		--rm -v ${PWD}:/local \ | ||||
| 		--user ${UID}:${GID} \ | ||||
| 		docker.io/openapitools/openapi-generator-cli:v7.11.0 generate \ | ||||
| 		--input-spec /local/schema.yml \ | ||||
| 		--generator-name typescript-fetch \ | ||||
| 		--output /local/${GEN_API_TS} \ | ||||
| 		--config /local/scripts/api-ts-config.yaml \ | ||||
| 		-i /local/schema.yml \ | ||||
| 		-g typescript-fetch \ | ||||
| 		-o /local/${GEN_API_TS} \ | ||||
| 		-c /local/scripts/api-ts-config.yaml \ | ||||
| 		--additional-properties=npmVersion=${NPM_VERSION} \ | ||||
| 		--git-repo-id authentik \ | ||||
| 		--git-user-id goauthentik | ||||
|  | ||||
| 	npm install | ||||
| 	mkdir -p web/node_modules/@goauthentik/api | ||||
| 	cd ./${GEN_API_TS} && npm i | ||||
| 	\cp -rf ./${GEN_API_TS}/* web/node_modules/@goauthentik/api | ||||
|  | ||||
| gen-client-py: gen-clean-py ## Build and install the authentik API for Python | ||||
|  | ||||
| 	docker run \ | ||||
| 		--rm -v ${PWD}:/local \ | ||||
| 		--user ${UID}:${GID} \ | ||||
| 		docker.io/openapitools/openapi-generator-cli:v7.11.0 generate \ | ||||
| 		--input-spec /local/schema.yml \ | ||||
| 		--generator-name python \ | ||||
| 		--output /local/${GEN_API_PY} \ | ||||
| 		--config /local/scripts/api-py-config.yaml \ | ||||
| 		-i /local/schema.yml \ | ||||
| 		-g python \ | ||||
| 		-o /local/${GEN_API_PY} \ | ||||
| 		-c /local/scripts/api-py-config.yaml \ | ||||
| 		--additional-properties=packageVersion=${NPM_VERSION} \ | ||||
| 		--git-repo-id authentik \ | ||||
| 		--git-user-id goauthentik | ||||
|  | ||||
| 	pip install ./${GEN_API_PY} | ||||
|  | ||||
| gen-client-go: gen-clean-go  ## Build and install the authentik API for Golang | ||||
| 	mkdir -p ./${GEN_API_GO} ./${GEN_API_GO}/templates | ||||
|  | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml \ | ||||
| 		-O ./${GEN_API_GO}/config.yaml | ||||
|  | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache \ | ||||
| 		-O ./${GEN_API_GO}/templates/README.mustache | ||||
|  | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache \ | ||||
| 		-O ./${GEN_API_GO}/templates/go.mod.mustache | ||||
|  | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./${GEN_API_GO}/config.yaml | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./${GEN_API_GO}/templates/README.mustache | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O ./${GEN_API_GO}/templates/go.mod.mustache | ||||
| 	cp schema.yml ./${GEN_API_GO}/ | ||||
|  | ||||
| 	docker run \ | ||||
| 		--rm -v ${PWD}/${GEN_API_GO}:/local \ | ||||
| 		--user ${UID}:${GID} \ | ||||
| 		docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \ | ||||
| 		--input-spec /local/schema.yml \ | ||||
| 		--generator-name go \ | ||||
| 		--output /local/ \ | ||||
| 		--config /local/config.yaml | ||||
|  | ||||
| 		-i /local/schema.yml \ | ||||
| 		-g go \ | ||||
| 		-o /local/ \ | ||||
| 		-c /local/config.yaml | ||||
| 	go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO} | ||||
|  | ||||
| 	rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/ | ||||
|  | ||||
| gen-dev-config:  ## Generate a local development config file | ||||
| @ -207,38 +184,56 @@ gen: gen-build gen-client-ts | ||||
| ## Web | ||||
| ######################### | ||||
|  | ||||
| web: web-lint-fix web-lint node-check-compile  ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it | ||||
| web-build: web-install  ## Build the Authentik UI | ||||
| 	cd web && npm run build | ||||
|  | ||||
| web: web-lint-fix web-lint web-check-compile  ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it | ||||
|  | ||||
| web-install:  ## Install the necessary libraries to build the Authentik UI | ||||
| 	cd web && npm ci | ||||
|  | ||||
| web-test: ## Run tests for the Authentik UI | ||||
| 	npm run test -w @goauthentik/web | ||||
| 	cd web && npm run test | ||||
|  | ||||
| web-watch:  ## Build and watch the Authentik UI for changes, updating automatically | ||||
| 	npm run watch -w @goauthentik/web | ||||
| 	rm -rf web/dist/ | ||||
| 	mkdir web/dist/ | ||||
| 	touch web/dist/.gitkeep | ||||
| 	cd web && npm run watch | ||||
|  | ||||
| web-storybook-watch:  ## Build and run the storybook documentation server | ||||
| 	npm run storybook -w @goauthentik/web | ||||
| 	cd web && npm run storybook | ||||
|  | ||||
| web-lint-fix: | ||||
| 	npm run prettier -w @goauthentik/web | ||||
| 	cd web && npm run prettier | ||||
|  | ||||
| web-lint: | ||||
| 	npm run lint -w @goauthentik/web | ||||
| 	npm run lit-analyse -w @goauthentik/web | ||||
| 	cd web && npm run lint | ||||
| 	cd web && npm run lit-analyse | ||||
|  | ||||
| web-check-compile: | ||||
| 	cd web && npm run tsc | ||||
|  | ||||
| web-i18n-extract: | ||||
| 	npm run extract-locales -w @goauthentik/web | ||||
| 	cd web && npm run extract-locales | ||||
|  | ||||
| ######################### | ||||
| ## Website | ||||
| ######################### | ||||
|  | ||||
| website: node-lint-fix website-build  ## Automatically fix formatting issues in the Authentik website/docs source code, lint the code, and compile it | ||||
| website: website-lint-fix website-build  ## Automatically fix formatting issues in the Authentik website/docs source code, lint the code, and compile it | ||||
|  | ||||
| website-install: | ||||
| 	cd website && npm ci | ||||
|  | ||||
| website-lint-fix: lint-codespell | ||||
| 	cd website && npm run prettier | ||||
|  | ||||
| website-build: | ||||
| 	npm run build -w @goauthentik/docs | ||||
| 	cd website && npm run build | ||||
|  | ||||
| website-watch:  ## Build and watch the documentation website, updating automatically | ||||
| 	npm run watch -w @goauthentik/docs | ||||
| 	cd website && npm run watch | ||||
|  | ||||
| ######################### | ||||
| ## Docker | ||||
|  | ||||
| @ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni | ||||
|  | ||||
| | Version   | Supported | | ||||
| | --------- | --------- | | ||||
| | 2024.12.x | ✅        | | ||||
| | 2025.2.x  | ✅        | | ||||
| | 2025.4.x  | ✅        | | ||||
|  | ||||
| ## Reporting a Vulnerability | ||||
|  | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|  | ||||
| from os import environ | ||||
|  | ||||
| __version__ = "2025.2.4" | ||||
| __version__ = "2025.4.3" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -16,7 +16,7 @@ def migrate_custom_css(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     if not path.exists(): | ||||
|         return | ||||
|     css = path.read_text() | ||||
|     Brand.objects.using(db_alias).update(branding_custom_css=css) | ||||
|     Brand.objects.using(db_alias).all().update(branding_custom_css=css) | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
| @ -99,9 +99,8 @@ class GroupSerializer(ModelSerializer): | ||||
|             if superuser | ||||
|             else "authentik_core.disable_group_superuser" | ||||
|         ) | ||||
|         has_perm = user.has_perm(perm) | ||||
|         if self.instance and not has_perm: | ||||
|             has_perm = user.has_perm(perm, self.instance) | ||||
|         if self.instance or superuser: | ||||
|             has_perm = user.has_perm(perm) or user.has_perm(perm, self.instance) | ||||
|             if not has_perm: | ||||
|                 raise ValidationError( | ||||
|                     _( | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.contrib.auth.management import create_permissions | ||||
| from django.core.management import call_command | ||||
| from django.core.management.base import BaseCommand, no_translations | ||||
| from guardian.management import create_anonymous_user | ||||
|  | ||||
| @ -16,6 +17,10 @@ class Command(BaseCommand): | ||||
|         """Check permissions for all apps""" | ||||
|         for tenant in Tenant.objects.filter(ready=True): | ||||
|             with tenant: | ||||
|                 # See https://code.djangoproject.com/ticket/28417 | ||||
|                 # Remove potential lingering old permissions | ||||
|                 call_command("remove_stale_contenttypes", "--no-input") | ||||
|  | ||||
|                 for app in apps.get_app_configs(): | ||||
|                     self.stdout.write(f"Checking app {app.name} ({app.label})\n") | ||||
|                     create_permissions(app, verbosity=0) | ||||
|  | ||||
| @ -31,7 +31,10 @@ class PickleSerializer: | ||||
|  | ||||
|     def loads(self, data): | ||||
|         """Unpickle data to be loaded from redis""" | ||||
|         try: | ||||
|             return pickle.loads(data)  # nosec | ||||
|         except Exception: | ||||
|             return {} | ||||
|  | ||||
|  | ||||
| def _migrate_session( | ||||
| @ -76,6 +79,7 @@ def _migrate_session( | ||||
|         AuthenticatedSession.objects.using(db_alias).create( | ||||
|             session=session, | ||||
|             user=old_auth_session.user, | ||||
|             uuid=old_auth_session.uuid, | ||||
|         ) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -0,0 +1,103 @@ | ||||
| # Generated by Django 5.1.9 on 2025-05-14 11:15 | ||||
|  | ||||
| from django.apps.registry import Apps, apps as global_apps | ||||
| from django.db import migrations | ||||
| from django.contrib.contenttypes.management import create_contenttypes | ||||
| from django.contrib.auth.management import create_permissions | ||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||
|  | ||||
|  | ||||
| def migrate_authenticated_session_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     """Migrate permissions from OldAuthenticatedSession to AuthenticatedSession""" | ||||
|     db_alias = schema_editor.connection.alias | ||||
|  | ||||
|     # `apps` here is just an instance of `django.db.migrations.state.AppConfigStub`, we need the | ||||
|     # real config for creating permissions and content types | ||||
|     authentik_core_config = global_apps.get_app_config("authentik_core") | ||||
|     # These are only ran by django after all migrations, but we need them right now. | ||||
|     # `global_apps` is needed, | ||||
|     create_permissions(authentik_core_config, using=db_alias, verbosity=1) | ||||
|     create_contenttypes(authentik_core_config, using=db_alias, verbosity=1) | ||||
|  | ||||
|     # But from now on, this is just a regular migration, so use `apps` | ||||
|     Permission = apps.get_model("auth", "Permission") | ||||
|     ContentType = apps.get_model("contenttypes", "ContentType") | ||||
|  | ||||
|     try: | ||||
|         old_ct = ContentType.objects.using(db_alias).get( | ||||
|             app_label="authentik_core", model="oldauthenticatedsession" | ||||
|         ) | ||||
|         new_ct = ContentType.objects.using(db_alias).get( | ||||
|             app_label="authentik_core", model="authenticatedsession" | ||||
|         ) | ||||
|     except ContentType.DoesNotExist: | ||||
|         # This should exist at this point, but if not, let's cut our losses | ||||
|         return | ||||
|  | ||||
|     # Get all permissions for the old content type | ||||
|     old_perms = Permission.objects.using(db_alias).filter(content_type=old_ct) | ||||
|  | ||||
|     # Create equivalent permissions for the new content type | ||||
|     for old_perm in old_perms: | ||||
|         new_perm = ( | ||||
|             Permission.objects.using(db_alias) | ||||
|             .filter( | ||||
|                 content_type=new_ct, | ||||
|                 codename=old_perm.codename, | ||||
|             ) | ||||
|             .first() | ||||
|         ) | ||||
|         if not new_perm: | ||||
|             # This should exist at this point, but if not, let's cut our losses | ||||
|             continue | ||||
|  | ||||
|         # Global user permissions | ||||
|         User = apps.get_model("authentik_core", "User") | ||||
|         User.user_permissions.through.objects.using(db_alias).filter( | ||||
|             permission=old_perm | ||||
|         ).all().update(permission=new_perm) | ||||
|  | ||||
|         # Global role permissions | ||||
|         DjangoGroup = apps.get_model("auth", "Group") | ||||
|         DjangoGroup.permissions.through.objects.using(db_alias).filter( | ||||
|             permission=old_perm | ||||
|         ).all().update(permission=new_perm) | ||||
|  | ||||
|         # Object user permissions | ||||
|         UserObjectPermission = apps.get_model("guardian", "UserObjectPermission") | ||||
|         UserObjectPermission.objects.using(db_alias).filter(permission=old_perm).all().update( | ||||
|             permission=new_perm, content_type=new_ct | ||||
|         ) | ||||
|  | ||||
|         # Object role permissions | ||||
|         GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission") | ||||
|         GroupObjectPermission.objects.using(db_alias).filter(permission=old_perm).all().update( | ||||
|             permission=new_perm, content_type=new_ct | ||||
|         ) | ||||
|  | ||||
|  | ||||
| def remove_old_authenticated_session_content_type( | ||||
|     apps: Apps, schema_editor: BaseDatabaseSchemaEditor | ||||
| ): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     ContentType = apps.get_model("contenttypes", "ContentType") | ||||
|  | ||||
|     ContentType.objects.using(db_alias).filter(model="oldauthenticatedsession").delete() | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0047_delete_oldauthenticatedsession"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             code=migrate_authenticated_session_permissions, | ||||
|             reverse_code=migrations.RunPython.noop, | ||||
|         ), | ||||
|         migrations.RunPython( | ||||
|             code=remove_old_authenticated_session_content_type, | ||||
|             reverse_code=migrations.RunPython.noop, | ||||
|         ), | ||||
|     ] | ||||
| @ -4,8 +4,8 @@ | ||||
| <script> | ||||
|     window.authentik = { | ||||
|         locale: "{{ LANGUAGE_CODE }}", | ||||
|     config: JSON.parse("{{ config_json|escapejs }}" || "{}"), | ||||
|     brand: JSON.parse("{{ brand_json|escapejs }}" || "{}"), | ||||
|         config: JSON.parse('{{ config_json|escapejs }}'), | ||||
|         brand: JSON.parse('{{ brand_json|escapejs }}'), | ||||
|         versionFamily: "{{ version_family }}", | ||||
|         versionSubdomain: "{{ version_subdomain }}", | ||||
|         build: "{{ build }}", | ||||
| @ -14,8 +14,6 @@ | ||||
|             relBase: "{{ base_url_rel }}", | ||||
|         }, | ||||
|     }; | ||||
|  | ||||
|   {% if messages %} | ||||
|     window.addEventListener("DOMContentLoaded", function () { | ||||
|         {% for message in messages %} | ||||
|         window.dispatchEvent( | ||||
| @ -30,5 +28,4 @@ | ||||
|         ); | ||||
|         {% endfor %} | ||||
|     }); | ||||
|   {% endif %} | ||||
| </script> | ||||
|  | ||||
| @ -2,79 +2,31 @@ | ||||
| {% load i18n %} | ||||
| {% load authentik_core %} | ||||
|  | ||||
| <!doctype html> | ||||
| <!DOCTYPE html> | ||||
|  | ||||
| <html> | ||||
|     <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> | ||||
|  | ||||
|     {% comment %} | ||||
|     Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we | ||||
|     default to a dark theme based on preferred colour-scheme | ||||
|     {% endcomment %} | ||||
|  | ||||
|     <meta name="darkreader-lock" /> | ||||
|  | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||
|         {# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #} | ||||
|         <meta name="darkreader-lock"> | ||||
|         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> | ||||
|  | ||||
|     <link rel="icon" href="{{ brand.branding_favicon_url }}" /> | ||||
|     <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}" /> | ||||
|  | ||||
|         <link rel="icon" href="{{ brand.branding_favicon_url }}"> | ||||
|         <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> | ||||
|         {% block head_before %} | ||||
|         {% endblock %} | ||||
|  | ||||
|     <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}" /> | ||||
|  | ||||
|     <style data-test-id="color-scheme"> | ||||
|       @media (prefers-color-scheme: dark) { | ||||
|         :root { | ||||
|           color-scheme: dark light; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       @media (prefers-color-scheme: light) { | ||||
|         :root { | ||||
|           color-scheme: light dark; | ||||
|         } | ||||
|       } | ||||
|     </style> | ||||
|  | ||||
|     <style data-test-id="custom-branding-css"> | ||||
|       {{ brand.branding_custom_css }} | ||||
|     </style> | ||||
|  | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> | ||||
|         <style>{{ brand.branding_custom_css }}</style> | ||||
|         <script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script> | ||||
|     <script | ||||
|       src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" | ||||
|       type="module" | ||||
|     ></script> | ||||
|  | ||||
|         <script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script> | ||||
|         {% block head %} | ||||
|         {% endblock %} | ||||
|  | ||||
|         <meta name="sentry-trace" content="{{ sentry_trace }}" /> | ||||
|     </head> | ||||
|  | ||||
|     <body> | ||||
|     {% block body %}{% endblock %} | ||||
|     {% block scripts %}{% endblock %} | ||||
|  | ||||
|     <noscript> | ||||
|       <style> | ||||
|         body { | ||||
|           font-family: var(--ak-font-family-base), sans-serif; | ||||
|         } | ||||
|       </style> | ||||
|  | ||||
|       <h1> | ||||
|         JavaScript is required to use | ||||
|         {% trans title|default:brand.branding_title %} | ||||
|       </h1> | ||||
|       <p> | ||||
|         Please enable JavaScript in your browser settings and reload the page. If you are using a | ||||
|         browser extension that blocks JavaScript, please disable it for this site. | ||||
|       </p> | ||||
|     </noscript> | ||||
|         {% block body %} | ||||
|         {% endblock %} | ||||
|         {% block scripts %} | ||||
|         {% endblock %} | ||||
|     </body> | ||||
| </html> | ||||
|  | ||||
| @ -4,15 +4,13 @@ | ||||
|  | ||||
| {% block head %} | ||||
| <script src="{% versioned_script 'dist/admin/AdminInterface-%v.js' %}" type="module"></script> | ||||
|  | ||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)" /> | ||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> | ||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | ||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | ||||
| {% include "base/header_js.html" %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block body %} | ||||
| <ak-message-container></ak-message-container> | ||||
|  | ||||
| <ak-interface-admin> | ||||
|     <ak-loading></ak-loading> | ||||
| </ak-interface-admin> | ||||
|  | ||||
| @ -14,12 +14,7 @@ | ||||
| {% block card %} | ||||
| <form method="POST" class="pf-c-form"> | ||||
|     <p>{% trans message %}</p> | ||||
|  | ||||
|   <a | ||||
|     id="ak-back-home" | ||||
|     href="{% url 'authentik_core:root-redirect' %}" | ||||
|     class="pf-c-button pf-m-primary" | ||||
|   > | ||||
|     <a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary"> | ||||
|         {% trans 'Go home' %} | ||||
|     </a> | ||||
| </form> | ||||
|  | ||||
| @ -4,16 +4,13 @@ | ||||
|  | ||||
| {% block head %} | ||||
| <script src="{% versioned_script 'dist/user/UserInterface-%v.js' %}" type="module"></script> | ||||
|  | ||||
| <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)" /> | ||||
| <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)" /> | ||||
|  | ||||
| <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)"> | ||||
| <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)"> | ||||
| {% include "base/header_js.html" %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block body %} | ||||
| <ak-message-container></ak-message-container> | ||||
|  | ||||
| <ak-interface-user> | ||||
|     <ak-loading></ak-loading> | ||||
| </ak-interface-user> | ||||
|  | ||||
| @ -5,50 +5,46 @@ | ||||
|  | ||||
| {% block head_before %} | ||||
| <link rel="prefetch" href="{{ request.brand.branding_default_flow_background_url }}" /> | ||||
| <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}" /> | ||||
| <link | ||||
|   rel="stylesheet" | ||||
|   type="text/css" | ||||
|   href="{% static 'dist/theme-dark.css' %}" | ||||
|   media="(prefers-color-scheme: dark)" | ||||
| /> | ||||
| <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}"> | ||||
| <link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)"> | ||||
| {% include "base/header_js.html" %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block head %} | ||||
| <style data-test-id="base-full-root-styles"> | ||||
|   :root { | ||||
| <style> | ||||
| :root { | ||||
|     --ak-flow-background: url("{{ request.brand.branding_default_flow_background_url }}"); | ||||
|     --pf-c-background-image--BackgroundImage: var(--ak-flow-background); | ||||
|     --pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background); | ||||
|     --pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background); | ||||
|     --pf-c-background-image--BackgroundImage--sm-2x: var(--ak-flow-background); | ||||
|     --pf-c-background-image--BackgroundImage--lg: var(--ak-flow-background); | ||||
|   } | ||||
|   /* Form with user */ | ||||
|   .form-control-static { | ||||
| } | ||||
| /* Form with user */ | ||||
| .form-control-static { | ||||
|     margin-top: var(--pf-global--spacer--sm); | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: space-between; | ||||
|   } | ||||
|   .form-control-static .avatar { | ||||
| } | ||||
| .form-control-static .avatar { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|   } | ||||
|   .form-control-static img { | ||||
| } | ||||
| .form-control-static img { | ||||
|     margin-right: var(--pf-global--spacer--xs); | ||||
|   } | ||||
|   .form-control-static a { | ||||
| } | ||||
| .form-control-static a { | ||||
|     padding-top: var(--pf-global--spacer--xs); | ||||
|     padding-bottom: var(--pf-global--spacer--xs); | ||||
|     line-height: var(--pf-global--spacer--xl); | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block body %} | ||||
| <div class="pf-c-background-image"></div> | ||||
| <div class="pf-c-background-image"> | ||||
| </div> | ||||
| <ak-message-container></ak-message-container> | ||||
| <div class="pf-c-login stacked"> | ||||
|     <div class="ak-login-container"> | ||||
|  | ||||
| @ -124,6 +124,16 @@ class TestGroupsAPI(APITestCase): | ||||
|             {"is_superuser": ["User does not have permission to set superuser status to True."]}, | ||||
|         ) | ||||
|  | ||||
|     def test_superuser_no_perm_no_superuser(self): | ||||
|         """Test creating a group without permission and without superuser flag""" | ||||
|         assign_perm("authentik_core.add_group", self.login_user) | ||||
|         self.client.force_login(self.login_user) | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_api:group-list"), | ||||
|             data={"name": generate_id(), "is_superuser": False}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 201) | ||||
|  | ||||
|     def test_superuser_update_no_perm(self): | ||||
|         """Test updating a superuser group without permission""" | ||||
|         group = Group.objects.create(name=generate_id(), is_superuser=True) | ||||
|  | ||||
| @ -13,7 +13,10 @@ from authentik.core.models import ( | ||||
|     TokenIntents, | ||||
|     User, | ||||
| ) | ||||
| from authentik.core.tasks import clean_expired_models, clean_temporary_users | ||||
| from authentik.core.tasks import ( | ||||
|     clean_expired_models, | ||||
|     clean_temporary_users, | ||||
| ) | ||||
| from authentik.core.tests.utils import create_test_admin_user | ||||
| from authentik.lib.generators import generate_id | ||||
|  | ||||
|  | ||||
| @ -132,13 +132,14 @@ class LicenseKey: | ||||
|         """Get a summarized version of all (not expired) licenses""" | ||||
|         total = LicenseKey(get_license_aud(), 0, "Summarized license", 0, 0) | ||||
|         for lic in License.objects.all(): | ||||
|             if lic.is_valid: | ||||
|                 total.internal_users += lic.internal_users | ||||
|                 total.external_users += lic.external_users | ||||
|                 total.license_flags.extend(lic.status.license_flags) | ||||
|             exp_ts = int(mktime(lic.expiry.timetuple())) | ||||
|             if total.exp == 0: | ||||
|                 total.exp = exp_ts | ||||
|             total.exp = max(total.exp, exp_ts) | ||||
|             total.license_flags.extend(lic.status.license_flags) | ||||
|         return total | ||||
|  | ||||
|     @staticmethod | ||||
|  | ||||
| @ -39,6 +39,10 @@ class License(SerializerModel): | ||||
|     internal_users = models.BigIntegerField() | ||||
|     external_users = models.BigIntegerField() | ||||
|  | ||||
|     @property | ||||
|     def is_valid(self) -> bool: | ||||
|         return self.expiry >= now() | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.enterprise.api import LicenseSerializer | ||||
|  | ||||
							
								
								
									
										0
									
								
								authentik/enterprise/policies/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/enterprise/policies/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										27
									
								
								authentik/enterprise/policies/unique_password/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								authentik/enterprise/policies/unique_password/api.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.enterprise.api import EnterpriseRequiredMixin | ||||
| from authentik.enterprise.policies.unique_password.models import UniquePasswordPolicy | ||||
| from authentik.policies.api.policies import PolicySerializer | ||||
|  | ||||
|  | ||||
| class UniquePasswordPolicySerializer(EnterpriseRequiredMixin, PolicySerializer): | ||||
|     """Password Uniqueness Policy Serializer""" | ||||
|  | ||||
|     class Meta: | ||||
|         model = UniquePasswordPolicy | ||||
|         fields = PolicySerializer.Meta.fields + [ | ||||
|             "password_field", | ||||
|             "num_historical_passwords", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class UniquePasswordPolicyViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Password Uniqueness Policy Viewset""" | ||||
|  | ||||
|     queryset = UniquePasswordPolicy.objects.all() | ||||
|     serializer_class = UniquePasswordPolicySerializer | ||||
|     filterset_fields = "__all__" | ||||
|     ordering = ["name"] | ||||
|     search_fields = ["name"] | ||||
							
								
								
									
										10
									
								
								authentik/enterprise/policies/unique_password/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								authentik/enterprise/policies/unique_password/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,10 @@ | ||||
| """authentik Unique Password policy app config""" | ||||
|  | ||||
| from authentik.enterprise.apps import EnterpriseConfig | ||||
|  | ||||
|  | ||||
| class AuthentikEnterprisePoliciesUniquePasswordConfig(EnterpriseConfig): | ||||
|     name = "authentik.enterprise.policies.unique_password" | ||||
|     label = "authentik_policies_unique_password" | ||||
|     verbose_name = "authentik Enterprise.Policies.Unique Password" | ||||
|     default = True | ||||
| @ -0,0 +1,81 @@ | ||||
| # Generated by Django 5.0.13 on 2025-03-26 23:02 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_policies", "0011_policybinding_failure_result_and_more"), | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="UniquePasswordPolicy", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "policy_ptr", | ||||
|                     models.OneToOneField( | ||||
|                         auto_created=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         parent_link=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         to="authentik_policies.policy", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "password_field", | ||||
|                     models.TextField( | ||||
|                         default="password", | ||||
|                         help_text="Field key to check, field keys defined in Prompt stages are available.", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "num_historical_passwords", | ||||
|                     models.PositiveIntegerField( | ||||
|                         default=1, help_text="Number of passwords to check against." | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Password Uniqueness Policy", | ||||
|                 "verbose_name_plural": "Password Uniqueness Policies", | ||||
|                 "indexes": [ | ||||
|                     models.Index(fields=["policy_ptr_id"], name="authentik_p_policy__f559aa_idx") | ||||
|                 ], | ||||
|             }, | ||||
|             bases=("authentik_policies.policy",), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="UserPasswordHistory", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.AutoField( | ||||
|                         auto_created=True, primary_key=True, serialize=False, verbose_name="ID" | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("old_password", models.CharField(max_length=128)), | ||||
|                 ("created_at", models.DateTimeField(auto_now_add=True)), | ||||
|                 ("hibp_prefix_sha1", models.CharField(max_length=5)), | ||||
|                 ("hibp_pw_hash", models.TextField()), | ||||
|                 ( | ||||
|                     "user", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         related_name="old_passwords", | ||||
|                         to=settings.AUTH_USER_MODEL, | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "User Password History", | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										151
									
								
								authentik/enterprise/policies/unique_password/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								authentik/enterprise/policies/unique_password/models.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,151 @@ | ||||
| from hashlib import sha1 | ||||
|  | ||||
| from django.contrib.auth.hashers import identify_hasher, make_password | ||||
| from django.db import models | ||||
| from django.utils.translation import gettext as _ | ||||
| from rest_framework.serializers import BaseSerializer | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.policies.models import Policy | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| class UniquePasswordPolicy(Policy): | ||||
|     """This policy prevents users from reusing old passwords.""" | ||||
|  | ||||
|     password_field = models.TextField( | ||||
|         default="password", | ||||
|         help_text=_("Field key to check, field keys defined in Prompt stages are available."), | ||||
|     ) | ||||
|  | ||||
|     # Limit on the number of previous passwords the policy evaluates | ||||
|     # Also controls number of old passwords the system stores. | ||||
|     num_historical_passwords = models.PositiveIntegerField( | ||||
|         default=1, | ||||
|         help_text=_("Number of passwords to check against."), | ||||
|     ) | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.enterprise.policies.unique_password.api import UniquePasswordPolicySerializer | ||||
|  | ||||
|         return UniquePasswordPolicySerializer | ||||
|  | ||||
|     @property | ||||
|     def component(self) -> str: | ||||
|         return "ak-policy-password-uniqueness-form" | ||||
|  | ||||
|     def passes(self, request: PolicyRequest) -> PolicyResult: | ||||
|         from authentik.enterprise.policies.unique_password.models import UserPasswordHistory | ||||
|  | ||||
|         password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get( | ||||
|             self.password_field, request.context.get(self.password_field) | ||||
|         ) | ||||
|         if not password: | ||||
|             LOGGER.warning( | ||||
|                 "Password field not found in request when checking UniquePasswordPolicy", | ||||
|                 field=self.password_field, | ||||
|                 fields=request.context.keys(), | ||||
|             ) | ||||
|             return PolicyResult(False, _("Password not set in context")) | ||||
|         password = str(password) | ||||
|  | ||||
|         if not self.num_historical_passwords: | ||||
|             # Policy not configured to check against any passwords | ||||
|             return PolicyResult(True) | ||||
|  | ||||
|         num_to_check = self.num_historical_passwords | ||||
|         password_history = UserPasswordHistory.objects.filter(user=request.user).order_by( | ||||
|             "-created_at" | ||||
|         )[:num_to_check] | ||||
|  | ||||
|         if not password_history: | ||||
|             return PolicyResult(True) | ||||
|  | ||||
|         for record in password_history: | ||||
|             if not record.old_password: | ||||
|                 continue | ||||
|  | ||||
|             if self._passwords_match(new_password=password, old_password=record.old_password): | ||||
|                 # Return on first match. Authentik does not consider timing attacks | ||||
|                 # on old passwords to be an attack surface. | ||||
|                 return PolicyResult( | ||||
|                     False, | ||||
|                     _("This password has been used previously. Please choose a different one."), | ||||
|                 ) | ||||
|  | ||||
|         return PolicyResult(True) | ||||
|  | ||||
|     def _passwords_match(self, *, new_password: str, old_password: str) -> bool: | ||||
|         try: | ||||
|             hasher = identify_hasher(old_password) | ||||
|         except ValueError: | ||||
|             LOGGER.warning( | ||||
|                 "Skipping password; could not load hash algorithm", | ||||
|             ) | ||||
|             return False | ||||
|  | ||||
|         return hasher.verify(new_password, old_password) | ||||
|  | ||||
|     @classmethod | ||||
|     def is_in_use(cls): | ||||
|         """Check if any UniquePasswordPolicy is in use, either through policy bindings | ||||
|         or direct attachment to a PromptStage. | ||||
|  | ||||
|         Returns: | ||||
|             bool: True if any policy is in use, False otherwise | ||||
|         """ | ||||
|         from authentik.policies.models import PolicyBinding | ||||
|  | ||||
|         # Check if any policy is in use through bindings | ||||
|         if PolicyBinding.in_use.for_policy(cls).exists(): | ||||
|             return True | ||||
|  | ||||
|         # Check if any policy is attached to a PromptStage | ||||
|         if cls.objects.filter(promptstage__isnull=False).exists(): | ||||
|             return True | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     class Meta(Policy.PolicyMeta): | ||||
|         verbose_name = _("Password Uniqueness Policy") | ||||
|         verbose_name_plural = _("Password Uniqueness Policies") | ||||
|  | ||||
|  | ||||
| class UserPasswordHistory(models.Model): | ||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="old_passwords") | ||||
|     # Mimic's column type of AbstractBaseUser.password | ||||
|     old_password = models.CharField(max_length=128) | ||||
|     created_at = models.DateTimeField(auto_now_add=True) | ||||
|  | ||||
|     hibp_prefix_sha1 = models.CharField(max_length=5) | ||||
|     hibp_pw_hash = models.TextField() | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("User Password History") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         timestamp = f"{self.created_at:%Y/%m/%d %X}" if self.created_at else "N/A" | ||||
|         return f"Previous Password (user: {self.user_id}, recorded: {timestamp})" | ||||
|  | ||||
|     @classmethod | ||||
|     def create_for_user(cls, user: User, password: str): | ||||
|         # To check users' passwords against Have I been Pwned, we need the first 5 chars | ||||
|         # of the password hashed with SHA1 without a salt... | ||||
|         pw_hash_sha1 = sha1(password.encode("utf-8")).hexdigest()  # nosec | ||||
|         # ...however that'll give us a list of hashes from HIBP, and to compare that we still | ||||
|         # need a full unsalted SHA1 of the password. We don't want to save that directly in | ||||
|         # the database, so we hash that SHA1 again with a modern hashing alg, | ||||
|         # and then when we check users' passwords against HIBP we can use `check_password` | ||||
|         # which will take care of this. | ||||
|         hibp_hash_hash = make_password(pw_hash_sha1) | ||||
|         return cls.objects.create( | ||||
|             user=user, | ||||
|             old_password=password, | ||||
|             hibp_prefix_sha1=pw_hash_sha1[:5], | ||||
|             hibp_pw_hash=hibp_hash_hash, | ||||
|         ) | ||||
							
								
								
									
										20
									
								
								authentik/enterprise/policies/unique_password/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								authentik/enterprise/policies/unique_password/settings.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| """Unique Password Policy settings""" | ||||
|  | ||||
| from celery.schedules import crontab | ||||
|  | ||||
| from authentik.lib.utils.time import fqdn_rand | ||||
|  | ||||
| CELERY_BEAT_SCHEDULE = { | ||||
|     "policies_unique_password_trim_history": { | ||||
|         "task": "authentik.enterprise.policies.unique_password.tasks.trim_password_histories", | ||||
|         "schedule": crontab(minute=fqdn_rand("policies_unique_password_trim"), hour="*/12"), | ||||
|         "options": {"queue": "authentik_scheduled"}, | ||||
|     }, | ||||
|     "policies_unique_password_check_purge": { | ||||
|         "task": ( | ||||
|             "authentik.enterprise.policies.unique_password.tasks.check_and_purge_password_history" | ||||
|         ), | ||||
|         "schedule": crontab(minute=fqdn_rand("policies_unique_password_purge"), hour="*/24"), | ||||
|         "options": {"queue": "authentik_scheduled"}, | ||||
|     }, | ||||
| } | ||||
							
								
								
									
										23
									
								
								authentik/enterprise/policies/unique_password/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								authentik/enterprise/policies/unique_password/signals.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| """authentik policy signals""" | ||||
|  | ||||
| from django.dispatch import receiver | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.core.signals import password_changed | ||||
| from authentik.enterprise.policies.unique_password.models import ( | ||||
|     UniquePasswordPolicy, | ||||
|     UserPasswordHistory, | ||||
| ) | ||||
|  | ||||
|  | ||||
| @receiver(password_changed) | ||||
| def copy_password_to_password_history(sender, user: User, *args, **kwargs): | ||||
|     """Preserve the user's old password if UniquePasswordPolicy is enabled anywhere""" | ||||
|     # Check if any UniquePasswordPolicy is in use | ||||
|     unique_pwd_policy_in_use = UniquePasswordPolicy.is_in_use() | ||||
|  | ||||
|     if unique_pwd_policy_in_use: | ||||
|         """NOTE: Because we run this in a signal after saving the user, | ||||
|         we are not atomically guaranteed to save password history. | ||||
|         """ | ||||
|         UserPasswordHistory.create_for_user(user, user.password) | ||||
							
								
								
									
										66
									
								
								authentik/enterprise/policies/unique_password/tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								authentik/enterprise/policies/unique_password/tasks.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,66 @@ | ||||
| from django.db.models.aggregates import Count | ||||
| from structlog import get_logger | ||||
|  | ||||
| from authentik.enterprise.policies.unique_password.models import ( | ||||
|     UniquePasswordPolicy, | ||||
|     UserPasswordHistory, | ||||
| ) | ||||
| from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task | ||||
| from authentik.root.celery import CELERY_APP | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task(bind=True, base=SystemTask) | ||||
| @prefill_task | ||||
| def check_and_purge_password_history(self: SystemTask): | ||||
|     """Check if any UniquePasswordPolicy exists, and if not, purge the password history table. | ||||
|     This is run on a schedule instead of being triggered by policy binding deletion. | ||||
|     """ | ||||
|     if not UniquePasswordPolicy.objects.exists(): | ||||
|         UserPasswordHistory.objects.all().delete() | ||||
|         LOGGER.debug("Purged UserPasswordHistory table as no policies are in use") | ||||
|         self.set_status(TaskStatus.SUCCESSFUL, "Successfully purged UserPasswordHistory") | ||||
|         return | ||||
|  | ||||
|     self.set_status( | ||||
|         TaskStatus.SUCCESSFUL, "Not purging password histories, a unique password policy exists" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task(bind=True, base=SystemTask) | ||||
| def trim_password_histories(self: SystemTask): | ||||
|     """Removes rows from UserPasswordHistory older than | ||||
|     the `n` most recent entries. | ||||
|  | ||||
|     The `n` is defined by the largest configured value for all bound | ||||
|     UniquePasswordPolicy policies. | ||||
|     """ | ||||
|  | ||||
|     # No policy, we'll let the cleanup above do its thing | ||||
|     if not UniquePasswordPolicy.objects.exists(): | ||||
|         return | ||||
|  | ||||
|     num_rows_to_preserve = 0 | ||||
|     for policy in UniquePasswordPolicy.objects.all(): | ||||
|         num_rows_to_preserve = max(num_rows_to_preserve, policy.num_historical_passwords) | ||||
|  | ||||
|     all_pks_to_keep = [] | ||||
|  | ||||
|     # Get all users who have password history entries | ||||
|     users_with_history = ( | ||||
|         UserPasswordHistory.objects.values("user") | ||||
|         .annotate(count=Count("user")) | ||||
|         .filter(count__gt=0) | ||||
|         .values_list("user", flat=True) | ||||
|     ) | ||||
|     for user_pk in users_with_history: | ||||
|         entries = UserPasswordHistory.objects.filter(user__pk=user_pk) | ||||
|         pks_to_keep = entries.order_by("-created_at")[:num_rows_to_preserve].values_list( | ||||
|             "pk", flat=True | ||||
|         ) | ||||
|         all_pks_to_keep.extend(pks_to_keep) | ||||
|  | ||||
|     num_deleted, _ = UserPasswordHistory.objects.exclude(pk__in=all_pks_to_keep).delete() | ||||
|     LOGGER.debug("Deleted stale password history records", count=num_deleted) | ||||
|     self.set_status(TaskStatus.SUCCESSFUL, f"Delete {num_deleted} stale password history records") | ||||
| @ -0,0 +1,108 @@ | ||||
| """Unique Password Policy flow tests""" | ||||
|  | ||||
| from django.contrib.auth.hashers import make_password | ||||
| from django.urls.base import reverse | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_flow, create_test_user | ||||
| from authentik.enterprise.policies.unique_password.models import ( | ||||
|     UniquePasswordPolicy, | ||||
|     UserPasswordHistory, | ||||
| ) | ||||
| from authentik.flows.models import FlowDesignation, FlowStageBinding | ||||
| from authentik.flows.tests import FlowTestCase | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage | ||||
|  | ||||
|  | ||||
| class TestUniquePasswordPolicyFlow(FlowTestCase): | ||||
|     """Test Unique Password Policy in a flow""" | ||||
|  | ||||
|     REUSED_PASSWORD = "hunter1"  # nosec B105 | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.user = create_test_user() | ||||
|         self.flow = create_test_flow(FlowDesignation.AUTHENTICATION) | ||||
|  | ||||
|         password_prompt = Prompt.objects.create( | ||||
|             name=generate_id(), | ||||
|             field_key="password", | ||||
|             label="PASSWORD_LABEL", | ||||
|             type=FieldTypes.PASSWORD, | ||||
|             required=True, | ||||
|             placeholder="PASSWORD_PLACEHOLDER", | ||||
|         ) | ||||
|  | ||||
|         self.policy = UniquePasswordPolicy.objects.create( | ||||
|             name="password_must_unique", | ||||
|             password_field=password_prompt.field_key, | ||||
|             num_historical_passwords=1, | ||||
|         ) | ||||
|         stage = PromptStage.objects.create(name="prompt-stage") | ||||
|         stage.validation_policies.set([self.policy]) | ||||
|         stage.fields.set( | ||||
|             [ | ||||
|                 password_prompt, | ||||
|             ] | ||||
|         ) | ||||
|         FlowStageBinding.objects.create(target=self.flow, stage=stage, order=2) | ||||
|  | ||||
|         # Seed the user's password history | ||||
|         UserPasswordHistory.create_for_user(self.user, make_password(self.REUSED_PASSWORD)) | ||||
|  | ||||
|     def test_prompt_data(self): | ||||
|         """Test policy attached to a prompt stage""" | ||||
|         # Test the policy directly | ||||
|         from authentik.policies.types import PolicyRequest | ||||
|         from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||
|  | ||||
|         # Create a policy request with the reused password | ||||
|         request = PolicyRequest(user=self.user) | ||||
|         request.context[PLAN_CONTEXT_PROMPT] = {"password": self.REUSED_PASSWORD} | ||||
|  | ||||
|         # Test the policy directly | ||||
|         result = self.policy.passes(request) | ||||
|  | ||||
|         # Verify that the policy fails (returns False) with the expected error message | ||||
|         self.assertFalse(result.passing, "Policy should fail for reused password") | ||||
|         self.assertEqual( | ||||
|             result.messages[0], | ||||
|             "This password has been used previously. Please choose a different one.", | ||||
|             "Incorrect error message", | ||||
|         ) | ||||
|  | ||||
|         # API-based testing approach: | ||||
|  | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|         # Send a POST request to the flow executor with the reused password | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             {"password": self.REUSED_PASSWORD}, | ||||
|         ) | ||||
|         self.assertStageResponse( | ||||
|             response, | ||||
|             self.flow, | ||||
|             component="ak-stage-prompt", | ||||
|             fields=[ | ||||
|                 { | ||||
|                     "choices": None, | ||||
|                     "field_key": "password", | ||||
|                     "label": "PASSWORD_LABEL", | ||||
|                     "order": 0, | ||||
|                     "placeholder": "PASSWORD_PLACEHOLDER", | ||||
|                     "initial_value": "", | ||||
|                     "required": True, | ||||
|                     "type": "password", | ||||
|                     "sub_text": "", | ||||
|                 } | ||||
|             ], | ||||
|             response_errors={ | ||||
|                 "non_field_errors": [ | ||||
|                     { | ||||
|                         "code": "invalid", | ||||
|                         "string": "This password has been used previously. " | ||||
|                         "Please choose a different one.", | ||||
|                     } | ||||
|                 ] | ||||
|             }, | ||||
|         ) | ||||
| @ -0,0 +1,77 @@ | ||||
| """Unique Password Policy tests""" | ||||
|  | ||||
| from django.contrib.auth.hashers import make_password | ||||
| from django.test import TestCase | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.enterprise.policies.unique_password.models import ( | ||||
|     UniquePasswordPolicy, | ||||
|     UserPasswordHistory, | ||||
| ) | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||
|  | ||||
|  | ||||
| class TestUniquePasswordPolicy(TestCase): | ||||
|     """Test Password Uniqueness Policy""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.policy = UniquePasswordPolicy.objects.create( | ||||
|             name="test_unique_password", num_historical_passwords=1 | ||||
|         ) | ||||
|         self.user = User.objects.create(username="test-user") | ||||
|  | ||||
|     def test_invalid(self): | ||||
|         """Test without password present in request""" | ||||
|         request = PolicyRequest(get_anonymous_user()) | ||||
|         result: PolicyResult = self.policy.passes(request) | ||||
|         self.assertFalse(result.passing) | ||||
|         self.assertEqual(result.messages[0], "Password not set in context") | ||||
|  | ||||
|     def test_passes_no_previous_passwords(self): | ||||
|         request = PolicyRequest(get_anonymous_user()) | ||||
|         request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}} | ||||
|         result: PolicyResult = self.policy.passes(request) | ||||
|         self.assertTrue(result.passing) | ||||
|  | ||||
|     def test_passes_passwords_are_different(self): | ||||
|         # Seed database with an old password | ||||
|         UserPasswordHistory.create_for_user(self.user, make_password("hunter1")) | ||||
|  | ||||
|         request = PolicyRequest(self.user) | ||||
|         request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}} | ||||
|         result: PolicyResult = self.policy.passes(request) | ||||
|         self.assertTrue(result.passing) | ||||
|  | ||||
|     def test_passes_multiple_old_passwords(self): | ||||
|         # Seed with multiple old passwords | ||||
|         UserPasswordHistory.objects.bulk_create( | ||||
|             [ | ||||
|                 UserPasswordHistory(user=self.user, old_password=make_password("hunter1")), | ||||
|                 UserPasswordHistory(user=self.user, old_password=make_password("hunter2")), | ||||
|             ] | ||||
|         ) | ||||
|         request = PolicyRequest(self.user) | ||||
|         request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter3"}} | ||||
|         result: PolicyResult = self.policy.passes(request) | ||||
|         self.assertTrue(result.passing) | ||||
|  | ||||
|     def test_fails_password_matches_old_password(self): | ||||
|         # Seed database with an old password | ||||
|  | ||||
|         UserPasswordHistory.create_for_user(self.user, make_password("hunter1")) | ||||
|  | ||||
|         request = PolicyRequest(self.user) | ||||
|         request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter1"}} | ||||
|         result: PolicyResult = self.policy.passes(request) | ||||
|         self.assertFalse(result.passing) | ||||
|  | ||||
|     def test_fails_if_identical_password_with_different_hash_algos(self): | ||||
|         UserPasswordHistory.create_for_user( | ||||
|             self.user, make_password("hunter2", "somesalt", "scrypt") | ||||
|         ) | ||||
|         request = PolicyRequest(self.user) | ||||
|         request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}} | ||||
|         result: PolicyResult = self.policy.passes(request) | ||||
|         self.assertFalse(result.passing) | ||||
| @ -0,0 +1,90 @@ | ||||
| from django.urls import reverse | ||||
|  | ||||
| from authentik.core.models import Group, Source, User | ||||
| from authentik.core.tests.utils import create_test_flow, create_test_user | ||||
| from authentik.enterprise.policies.unique_password.models import ( | ||||
|     UniquePasswordPolicy, | ||||
|     UserPasswordHistory, | ||||
| ) | ||||
| from authentik.flows.markers import StageMarker | ||||
| from authentik.flows.models import FlowStageBinding | ||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan | ||||
| from authentik.flows.tests import FlowTestCase | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.generators import generate_key | ||||
| from authentik.policies.models import PolicyBinding, PolicyBindingModel | ||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||
| from authentik.stages.user_write.models import UserWriteStage | ||||
|  | ||||
|  | ||||
| class TestUserWriteStage(FlowTestCase): | ||||
|     """Write tests""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|         self.flow = create_test_flow() | ||||
|         self.group = Group.objects.create(name="test-group") | ||||
|         self.other_group = Group.objects.create(name="other-group") | ||||
|         self.stage: UserWriteStage = UserWriteStage.objects.create( | ||||
|             name="write", create_users_as_inactive=True, create_users_group=self.group | ||||
|         ) | ||||
|         self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) | ||||
|         self.source = Source.objects.create(name="fake_source") | ||||
|  | ||||
|     def test_save_password_history_if_policy_binding_enforced(self): | ||||
|         """Test user's new password is recorded when ANY enabled UniquePasswordPolicy exists""" | ||||
|         unique_password_policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5) | ||||
|         pbm = PolicyBindingModel.objects.create() | ||||
|         PolicyBinding.objects.create( | ||||
|             target=pbm, policy=unique_password_policy, order=0, enabled=True | ||||
|         ) | ||||
|  | ||||
|         test_user = create_test_user() | ||||
|         # Store original password for verification | ||||
|         original_password = test_user.password | ||||
|  | ||||
|         # We're changing our own password | ||||
|         self.client.force_login(test_user) | ||||
|  | ||||
|         new_password = generate_key() | ||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = test_user | ||||
|         plan.context[PLAN_CONTEXT_PROMPT] = { | ||||
|             "username": test_user.username, | ||||
|             "password": new_password, | ||||
|         } | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|         # Password history should be recorded | ||||
|         user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user) | ||||
|         self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded") | ||||
|         self.assertEqual(len(user_password_history_qs), 1, "expected 1 recorded password") | ||||
|  | ||||
|         # Create a password history entry manually to simulate the signal behavior | ||||
|         # This is what would happen if the signal worked correctly | ||||
|         UserPasswordHistory.objects.create(user=test_user, old_password=original_password) | ||||
|         user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user) | ||||
|         self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded") | ||||
|         self.assertEqual(len(user_password_history_qs), 2, "expected 2 recorded password") | ||||
|  | ||||
|         # Execute the flow by sending a POST request to the flow executor endpoint | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         ) | ||||
|  | ||||
|         # Verify that the request was successful | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         user_qs = User.objects.filter(username=plan.context[PLAN_CONTEXT_PROMPT]["username"]) | ||||
|         self.assertTrue(user_qs.exists()) | ||||
|  | ||||
|         # Verify the password history entry exists | ||||
|         user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user) | ||||
|         self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded") | ||||
|  | ||||
|         self.assertEqual(len(user_password_history_qs), 3, "expected 3 recorded password") | ||||
|         # Verify that one of the entries contains the original password | ||||
|         self.assertTrue( | ||||
|             any(entry.old_password == original_password for entry in user_password_history_qs), | ||||
|             "original password should be in password history table", | ||||
|         ) | ||||
| @ -0,0 +1,178 @@ | ||||
| from datetime import datetime, timedelta | ||||
|  | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_user | ||||
| from authentik.enterprise.policies.unique_password.models import ( | ||||
|     UniquePasswordPolicy, | ||||
|     UserPasswordHistory, | ||||
| ) | ||||
| from authentik.enterprise.policies.unique_password.tasks import ( | ||||
|     check_and_purge_password_history, | ||||
|     trim_password_histories, | ||||
| ) | ||||
| from authentik.policies.models import PolicyBinding, PolicyBindingModel | ||||
|  | ||||
|  | ||||
| class TestUniquePasswordPolicyModel(TestCase): | ||||
|     """Test the UniquePasswordPolicy model methods""" | ||||
|  | ||||
|     def test_is_in_use_with_binding(self): | ||||
|         """Test is_in_use returns True when a policy binding exists""" | ||||
|         # Create a UniquePasswordPolicy and a PolicyBinding for it | ||||
|         policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5) | ||||
|         pbm = PolicyBindingModel.objects.create() | ||||
|         PolicyBinding.objects.create(target=pbm, policy=policy, order=0, enabled=True) | ||||
|  | ||||
|         # Verify is_in_use returns True | ||||
|         self.assertTrue(UniquePasswordPolicy.is_in_use()) | ||||
|  | ||||
|     def test_is_in_use_with_promptstage(self): | ||||
|         """Test is_in_use returns True when attached to a PromptStage""" | ||||
|         from authentik.stages.prompt.models import PromptStage | ||||
|  | ||||
|         # Create a UniquePasswordPolicy and attach it to a PromptStage | ||||
|         policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5) | ||||
|         prompt_stage = PromptStage.objects.create( | ||||
|             name="Test Prompt Stage", | ||||
|         ) | ||||
|         # Use the set() method for many-to-many relationships | ||||
|         prompt_stage.validation_policies.set([policy]) | ||||
|  | ||||
|         # Verify is_in_use returns True | ||||
|         self.assertTrue(UniquePasswordPolicy.is_in_use()) | ||||
|  | ||||
|  | ||||
| class TestTrimAllPasswordHistories(TestCase): | ||||
|     """Test the task that trims password history for all users""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         self.user1 = create_test_user("test-user1") | ||||
|         self.user2 = create_test_user("test-user2") | ||||
|         self.pbm = PolicyBindingModel.objects.create() | ||||
|         # Create a policy with a limit of 1 password | ||||
|         self.policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1) | ||||
|         PolicyBinding.objects.create( | ||||
|             target=self.pbm, | ||||
|             policy=self.policy, | ||||
|             enabled=True, | ||||
|             order=0, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class TestCheckAndPurgePasswordHistory(TestCase): | ||||
|     """Test the scheduled task that checks if any policy is in use and purges if not""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         self.user = create_test_user("test-user") | ||||
|         self.pbm = PolicyBindingModel.objects.create() | ||||
|  | ||||
|     def test_purge_when_no_policy_in_use(self): | ||||
|         """Test that the task purges the table when no policy is in use""" | ||||
|         # Create some password history entries | ||||
|         UserPasswordHistory.create_for_user(self.user, "hunter2") | ||||
|  | ||||
|         # Verify we have entries | ||||
|         self.assertTrue(UserPasswordHistory.objects.exists()) | ||||
|  | ||||
|         # Run the task - should purge since no policy is in use | ||||
|         check_and_purge_password_history() | ||||
|  | ||||
|         # Verify the table is empty | ||||
|         self.assertFalse(UserPasswordHistory.objects.exists()) | ||||
|  | ||||
|     def test_no_purge_when_policy_in_use(self): | ||||
|         """Test that the task doesn't purge when a policy is in use""" | ||||
|         # Create a policy and binding | ||||
|         policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5) | ||||
|         PolicyBinding.objects.create( | ||||
|             target=self.pbm, | ||||
|             policy=policy, | ||||
|             enabled=True, | ||||
|             order=0, | ||||
|         ) | ||||
|  | ||||
|         # Create some password history entries | ||||
|         UserPasswordHistory.create_for_user(self.user, "hunter2") | ||||
|  | ||||
|         # Verify we have entries | ||||
|         self.assertTrue(UserPasswordHistory.objects.exists()) | ||||
|  | ||||
|         # Run the task - should NOT purge since a policy is in use | ||||
|         check_and_purge_password_history() | ||||
|  | ||||
|         # Verify the entries still exist | ||||
|         self.assertTrue(UserPasswordHistory.objects.exists()) | ||||
|  | ||||
|  | ||||
| class TestTrimPasswordHistory(TestCase): | ||||
|     """Test password history cleanup task""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         self.user = create_test_user("test-user") | ||||
|         self.pbm = PolicyBindingModel.objects.create() | ||||
|  | ||||
|     def test_trim_password_history_ok(self): | ||||
|         """Test passwords over the define limit are deleted""" | ||||
|         _now = datetime.now() | ||||
|         UserPasswordHistory.objects.bulk_create( | ||||
|             [ | ||||
|                 UserPasswordHistory( | ||||
|                     user=self.user, | ||||
|                     old_password="hunter1",  # nosec B106 | ||||
|                     created_at=_now - timedelta(days=3), | ||||
|                 ), | ||||
|                 UserPasswordHistory( | ||||
|                     user=self.user, | ||||
|                     old_password="hunter2",  # nosec B106 | ||||
|                     created_at=_now - timedelta(days=2), | ||||
|                 ), | ||||
|                 UserPasswordHistory( | ||||
|                     user=self.user, | ||||
|                     old_password="hunter3",  # nosec B106 | ||||
|                     created_at=_now, | ||||
|                 ), | ||||
|             ] | ||||
|         ) | ||||
|  | ||||
|         policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1) | ||||
|         PolicyBinding.objects.create( | ||||
|             target=self.pbm, | ||||
|             policy=policy, | ||||
|             enabled=True, | ||||
|             order=0, | ||||
|         ) | ||||
|         trim_password_histories.delay() | ||||
|         user_pwd_history_qs = UserPasswordHistory.objects.filter(user=self.user) | ||||
|         self.assertEqual(len(user_pwd_history_qs), 1) | ||||
|  | ||||
|     def test_trim_password_history_policy_diabled_no_op(self): | ||||
|         """Test no passwords removed if policy binding is disabled""" | ||||
|  | ||||
|         # Insert a record to ensure it's not deleted after executing task | ||||
|         UserPasswordHistory.create_for_user(self.user, "hunter2") | ||||
|  | ||||
|         policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1) | ||||
|         PolicyBinding.objects.create( | ||||
|             target=self.pbm, | ||||
|             policy=policy, | ||||
|             enabled=False, | ||||
|             order=0, | ||||
|         ) | ||||
|         trim_password_histories.delay() | ||||
|         self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists()) | ||||
|  | ||||
|     def test_trim_password_history_fewer_records_than_maximum_is_no_op(self): | ||||
|         """Test no passwords deleted if fewer passwords exist than limit""" | ||||
|  | ||||
|         UserPasswordHistory.create_for_user(self.user, "hunter2") | ||||
|  | ||||
|         policy = UniquePasswordPolicy.objects.create(num_historical_passwords=2) | ||||
|         PolicyBinding.objects.create( | ||||
|             target=self.pbm, | ||||
|             policy=policy, | ||||
|             enabled=True, | ||||
|             order=0, | ||||
|         ) | ||||
|         trim_password_histories.delay() | ||||
|         self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists()) | ||||
							
								
								
									
										7
									
								
								authentik/enterprise/policies/unique_password/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								authentik/enterprise/policies/unique_password/urls.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| """API URLs""" | ||||
|  | ||||
| from authentik.enterprise.policies.unique_password.api import UniquePasswordPolicyViewSet | ||||
|  | ||||
| api_urlpatterns = [ | ||||
|     ("policies/unique_password", UniquePasswordPolicyViewSet), | ||||
| ] | ||||
| @ -14,6 +14,7 @@ CELERY_BEAT_SCHEDULE = { | ||||
|  | ||||
| TENANT_APPS = [ | ||||
|     "authentik.enterprise.audit", | ||||
|     "authentik.enterprise.policies.unique_password", | ||||
|     "authentik.enterprise.providers.google_workspace", | ||||
|     "authentik.enterprise.providers.microsoft_entra", | ||||
|     "authentik.enterprise.providers.ssf", | ||||
|  | ||||
| @ -8,6 +8,7 @@ from django.test import TestCase | ||||
| from django.utils.timezone import now | ||||
| from rest_framework.exceptions import ValidationError | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.enterprise.license import LicenseKey | ||||
| from authentik.enterprise.models import ( | ||||
|     THRESHOLD_READ_ONLY_WEEKS, | ||||
| @ -71,9 +72,9 @@ class TestEnterpriseLicense(TestCase): | ||||
|     ) | ||||
|     def test_valid_multiple(self): | ||||
|         """Check license verification""" | ||||
|         lic = License.objects.create(key=generate_id()) | ||||
|         lic = License.objects.create(key=generate_id(), expiry=expiry_valid) | ||||
|         self.assertTrue(lic.status.status().is_valid) | ||||
|         lic2 = License.objects.create(key=generate_id()) | ||||
|         lic2 = License.objects.create(key=generate_id(), expiry=expiry_valid) | ||||
|         self.assertTrue(lic2.status.status().is_valid) | ||||
|         total = LicenseKey.get_total() | ||||
|         self.assertEqual(total.internal_users, 200) | ||||
| @ -232,7 +233,9 @@ class TestEnterpriseLicense(TestCase): | ||||
|     ) | ||||
|     def test_expiry_expired(self): | ||||
|         """Check license verification""" | ||||
|         License.objects.create(key=generate_id()) | ||||
|         User.objects.all().delete() | ||||
|         License.objects.all().delete() | ||||
|         License.objects.create(key=generate_id(), expiry=expiry_expired) | ||||
|         self.assertEqual(LicenseKey.get_total().summary().status, LicenseUsageStatus.EXPIRED) | ||||
|  | ||||
|     @patch( | ||||
|  | ||||
| @ -2,19 +2,20 @@ | ||||
| {% load i18n %} | ||||
| {% load authentik_core %} | ||||
|  | ||||
| <!doctype html> | ||||
| <!DOCTYPE html> | ||||
|  | ||||
| <html lang="en"> | ||||
|     <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||
|         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> | ||||
|     <link rel="icon" href="{{ brand.branding_favicon_url }}" /> | ||||
|     <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}" /> | ||||
|         <link rel="icon" href="{{ brand.branding_favicon_url }}"> | ||||
|         <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> | ||||
|         {% block head_before %} | ||||
|         {% endblock %} | ||||
|     <link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}" /> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}"> | ||||
|         <meta name="sentry-trace" content="{{ sentry_trace }}" /> | ||||
|         <link rel="prefetch" href="{{ flow_background_url }}" /> | ||||
|         {% include "base/header_js.html" %} | ||||
|         <style> | ||||
|           html, | ||||
| @ -22,7 +23,7 @@ | ||||
|             height: 100%; | ||||
|           } | ||||
|           body { | ||||
|         background-image: url("{{ flow.background_url }}"); | ||||
|             background-image: url("{{ flow_background_url }}"); | ||||
|             background-repeat: no-repeat; | ||||
|             background-size: cover; | ||||
|           } | ||||
| @ -45,7 +46,8 @@ | ||||
|     </head> | ||||
|     <body class="d-flex align-items-center py-4 bg-body-tertiary"> | ||||
|       <div class="card m-auto"> | ||||
|       <main class="form-signin w-100 m-auto" id="flow-sfe-container"></main> | ||||
|         <main class="form-signin w-100 m-auto" id="flow-sfe-container"> | ||||
|         </main> | ||||
|         <span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span> | ||||
|       </div> | ||||
|       <script src="{% static 'dist/sfe/index.js' %}"></script> | ||||
|  | ||||
| @ -1,34 +1,28 @@ | ||||
| {% extends "base/skeleton.html" %} | ||||
|  | ||||
| {% load static %} | ||||
| {% load authentik_core %} | ||||
|  | ||||
| {% block head_before %} | ||||
| {{ block.super }} | ||||
|  | ||||
| <link rel="prefetch" href="{{ flow.background_url }}" /> | ||||
|  | ||||
| <link rel="prefetch" href="{{ flow_background_url }}" /> | ||||
| {% if flow.compatibility_mode and not inspector %} | ||||
| <script> | ||||
|   ShadyDOM = { force: !navigator.webdriver }; | ||||
| </script> | ||||
| <script>ShadyDOM = { force: !navigator.webdriver };</script> | ||||
| {% endif %} | ||||
|  | ||||
| {% include "base/header_js.html" %} | ||||
|  | ||||
| <script> | ||||
|   window.authentik.flow = { | ||||
|     layout: "{{ flow.layout }}", | ||||
|   }; | ||||
| window.authentik.flow = { | ||||
|     "layout": "{{ flow.layout }}", | ||||
| }; | ||||
| </script> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block head %} | ||||
| <script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script> | ||||
|  | ||||
| <style data-test-id="flow-root-styles"> | ||||
|   :root { | ||||
|     --ak-flow-background: url("{{ flow.background_url }}"); | ||||
|   } | ||||
| <style> | ||||
| :root { | ||||
|     --ak-flow-background: url("{{ flow_background_url }}"); | ||||
| } | ||||
| </style> | ||||
| {% endblock %} | ||||
|  | ||||
|  | ||||
| @ -48,6 +48,7 @@ class TestFlowInspector(APITestCase): | ||||
|                 "allow_show_password": False, | ||||
|                 "captcha_stage": None, | ||||
|                 "component": "ak-stage-identification", | ||||
|                 "enable_remember_me": False, | ||||
|                 "flow_info": { | ||||
|                     "background": "/static/dist/assets/images/flow_background.jpg", | ||||
|                     "cancel_url": reverse("authentik_flows:cancel"), | ||||
|  | ||||
| @ -69,7 +69,6 @@ SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre" | ||||
| SESSION_KEY_GET = "authentik/flows/get" | ||||
| SESSION_KEY_POST = "authentik/flows/post" | ||||
| SESSION_KEY_HISTORY = "authentik/flows/history" | ||||
| SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started" | ||||
| QS_KEY_TOKEN = "flow_token"  # nosec | ||||
| QS_QUERY = "query" | ||||
|  | ||||
| @ -454,7 +453,6 @@ class FlowExecutorView(APIView): | ||||
|             SESSION_KEY_APPLICATION_PRE, | ||||
|             SESSION_KEY_PLAN, | ||||
|             SESSION_KEY_GET, | ||||
|             SESSION_KEY_AUTH_STARTED, | ||||
|             # We might need the initial POST payloads for later requests | ||||
|             # SESSION_KEY_POST, | ||||
|             # We don't delete the history on purpose, as a user might | ||||
|  | ||||
| @ -6,8 +6,7 @@ from django.shortcuts import get_object_or_404 | ||||
| from ua_parser.user_agent_parser import Parse | ||||
|  | ||||
| from authentik.core.views.interface import InterfaceView | ||||
| from authentik.flows.models import Flow, FlowDesignation | ||||
| from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED | ||||
| from authentik.flows.models import Flow | ||||
|  | ||||
|  | ||||
| class FlowInterfaceView(InterfaceView): | ||||
| @ -16,12 +15,7 @@ class FlowInterfaceView(InterfaceView): | ||||
|     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||
|         flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) | ||||
|         kwargs["flow"] = flow | ||||
|         if ( | ||||
|             not self.request.user.is_authenticated | ||||
|             and flow.designation == FlowDesignation.AUTHENTICATION | ||||
|         ): | ||||
|             self.request.session[SESSION_KEY_AUTH_STARTED] = True | ||||
|             self.request.session.save() | ||||
|         kwargs["flow_background_url"] = flow.background_url(self.request) | ||||
|         kwargs["inspector"] = "inspector" in self.request.GET | ||||
|         return super().get_context_data(**kwargs) | ||||
|  | ||||
|  | ||||
| @ -363,6 +363,9 @@ def django_db_config(config: ConfigLoader | None = None) -> dict: | ||||
|         pool_options = config.get_dict_from_b64_json("postgresql.pool_options", True) | ||||
|         if not pool_options: | ||||
|             pool_options = True | ||||
|     # FIXME: Temporarily force pool to be deactivated. | ||||
|     # See https://github.com/goauthentik/authentik/issues/14320 | ||||
|     pool_options = False | ||||
|  | ||||
|     db = { | ||||
|         "default": { | ||||
|  | ||||
| @ -494,86 +494,88 @@ class TestConfig(TestCase): | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_db_pool(self): | ||||
|         """Test DB Config with pool""" | ||||
|         config = ConfigLoader() | ||||
|         config.set("postgresql.host", "foo") | ||||
|         config.set("postgresql.name", "foo") | ||||
|         config.set("postgresql.user", "foo") | ||||
|         config.set("postgresql.password", "foo") | ||||
|         config.set("postgresql.port", "foo") | ||||
|         config.set("postgresql.test.name", "foo") | ||||
|         config.set("postgresql.use_pool", True) | ||||
|         conf = django_db_config(config) | ||||
|         self.assertEqual( | ||||
|             conf, | ||||
|             { | ||||
|                 "default": { | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "foo", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "pool": True, | ||||
|                         "sslcert": None, | ||||
|                         "sslkey": None, | ||||
|                         "sslmode": None, | ||||
|                         "sslrootcert": None, | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     # FIXME: Temporarily force pool to be deactivated. | ||||
|     # See https://github.com/goauthentik/authentik/issues/14320 | ||||
|     # def test_db_pool(self): | ||||
|     #     """Test DB Config with pool""" | ||||
|     #     config = ConfigLoader() | ||||
|     #     config.set("postgresql.host", "foo") | ||||
|     #     config.set("postgresql.name", "foo") | ||||
|     #     config.set("postgresql.user", "foo") | ||||
|     #     config.set("postgresql.password", "foo") | ||||
|     #     config.set("postgresql.port", "foo") | ||||
|     #     config.set("postgresql.test.name", "foo") | ||||
|     #     config.set("postgresql.use_pool", True) | ||||
|     #     conf = django_db_config(config) | ||||
|     #     self.assertEqual( | ||||
|     #         conf, | ||||
|     #         { | ||||
|     #             "default": { | ||||
|     #                 "ENGINE": "authentik.root.db", | ||||
|     #                 "HOST": "foo", | ||||
|     #                 "NAME": "foo", | ||||
|     #                 "OPTIONS": { | ||||
|     #                     "pool": True, | ||||
|     #                     "sslcert": None, | ||||
|     #                     "sslkey": None, | ||||
|     #                     "sslmode": None, | ||||
|     #                     "sslrootcert": None, | ||||
|     #                 }, | ||||
|     #                 "PASSWORD": "foo", | ||||
|     #                 "PORT": "foo", | ||||
|     #                 "TEST": {"NAME": "foo"}, | ||||
|     #                 "USER": "foo", | ||||
|     #                 "CONN_MAX_AGE": 0, | ||||
|     #                 "CONN_HEALTH_CHECKS": False, | ||||
|     #                 "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|     #             } | ||||
|     #         }, | ||||
|     #     ) | ||||
|  | ||||
|     def test_db_pool_options(self): | ||||
|         """Test DB Config with pool""" | ||||
|         config = ConfigLoader() | ||||
|         config.set("postgresql.host", "foo") | ||||
|         config.set("postgresql.name", "foo") | ||||
|         config.set("postgresql.user", "foo") | ||||
|         config.set("postgresql.password", "foo") | ||||
|         config.set("postgresql.port", "foo") | ||||
|         config.set("postgresql.test.name", "foo") | ||||
|         config.set("postgresql.use_pool", True) | ||||
|         config.set( | ||||
|             "postgresql.pool_options", | ||||
|             base64.b64encode( | ||||
|                 dumps( | ||||
|                     { | ||||
|                         "max_size": 15, | ||||
|                     } | ||||
|                 ).encode() | ||||
|             ).decode(), | ||||
|         ) | ||||
|         conf = django_db_config(config) | ||||
|         self.assertEqual( | ||||
|             conf, | ||||
|             { | ||||
|                 "default": { | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "foo", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "pool": { | ||||
|                             "max_size": 15, | ||||
|                         }, | ||||
|                         "sslcert": None, | ||||
|                         "sslkey": None, | ||||
|                         "sslmode": None, | ||||
|                         "sslrootcert": None, | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|     # def test_db_pool_options(self): | ||||
|     #     """Test DB Config with pool""" | ||||
|     #     config = ConfigLoader() | ||||
|     #     config.set("postgresql.host", "foo") | ||||
|     #     config.set("postgresql.name", "foo") | ||||
|     #     config.set("postgresql.user", "foo") | ||||
|     #     config.set("postgresql.password", "foo") | ||||
|     #     config.set("postgresql.port", "foo") | ||||
|     #     config.set("postgresql.test.name", "foo") | ||||
|     #     config.set("postgresql.use_pool", True) | ||||
|     #     config.set( | ||||
|     #         "postgresql.pool_options", | ||||
|     #         base64.b64encode( | ||||
|     #             dumps( | ||||
|     #                 { | ||||
|     #                     "max_size": 15, | ||||
|     #                 } | ||||
|     #             ).encode() | ||||
|     #         ).decode(), | ||||
|     #     ) | ||||
|     #     conf = django_db_config(config) | ||||
|     #     self.assertEqual( | ||||
|     #         conf, | ||||
|     #         { | ||||
|     #             "default": { | ||||
|     #                 "ENGINE": "authentik.root.db", | ||||
|     #                 "HOST": "foo", | ||||
|     #                 "NAME": "foo", | ||||
|     #                 "OPTIONS": { | ||||
|     #                     "pool": { | ||||
|     #                         "max_size": 15, | ||||
|     #                     }, | ||||
|     #                     "sslcert": None, | ||||
|     #                     "sslkey": None, | ||||
|     #                     "sslmode": None, | ||||
|     #                     "sslrootcert": None, | ||||
|     #                 }, | ||||
|     #                 "PASSWORD": "foo", | ||||
|     #                 "PORT": "foo", | ||||
|     #                 "TEST": {"NAME": "foo"}, | ||||
|     #                 "USER": "foo", | ||||
|     #                 "CONN_MAX_AGE": 0, | ||||
|     #                 "CONN_HEALTH_CHECKS": False, | ||||
|     #                 "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|     #             } | ||||
|     #         }, | ||||
|     #     ) | ||||
|  | ||||
| @ -74,6 +74,8 @@ class OutpostConfig: | ||||
|     kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict) | ||||
|     kubernetes_ingress_secret_name: str = field(default="authentik-outpost-tls") | ||||
|     kubernetes_ingress_class_name: str | None = field(default=None) | ||||
|     kubernetes_httproute_annotations: dict[str, str] = field(default_factory=dict) | ||||
|     kubernetes_httproute_parent_refs: list[dict[str, str]] = field(default_factory=list) | ||||
|     kubernetes_service_type: str = field(default="ClusterIP") | ||||
|     kubernetes_disabled_components: list[str] = field(default_factory=list) | ||||
|     kubernetes_image_pull_secrets: list[str] = field(default_factory=list) | ||||
|  | ||||
| @ -1,4 +1,8 @@ | ||||
| """authentik policies app config""" | ||||
| """Authentik policies app config | ||||
|  | ||||
| Every system policy should be its own Django app under the `policies` app. | ||||
| For example: The 'dummy' policy is available at `authentik.policies.dummy`. | ||||
| """ | ||||
|  | ||||
| from prometheus_client import Gauge, Histogram | ||||
|  | ||||
| @ -35,4 +39,3 @@ class AuthentikPoliciesConfig(ManagedAppConfig): | ||||
|     label = "authentik_policies" | ||||
|     verbose_name = "authentik Policies" | ||||
|     default = True | ||||
|     mountpoint = "policy/" | ||||
|  | ||||
| @ -52,6 +52,13 @@ class PolicyBindingModel(models.Model): | ||||
|         return ["policy", "user", "group"] | ||||
|  | ||||
|  | ||||
| class BoundPolicyQuerySet(models.QuerySet): | ||||
|     """QuerySet for filtering enabled bindings for a Policy type""" | ||||
|  | ||||
|     def for_policy(self, policy: "Policy"): | ||||
|         return self.filter(policy__in=policy._default_manager.all()).filter(enabled=True) | ||||
|  | ||||
|  | ||||
| class PolicyBinding(SerializerModel): | ||||
|     """Relationship between a Policy and a PolicyBindingModel.""" | ||||
|  | ||||
| @ -148,6 +155,9 @@ class PolicyBinding(SerializerModel): | ||||
|             return f"Binding - #{self.order} to {suffix}" | ||||
|         return "" | ||||
|  | ||||
|     objects = models.Manager() | ||||
|     in_use = BoundPolicyQuerySet.as_manager() | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Policy Binding") | ||||
|         verbose_name_plural = _("Policy Bindings") | ||||
|  | ||||
| @ -2,4 +2,6 @@ | ||||
|  | ||||
| from authentik.policies.password.api import PasswordPolicyViewSet | ||||
|  | ||||
| api_urlpatterns = [("policies/password", PasswordPolicyViewSet)] | ||||
| api_urlpatterns = [ | ||||
|     ("policies/password", PasswordPolicyViewSet), | ||||
| ] | ||||
|  | ||||
| @ -1,89 +0,0 @@ | ||||
| {% extends 'login/base_full.html' %} | ||||
|  | ||||
| {% load static %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block head %} | ||||
| {{ block.super }} | ||||
| <script> | ||||
|   let redirecting = false; | ||||
|   const checkAuth = async () => { | ||||
|     if (redirecting) return true; | ||||
|     const url = "{{ check_auth_url }}"; | ||||
|     console.debug("authentik/policies/buffer: Checking authentication..."); | ||||
|     try { | ||||
|       const result = await fetch(url, { | ||||
|         method: "HEAD", | ||||
|       }); | ||||
|       if (result.status >= 400) { | ||||
|         return false | ||||
|       } | ||||
|       console.debug("authentik/policies/buffer: Continuing"); | ||||
|       redirecting = true; | ||||
|       if ("{{ auth_req_method }}" === "post") { | ||||
|         document.querySelector("form").submit(); | ||||
|       } else { | ||||
|         window.location.assign("{{ continue_url|escapejs }}"); | ||||
|       } | ||||
|     } catch { | ||||
|       return false; | ||||
|     } | ||||
|   }; | ||||
|   let timeout = 100; | ||||
|   let offset = 20; | ||||
|   let attempt = 0; | ||||
|   const main = async () => { | ||||
|     attempt += 1; | ||||
|     await checkAuth(); | ||||
|     console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`); | ||||
|     setTimeout(main, timeout); | ||||
|     timeout += (offset * attempt); | ||||
|     if (timeout >= 2000) { | ||||
|       timeout = 2000; | ||||
|     } | ||||
|   } | ||||
|   document.addEventListener("visibilitychange", async () => { | ||||
|     if (document.hidden) return; | ||||
|     console.debug("authentik/policies/buffer: Checking authentication on tab activate..."); | ||||
|     await checkAuth(); | ||||
|   }); | ||||
|   main(); | ||||
| </script> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block title %} | ||||
| {% trans 'Waiting for authentication...' %} - {{ brand.branding_title }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block card_title %} | ||||
| {% trans 'Waiting for authentication...' %} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block card %} | ||||
| <form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}"> | ||||
|   {% if auth_req_method == "post" %} | ||||
|     {% for key, value in auth_req_body.items %} | ||||
|       <input type="hidden" name="{{ key }}" value="{{ value }}" /> | ||||
|     {% endfor %} | ||||
|   {% endif %} | ||||
|   <div class="pf-c-empty-state"> | ||||
|     <div class="pf-c-empty-state__content"> | ||||
|       <div class="pf-c-empty-state__icon"> | ||||
|         <span class="pf-c-spinner pf-m-xl" role="progressbar"> | ||||
|           <span class="pf-c-spinner__clipper"></span> | ||||
|           <span class="pf-c-spinner__lead-ball"></span> | ||||
|           <span class="pf-c-spinner__tail-ball"></span> | ||||
|         </span> | ||||
|       </div> | ||||
|       <h1 class="pf-c-title pf-m-lg"> | ||||
|         {% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %} | ||||
|       </h1> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="pf-c-form__group pf-m-action"> | ||||
|     <a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block"> | ||||
|       {% trans "Authenticate in this tab" %} | ||||
|     </a> | ||||
|   </div> | ||||
| </form> | ||||
| {% endblock %} | ||||
| @ -1,121 +0,0 @@ | ||||
| from django.contrib.auth.models import AnonymousUser | ||||
| from django.contrib.sessions.middleware import SessionMiddleware | ||||
| from django.http import HttpResponse | ||||
| from django.test import RequestFactory, TestCase | ||||
| from django.urls import reverse | ||||
|  | ||||
| from authentik.core.models import Application, Provider | ||||
| from authentik.core.tests.utils import create_test_flow, create_test_user | ||||
| from authentik.flows.models import FlowDesignation | ||||
| from authentik.flows.planner import FlowPlan | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.tests.utils import dummy_get_response | ||||
| from authentik.policies.views import ( | ||||
|     QS_BUFFER_ID, | ||||
|     SESSION_KEY_BUFFER, | ||||
|     BufferedPolicyAccessView, | ||||
|     BufferView, | ||||
|     PolicyAccessView, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class TestPolicyViews(TestCase): | ||||
|     """Test PolicyAccessView""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|         self.factory = RequestFactory() | ||||
|         self.user = create_test_user() | ||||
|  | ||||
|     def test_pav(self): | ||||
|         """Test simple policy access view""" | ||||
|         provider = Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|         ) | ||||
|         app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider) | ||||
|  | ||||
|         class TestView(PolicyAccessView): | ||||
|             def resolve_provider_application(self): | ||||
|                 self.provider = provider | ||||
|                 self.application = app | ||||
|  | ||||
|             def get(self, *args, **kwargs): | ||||
|                 return HttpResponse("foo") | ||||
|  | ||||
|         req = self.factory.get("/") | ||||
|         req.user = self.user | ||||
|         res = TestView.as_view()(req) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|         self.assertEqual(res.content, b"foo") | ||||
|  | ||||
|     def test_pav_buffer(self): | ||||
|         """Test simple policy access view""" | ||||
|         provider = Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|         ) | ||||
|         app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider) | ||||
|         flow = create_test_flow(FlowDesignation.AUTHENTICATION) | ||||
|  | ||||
|         class TestView(BufferedPolicyAccessView): | ||||
|             def resolve_provider_application(self): | ||||
|                 self.provider = provider | ||||
|                 self.application = app | ||||
|  | ||||
|             def get(self, *args, **kwargs): | ||||
|                 return HttpResponse("foo") | ||||
|  | ||||
|         req = self.factory.get("/") | ||||
|         req.user = AnonymousUser() | ||||
|         middleware = SessionMiddleware(dummy_get_response) | ||||
|         middleware.process_request(req) | ||||
|         req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk) | ||||
|         req.session.save() | ||||
|         res = TestView.as_view()(req) | ||||
|         self.assertEqual(res.status_code, 302) | ||||
|         self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer"))) | ||||
|  | ||||
|     def test_pav_buffer_skip(self): | ||||
|         """Test simple policy access view (skip buffer)""" | ||||
|         provider = Provider.objects.create( | ||||
|             name=generate_id(), | ||||
|         ) | ||||
|         app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider) | ||||
|         flow = create_test_flow(FlowDesignation.AUTHENTICATION) | ||||
|  | ||||
|         class TestView(BufferedPolicyAccessView): | ||||
|             def resolve_provider_application(self): | ||||
|                 self.provider = provider | ||||
|                 self.application = app | ||||
|  | ||||
|             def get(self, *args, **kwargs): | ||||
|                 return HttpResponse("foo") | ||||
|  | ||||
|         req = self.factory.get("/?skip_buffer=true") | ||||
|         req.user = AnonymousUser() | ||||
|         middleware = SessionMiddleware(dummy_get_response) | ||||
|         middleware.process_request(req) | ||||
|         req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk) | ||||
|         req.session.save() | ||||
|         res = TestView.as_view()(req) | ||||
|         self.assertEqual(res.status_code, 302) | ||||
|         self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication"))) | ||||
|  | ||||
|     def test_buffer(self): | ||||
|         """Test buffer view""" | ||||
|         uid = generate_id() | ||||
|         req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}") | ||||
|         req.user = AnonymousUser() | ||||
|         middleware = SessionMiddleware(dummy_get_response) | ||||
|         middleware.process_request(req) | ||||
|         ts = generate_id() | ||||
|         req.session[SESSION_KEY_BUFFER % uid] = { | ||||
|             "method": "get", | ||||
|             "body": {}, | ||||
|             "url": f"/{ts}", | ||||
|         } | ||||
|         req.session.save() | ||||
|  | ||||
|         res = BufferView.as_view()(req) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|         self.assertIn(ts, res.render().content.decode()) | ||||
| @ -1,14 +1,7 @@ | ||||
| """API URLs""" | ||||
|  | ||||
| from django.urls import path | ||||
|  | ||||
| from authentik.policies.api.bindings import PolicyBindingViewSet | ||||
| from authentik.policies.api.policies import PolicyViewSet | ||||
| from authentik.policies.views import BufferView | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("buffer", BufferView.as_view(), name="buffer"), | ||||
| ] | ||||
|  | ||||
| api_urlpatterns = [ | ||||
|     ("policies/all", PolicyViewSet), | ||||
|  | ||||
| @ -1,37 +1,23 @@ | ||||
| """authentik access helper classes""" | ||||
|  | ||||
| from typing import Any | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from django.contrib import messages | ||||
| from django.contrib.auth.mixins import AccessMixin | ||||
| from django.contrib.auth.views import redirect_to_login | ||||
| from django.http import HttpRequest, HttpResponse, QueryDict | ||||
| from django.shortcuts import redirect | ||||
| from django.urls import reverse | ||||
| from django.utils.http import urlencode | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.utils.translation import gettext as _ | ||||
| from django.views.generic.base import TemplateView, View | ||||
| from django.views.generic.base import View | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import Application, Provider, User | ||||
| from authentik.flows.models import Flow, FlowDesignation | ||||
| from authentik.flows.planner import FlowPlan | ||||
| from authentik.flows.views.executor import ( | ||||
|     SESSION_KEY_APPLICATION_PRE, | ||||
|     SESSION_KEY_AUTH_STARTED, | ||||
|     SESSION_KEY_PLAN, | ||||
|     SESSION_KEY_POST, | ||||
| ) | ||||
| from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.policies.denied import AccessDeniedResponse | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| QS_BUFFER_ID = "af_bf_id" | ||||
| QS_SKIP_BUFFER = "skip_buffer" | ||||
| SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s" | ||||
|  | ||||
|  | ||||
| class RequestValidationError(SentryIgnoredException): | ||||
| @ -139,65 +125,3 @@ class PolicyAccessView(AccessMixin, View): | ||||
|             for message in result.messages: | ||||
|                 messages.error(self.request, _(message)) | ||||
|         return result | ||||
|  | ||||
|  | ||||
| def url_with_qs(url: str, **kwargs): | ||||
|     """Update/set querystring of `url` with the parameters in `kwargs`. Original query string | ||||
|     parameters are retained""" | ||||
|     if "?" not in url: | ||||
|         return url + f"?{urlencode(kwargs)}" | ||||
|     url, _, qs = url.partition("?") | ||||
|     qs = QueryDict(qs, mutable=True) | ||||
|     qs.update(kwargs) | ||||
|     return url + f"?{urlencode(qs.items())}" | ||||
|  | ||||
|  | ||||
| class BufferView(TemplateView): | ||||
|     """Buffer view""" | ||||
|  | ||||
|     template_name = "policies/buffer.html" | ||||
|  | ||||
|     def get_context_data(self, **kwargs): | ||||
|         buf_id = self.request.GET.get(QS_BUFFER_ID) | ||||
|         buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id) | ||||
|         kwargs["auth_req_method"] = buffer["method"] | ||||
|         kwargs["auth_req_body"] = buffer["body"] | ||||
|         kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True}) | ||||
|         kwargs["check_auth_url"] = reverse("authentik_api:user-me") | ||||
|         kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id}) | ||||
|         return super().get_context_data(**kwargs) | ||||
|  | ||||
|  | ||||
| class BufferedPolicyAccessView(PolicyAccessView): | ||||
|     """PolicyAccessView which buffers access requests in case the user is not logged in""" | ||||
|  | ||||
|     def handle_no_permission(self): | ||||
|         plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN) | ||||
|         authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED) | ||||
|         if plan: | ||||
|             flow = Flow.objects.filter(pk=plan.flow_pk).first() | ||||
|             if not flow or flow.designation != FlowDesignation.AUTHENTICATION: | ||||
|                 LOGGER.debug("Not buffering request, no flow or flow not for authentication") | ||||
|                 return super().handle_no_permission() | ||||
|         if not plan and authenticating is None: | ||||
|             LOGGER.debug("Not buffering request, no flow plan active") | ||||
|             return super().handle_no_permission() | ||||
|         if self.request.GET.get(QS_SKIP_BUFFER): | ||||
|             LOGGER.debug("Not buffering request, explicit skip") | ||||
|             return super().handle_no_permission() | ||||
|         buffer_id = str(uuid4()) | ||||
|         LOGGER.debug("Buffering access request", bf_id=buffer_id) | ||||
|         self.request.session[SESSION_KEY_BUFFER % buffer_id] = { | ||||
|             "body": self.request.POST, | ||||
|             "url": self.request.build_absolute_uri(self.request.get_full_path()), | ||||
|             "method": self.request.method.lower(), | ||||
|         } | ||||
|         return redirect( | ||||
|             url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id}) | ||||
|         ) | ||||
|  | ||||
|     def dispatch(self, request, *args, **kwargs): | ||||
|         response = super().dispatch(request, *args, **kwargs) | ||||
|         if QS_BUFFER_ID in self.request.GET: | ||||
|             self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None) | ||||
|         return response | ||||
|  | ||||
| @ -30,7 +30,7 @@ from authentik.flows.stage import StageView | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.policies.types import PolicyRequest | ||||
| from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError | ||||
| from authentik.policies.views import PolicyAccessView, RequestValidationError | ||||
| from authentik.providers.oauth2.constants import ( | ||||
|     PKCE_METHOD_PLAIN, | ||||
|     PKCE_METHOD_S256, | ||||
| @ -326,7 +326,7 @@ class OAuthAuthorizationParams: | ||||
|         return code | ||||
|  | ||||
|  | ||||
| class AuthorizationFlowInitView(BufferedPolicyAccessView): | ||||
| class AuthorizationFlowInitView(PolicyAccessView): | ||||
|     """OAuth2 Flow initializer, checks access to application and starts flow""" | ||||
|  | ||||
|     params: OAuthAuthorizationParams | ||||
|  | ||||
							
								
								
									
										234
									
								
								authentik/providers/proxy/controllers/k8s/httproute.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								authentik/providers/proxy/controllers/k8s/httproute.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,234 @@ | ||||
| from dataclasses import asdict, dataclass, field | ||||
| from typing import TYPE_CHECKING | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| from dacite.core import from_dict | ||||
| from kubernetes.client import ApiextensionsV1Api, CustomObjectsApi, V1ObjectMeta | ||||
|  | ||||
| from authentik.outposts.controllers.base import FIELD_MANAGER | ||||
| from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler | ||||
| from authentik.outposts.controllers.k8s.triggers import NeedsUpdate | ||||
| from authentik.outposts.controllers.kubernetes import KubernetesController | ||||
| from authentik.providers.proxy.models import ProxyMode, ProxyProvider | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.outposts.controllers.kubernetes import KubernetesController | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| class RouteBackendRef: | ||||
|     name: str | ||||
|     port: int | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| class RouteSpecParentRefs: | ||||
|     name: str | ||||
|     sectionName: str | None = None | ||||
|     port: int | None = None | ||||
|     namespace: str | None = None | ||||
|     kind: str = "Gateway" | ||||
|     group: str = "gateway.networking.k8s.io" | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| class HTTPRouteSpecRuleMatchPath: | ||||
|     type: str | ||||
|     value: str | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| class HTTPRouteSpecRuleMatchHeader: | ||||
|     name: str | ||||
|     value: str | ||||
|     type: str = "Exact" | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| class HTTPRouteSpecRuleMatch: | ||||
|     path: HTTPRouteSpecRuleMatchPath | ||||
|     headers: list[HTTPRouteSpecRuleMatchHeader] | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| class HTTPRouteSpecRule: | ||||
|     backendRefs: list[RouteBackendRef] | ||||
|     matches: list[HTTPRouteSpecRuleMatch] | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| class HTTPRouteSpec: | ||||
|     parentRefs: list[RouteSpecParentRefs] | ||||
|     hostnames: list[str] | ||||
|     rules: list[HTTPRouteSpecRule] | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| class HTTPRouteMetadata: | ||||
|     name: str | ||||
|     namespace: str | ||||
|     annotations: dict = field(default_factory=dict) | ||||
|     labels: dict = field(default_factory=dict) | ||||
|  | ||||
|  | ||||
| @dataclass(slots=True) | ||||
| class HTTPRoute: | ||||
|     apiVersion: str | ||||
|     kind: str | ||||
|     metadata: HTTPRouteMetadata | ||||
|     spec: HTTPRouteSpec | ||||
|  | ||||
|  | ||||
| class HTTPRouteReconciler(KubernetesObjectReconciler): | ||||
|     """Kubernetes Gateway API HTTPRoute Reconciler""" | ||||
|  | ||||
|     def __init__(self, controller: "KubernetesController") -> None: | ||||
|         super().__init__(controller) | ||||
|         self.api_ex = ApiextensionsV1Api(controller.client) | ||||
|         self.api = CustomObjectsApi(controller.client) | ||||
|         self.crd_group = "gateway.networking.k8s.io" | ||||
|         self.crd_version = "v1" | ||||
|         self.crd_plural = "httproutes" | ||||
|  | ||||
|     @staticmethod | ||||
|     def reconciler_name() -> str: | ||||
|         return "httproute" | ||||
|  | ||||
|     @property | ||||
|     def noop(self) -> bool: | ||||
|         if not self.crd_exists(): | ||||
|             self.logger.debug("CRD doesn't exist") | ||||
|             return True | ||||
|         if not self.controller.outpost.config.kubernetes_httproute_parent_refs: | ||||
|             self.logger.debug("HTTPRoute parentRefs not set.") | ||||
|             return True | ||||
|         return False | ||||
|  | ||||
|     def crd_exists(self) -> bool: | ||||
|         """Check if the Gateway API resources exists""" | ||||
|         return bool( | ||||
|             len( | ||||
|                 self.api_ex.list_custom_resource_definition( | ||||
|                     field_selector=f"metadata.name={self.crd_plural}.{self.crd_group}" | ||||
|                 ).items | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     def reconcile(self, current: HTTPRoute, reference: HTTPRoute): | ||||
|         super().reconcile(current, reference) | ||||
|         if current.metadata.annotations != reference.metadata.annotations: | ||||
|             raise NeedsUpdate() | ||||
|         if current.spec.parentRefs != reference.spec.parentRefs: | ||||
|             raise NeedsUpdate() | ||||
|         if current.spec.hostnames != reference.spec.hostnames: | ||||
|             raise NeedsUpdate() | ||||
|         if current.spec.rules != reference.spec.rules: | ||||
|             raise NeedsUpdate() | ||||
|  | ||||
|     def get_object_meta(self, **kwargs) -> V1ObjectMeta: | ||||
|         return super().get_object_meta( | ||||
|             **kwargs, | ||||
|         ) | ||||
|  | ||||
|     def get_reference_object(self) -> HTTPRoute: | ||||
|         hostnames = [] | ||||
|         rules = [] | ||||
|  | ||||
|         for proxy_provider in ProxyProvider.objects.filter(outpost__in=[self.controller.outpost]): | ||||
|             proxy_provider: ProxyProvider | ||||
|             external_host_name = urlparse(proxy_provider.external_host) | ||||
|             if proxy_provider.mode in [ProxyMode.FORWARD_SINGLE, ProxyMode.FORWARD_DOMAIN]: | ||||
|                 rule = HTTPRouteSpecRule( | ||||
|                     backendRefs=[RouteBackendRef(name=self.name, port=9000)], | ||||
|                     matches=[ | ||||
|                         HTTPRouteSpecRuleMatch( | ||||
|                             headers=[ | ||||
|                                 HTTPRouteSpecRuleMatchHeader( | ||||
|                                     name="Host", | ||||
|                                     value=external_host_name.hostname, | ||||
|                                 ) | ||||
|                             ], | ||||
|                             path=HTTPRouteSpecRuleMatchPath( | ||||
|                                 type="PathPrefix", value="/outpost.goauthentik.io" | ||||
|                             ), | ||||
|                         ) | ||||
|                     ], | ||||
|                 ) | ||||
|             else: | ||||
|                 rule = HTTPRouteSpecRule( | ||||
|                     backendRefs=[RouteBackendRef(name=self.name, port=9000)], | ||||
|                     matches=[ | ||||
|                         HTTPRouteSpecRuleMatch( | ||||
|                             headers=[ | ||||
|                                 HTTPRouteSpecRuleMatchHeader( | ||||
|                                     name="Host", | ||||
|                                     value=external_host_name.hostname, | ||||
|                                 ) | ||||
|                             ], | ||||
|                             path=HTTPRouteSpecRuleMatchPath(type="PathPrefix", value="/"), | ||||
|                         ) | ||||
|                     ], | ||||
|                 ) | ||||
|             hostnames.append(external_host_name.hostname) | ||||
|             rules.append(rule) | ||||
|  | ||||
|         return HTTPRoute( | ||||
|             apiVersion=f"{self.crd_group}/{self.crd_version}", | ||||
|             kind="HTTPRoute", | ||||
|             metadata=HTTPRouteMetadata( | ||||
|                 name=self.name, | ||||
|                 namespace=self.namespace, | ||||
|                 annotations=self.controller.outpost.config.kubernetes_httproute_annotations, | ||||
|                 labels=self.get_object_meta().labels, | ||||
|             ), | ||||
|             spec=HTTPRouteSpec( | ||||
|                 parentRefs=[ | ||||
|                     from_dict(RouteSpecParentRefs, spec) | ||||
|                     for spec in self.controller.outpost.config.kubernetes_httproute_parent_refs | ||||
|                 ], | ||||
|                 hostnames=hostnames, | ||||
|                 rules=rules, | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     def create(self, reference: HTTPRoute): | ||||
|         return self.api.create_namespaced_custom_object( | ||||
|             group=self.crd_group, | ||||
|             version=self.crd_version, | ||||
|             plural=self.crd_plural, | ||||
|             namespace=self.namespace, | ||||
|             body=asdict(reference), | ||||
|             field_manager=FIELD_MANAGER, | ||||
|         ) | ||||
|  | ||||
|     def delete(self, reference: HTTPRoute): | ||||
|         return self.api.delete_namespaced_custom_object( | ||||
|             group=self.crd_group, | ||||
|             version=self.crd_version, | ||||
|             plural=self.crd_plural, | ||||
|             namespace=self.namespace, | ||||
|             name=self.name, | ||||
|         ) | ||||
|  | ||||
|     def retrieve(self) -> HTTPRoute: | ||||
|         return from_dict( | ||||
|             HTTPRoute, | ||||
|             self.api.get_namespaced_custom_object( | ||||
|                 group=self.crd_group, | ||||
|                 version=self.crd_version, | ||||
|                 plural=self.crd_plural, | ||||
|                 namespace=self.namespace, | ||||
|                 name=self.name, | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     def update(self, current: HTTPRoute, reference: HTTPRoute): | ||||
|         return self.api.patch_namespaced_custom_object( | ||||
|             group=self.crd_group, | ||||
|             version=self.crd_version, | ||||
|             plural=self.crd_plural, | ||||
|             namespace=self.namespace, | ||||
|             name=self.name, | ||||
|             body=asdict(reference), | ||||
|             field_manager=FIELD_MANAGER, | ||||
|         ) | ||||
| @ -3,6 +3,7 @@ | ||||
| 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.httproute import HTTPRouteReconciler | ||||
| from authentik.providers.proxy.controllers.k8s.ingress import IngressReconciler | ||||
| from authentik.providers.proxy.controllers.k8s.traefik import TraefikMiddlewareReconciler | ||||
|  | ||||
| @ -18,8 +19,10 @@ class ProxyKubernetesController(KubernetesController): | ||||
|             DeploymentPort(9443, "https", "tcp"), | ||||
|         ] | ||||
|         self.reconcilers[IngressReconciler.reconciler_name()] = IngressReconciler | ||||
|         self.reconcilers[HTTPRouteReconciler.reconciler_name()] = HTTPRouteReconciler | ||||
|         self.reconcilers[TraefikMiddlewareReconciler.reconciler_name()] = ( | ||||
|             TraefikMiddlewareReconciler | ||||
|         ) | ||||
|         self.reconcile_order.append(IngressReconciler.reconciler_name()) | ||||
|         self.reconcile_order.append(HTTPRouteReconciler.reconciler_name()) | ||||
|         self.reconcile_order.append(TraefikMiddlewareReconciler.reconciler_name()) | ||||
|  | ||||
| @ -66,7 +66,10 @@ class RACClientConsumer(AsyncWebsocketConsumer): | ||||
|     def init_outpost_connection(self): | ||||
|         """Initialize guac connection settings""" | ||||
|         self.token = ( | ||||
|             ConnectionToken.filter_not_expired(token=self.scope["url_route"]["kwargs"]["token"]) | ||||
|             ConnectionToken.filter_not_expired( | ||||
|                 token=self.scope["url_route"]["kwargs"]["token"], | ||||
|                 session__session__session_key=self.scope["session"].session_key, | ||||
|             ) | ||||
|             .select_related("endpoint", "provider", "session", "session__user") | ||||
|             .first() | ||||
|         ) | ||||
|  | ||||
| @ -4,13 +4,10 @@ | ||||
|  | ||||
| {% block head %} | ||||
| <script src="{% versioned_script 'dist/rac/index-%v.js' %}" type="module"></script> | ||||
|  | ||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)" /> | ||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> | ||||
|  | ||||
| <link rel="icon" href="{{ tenant.branding_favicon_url }}" /> | ||||
| <link rel="shortcut icon" href="{{ tenant.branding_favicon_url }}" /> | ||||
|  | ||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | ||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | ||||
| <link rel="icon" href="{{ tenant.branding_favicon_url }}"> | ||||
| <link rel="shortcut icon" href="{{ tenant.branding_favicon_url }}"> | ||||
| {% include "base/header_js.html" %} | ||||
| {% endblock %} | ||||
|  | ||||
|  | ||||
| @ -87,3 +87,22 @@ class TestRACViews(APITestCase): | ||||
|         ) | ||||
|         body = loads(flow_response.content) | ||||
|         self.assertEqual(body["component"], "ak-stage-access-denied") | ||||
|  | ||||
|     def test_different_session(self): | ||||
|         """Test request""" | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get( | ||||
|             reverse( | ||||
|                 "authentik_providers_rac:start", | ||||
|                 kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)}, | ||||
|             ) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         flow_response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         ) | ||||
|         body = loads(flow_response.content) | ||||
|         next_url = body["to"] | ||||
|         self.client.logout() | ||||
|         final_response = self.client.get(next_url) | ||||
|         self.assertEqual(final_response.url, reverse("authentik_core:if-user")) | ||||
|  | ||||
| @ -18,11 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| from authentik.flows.stage import RedirectStage | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.policies.views import BufferedPolicyAccessView | ||||
| from authentik.policies.views import PolicyAccessView | ||||
| from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider | ||||
|  | ||||
|  | ||||
| class RACStartView(BufferedPolicyAccessView): | ||||
| class RACStartView(PolicyAccessView): | ||||
|     """Start a RAC connection by checking access and creating a connection token""" | ||||
|  | ||||
|     endpoint: Endpoint | ||||
| @ -65,7 +65,10 @@ class RACInterface(InterfaceView): | ||||
|  | ||||
|     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: | ||||
|         # Early sanity check to ensure token still exists | ||||
|         token = ConnectionToken.filter_not_expired(token=self.kwargs["token"]).first() | ||||
|         token = ConnectionToken.filter_not_expired( | ||||
|             token=self.kwargs["token"], | ||||
|             session__session__session_key=request.session.session_key, | ||||
|         ).first() | ||||
|         if not token: | ||||
|             return redirect("authentik_core:if-user") | ||||
|         self.token = token | ||||
|  | ||||
| @ -15,7 +15,7 @@ from authentik.flows.models import in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | ||||
| from authentik.flows.views.executor import SESSION_KEY_POST | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.policies.views import BufferedPolicyAccessView | ||||
| from authentik.policies.views import PolicyAccessView | ||||
| from authentik.providers.saml.exceptions import CannotHandleAssertion | ||||
| from authentik.providers.saml.models import SAMLBindings, SAMLProvider | ||||
| from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser | ||||
| @ -35,7 +35,7 @@ from authentik.stages.consent.stage import ( | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| class SAMLSSOView(BufferedPolicyAccessView): | ||||
| class SAMLSSOView(PolicyAccessView): | ||||
|     """SAML SSO Base View, which plans a flow and injects our final stage. | ||||
|     Calls get/post handler.""" | ||||
|  | ||||
| @ -83,7 +83,7 @@ class SAMLSSOView(BufferedPolicyAccessView): | ||||
|  | ||||
|     def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: | ||||
|         """GET and POST use the same handler, but we can't | ||||
|         override .dispatch easily because BufferedPolicyAccessView's dispatch""" | ||||
|         override .dispatch easily because PolicyAccessView's dispatch""" | ||||
|         return self.get(request, application_slug) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -99,6 +99,7 @@ class RBACPermissionViewSet(ReadOnlyModelViewSet): | ||||
|     filterset_class = PermissionFilter | ||||
|     permission_classes = [IsAuthenticated] | ||||
|     search_fields = [ | ||||
|         "name", | ||||
|         "codename", | ||||
|         "content_type__model", | ||||
|         "content_type__app_label", | ||||
|  | ||||
| @ -97,6 +97,7 @@ class GroupsView(SCIMObjectView): | ||||
|                     self.logger.warning("Invalid group member", exc=exc) | ||||
|                     continue | ||||
|                 query |= Q(uuid=member.value) | ||||
|             if query: | ||||
|                 group.users.set(User.objects.filter(query)) | ||||
|         if not connection: | ||||
|             connection, _ = SCIMSourceGroup.objects.get_or_create( | ||||
|  | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @ -36,6 +36,7 @@ class IdentificationStageSerializer(StageSerializer): | ||||
|             "sources", | ||||
|             "show_source_labels", | ||||
|             "pretend_user_exists", | ||||
|             "enable_remember_me", | ||||
|         ] | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -0,0 +1,21 @@ | ||||
| # Generated by Django 5.1.8 on 2025-04-16 17:14 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_stages_identification", "0015_identificationstage_captcha_stage"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="identificationstage", | ||||
|             name="enable_remember_me", | ||||
|             field=models.BooleanField( | ||||
|                 default=False, | ||||
|                 help_text="Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password.", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -76,7 +76,13 @@ class IdentificationStage(Stage): | ||||
|             "is entered." | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     enable_remember_me = models.BooleanField( | ||||
|         default=False, | ||||
|         help_text=_( | ||||
|             "Show the user the 'Remember me on this device' toggle, allowing repeat " | ||||
|             "users to skip straight to entering their password." | ||||
|         ), | ||||
|     ) | ||||
|     enrollment_flow = models.ForeignKey( | ||||
|         Flow, | ||||
|         on_delete=models.SET_DEFAULT, | ||||
|  | ||||
| @ -85,6 +85,7 @@ class IdentificationChallenge(Challenge): | ||||
|     primary_action = CharField() | ||||
|     sources = LoginSourceSerializer(many=True, required=False) | ||||
|     show_source_labels = BooleanField() | ||||
|     enable_remember_me = BooleanField(required=False, default=True) | ||||
|  | ||||
|     component = CharField(default="ak-stage-identification") | ||||
|  | ||||
| @ -235,6 +236,7 @@ class IdentificationStageView(ChallengeStageView): | ||||
|                 and current_stage.password_stage.allow_show_password, | ||||
|                 "show_source_labels": current_stage.show_source_labels, | ||||
|                 "flow_designation": self.executor.flow.designation, | ||||
|                 "enable_remember_me": current_stage.enable_remember_me, | ||||
|             } | ||||
|         ) | ||||
|         # If the user has been redirected to us whilst trying to access an | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	