Compare commits
	
		
			3 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 63cfbb721c | |||
| 2b74a1f03b | |||
| 093573f89a | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 2023.4.3 | ||||
| current_version = 2023.2.3 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+) | ||||
|  | ||||
| @ -7,14 +7,8 @@ charset = utf-8 | ||||
| trim_trailing_whitespace = true | ||||
| insert_final_newline = true | ||||
|  | ||||
| [*.html] | ||||
| [html] | ||||
| indent_size = 2 | ||||
|  | ||||
| [*.{yaml,yml}] | ||||
| [yaml] | ||||
| indent_size = 2 | ||||
|  | ||||
| [*.go] | ||||
| indent_style = tab | ||||
|  | ||||
| [Makefile] | ||||
| indent_style = tab | ||||
|  | ||||
							
								
								
									
										6
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,11 +1,6 @@ | ||||
| name: 'Setup authentik testing environment' | ||||
| description: 'Setup authentik testing environment' | ||||
|  | ||||
| inputs: | ||||
|   postgresql_tag: | ||||
|     description: "Optional postgresql image tag" | ||||
|     default: "12" | ||||
|  | ||||
| runs: | ||||
|   using: "composite" | ||||
|   steps: | ||||
| @ -29,7 +24,6 @@ runs: | ||||
|     - name: Setup dependencies | ||||
|       shell: bash | ||||
|       run: | | ||||
|         export PSQL_TAG=${{ inputs.postgresql_tag }} | ||||
|         docker-compose -f .github/actions/setup/docker-compose.yml up -d | ||||
|         poetry env use python3.11 | ||||
|         poetry install | ||||
|  | ||||
							
								
								
									
										2
									
								
								.github/actions/setup/docker-compose.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/setup/docker-compose.yml
									
									
									
									
										vendored
									
									
								
							| @ -3,7 +3,7 @@ version: '3.7' | ||||
| services: | ||||
|   postgresql: | ||||
|     container_name: postgres | ||||
|     image: library/postgres:${PSQL_TAG:-12} | ||||
|     image: library/postgres:12 | ||||
|     volumes: | ||||
|     - db-data:/var/lib/postgresql/data | ||||
|     environment: | ||||
|  | ||||
							
								
								
									
										11
									
								
								.github/codecov.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/codecov.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,10 +1,3 @@ | ||||
| coverage: | ||||
|   status: | ||||
|     project: | ||||
|       default: | ||||
|         target: auto | ||||
|         # adjust accordingly based on how flaky your tests are | ||||
|         # this allows a 1% drop from the previous base commit coverage | ||||
|         threshold: 1% | ||||
|   notify: | ||||
|     after_n_builds: 3 | ||||
|   precision: 2 | ||||
|   round: up | ||||
|  | ||||
							
								
								
									
										1
									
								
								.github/codespell-dictionary.txt
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/codespell-dictionary.txt
									
									
									
									
										vendored
									
									
								
							| @ -1 +0,0 @@ | ||||
| authentic->authentik | ||||
							
								
								
									
										1
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/stale.yml
									
									
									
									
										vendored
									
									
								
							| @ -16,4 +16,3 @@ markComment: > | ||||
|   This issue has been automatically marked as stale because it has not had | ||||
|   recent activity. It will be closed if no further activity occurs. Thank you | ||||
|   for your contributions. | ||||
| only: issues | ||||
|  | ||||
							
								
								
									
										14
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -29,7 +29,6 @@ jobs: | ||||
|           - bandit | ||||
|           - pyright | ||||
|           - pending-migrations | ||||
|           - codespell | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
| @ -60,7 +59,7 @@ jobs: | ||||
|           cp authentik/lib/default.yml local.env.yml | ||||
|           cp -R .github .. | ||||
|           cp -R scripts .. | ||||
|           git checkout $(git describe --tags $(git rev-list --tags --max-count=1)) | ||||
|           git checkout $(git describe --abbrev=0 --match 'version/*') | ||||
|           rm -rf .github/ scripts/ | ||||
|           mv ../.github ../scripts . | ||||
|       - name: Setup authentik env (ensure stable deps are installed) | ||||
| @ -80,21 +79,12 @@ jobs: | ||||
|       - name: migrate to latest | ||||
|         run: poetry run python -m lifecycle.migrate | ||||
|   test-unittest: | ||||
|     name: test-unittest - PostgreSQL ${{ matrix.psql }} | ||||
|     runs-on: ubuntu-latest | ||||
|     timeout-minutes: 30 | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         psql: | ||||
|           - 11-alpine | ||||
|           - 12-alpine | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|         with: | ||||
|           postgresql_tag: ${{ matrix.psql }} | ||||
|       - name: run unittest | ||||
|         run: | | ||||
|           poetry run make test | ||||
| @ -138,8 +128,6 @@ jobs: | ||||
|             glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml* | ||||
|           - name: ldap | ||||
|             glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap* | ||||
|           - name: radius | ||||
|             glob: tests/e2e/test_provider_radius* | ||||
|           - name: flows | ||||
|             glob: tests/e2e/test_flows* | ||||
|     steps: | ||||
|  | ||||
							
								
								
									
										14
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -15,7 +15,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - uses: actions/setup-go@v4 | ||||
|       - uses: actions/setup-go@v3 | ||||
|         with: | ||||
|           go-version: "^1.17" | ||||
|       - name: Prepare and generate API | ||||
| @ -34,7 +34,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - uses: actions/setup-go@v4 | ||||
|       - uses: actions/setup-go@v3 | ||||
|         with: | ||||
|           go-version: "^1.17" | ||||
|       - name: Generate API | ||||
| @ -59,9 +59,8 @@ jobs: | ||||
|         type: | ||||
|           - proxy | ||||
|           - ldap | ||||
|           - radius | ||||
|         arch: | ||||
|           - "linux/amd64" | ||||
|           - 'linux/amd64' | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
| @ -107,18 +106,17 @@ jobs: | ||||
|         type: | ||||
|           - proxy | ||||
|           - ldap | ||||
|           - radius | ||||
|         goos: [linux] | ||||
|         goarch: [amd64, arm64] | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - uses: actions/setup-go@v4 | ||||
|       - uses: actions/setup-go@v3 | ||||
|         with: | ||||
|           go-version: "^1.17" | ||||
|       - uses: actions/setup-node@v3.6.0 | ||||
|         with: | ||||
|           node-version: "18" | ||||
|           cache: "npm" | ||||
|           node-version: '18' | ||||
|           cache: 'npm' | ||||
|           cache-dependency-path: web/package-lock.json | ||||
|       - name: Generate API | ||||
|         run: make gen-client-go | ||||
|  | ||||
							
								
								
									
										22
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							| @ -39,32 +39,10 @@ jobs: | ||||
|       - name: test | ||||
|         working-directory: website/ | ||||
|         run: npm test | ||||
|   build: | ||||
|     runs-on: ubuntu-latest | ||||
|     name: ${{ matrix.job }} | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         job: | ||||
|           - build | ||||
|           - build-docs-only | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - uses: actions/setup-node@v3.6.0 | ||||
|         with: | ||||
|           node-version: '18' | ||||
|           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: | ||||
|     needs: | ||||
|       - lint-prettier | ||||
|       - test | ||||
|       - build | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: echo mark | ||||
|  | ||||
							
								
								
									
										9
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/ghcr-retention.yml
									
									
									
									
										vendored
									
									
								
							| @ -10,18 +10,13 @@ jobs: | ||||
|     name: Delete old unused container images | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - id: generate_token | ||||
|         uses: tibdex/github-app-token@v1 | ||||
|         with: | ||||
|           app_id: ${{ secrets.GH_APP_ID }} | ||||
|           private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} | ||||
|       - name: Delete 'dev' containers older than a week | ||||
|         uses: snok/container-retention-policy@v2 | ||||
|         uses: snok/container-retention-policy@v1 | ||||
|         with: | ||||
|           image-names: dev-server,dev-ldap,dev-proxy | ||||
|           cut-off: One week ago UTC | ||||
|           account-type: org | ||||
|           org-name: goauthentik | ||||
|           untagged-only: false | ||||
|           token: ${{ steps.generate_token.outputs.token }} | ||||
|           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||
|           skip-tags: gh-next,gh-main | ||||
|  | ||||
							
								
								
									
										6
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -52,10 +52,9 @@ jobs: | ||||
|         type: | ||||
|           - proxy | ||||
|           - ldap | ||||
|           - radius | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - uses: actions/setup-go@v4 | ||||
|       - uses: actions/setup-go@v3 | ||||
|         with: | ||||
|           go-version: "^1.17" | ||||
|       - name: Set up QEMU | ||||
| @ -100,12 +99,11 @@ jobs: | ||||
|         type: | ||||
|           - proxy | ||||
|           - ldap | ||||
|           - radius | ||||
|         goos: [linux, darwin] | ||||
|         goarch: [amd64, arm64] | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - uses: actions/setup-go@v4 | ||||
|       - uses: actions/setup-go@v3 | ||||
|         with: | ||||
|           go-version: "^1.17" | ||||
|       - uses: actions/setup-node@v3.6.0 | ||||
|  | ||||
							
								
								
									
										9
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/release-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -22,23 +22,18 @@ jobs: | ||||
|           docker-compose up --no-start | ||||
|           docker-compose start postgresql redis | ||||
|           docker-compose run -u root server test-all | ||||
|       - id: generate_token | ||||
|         uses: tibdex/github-app-token@v1 | ||||
|         with: | ||||
|           app_id: ${{ secrets.GH_APP_ID }} | ||||
|           private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} | ||||
|       - name: Extract version number | ||||
|         id: get_version | ||||
|         uses: actions/github-script@v6 | ||||
|         with: | ||||
|           github-token: ${{ steps.generate_token.outputs.token }} | ||||
|           github-token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||
|           script: | | ||||
|             return context.payload.ref.replace(/\/refs\/tags\/version\//, ''); | ||||
|       - name: Create Release | ||||
|         id: create_release | ||||
|         uses: actions/create-release@v1.1.4 | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} | ||||
|           GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||
|         with: | ||||
|           tag_name: ${{ github.ref }} | ||||
|           release_name: Release ${{ steps.get_version.outputs.result }} | ||||
|  | ||||
							
								
								
									
										11
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								.github/workflows/translation-compile.yml
									
									
									
									
										vendored
									
									
								
							| @ -18,23 +18,18 @@ jobs: | ||||
|   compile: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - id: generate_token | ||||
|         uses: tibdex/github-app-token@v1 | ||||
|         with: | ||||
|           app_id: ${{ secrets.GH_APP_ID }} | ||||
|           private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} | ||||
|       - uses: actions/checkout@v3 | ||||
|         with: | ||||
|           token: ${{ steps.generate_token.outputs.token }} | ||||
|           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - name: run compile | ||||
|         run: poetry run ./manage.py compilemessages | ||||
|       - name: Create Pull Request | ||||
|         uses: peter-evans/create-pull-request@v5 | ||||
|         uses: peter-evans/create-pull-request@v4 | ||||
|         id: cpr | ||||
|         with: | ||||
|           token: ${{ steps.generate_token.outputs.token }} | ||||
|           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||
|           branch: compile-backend-translation | ||||
|           commit-message: "core: compile backend translations" | ||||
|           title: "core: compile backend translations" | ||||
|  | ||||
							
								
								
									
										15
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										15
									
								
								.github/workflows/web-api-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -9,14 +9,9 @@ jobs: | ||||
|   build: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - id: generate_token | ||||
|         uses: tibdex/github-app-token@v1 | ||||
|         with: | ||||
|           app_id: ${{ secrets.GH_APP_ID }} | ||||
|           private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} | ||||
|       - uses: actions/checkout@v3 | ||||
|         with: | ||||
|           token: ${{ steps.generate_token.outputs.token }} | ||||
|           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||
|       - uses: actions/setup-node@v3.6.0 | ||||
|         with: | ||||
|           node-version: '18' | ||||
| @ -35,10 +30,10 @@ jobs: | ||||
|         run: | | ||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` | ||||
|           npm i @goauthentik/api@$VERSION | ||||
|       - uses: peter-evans/create-pull-request@v5 | ||||
|       - uses: peter-evans/create-pull-request@v4 | ||||
|         id: cpr | ||||
|         with: | ||||
|           token: ${{ steps.generate_token.outputs.token }} | ||||
|           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||
|           branch: update-web-api-client | ||||
|           commit-message: "web: bump API Client version" | ||||
|           title: "web: bump API Client version" | ||||
| @ -47,8 +42,8 @@ jobs: | ||||
|           signoff: true | ||||
|           team-reviewers: "@goauthentik/core" | ||||
|           author: authentik bot <github-bot@goauthentik.io> | ||||
|       - uses: peter-evans/enable-pull-request-automerge@v3 | ||||
|       - uses: peter-evans/enable-pull-request-automerge@v2 | ||||
|         with: | ||||
|           token: ${{ steps.generate_token.outputs.token }} | ||||
|           token: ${{ secrets.BOT_GITHUB_TOKEN }} | ||||
|           pull-request-number: ${{ steps.cpr.outputs.pull-request-number }} | ||||
|           merge-method: squash | ||||
|  | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -200,6 +200,3 @@ media/ | ||||
| .idea/ | ||||
| /gen-*/ | ||||
| data/ | ||||
|  | ||||
| # Local Netlify folder | ||||
| .netlify | ||||
|  | ||||
							
								
								
									
										20
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								.vscode/extensions.json
									
									
									
									
										vendored
									
									
								
							| @ -1,20 +0,0 @@ | ||||
| { | ||||
|     "recommendations": [ | ||||
|         "EditorConfig.EditorConfig", | ||||
|         "bashmish.es6-string-css", | ||||
|         "bpruitt-goddard.mermaid-markdown-syntax-highlighting", | ||||
|         "dbaeumer.vscode-eslint", | ||||
|         "esbenp.prettier-vscode", | ||||
|         "golang.go", | ||||
|         "Gruntfuggly.todo-tree", | ||||
|         "mechatroner.rainbow-csv", | ||||
|         "ms-python.black-formatter", | ||||
|         "ms-python.isort", | ||||
|         "ms-python.pylint", | ||||
|         "ms-python.python", | ||||
|         "ms-python.vscode-pylance", | ||||
|         "redhat.vscode-yaml", | ||||
|         "Tobermory.es6-string-html", | ||||
|         "unifiedjs.vscode-mdx" | ||||
|     ] | ||||
| } | ||||
							
								
								
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -16,8 +16,7 @@ | ||||
|         "passwordless", | ||||
|         "kubernetes", | ||||
|         "sso", | ||||
|         "slo", | ||||
|         "scim", | ||||
|         "slo" | ||||
|     ], | ||||
|     "python.linting.pylintEnabled": true, | ||||
|     "todo-tree.tree.showCountsInTree": true, | ||||
|  | ||||
| @ -20,7 +20,6 @@ The following is a set of guidelines for contributing to authentik and its compo | ||||
| -   [Reporting Bugs](#reporting-bugs) | ||||
| -   [Suggesting Enhancements](#suggesting-enhancements) | ||||
| -   [Your First Code Contribution](#your-first-code-contribution) | ||||
| -   [Help with the Docs](#help-with-the-docs) | ||||
| -   [Pull Requests](#pull-requests) | ||||
|  | ||||
| [Styleguides](#styleguides) | ||||
| @ -136,9 +135,6 @@ authentik can be run locally, all though depending on which part you want to wor | ||||
|  | ||||
| This is documented in the [developer docs](https://goauthentik.io/developer-docs/?utm_source=github) | ||||
|  | ||||
| ### Help with the Docs | ||||
| Contributions to the technical documentation are greatly appreciated. Open a PR if you have improvements to make or new content to add. If you have questions or suggestions about the documentation, open an Issue. No contribution is too small. | ||||
|  | ||||
| ### Pull Requests | ||||
|  | ||||
| The process described here has several goals: | ||||
| @ -158,19 +154,12 @@ While the prerequisites above must be satisfied prior to having your pull reques | ||||
|  | ||||
| ## Styleguides | ||||
|  | ||||
| ### PR naming | ||||
|  | ||||
| -   Use the format of `<package>: <verb> <description>` | ||||
|     -   See [here](#authentik-packages) for `package` | ||||
|     -   Example: `providers/saml2: fix parsing of requests` | ||||
|  | ||||
| ### Git Commit Messages | ||||
|  | ||||
| -   Use the format of `<package>: <verb> <description>` | ||||
|     -   See [here](#authentik-packages) for `package` | ||||
|     -   Example: `providers/saml2: fix parsing of requests` | ||||
| -   Reference issues and pull requests liberally after the first line | ||||
| -   Naming of commits within a PR does not need to adhere to the guidelines as we squash merge PRs | ||||
|  | ||||
| ### Python Styleguide | ||||
|  | ||||
|  | ||||
							
								
								
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -20,7 +20,7 @@ WORKDIR /work/web | ||||
| RUN npm ci && npm run build | ||||
|  | ||||
| # Stage 3: Poetry to requirements.txt export | ||||
| FROM docker.io/python:3.11.3-slim-bullseye AS poetry-locker | ||||
| FROM docker.io/python:3.11.2-slim-bullseye AS poetry-locker | ||||
|  | ||||
| WORKDIR /work | ||||
| COPY ./pyproject.toml /work | ||||
| @ -31,7 +31,7 @@ RUN pip install --no-cache-dir poetry && \ | ||||
|     poetry export -f requirements.txt --dev --output requirements-dev.txt | ||||
|  | ||||
| # Stage 4: Build go proxy | ||||
| FROM docker.io/golang:1.20.3-bullseye AS go-builder | ||||
| FROM docker.io/golang:1.20.1-bullseye AS go-builder | ||||
|  | ||||
| WORKDIR /work | ||||
|  | ||||
| @ -47,7 +47,7 @@ COPY ./go.sum /work/go.sum | ||||
| RUN go build -o /work/authentik ./cmd/server/ | ||||
|  | ||||
| # Stage 5: MaxMind GeoIP | ||||
| FROM docker.io/maxmindinc/geoipupdate:v5.0 as geoip | ||||
| FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip | ||||
|  | ||||
| ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City" | ||||
| ENV GEOIPUPDATE_VERBOSE="true" | ||||
| @ -62,7 +62,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ | ||||
|     " | ||||
|  | ||||
| # Stage 6: Run | ||||
| FROM docker.io/python:3.11.3-slim-bullseye AS final-image | ||||
| FROM docker.io/python:3.11.2-slim-bullseye AS final-image | ||||
|  | ||||
| LABEL org.opencontainers.image.url https://goauthentik.io | ||||
| LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info. | ||||
| @ -96,13 +96,13 @@ RUN apt-get update && \ | ||||
|  | ||||
| COPY ./authentik/ /authentik | ||||
| COPY ./pyproject.toml / | ||||
| COPY ./schemas /schemas | ||||
| COPY ./xml /xml | ||||
| COPY ./locale /locale | ||||
| COPY ./tests /tests | ||||
| COPY ./manage.py / | ||||
| COPY ./blueprints /blueprints | ||||
| COPY ./lifecycle/ /lifecycle | ||||
| COPY --from=go-builder /work/authentik /bin/authentik | ||||
| COPY --from=go-builder /work/authentik /authentik-proxy | ||||
| COPY --from=web-builder /work/web/dist/ /web/dist/ | ||||
| COPY --from=web-builder /work/web/authentik/ /web/authentik/ | ||||
| COPY --from=website-builder /work/website/help/ /website/help/ | ||||
|  | ||||
							
								
								
									
										57
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										57
									
								
								Makefile
									
									
									
									
									
								
							| @ -4,20 +4,6 @@ UID = $(shell id -u) | ||||
| GID = $(shell id -g) | ||||
| NPM_VERSION = $(shell python -m scripts.npm_version) | ||||
|  | ||||
| CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \ | ||||
| 		-I .github/codespell-words.txt \ | ||||
| 		-S 'web/src/locales/**' \ | ||||
| 		authentik \ | ||||
| 		internal \ | ||||
| 		cmd \ | ||||
| 		web/src \ | ||||
| 		website/src \ | ||||
| 		website/blog \ | ||||
| 		website/developer-docs \ | ||||
| 		website/docs \ | ||||
| 		website/integrations \ | ||||
| 		website/src | ||||
|  | ||||
| all: lint-fix lint test gen web | ||||
|  | ||||
| test-go: | ||||
| @ -40,7 +26,14 @@ test: | ||||
| lint-fix: | ||||
| 	isort authentik tests scripts lifecycle | ||||
| 	black authentik tests scripts lifecycle | ||||
| 	codespell -w $(CODESPELL_ARGS) | ||||
| 	codespell -I .github/codespell-words.txt -S 'web/src/locales/**' -w \ | ||||
| 		authentik \ | ||||
| 		internal \ | ||||
| 		cmd \ | ||||
| 		web/src \ | ||||
| 		website/src \ | ||||
| 		website/docs \ | ||||
| 		website/developer-docs | ||||
|  | ||||
| lint: | ||||
| 	pylint authentik tests lifecycle | ||||
| @ -50,6 +43,9 @@ lint: | ||||
| migrate: | ||||
| 	python -m lifecycle.migrate | ||||
|  | ||||
| run: | ||||
| 	go run -v ./cmd/server/ | ||||
|  | ||||
| i18n-extract: i18n-extract-core web-extract | ||||
|  | ||||
| i18n-extract-core: | ||||
| @ -63,20 +59,15 @@ gen-build: | ||||
| 	AUTHENTIK_DEBUG=true ak make_blueprint_schema > blueprints/schema.json | ||||
| 	AUTHENTIK_DEBUG=true ak spectacular --file schema.yml | ||||
|  | ||||
| gen-changelog: | ||||
| 	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: | ||||
| 	git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > old_schema.yml | ||||
| 	git show $(shell git describe --abbrev=0):schema.yml > old_schema.yml | ||||
| 	docker run \ | ||||
| 		--rm -v ${PWD}:/local \ | ||||
| 		--user ${UID}:${GID} \ | ||||
| 		docker.io/openapitools/openapi-diff:2.1.0-beta.6 \ | ||||
| 		docker.io/openapitools/openapi-diff:2.1.0-beta.3 \ | ||||
| 		--markdown /local/diff.md \ | ||||
| 		/local/old_schema.yml /local/schema.yml | ||||
| 	rm old_schema.yml | ||||
| 	npx prettier --write diff.md | ||||
|  | ||||
| gen-clean: | ||||
| 	rm -rf web/api/src/ | ||||
| @ -86,7 +77,7 @@ gen-client-ts: | ||||
| 	docker run \ | ||||
| 		--rm -v ${PWD}:/local \ | ||||
| 		--user ${UID}:${GID} \ | ||||
| 		docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \ | ||||
| 		docker.io/openapitools/openapi-generator-cli:v6.0.0 generate \ | ||||
| 		-i /local/schema.yml \ | ||||
| 		-g typescript-fetch \ | ||||
| 		-o /local/gen-ts-api \ | ||||
| @ -99,21 +90,20 @@ gen-client-ts: | ||||
| 	\cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api | ||||
|  | ||||
| gen-client-go: | ||||
| 	mkdir -p ./gen-go-api ./gen-go-api/templates | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./gen-go-api/config.yaml | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./gen-go-api/templates/README.mustache | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O ./gen-go-api/templates/go.mod.mustache | ||||
| 	cp schema.yml ./gen-go-api/ | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O config.yaml | ||||
| 	mkdir -p templates | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O templates/README.mustache | ||||
| 	wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O templates/go.mod.mustache | ||||
| 	docker run \ | ||||
| 		--rm -v ${PWD}/gen-go-api:/local \ | ||||
| 		--rm -v ${PWD}:/local \ | ||||
| 		--user ${UID}:${GID} \ | ||||
| 		docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \ | ||||
| 		docker.io/openapitools/openapi-generator-cli:v6.0.0 generate \ | ||||
| 		-i /local/schema.yml \ | ||||
| 		-g go \ | ||||
| 		-o /local/ \ | ||||
| 		-o /local/gen-go-api \ | ||||
| 		-c /local/config.yaml | ||||
| 	go mod edit -replace goauthentik.io/api/v3=./gen-go-api | ||||
| 	rm -rf ./gen-go-api/config.yaml ./gen-go-api/templates/ | ||||
| 	rm -rf config.yaml ./templates/ | ||||
|  | ||||
| gen-dev-config: | ||||
| 	python -m scripts.generate_config | ||||
| @ -182,9 +172,6 @@ ci-pylint: ci--meta-debug | ||||
| ci-black: ci--meta-debug | ||||
| 	black --check $(PY_SOURCES) | ||||
|  | ||||
| ci-codespell: ci--meta-debug | ||||
| 	codespell $(CODESPELL_ARGS) -s | ||||
|  | ||||
| ci-isort: ci--meta-debug | ||||
| 	isort --check $(PY_SOURCES) | ||||
|  | ||||
|  | ||||
							
								
								
									
										12
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								README.md
									
									
									
									
									
								
							| @ -15,13 +15,13 @@ | ||||
|  | ||||
| ## What is authentik? | ||||
|  | ||||
| Authentik is an open-source Identity Provider that emphasizes flexibility and versatility. It can be seamlessly integrated into existing environments to support new protocols. Authentik is also a great solution for implementing sign-up, recovery, and other similar features in your application, saving you the hassle of dealing with them. | ||||
| authentik is an open-source Identity Provider focused on flexibility and versatility. You can use authentik in an existing environment to add support for new protocols. authentik is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it. | ||||
|  | ||||
| ## Installation | ||||
|  | ||||
| For small/test setups it is recommended to use Docker Compose; refer to the [documentation](https://goauthentik.io/docs/installation/docker-compose/?utm_source=github). | ||||
| For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/?utm_source=github) | ||||
|  | ||||
| For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/?utm_source=github). | ||||
| For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/?utm_source=github) | ||||
|  | ||||
| ## Screenshots | ||||
|  | ||||
| @ -32,15 +32,15 @@ For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/h | ||||
|  | ||||
| ## Development | ||||
|  | ||||
| See [Developer Documentation](https://goauthentik.io/developer-docs/?utm_source=github) | ||||
| See [Development Documentation](https://goauthentik.io/developer-docs/?utm_source=github) | ||||
|  | ||||
| ## Security | ||||
|  | ||||
| See [SECURITY.md](SECURITY.md) | ||||
|  | ||||
| ## Adoption and Contributions | ||||
| ## Support | ||||
|  | ||||
| Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [CONTRIBUTING.md file](./CONTRIBUTING.md). | ||||
| Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! | ||||
|  | ||||
| ## Sponsors | ||||
|  | ||||
|  | ||||
| @ -6,8 +6,8 @@ Authentik takes security very seriously. We follow the rules of [responsible dis | ||||
|  | ||||
| | Version   | Supported          | | ||||
| | --------- | ------------------ | | ||||
| | 2023.2.x  | :white_check_mark: | | ||||
| | 2023.3.x  | :white_check_mark: | | ||||
| | 2022.12.x | :white_check_mark: | | ||||
| | 2023.1.x  | :white_check_mark: | | ||||
|  | ||||
| ## Reporting a Vulnerability | ||||
|  | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
| from os import environ | ||||
| from typing import Optional | ||||
|  | ||||
| __version__ = "2023.4.3" | ||||
| __version__ = "2023.2.3" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| """authentik administration overview""" | ||||
| import os | ||||
| import platform | ||||
| from datetime import datetime | ||||
| from sys import version as python_version | ||||
| @ -33,6 +34,7 @@ class RuntimeDict(TypedDict): | ||||
| class SystemSerializer(PassiveSerializer): | ||||
|     """Get system information.""" | ||||
|  | ||||
|     env = SerializerMethodField() | ||||
|     http_headers = SerializerMethodField() | ||||
|     http_host = SerializerMethodField() | ||||
|     http_is_secure = SerializerMethodField() | ||||
| @ -41,6 +43,10 @@ class SystemSerializer(PassiveSerializer): | ||||
|     server_time = SerializerMethodField() | ||||
|     embedded_outpost_host = SerializerMethodField() | ||||
|  | ||||
|     def get_env(self, request: Request) -> dict[str, str]: | ||||
|         """Get Environment""" | ||||
|         return os.environ.copy() | ||||
|  | ||||
|     def get_http_headers(self, request: Request) -> dict[str, str]: | ||||
|         """Get HTTP Request headers""" | ||||
|         headers = {} | ||||
|  | ||||
| @ -9,7 +9,6 @@ from authentik.blueprints.tests import reconcile_app | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.core.tasks import clean_expired_models | ||||
| from authentik.events.monitored_tasks import TaskResultStatus | ||||
| from authentik.lib.generators import generate_id | ||||
|  | ||||
|  | ||||
| class TestAdminAPI(TestCase): | ||||
| @ -17,8 +16,8 @@ class TestAdminAPI(TestCase): | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|         self.user = User.objects.create(username=generate_id()) | ||||
|         self.group = Group.objects.create(name=generate_id(), is_superuser=True) | ||||
|         self.user = User.objects.create(username="test-user") | ||||
|         self.group = Group.objects.create(name="superusers", is_superuser=True) | ||||
|         self.group.users.add(self.user) | ||||
|         self.group.save() | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
| @ -1,5 +1,4 @@ | ||||
| """API Authentication""" | ||||
| from hmac import compare_digest | ||||
| from typing import Any, Optional | ||||
|  | ||||
| from django.conf import settings | ||||
| @ -79,7 +78,7 @@ def token_secret_key(value: str) -> Optional[User]: | ||||
|     and return the service account for the managed outpost""" | ||||
|     from authentik.outposts.apps import MANAGED_OUTPOST | ||||
|  | ||||
|     if not compare_digest(value, settings.SECRET_KEY): | ||||
|     if value != settings.SECRET_KEY: | ||||
|         return None | ||||
|     outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) | ||||
|     if not outposts: | ||||
|  | ||||
| @ -7,13 +7,82 @@ API Browser - {{ tenant.branding_title }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block head %} | ||||
| <script src="{% static 'dist/standalone/api-browser/index.js' %}?version={{ version }}" type="module"></script> | ||||
| <meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)"> | ||||
| <meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)"> | ||||
| <link rel="icon" href="{{ tenant.branding_favicon }}"> | ||||
| <link rel="shortcut icon" href="{{ tenant.branding_favicon }}"> | ||||
| <script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script> | ||||
| <script> | ||||
| function getCookie(name) { | ||||
|     let cookieValue = ""; | ||||
|     if (document.cookie && document.cookie !== "") { | ||||
|         const cookies = document.cookie.split(";"); | ||||
|         for (let i = 0; i < cookies.length; i++) { | ||||
|             const cookie = cookies[i].trim(); | ||||
|             // Does this cookie string begin with the name we want? | ||||
|             if (cookie.substring(0, name.length + 1) === name + "=") { | ||||
|                 cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     return cookieValue; | ||||
| } | ||||
| window.addEventListener('DOMContentLoaded', (event) => { | ||||
|     const rapidocEl = document.querySelector('rapi-doc'); | ||||
|     rapidocEl.addEventListener('before-try', (e) => { | ||||
|         e.detail.request.headers.append('X-authentik-CSRF', getCookie("authentik_csrf")); | ||||
|     }); | ||||
| }); | ||||
| </script> | ||||
| <style> | ||||
|     img.logo { | ||||
|         width: 100%; | ||||
|         padding: 1rem 0.5rem 1.5rem 0.5rem; | ||||
|         min-height: 48px; | ||||
|     } | ||||
| </style> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block body %} | ||||
| <ak-api-browser schemaPath="{{ path }}"></ak-api-browser> | ||||
| <rapi-doc | ||||
|     spec-url="{{ path }}" | ||||
|     heading-text="" | ||||
|     theme="light" | ||||
|     render-style="read" | ||||
|     default-schema-tab="schema" | ||||
|     primary-color="#fd4b2d" | ||||
|     nav-bg-color="#212427" | ||||
|     bg-color="#000000" | ||||
|     text-color="#000000" | ||||
|     nav-text-color="#ffffff" | ||||
|     nav-hover-bg-color="#3c3f42" | ||||
|     nav-accent-color="#4f5255" | ||||
|     nav-hover-text-color="#ffffff" | ||||
|     use-path-in-nav-bar="true" | ||||
|     nav-item-spacing="relaxed" | ||||
|     allow-server-selection="false" | ||||
|     show-header="false" | ||||
|     allow-spec-url-load="false" | ||||
|     allow-spec-file-load="false"> | ||||
|     <div slot="nav-logo"> | ||||
|         <img  alt="authentik Logo" class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" /> | ||||
|     </div> | ||||
| </rapi-doc> | ||||
| <script> | ||||
| const rapidoc = document.querySelector("rapi-doc"); | ||||
| const matcher = window.matchMedia("(prefers-color-scheme: light)"); | ||||
| const changer = (ev) => { | ||||
|     const style = getComputedStyle(document.documentElement); | ||||
|     let bg, text = ""; | ||||
|     if (matcher.matches) { | ||||
|         bg = style.getPropertyValue('--pf-global--BackgroundColor--light-300'); | ||||
|         text = style.getPropertyValue('--pf-global--Color--300'); | ||||
|     } else { | ||||
|         bg = style.getPropertyValue('--ak-dark-background'); | ||||
|         text = style.getPropertyValue('--ak-dark-foreground'); | ||||
|     } | ||||
|     rapidoc.attributes.getNamedItem("bg-color").value = bg.trim(); | ||||
|     rapidoc.attributes.getNamedItem("text-color").value = text.trim(); | ||||
|     rapidoc.requestUpdate(); | ||||
| }; | ||||
| matcher.addEventListener("change", changer); | ||||
| window.addEventListener("load", changer); | ||||
| </script> | ||||
| {% endblock %} | ||||
|  | ||||
| @ -4,7 +4,6 @@ from base64 import b64encode | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.test import TestCase | ||||
| from django.utils import timezone | ||||
| from rest_framework.exceptions import AuthenticationFailed | ||||
|  | ||||
| from authentik.api.authentication import bearer_auth | ||||
| @ -69,7 +68,6 @@ class TestAPIAuth(TestCase): | ||||
|             user=create_test_admin_user(), | ||||
|             provider=provider, | ||||
|             token=generate_id(), | ||||
|             auth_time=timezone.now(), | ||||
|             _scope=SCOPE_AUTHENTIK_API, | ||||
|             _id_token=json.dumps({}), | ||||
|         ) | ||||
| @ -84,7 +82,6 @@ class TestAPIAuth(TestCase): | ||||
|             user=create_test_admin_user(), | ||||
|             provider=provider, | ||||
|             token=generate_id(), | ||||
|             auth_time=timezone.now(), | ||||
|             _scope="", | ||||
|             _id_token=json.dumps({}), | ||||
|         ) | ||||
|  | ||||
| @ -4,7 +4,6 @@ from guardian.shortcuts import assign_perm | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import Application, User | ||||
| from authentik.lib.generators import generate_id | ||||
|  | ||||
|  | ||||
| class TestAPIDecorators(APITestCase): | ||||
| @ -17,7 +16,7 @@ class TestAPIDecorators(APITestCase): | ||||
|     def test_obj_perm_denied(self): | ||||
|         """Test object perm denied""" | ||||
|         self.client.force_login(self.user) | ||||
|         app = Application.objects.create(name=generate_id(), slug=generate_id()) | ||||
|         app = Application.objects.create(name="denied", slug="denied") | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) | ||||
|         ) | ||||
| @ -26,7 +25,7 @@ class TestAPIDecorators(APITestCase): | ||||
|     def test_other_perm_denied(self): | ||||
|         """Test other perm denied""" | ||||
|         self.client.force_login(self.user) | ||||
|         app = Application.objects.create(name=generate_id(), slug=generate_id()) | ||||
|         app = Application.objects.create(name="denied", slug="denied") | ||||
|         assign_perm("authentik_core.view_application", self.user, app) | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) | ||||
|  | ||||
| @ -56,11 +56,8 @@ from authentik.providers.oauth2.api.tokens import ( | ||||
|     RefreshTokenViewSet, | ||||
| ) | ||||
| from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet | ||||
| from authentik.providers.radius.api import RadiusOutpostConfigViewSet, RadiusProviderViewSet | ||||
| from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet | ||||
| from authentik.providers.saml.api.providers import SAMLProviderViewSet | ||||
| from authentik.providers.scim.api.property_mapping import SCIMMappingViewSet | ||||
| from authentik.providers.scim.api.providers import SCIMProviderViewSet | ||||
| from authentik.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet | ||||
| from authentik.sources.oauth.api.source import OAuthSourceViewSet | ||||
| from authentik.sources.oauth.api.source_connection import UserOAuthSourceConnectionViewSet | ||||
| @ -129,7 +126,6 @@ router.register("outposts/service_connections/docker", DockerServiceConnectionVi | ||||
| router.register("outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet) | ||||
| router.register("outposts/proxy", ProxyOutpostConfigViewSet) | ||||
| router.register("outposts/ldap", LDAPOutpostConfigViewSet) | ||||
| router.register("outposts/radius", RadiusOutpostConfigViewSet) | ||||
|  | ||||
| router.register("flows/instances", FlowViewSet) | ||||
| router.register("flows/bindings", FlowStageBindingViewSet) | ||||
| @ -167,8 +163,6 @@ router.register("providers/ldap", LDAPProviderViewSet) | ||||
| router.register("providers/proxy", ProxyProviderViewSet) | ||||
| router.register("providers/oauth2", OAuth2ProviderViewSet) | ||||
| router.register("providers/saml", SAMLProviderViewSet) | ||||
| router.register("providers/scim", SCIMProviderViewSet) | ||||
| router.register("providers/radius", RadiusProviderViewSet) | ||||
|  | ||||
| router.register("oauth2/authorization_codes", AuthorizationCodeViewSet) | ||||
| router.register("oauth2/refresh_tokens", RefreshTokenViewSet) | ||||
| @ -179,7 +173,6 @@ router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) | ||||
| router.register("propertymappings/saml", SAMLPropertyMappingViewSet) | ||||
| router.register("propertymappings/scope", ScopeMappingViewSet) | ||||
| router.register("propertymappings/notification", NotificationWebhookMappingViewSet) | ||||
| router.register("propertymappings/scim", SCIMMappingViewSet) | ||||
|  | ||||
| router.register("authenticators/all", DeviceViewSet, basename="device") | ||||
| router.register("authenticators/duo", DuoDeviceViewSet) | ||||
|  | ||||
| @ -55,11 +55,11 @@ class AuthentikBlueprintsConfig(ManagedAppConfig): | ||||
|         """Load v1 tasks""" | ||||
|         self.import_module("authentik.blueprints.v1.tasks") | ||||
|  | ||||
|     def reconcile_blueprints_discovery(self): | ||||
|     def reconcile_blueprints_discover(self): | ||||
|         """Run blueprint discovery""" | ||||
|         from authentik.blueprints.v1.tasks import blueprints_discovery, clear_failed_blueprints | ||||
|         from authentik.blueprints.v1.tasks import blueprints_discover, clear_failed_blueprints | ||||
|  | ||||
|         blueprints_discovery.delay() | ||||
|         blueprints_discover.delay() | ||||
|         clear_failed_blueprints.delay() | ||||
|  | ||||
|     def import_models(self): | ||||
|  | ||||
| @ -19,8 +19,10 @@ class Command(BaseCommand): | ||||
|         for blueprint_path in options.get("blueprints", []): | ||||
|             content = BlueprintInstance(path=blueprint_path).retrieve() | ||||
|             importer = Importer(content) | ||||
|             valid, _ = importer.validate() | ||||
|             valid, logs = importer.validate() | ||||
|             if not valid: | ||||
|                 for log in logs: | ||||
|                     getattr(LOGGER, log.pop("log_level"))(**log) | ||||
|                 self.stderr.write("blueprint invalid") | ||||
|                 sys_exit(1) | ||||
|             importer.apply() | ||||
|  | ||||
| @ -82,10 +82,7 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel): | ||||
|     def retrieve_file(self) -> str: | ||||
|         """Get blueprint from path""" | ||||
|         try: | ||||
|             base = Path(CONFIG.y("blueprints_dir")) | ||||
|             full_path = base.joinpath(Path(self.path)).resolve() | ||||
|             if not str(full_path).startswith(str(base.resolve())): | ||||
|                 raise BlueprintRetrievalFailed("Invalid blueprint path") | ||||
|             full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path)) | ||||
|             with full_path.open("r", encoding="utf-8") as _file: | ||||
|                 return _file.read() | ||||
|         except (IOError, OSError) as exc: | ||||
|  | ||||
| @ -5,7 +5,7 @@ from authentik.lib.utils.time import fqdn_rand | ||||
|  | ||||
| CELERY_BEAT_SCHEDULE = { | ||||
|     "blueprints_v1_discover": { | ||||
|         "task": "authentik.blueprints.v1.tasks.blueprints_discovery", | ||||
|         "task": "authentik.blueprints.v1.tasks.blueprints_discover", | ||||
|         "schedule": crontab(minute=fqdn_rand("blueprints_v1_discover"), hour="*"), | ||||
|         "options": {"queue": "authentik_scheduled"}, | ||||
|     }, | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| """Blueprint helpers""" | ||||
| from functools import wraps | ||||
| from pathlib import Path | ||||
| from typing import Callable | ||||
|  | ||||
| from django.apps import apps | ||||
| @ -44,3 +45,13 @@ def reconcile_app(app_name: str): | ||||
|         return wrapper | ||||
|  | ||||
|     return wrapper_outer | ||||
|  | ||||
|  | ||||
| def load_yaml_fixture(path: str, **kwargs) -> str: | ||||
|     """Load yaml fixture, optionally formatting it with kwargs""" | ||||
|     with open(Path(__file__).resolve().parent / Path(path), "r", encoding="utf-8") as _fixture: | ||||
|         fixture = _fixture.read() | ||||
|         try: | ||||
|             return fixture % kwargs | ||||
|         except TypeError: | ||||
|             return fixture | ||||
|  | ||||
| @ -1,15 +1,34 @@ | ||||
| """authentik managed models tests""" | ||||
| from typing import Callable, Type | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.blueprints.v1.importer import is_model_allowed | ||||
| from authentik.lib.models import SerializerModel | ||||
|  | ||||
|  | ||||
| class TestModels(TestCase): | ||||
|     """Test Models""" | ||||
|  | ||||
|     def test_retrieve_file(self): | ||||
|         """Test retrieve_file""" | ||||
|         instance = BlueprintInstance.objects.create(name=generate_id(), path="../etc/hosts") | ||||
|         with self.assertRaises(BlueprintRetrievalFailed): | ||||
|             instance.retrieve() | ||||
|  | ||||
| def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable: | ||||
|     """Test serializer""" | ||||
|  | ||||
|     def tester(self: TestModels): | ||||
|         if test_model._meta.abstract:  # pragma: no cover | ||||
|             return | ||||
|         model_class = test_model() | ||||
|         self.assertTrue(isinstance(model_class, SerializerModel)) | ||||
|         self.assertIsNotNone(model_class.serializer) | ||||
|  | ||||
|     return tester | ||||
|  | ||||
|  | ||||
| for app in apps.get_app_configs(): | ||||
|     if not app.label.startswith("authentik"): | ||||
|         continue | ||||
|     for model in app.get_models(): | ||||
|         if not is_model_allowed(model): | ||||
|             continue | ||||
|         setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model)) | ||||
|  | ||||
| @ -1,34 +0,0 @@ | ||||
| """authentik managed models tests""" | ||||
| from typing import Callable, Type | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.blueprints.v1.importer import is_model_allowed | ||||
| from authentik.lib.models import SerializerModel | ||||
|  | ||||
|  | ||||
| class TestModels(TestCase): | ||||
|     """Test Models""" | ||||
|  | ||||
|  | ||||
| def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable: | ||||
|     """Test serializer""" | ||||
|  | ||||
|     def tester(self: TestModels): | ||||
|         if test_model._meta.abstract:  # pragma: no cover | ||||
|             return | ||||
|         model_class = test_model() | ||||
|         self.assertTrue(isinstance(model_class, SerializerModel)) | ||||
|         self.assertIsNotNone(model_class.serializer) | ||||
|  | ||||
|     return tester | ||||
|  | ||||
|  | ||||
| for app in apps.get_app_configs(): | ||||
|     if not app.label.startswith("authentik"): | ||||
|         continue | ||||
|     for model in app.get_models(): | ||||
|         if not is_model_allowed(model): | ||||
|             continue | ||||
|         setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model)) | ||||
| @ -3,12 +3,12 @@ from os import environ | ||||
|  | ||||
| from django.test import TransactionTestCase | ||||
|  | ||||
| from authentik.blueprints.tests import load_yaml_fixture | ||||
| from authentik.blueprints.v1.exporter import FlowExporter | ||||
| from authentik.blueprints.v1.importer import Importer, transaction_rollback | ||||
| from authentik.core.models import Group | ||||
| from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.tests.utils import load_fixture | ||||
| from authentik.policies.expression.models import ExpressionPolicy | ||||
| from authentik.policies.models import PolicyBinding | ||||
| from authentik.sources.oauth.models import OAuthSource | ||||
| @ -113,14 +113,14 @@ class TestBlueprintsV1(TransactionTestCase): | ||||
|         """Test export and import it twice""" | ||||
|         count_initial = Prompt.objects.filter(field_key="username").count() | ||||
|  | ||||
|         importer = Importer(load_fixture("fixtures/static_prompt_export.yaml")) | ||||
|         importer = Importer(load_yaml_fixture("fixtures/static_prompt_export.yaml")) | ||||
|         self.assertTrue(importer.validate()[0]) | ||||
|         self.assertTrue(importer.apply()) | ||||
|  | ||||
|         count_before = Prompt.objects.filter(field_key="username").count() | ||||
|         self.assertEqual(count_initial + 1, count_before) | ||||
|  | ||||
|         importer = Importer(load_fixture("fixtures/static_prompt_export.yaml")) | ||||
|         importer = Importer(load_yaml_fixture("fixtures/static_prompt_export.yaml")) | ||||
|         self.assertTrue(importer.apply()) | ||||
|  | ||||
|         self.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before) | ||||
| @ -130,7 +130,7 @@ class TestBlueprintsV1(TransactionTestCase): | ||||
|         ExpressionPolicy.objects.filter(name="foo-bar-baz-qux").delete() | ||||
|         Group.objects.filter(name="test").delete() | ||||
|         environ["foo"] = generate_id() | ||||
|         importer = Importer(load_fixture("fixtures/tags.yaml"), {"bar": "baz"}) | ||||
|         importer = Importer(load_yaml_fixture("fixtures/tags.yaml"), {"bar": "baz"}) | ||||
|         self.assertTrue(importer.validate()[0]) | ||||
|         self.assertTrue(importer.apply()) | ||||
|         policy = ExpressionPolicy.objects.filter(name="foo-bar-baz-qux").first() | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| """Test blueprints v1""" | ||||
| from django.test import TransactionTestCase | ||||
|  | ||||
| from authentik.blueprints.tests import load_yaml_fixture | ||||
| from authentik.blueprints.v1.importer import Importer | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.tests.utils import load_fixture | ||||
|  | ||||
|  | ||||
| class TestBlueprintsV1Conditions(TransactionTestCase): | ||||
| @ -14,7 +14,7 @@ class TestBlueprintsV1Conditions(TransactionTestCase): | ||||
|         """Test conditions fulfilled""" | ||||
|         flow_slug1 = generate_id() | ||||
|         flow_slug2 = generate_id() | ||||
|         import_yaml = load_fixture( | ||||
|         import_yaml = load_yaml_fixture( | ||||
|             "fixtures/conditions_fulfilled.yaml", id1=flow_slug1, id2=flow_slug2 | ||||
|         ) | ||||
|  | ||||
| @ -31,7 +31,7 @@ class TestBlueprintsV1Conditions(TransactionTestCase): | ||||
|         """Test conditions not fulfilled""" | ||||
|         flow_slug1 = generate_id() | ||||
|         flow_slug2 = generate_id() | ||||
|         import_yaml = load_fixture( | ||||
|         import_yaml = load_yaml_fixture( | ||||
|             "fixtures/conditions_not_fulfilled.yaml", id1=flow_slug1, id2=flow_slug2 | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| """Test blueprints v1""" | ||||
| from django.test import TransactionTestCase | ||||
|  | ||||
| from authentik.blueprints.tests import load_yaml_fixture | ||||
| from authentik.blueprints.v1.importer import Importer | ||||
| from authentik.flows.models import Flow | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.tests.utils import load_fixture | ||||
|  | ||||
|  | ||||
| class TestBlueprintsV1State(TransactionTestCase): | ||||
| @ -13,7 +13,7 @@ class TestBlueprintsV1State(TransactionTestCase): | ||||
|     def test_state_present(self): | ||||
|         """Test state present""" | ||||
|         flow_slug = generate_id() | ||||
|         import_yaml = load_fixture("fixtures/state_present.yaml", id=flow_slug) | ||||
|         import_yaml = load_yaml_fixture("fixtures/state_present.yaml", id=flow_slug) | ||||
|  | ||||
|         importer = Importer(import_yaml) | ||||
|         self.assertTrue(importer.validate()[0]) | ||||
| @ -39,7 +39,7 @@ class TestBlueprintsV1State(TransactionTestCase): | ||||
|     def test_state_created(self): | ||||
|         """Test state created""" | ||||
|         flow_slug = generate_id() | ||||
|         import_yaml = load_fixture("fixtures/state_created.yaml", id=flow_slug) | ||||
|         import_yaml = load_yaml_fixture("fixtures/state_created.yaml", id=flow_slug) | ||||
|  | ||||
|         importer = Importer(import_yaml) | ||||
|         self.assertTrue(importer.validate()[0]) | ||||
| @ -65,7 +65,7 @@ class TestBlueprintsV1State(TransactionTestCase): | ||||
|     def test_state_absent(self): | ||||
|         """Test state absent""" | ||||
|         flow_slug = generate_id() | ||||
|         import_yaml = load_fixture("fixtures/state_created.yaml", id=flow_slug) | ||||
|         import_yaml = load_yaml_fixture("fixtures/state_created.yaml", id=flow_slug) | ||||
|  | ||||
|         importer = Importer(import_yaml) | ||||
|         self.assertTrue(importer.validate()[0]) | ||||
| @ -74,7 +74,7 @@ class TestBlueprintsV1State(TransactionTestCase): | ||||
|         flow: Flow = Flow.objects.filter(slug=flow_slug).first() | ||||
|         self.assertEqual(flow.slug, flow_slug) | ||||
|  | ||||
|         import_yaml = load_fixture("fixtures/state_absent.yaml", id=flow_slug) | ||||
|         import_yaml = load_yaml_fixture("fixtures/state_absent.yaml", id=flow_slug) | ||||
|         importer = Importer(import_yaml) | ||||
|         self.assertTrue(importer.validate()[0]) | ||||
|         self.assertTrue(importer.apply()) | ||||
|  | ||||
| @ -6,7 +6,7 @@ from django.test import TransactionTestCase | ||||
| from yaml import dump | ||||
|  | ||||
| from authentik.blueprints.models import BlueprintInstance, BlueprintInstanceStatus | ||||
| from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_discovery, blueprints_find | ||||
| from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_discover, blueprints_find | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.generators import generate_id | ||||
|  | ||||
| @ -53,7 +53,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase): | ||||
|             file.seek(0) | ||||
|             file_hash = sha512(file.read().encode()).hexdigest() | ||||
|             file.flush() | ||||
|             blueprints_discovery()  # pylint: disable=no-value-for-parameter | ||||
|             blueprints_discover()  # pylint: disable=no-value-for-parameter | ||||
|             instance = BlueprintInstance.objects.filter(name=blueprint_id).first() | ||||
|             self.assertEqual(instance.last_applied_hash, file_hash) | ||||
|             self.assertEqual( | ||||
| @ -81,7 +81,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase): | ||||
|                 ) | ||||
|             ) | ||||
|             file.flush() | ||||
|             blueprints_discovery()  # pylint: disable=no-value-for-parameter | ||||
|             blueprints_discover()  # pylint: disable=no-value-for-parameter | ||||
|             blueprint = BlueprintInstance.objects.filter(name="foo").first() | ||||
|             self.assertEqual( | ||||
|                 blueprint.last_applied_hash, | ||||
| @ -106,7 +106,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase): | ||||
|                 ) | ||||
|             ) | ||||
|             file.flush() | ||||
|             blueprints_discovery()  # pylint: disable=no-value-for-parameter | ||||
|             blueprints_discover()  # pylint: disable=no-value-for-parameter | ||||
|             blueprint.refresh_from_db() | ||||
|             self.assertEqual( | ||||
|                 blueprint.last_applied_hash, | ||||
|  | ||||
| @ -40,10 +40,6 @@ from authentik.lib.models import SerializerModel | ||||
| from authentik.outposts.models import OutpostServiceConnection | ||||
| from authentik.policies.models import Policy, PolicyBindingModel | ||||
|  | ||||
| # Context set when the serializer is created in a blueprint context | ||||
| # Update website/developer-docs/blueprints/v1/models.md when used | ||||
| SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry" | ||||
|  | ||||
|  | ||||
| def is_model_allowed(model: type[Model]) -> bool: | ||||
|     """Check if model is allowed""" | ||||
| @ -162,12 +158,7 @@ class Importer: | ||||
|             raise EntryInvalidError(f"Model {model} not allowed") | ||||
|         if issubclass(model, BaseMetaModel): | ||||
|             serializer_class: type[Serializer] = model.serializer() | ||||
|             serializer = serializer_class( | ||||
|                 data=entry.get_attrs(self.__import), | ||||
|                 context={ | ||||
|                     SERIALIZER_CONTEXT_BLUEPRINT: entry, | ||||
|                 }, | ||||
|             ) | ||||
|             serializer = serializer_class(data=entry.get_attrs(self.__import)) | ||||
|             try: | ||||
|                 serializer.is_valid(raise_exception=True) | ||||
|             except ValidationError as exc: | ||||
| @ -226,12 +217,7 @@ class Importer: | ||||
|         always_merger.merge(full_data, updated_identifiers) | ||||
|         serializer_kwargs["data"] = full_data | ||||
|  | ||||
|         serializer: Serializer = model().serializer( | ||||
|             context={ | ||||
|                 SERIALIZER_CONTEXT_BLUEPRINT: entry, | ||||
|             }, | ||||
|             **serializer_kwargs, | ||||
|         ) | ||||
|         serializer: Serializer = model().serializer(**serializer_kwargs) | ||||
|         try: | ||||
|             serializer.is_valid(raise_exception=True) | ||||
|         except ValidationError as exc: | ||||
|  | ||||
| @ -76,7 +76,7 @@ class BlueprintEventHandler(FileSystemEventHandler): | ||||
|             return | ||||
|         if isinstance(event, FileCreatedEvent): | ||||
|             LOGGER.debug("new blueprint file created, starting discovery") | ||||
|             blueprints_discovery.delay() | ||||
|             blueprints_discover.delay() | ||||
|         if isinstance(event, FileModifiedEvent): | ||||
|             path = Path(event.src_path) | ||||
|             root = Path(CONFIG.y("blueprints_dir")).absolute() | ||||
| @ -122,7 +122,7 @@ def blueprints_find(): | ||||
|         ) | ||||
|         blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None | ||||
|         blueprints.append(blueprint) | ||||
|         LOGGER.debug( | ||||
|         LOGGER.info( | ||||
|             "parsed & loaded blueprint", | ||||
|             hash=file_hash, | ||||
|             path=str(path), | ||||
| @ -134,7 +134,7 @@ def blueprints_find(): | ||||
|     throws=(DatabaseError, ProgrammingError, InternalError), base=MonitoredTask, bind=True | ||||
| ) | ||||
| @prefill_task | ||||
| def blueprints_discovery(self: MonitoredTask): | ||||
| def blueprints_discover(self: MonitoredTask): | ||||
|     """Find blueprints and check if they need to be created in the database""" | ||||
|     count = 0 | ||||
|     for blueprint in blueprints_find(): | ||||
|  | ||||
| @ -37,6 +37,7 @@ from authentik.lib.utils.file import ( | ||||
| from authentik.policies.api.exec import PolicyTestResultSerializer | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.policies.types import PolicyResult | ||||
| from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -185,6 +186,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | ||||
|         if superuser_full_list and request.user.is_superuser: | ||||
|             return super().list(request) | ||||
|  | ||||
|         # To prevent the user from having to double login when prompt is set to login | ||||
|         # and the user has just signed it. This session variable is set in the UserLoginStage | ||||
|         # and is (quite hackily) removed from the session in applications's API's List method | ||||
|         self.request.session.pop(USER_LOGIN_AUTHENTICATED, None) | ||||
|         queryset = self._filter_queryset_for_list(self.get_queryset()) | ||||
|         self.paginate_queryset(queryset) | ||||
|  | ||||
|  | ||||
| @ -24,6 +24,7 @@ from authentik.core.models import Group, User | ||||
| class GroupMemberSerializer(ModelSerializer): | ||||
|     """Stripped down user serializer to show relevant users for groups""" | ||||
|  | ||||
|     avatar = CharField(read_only=True) | ||||
|     attributes = JSONField(validators=[is_dict], required=False) | ||||
|     uid = CharField(read_only=True) | ||||
|  | ||||
| @ -36,6 +37,7 @@ class GroupMemberSerializer(ModelSerializer): | ||||
|             "is_active", | ||||
|             "last_login", | ||||
|             "email", | ||||
|             "avatar", | ||||
|             "attributes", | ||||
|             "uid", | ||||
|         ] | ||||
|  | ||||
| @ -35,7 +35,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): | ||||
|         fields = [ | ||||
|             "pk", | ||||
|             "name", | ||||
|             "authentication_flow", | ||||
|             "authorization_flow", | ||||
|             "property_mappings", | ||||
|             "component", | ||||
| @ -45,9 +44,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer): | ||||
|             "verbose_name_plural", | ||||
|             "meta_model_name", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "authorization_flow": {"required": True, "allow_null": False}, | ||||
|         } | ||||
|  | ||||
|  | ||||
| class ProviderViewSet( | ||||
|  | ||||
| @ -206,6 +206,5 @@ class UserSourceConnectionViewSet( | ||||
|     queryset = UserSourceConnection.objects.all() | ||||
|     serializer_class = UserSourceConnectionSerializer | ||||
|     permission_classes = [OwnerSuperuserPermissions] | ||||
|     filterset_fields = ["user"] | ||||
|     filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] | ||||
|     ordering = ["pk"] | ||||
|  | ||||
| @ -16,7 +16,6 @@ from rest_framework.viewsets import ModelViewSet | ||||
| from authentik.api.authorization import OwnerSuperuserPermissions | ||||
| from authentik.api.decorators import permission_required | ||||
| from authentik.blueprints.api import ManagedSerializer | ||||
| from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.users import UserSerializer | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| @ -30,20 +29,9 @@ class TokenSerializer(ManagedSerializer, ModelSerializer): | ||||
|  | ||||
|     user_obj = UserSerializer(required=False, source="user", read_only=True) | ||||
|  | ||||
|     def __init__(self, *args, **kwargs) -> None: | ||||
|         super().__init__(*args, **kwargs) | ||||
|         if SERIALIZER_CONTEXT_BLUEPRINT in self.context: | ||||
|             self.fields["key"] = CharField() | ||||
|  | ||||
|     def validate(self, attrs: dict[Any, str]) -> dict[Any, str]: | ||||
|         """Ensure only API or App password tokens are created.""" | ||||
|         request: Request = self.context.get("request") | ||||
|         if not request: | ||||
|             if "user" not in attrs: | ||||
|                 raise ValidationError("Missing user") | ||||
|             if "intent" not in attrs: | ||||
|                 raise ValidationError("Missing intent") | ||||
|         else: | ||||
|         request: Request = self.context["request"] | ||||
|         attrs.setdefault("user", request.user) | ||||
|         attrs.setdefault("intent", TokenIntents.INTENT_API) | ||||
|         if attrs.get("intent") not in [TokenIntents.INTENT_API, TokenIntents.INTENT_APP_PASSWORD]: | ||||
|  | ||||
| @ -38,7 +38,6 @@ from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ( | ||||
|     BooleanField, | ||||
|     DateTimeField, | ||||
|     ListSerializer, | ||||
|     ModelSerializer, | ||||
|     PrimaryKeyRelatedField, | ||||
| @ -67,12 +66,10 @@ from authentik.core.models import ( | ||||
|     TokenIntents, | ||||
|     User, | ||||
| ) | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.flows.exceptions import FlowNonApplicableException | ||||
| from authentik.events.models import EventAction | ||||
| from authentik.flows.models import FlowToken | ||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner | ||||
| from authentik.flows.views.executor import QS_KEY_TOKEN | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.stages.email.models import EmailStage | ||||
| from authentik.stages.email.tasks import send_mails | ||||
| from authentik.stages.email.utils import TemplateEmailMessage | ||||
| @ -212,9 +209,8 @@ class UserMetricsSerializer(PassiveSerializer): | ||||
|     def get_logins(self, _): | ||||
|         """Get successful logins per 8 hours for the last 7 days""" | ||||
|         user = self.context["user"] | ||||
|         request = self.context["request"] | ||||
|         return ( | ||||
|             get_objects_for_user(request.user, "authentik_events.view_event").filter( | ||||
|             get_objects_for_user(user, "authentik_events.view_event").filter( | ||||
|                 action=EventAction.LOGIN, user__pk=user.pk | ||||
|             ) | ||||
|             # 3 data points per day, so 8 hour spans | ||||
| @ -225,9 +221,8 @@ class UserMetricsSerializer(PassiveSerializer): | ||||
|     def get_logins_failed(self, _): | ||||
|         """Get failed logins per 8 hours for the last 7 days""" | ||||
|         user = self.context["user"] | ||||
|         request = self.context["request"] | ||||
|         return ( | ||||
|             get_objects_for_user(request.user, "authentik_events.view_event").filter( | ||||
|             get_objects_for_user(user, "authentik_events.view_event").filter( | ||||
|                 action=EventAction.LOGIN_FAILED, context__username=user.username | ||||
|             ) | ||||
|             # 3 data points per day, so 8 hour spans | ||||
| @ -238,9 +233,8 @@ class UserMetricsSerializer(PassiveSerializer): | ||||
|     def get_authorizations(self, _): | ||||
|         """Get failed logins per 8 hours for the last 7 days""" | ||||
|         user = self.context["user"] | ||||
|         request = self.context["request"] | ||||
|         return ( | ||||
|             get_objects_for_user(request.user, "authentik_events.view_event").filter( | ||||
|             get_objects_for_user(user, "authentik_events.view_event").filter( | ||||
|                 action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk | ||||
|             ) | ||||
|             # 3 data points per day, so 8 hour spans | ||||
| @ -331,16 +325,12 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|         user: User = self.get_object() | ||||
|         planner = FlowPlanner(flow) | ||||
|         planner.allow_empty_flows = True | ||||
|         try: | ||||
|         plan = planner.plan( | ||||
|             self.request._request, | ||||
|             { | ||||
|                 PLAN_CONTEXT_PENDING_USER: user, | ||||
|             }, | ||||
|         ) | ||||
|         except FlowNonApplicableException: | ||||
|             LOGGER.warning("Recovery flow not applicable to user") | ||||
|             return None, None | ||||
|         token, __ = FlowToken.objects.update_or_create( | ||||
|             identifier=f"{user.uid}-password-reset", | ||||
|             defaults={ | ||||
| @ -363,11 +353,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|             { | ||||
|                 "name": CharField(required=True), | ||||
|                 "create_group": BooleanField(default=False), | ||||
|                 "expiring": BooleanField(default=True), | ||||
|                 "expires": DateTimeField( | ||||
|                     required=False, | ||||
|                     help_text="If not provided, valid for 360 days", | ||||
|                 ), | ||||
|             }, | ||||
|         ), | ||||
|         responses={ | ||||
| @ -388,20 +373,14 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|         """Create a new user account that is marked as a service account""" | ||||
|         username = request.data.get("name") | ||||
|         create_group = request.data.get("create_group", False) | ||||
|         expiring = request.data.get("expiring", True) | ||||
|         expires = request.data.get("expires", now() + timedelta(days=360)) | ||||
|  | ||||
|         with atomic(): | ||||
|             try: | ||||
|                 user: User = User.objects.create( | ||||
|                 user = User.objects.create( | ||||
|                     username=username, | ||||
|                     name=username, | ||||
|                     attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: expiring}, | ||||
|                     attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False}, | ||||
|                     path=USER_PATH_SERVICE_ACCOUNT, | ||||
|                 ) | ||||
|                 user.set_unusable_password() | ||||
|                 user.save() | ||||
|  | ||||
|                 response = { | ||||
|                     "username": user.username, | ||||
|                     "user_uid": user.uid, | ||||
| @ -417,8 +396,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|                     identifier=slugify(f"service-account-{username}-password"), | ||||
|                     intent=TokenIntents.INTENT_APP_PASSWORD, | ||||
|                     user=user, | ||||
|                     expires=expires, | ||||
|                     expiring=expiring, | ||||
|                     expires=now() + timedelta(days=360), | ||||
|                 ) | ||||
|                 response["token"] = token.key | ||||
|                 return Response(response) | ||||
| @ -475,9 +453,8 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|     def metrics(self, request: Request, pk: int) -> Response: | ||||
|         """User metrics per 1h""" | ||||
|         user: User = self.get_object() | ||||
|         serializer = UserMetricsSerializer(instance={}) | ||||
|         serializer = UserMetricsSerializer(True) | ||||
|         serializer.context["user"] = user | ||||
|         serializer.context["request"] = request | ||||
|         return Response(serializer.data) | ||||
|  | ||||
|     @permission_required("authentik_core.reset_user_password") | ||||
| @ -544,58 +521,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|         send_mails(email_stage, message) | ||||
|         return Response(status=204) | ||||
|  | ||||
|     @permission_required("authentik_core.impersonate") | ||||
|     @extend_schema( | ||||
|         request=OpenApiTypes.NONE, | ||||
|         responses={ | ||||
|             "204": OpenApiResponse(description="Successfully started impersonation"), | ||||
|             "401": OpenApiResponse(description="Access denied"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=True, methods=["POST"]) | ||||
|     def impersonate(self, request: Request, pk: int) -> Response: | ||||
|         """Impersonate a user""" | ||||
|         if not CONFIG.y_bool("impersonation"): | ||||
|             LOGGER.debug("User attempted to impersonate", user=request.user) | ||||
|             return Response(status=401) | ||||
|         if not request.user.has_perm("impersonate"): | ||||
|             LOGGER.debug("User attempted to impersonate without permissions", user=request.user) | ||||
|             return Response(status=401) | ||||
|  | ||||
|         user_to_be = self.get_object() | ||||
|  | ||||
|         request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user | ||||
|         request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be | ||||
|  | ||||
|         Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) | ||||
|  | ||||
|         return Response(status=201) | ||||
|  | ||||
|     @extend_schema( | ||||
|         request=OpenApiTypes.NONE, | ||||
|         responses={ | ||||
|             "204": OpenApiResponse(description="Successfully started impersonation"), | ||||
|         }, | ||||
|     ) | ||||
|     @action(detail=False, methods=["GET"]) | ||||
|     def impersonate_end(self, request: Request) -> Response: | ||||
|         """End Impersonation a user""" | ||||
|         if ( | ||||
|             SESSION_KEY_IMPERSONATE_USER not in request.session | ||||
|             or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session | ||||
|         ): | ||||
|             LOGGER.debug("Can't end impersonation", user=request.user) | ||||
|             return Response(status=204) | ||||
|  | ||||
|         original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] | ||||
|  | ||||
|         del request.session[SESSION_KEY_IMPERSONATE_USER] | ||||
|         del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] | ||||
|  | ||||
|         Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user) | ||||
|  | ||||
|         return Response(status=204) | ||||
|  | ||||
|     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: | ||||
|         """Custom filter_queryset method which ignores guardian, but still supports sorting""" | ||||
|         for backend in list(self.filter_backends): | ||||
|  | ||||
| @ -11,7 +11,6 @@ class AuthentikCoreConfig(ManagedAppConfig): | ||||
|     label = "authentik_core" | ||||
|     verbose_name = "authentik Core" | ||||
|     mountpoint = "" | ||||
|     ws_mountpoint = "authentik.core.urls" | ||||
|     default = True | ||||
|  | ||||
|     def reconcile_load_core_signals(self): | ||||
|  | ||||
| @ -1,9 +1,8 @@ | ||||
| """Property Mapping Evaluator""" | ||||
| from typing import Any, Optional | ||||
| from typing import Optional | ||||
|  | ||||
| from django.db.models import Model | ||||
| from django.http import HttpRequest | ||||
| from prometheus_client import Histogram | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.events.models import Event, EventAction | ||||
| @ -11,24 +10,15 @@ from authentik.lib.expression.evaluator import BaseEvaluator | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.policies.types import PolicyRequest | ||||
|  | ||||
| PROPERTY_MAPPING_TIME = Histogram( | ||||
|     "authentik_property_mapping_execution_time", | ||||
|     "Evaluation time of property mappings", | ||||
|     ["mapping_name"], | ||||
| ) | ||||
|  | ||||
|  | ||||
| class PropertyMappingEvaluator(BaseEvaluator): | ||||
|     """Custom Evaluator that adds some different context variables.""" | ||||
|  | ||||
|     dry_run: bool | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         model: Model, | ||||
|         user: Optional[User] = None, | ||||
|         request: Optional[HttpRequest] = None, | ||||
|         dry_run: Optional[bool] = False, | ||||
|         **kwargs, | ||||
|     ): | ||||
|         if hasattr(model, "name"): | ||||
| @ -45,13 +35,9 @@ class PropertyMappingEvaluator(BaseEvaluator): | ||||
|             req.http_request = request | ||||
|         self._context["request"] = req | ||||
|         self._context.update(**kwargs) | ||||
|         self.dry_run = dry_run | ||||
|  | ||||
|     def handle_error(self, exc: Exception, expression_source: str): | ||||
|         """Exception Handler""" | ||||
|         # For dry-run requests we don't save exceptions | ||||
|         if self.dry_run: | ||||
|             return | ||||
|         error_string = exception_to_string(exc) | ||||
|         event = Event.new( | ||||
|             EventAction.PROPERTY_MAPPING_EXCEPTION, | ||||
| @ -63,7 +49,3 @@ class PropertyMappingEvaluator(BaseEvaluator): | ||||
|             event.from_http(req.http_request, req.user) | ||||
|             return | ||||
|         event.save() | ||||
|  | ||||
|     def evaluate(self, *args, **kwargs) -> Any: | ||||
|         with PROPERTY_MAPPING_TIME.labels(mapping_name=self._filename).time(): | ||||
|             return super().evaluate(*args, **kwargs) | ||||
|  | ||||
| @ -18,13 +18,13 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|  | ||||
|     akadmin, _ = User.objects.using(db_alias).get_or_create( | ||||
|         username="akadmin", | ||||
|         email=environ.get("AUTHENTIK_BOOTSTRAP_EMAIL", "root@localhost"), | ||||
|         name="authentik Default Admin", | ||||
|         username="akadmin", email="root@localhost", name="authentik Default Admin" | ||||
|     ) | ||||
|     password = None | ||||
|     if "TF_BUILD" in environ or settings.TEST: | ||||
|         password = "akadmin"  # noqa # nosec | ||||
|     if "AK_ADMIN_PASS" in environ: | ||||
|         password = environ["AK_ADMIN_PASS"] | ||||
|     if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ: | ||||
|         password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"] | ||||
|     if password: | ||||
|  | ||||
| @ -46,9 +46,13 @@ def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEdito | ||||
|     akadmin = User.objects.using(db_alias).filter(username="akadmin") | ||||
|     if not akadmin.exists(): | ||||
|         return | ||||
|     if "AUTHENTIK_BOOTSTRAP_TOKEN" not in environ: | ||||
|         return | ||||
|     key = None | ||||
|     if "AK_ADMIN_TOKEN" in environ: | ||||
|         key = environ["AK_ADMIN_TOKEN"] | ||||
|     if "AUTHENTIK_BOOTSTRAP_TOKEN" in environ: | ||||
|         key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"] | ||||
|     if not key: | ||||
|         return | ||||
|     Token.objects.using(db_alias).create( | ||||
|         identifier="authentik-bootstrap-token", | ||||
|         user=akadmin.first(), | ||||
| @ -182,9 +186,7 @@ class Migration(migrations.Migration): | ||||
|             model_name="application", | ||||
|             name="meta_launch_url", | ||||
|             field=models.TextField( | ||||
|                 blank=True, | ||||
|                 default="", | ||||
|                 validators=[authentik.lib.models.DomainlessFormattedURLValidator()], | ||||
|                 blank=True, default="", validators=[authentik.lib.models.DomainlessURLValidator()] | ||||
|             ), | ||||
|         ), | ||||
|         migrations.RunPython( | ||||
|  | ||||
| @ -1,25 +0,0 @@ | ||||
| # Generated by Django 4.1.7 on 2023-03-02 21:32 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_flows", "0025_alter_flowstagebinding_evaluate_on_plan_and_more"), | ||||
|         ("authentik_core", "0024_source_icon"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="provider", | ||||
|             name="authorization_flow", | ||||
|             field=models.ForeignKey( | ||||
|                 help_text="Flow used when authorizing this provider.", | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.CASCADE, | ||||
|                 related_name="provider_authorization", | ||||
|                 to="authentik_flows.flow", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,26 +0,0 @@ | ||||
| # Generated by Django 4.1.7 on 2023-03-07 13:41 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
| from authentik.lib.migrations import fallback_names | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0025_alter_provider_authorization_flow"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(fallback_names("authentik_core", "propertymapping", "name")), | ||||
|         migrations.RunPython(fallback_names("authentik_core", "provider", "name")), | ||||
|         migrations.AlterField( | ||||
|             model_name="propertymapping", | ||||
|             name="name", | ||||
|             field=models.TextField(unique=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="provider", | ||||
|             name="name", | ||||
|             field=models.TextField(unique=True), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,19 +0,0 @@ | ||||
| # Generated by Django 4.1.7 on 2023-03-19 21:57 | ||||
|  | ||||
| import uuid | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0026_alter_propertymapping_name_alter_provider_name"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="user", | ||||
|             name="uuid", | ||||
|             field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,25 +0,0 @@ | ||||
| # Generated by Django 4.1.7 on 2023-03-23 21:44 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_flows", "0025_alter_flowstagebinding_evaluate_on_plan_and_more"), | ||||
|         ("authentik_core", "0027_alter_user_uuid"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="provider", | ||||
|             name="authentication_flow", | ||||
|             field=models.ForeignKey( | ||||
|                 help_text="Flow used for authentication when the associated application is accessed by an un-authenticated user.", | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|                 related_name="provider_authentication", | ||||
|                 to="authentik_flows.flow", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -22,15 +22,12 @@ from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.blueprints.models import ManagedModel | ||||
| from authentik.core.exceptions import PropertyMappingExpressionException | ||||
| from authentik.core.signals import password_changed | ||||
| from authentik.core.types import UILoginButton, UserSettingSerializer | ||||
| from authentik.lib.avatars import get_avatar | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.models import ( | ||||
|     CreatedUpdatedModel, | ||||
|     DomainlessFormattedURLValidator, | ||||
|     SerializerModel, | ||||
| ) | ||||
| from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel | ||||
| from authentik.lib.utils.http import get_client_ip | ||||
| from authentik.policies.models import PolicyBindingModel | ||||
|  | ||||
| @ -146,7 +143,7 @@ class UserManager(DjangoUserManager): | ||||
| class User(SerializerModel, GuardianUserMixin, AbstractUser): | ||||
|     """Custom User model to allow easier adding of user-based settings""" | ||||
|  | ||||
|     uuid = models.UUIDField(default=uuid4, editable=False, unique=True) | ||||
|     uuid = models.UUIDField(default=uuid4, editable=False) | ||||
|     name = models.TextField(help_text=_("User's display name.")) | ||||
|     path = models.TextField(default="users") | ||||
|  | ||||
| @ -192,8 +189,6 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): | ||||
|  | ||||
|     def set_password(self, raw_password, signal=True): | ||||
|         if self.pk and signal: | ||||
|             from authentik.core.signals import password_changed | ||||
|  | ||||
|             password_changed.send(sender=self, user=self, password=raw_password) | ||||
|         self.password_change_date = now() | ||||
|         return super().set_password(raw_password) | ||||
| @ -247,23 +242,11 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): | ||||
| class Provider(SerializerModel): | ||||
|     """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" | ||||
|  | ||||
|     name = models.TextField(unique=True) | ||||
|  | ||||
|     authentication_flow = models.ForeignKey( | ||||
|         "authentik_flows.Flow", | ||||
|         null=True, | ||||
|         on_delete=models.SET_NULL, | ||||
|         help_text=_( | ||||
|             "Flow used for authentication when the associated application is accessed by an " | ||||
|             "un-authenticated user." | ||||
|         ), | ||||
|         related_name="provider_authentication", | ||||
|     ) | ||||
|     name = models.TextField() | ||||
|  | ||||
|     authorization_flow = models.ForeignKey( | ||||
|         "authentik_flows.Flow", | ||||
|         on_delete=models.CASCADE, | ||||
|         null=True, | ||||
|         help_text=_("Flow used when authorizing this provider."), | ||||
|         related_name="provider_authorization", | ||||
|     ) | ||||
| @ -306,7 +289,7 @@ class Application(SerializerModel, PolicyBindingModel): | ||||
|     ) | ||||
|  | ||||
|     meta_launch_url = models.TextField( | ||||
|         default="", blank=True, validators=[DomainlessFormattedURLValidator()] | ||||
|         default="", blank=True, validators=[DomainlessURLValidator()] | ||||
|     ) | ||||
|  | ||||
|     open_in_new_tab = models.BooleanField( | ||||
| @ -623,7 +606,7 @@ class PropertyMapping(SerializerModel, ManagedModel): | ||||
|     """User-defined key -> x mapping which can be used by providers to expose extra data.""" | ||||
|  | ||||
|     pm_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||
|     name = models.TextField(unique=True) | ||||
|     name = models.TextField() | ||||
|     expression = models.TextField() | ||||
|  | ||||
|     objects = InheritanceManager() | ||||
| @ -646,7 +629,7 @@ class PropertyMapping(SerializerModel, ManagedModel): | ||||
|         try: | ||||
|             return evaluator.evaluate(self.expression) | ||||
|         except Exception as exc: | ||||
|             raise PropertyMappingExpressionException(exc) from exc | ||||
|             raise PropertyMappingExpressionException(str(exc)) from exc | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"Property Mapping {self.name}" | ||||
|  | ||||
| @ -10,25 +10,25 @@ from django.db.models.signals import post_save, pre_delete | ||||
| from django.dispatch import receiver | ||||
| from django.http.request import HttpRequest | ||||
|  | ||||
| from authentik.core.models import Application, AuthenticatedSession | ||||
|  | ||||
| # Arguments: user: User, password: str | ||||
| password_changed = Signal() | ||||
| # Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage | ||||
| login_failed = Signal() | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.core.models import User | ||||
|     from authentik.core.models import AuthenticatedSession, User | ||||
|  | ||||
|  | ||||
| @receiver(post_save, sender=Application) | ||||
| @receiver(post_save) | ||||
| def post_save_application(sender: type[Model], instance, created: bool, **_): | ||||
|     """Clear user's application cache upon application creation""" | ||||
|     from authentik.core.api.applications import user_app_cache_key | ||||
|     from authentik.core.models import Application | ||||
|  | ||||
|     if sender != Application: | ||||
|         return | ||||
|     if not created:  # pragma: no cover | ||||
|         return | ||||
|  | ||||
|     # Also delete user application cache | ||||
|     keys = cache.keys(user_app_cache_key("*")) | ||||
|     cache.delete_many(keys) | ||||
| @ -37,6 +37,7 @@ def post_save_application(sender: type[Model], instance, created: bool, **_): | ||||
| @receiver(user_logged_in) | ||||
| def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): | ||||
|     """Create an AuthenticatedSession from request""" | ||||
|     from authentik.core.models import AuthenticatedSession | ||||
|  | ||||
|     session = AuthenticatedSession.from_request(request, user) | ||||
|     if session: | ||||
| @ -46,11 +47,18 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): | ||||
| @receiver(user_logged_out) | ||||
| def user_logged_out_session(sender, request: HttpRequest, user: "User", **_): | ||||
|     """Delete AuthenticatedSession if it exists""" | ||||
|     from authentik.core.models import AuthenticatedSession | ||||
|  | ||||
|     AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete() | ||||
|  | ||||
|  | ||||
| @receiver(pre_delete, sender=AuthenticatedSession) | ||||
| @receiver(pre_delete) | ||||
| def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): | ||||
|     """Delete session when authenticated session is deleted""" | ||||
|     from authentik.core.models import AuthenticatedSession | ||||
|  | ||||
|     if sender != AuthenticatedSession: | ||||
|         return | ||||
|  | ||||
|     cache_key = f"{KEY_PREFIX}{instance.session_key}" | ||||
|     cache.delete(cache_key) | ||||
|  | ||||
| @ -9,13 +9,15 @@ | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||
|         <title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title> | ||||
|         <link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}"> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}"> | ||||
|         {% block head_before %} | ||||
|         {% endblock %} | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.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/custom.css' %}" data-inject> | ||||
|         <script src="{% static 'dist/poly.js' %}?version={{ version }}" type="module"></script> | ||||
|         <script src="{% static 'dist/standalone/loading/index.js' %}?version={{ version }}" type="module"></script> | ||||
|         <link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}"> | ||||
|         <script src="{% static 'dist/poly.js' %}" type="module"></script> | ||||
|         {% block head %} | ||||
|         {% endblock %} | ||||
|         <meta name="sentry-trace" content="{{ sentry_trace }}" /> | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| {% extends "base/skeleton.html" %} | ||||
|  | ||||
| {% load static %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block head %} | ||||
| <script src="{% static 'dist/admin/AdminInterface.js' %}?version={{ version }}" type="module"></script> | ||||
| @ -14,6 +15,19 @@ | ||||
| {% block body %} | ||||
| <ak-message-container></ak-message-container> | ||||
| <ak-interface-admin> | ||||
|     <ak-loading></ak-loading> | ||||
|     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||
|             <div class="pf-c-empty-state__content"> | ||||
|                 <span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}"> | ||||
|                     <span class="pf-c-spinner__clipper"></span> | ||||
|                     <span class="pf-c-spinner__lead-ball"></span> | ||||
|                     <span class="pf-c-spinner__tail-ball"></span> | ||||
|                 </span> | ||||
|                 <h1 class="pf-c-title pf-m-lg"> | ||||
|                     {% trans "Loading..." %} | ||||
|                 </h1> | ||||
|             </div> | ||||
|         </div> | ||||
|     </section> | ||||
| </ak-interface-admin> | ||||
| {% endblock %} | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| {% extends "base/skeleton.html" %} | ||||
|  | ||||
| {% load static %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block head_before %} | ||||
| {{ block.super }} | ||||
| @ -30,6 +31,19 @@ window.authentik.flow = { | ||||
| {% block body %} | ||||
| <ak-message-container></ak-message-container> | ||||
| <ak-flow-executor> | ||||
|     <ak-loading></ak-loading> | ||||
|     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||
|             <div class="pf-c-empty-state__content"> | ||||
|                 <span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}"> | ||||
|                     <span class="pf-c-spinner__clipper"></span> | ||||
|                     <span class="pf-c-spinner__lead-ball"></span> | ||||
|                     <span class="pf-c-spinner__tail-ball"></span> | ||||
|                 </span> | ||||
|                 <h1 class="pf-c-title pf-m-lg"> | ||||
|                     {% trans "Loading..." %} | ||||
|                 </h1> | ||||
|             </div> | ||||
|         </div> | ||||
|     </section> | ||||
| </ak-flow-executor> | ||||
| {% endblock %} | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| {% extends "base/skeleton.html" %} | ||||
|  | ||||
| {% load static %} | ||||
| {% load i18n %} | ||||
|  | ||||
| {% block head %} | ||||
| <script src="{% static 'dist/user/UserInterface.js' %}?version={{ version }}" type="module"></script> | ||||
| @ -14,6 +15,19 @@ | ||||
| {% block body %} | ||||
| <ak-message-container></ak-message-container> | ||||
| <ak-interface-user> | ||||
|     <ak-loading></ak-loading> | ||||
|     <section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> | ||||
|         <div class="pf-c-empty-state" style="height: 100vh;"> | ||||
|             <div class="pf-c-empty-state__content"> | ||||
|                 <span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}"> | ||||
|                     <span class="pf-c-spinner__clipper"></span> | ||||
|                     <span class="pf-c-spinner__lead-ball"></span> | ||||
|                     <span class="pf-c-spinner__tail-ball"></span> | ||||
|                 </span> | ||||
|                 <h1 class="pf-c-title pf-m-lg"> | ||||
|                     {% trans "Loading..." %} | ||||
|                 </h1> | ||||
|             </div> | ||||
|         </div> | ||||
|     </section> | ||||
| </ak-interface-user> | ||||
| {% endblock %} | ||||
|  | ||||
| @ -37,22 +37,6 @@ class TestApplicationsAPI(APITestCase): | ||||
|             order=0, | ||||
|         ) | ||||
|  | ||||
|     def test_formatted_launch_url(self): | ||||
|         """Test formatted launch URL""" | ||||
|         self.client.force_login(self.user) | ||||
|         self.assertEqual( | ||||
|             self.client.patch( | ||||
|                 reverse("authentik_api:application-detail", kwargs={"slug": self.allowed.slug}), | ||||
|                 {"meta_launch_url": "https://%(username)s-test.test.goauthentik.io/%(username)s"}, | ||||
|             ).status_code, | ||||
|             200, | ||||
|         ) | ||||
|         self.allowed.refresh_from_db() | ||||
|         self.assertEqual( | ||||
|             self.allowed.get_launch_url(self.user), | ||||
|             f"https://{self.user.username}-test.test.goauthentik.io/{self.user.username}", | ||||
|         ) | ||||
|  | ||||
|     def test_set_icon(self): | ||||
|         """Test set_icon""" | ||||
|         file = ContentFile(b"text", "name") | ||||
| @ -129,7 +113,6 @@ class TestApplicationsAPI(APITestCase): | ||||
|                         "provider_obj": { | ||||
|                             "assigned_application_name": "allowed", | ||||
|                             "assigned_application_slug": "allowed", | ||||
|                             "authentication_flow": None, | ||||
|                             "authorization_flow": str(self.provider.authorization_flow.pk), | ||||
|                             "component": "ak-provider-oauth2-form", | ||||
|                             "meta_model_name": "authentik_providers_oauth2.oauth2provider", | ||||
| @ -179,7 +162,6 @@ class TestApplicationsAPI(APITestCase): | ||||
|                         "provider_obj": { | ||||
|                             "assigned_application_name": "allowed", | ||||
|                             "assigned_application_slug": "allowed", | ||||
|                             "authentication_flow": None, | ||||
|                             "authorization_flow": str(self.provider.authorization_flow.pk), | ||||
|                             "component": "ak-provider-oauth2-form", | ||||
|                             "meta_model_name": "authentik_providers_oauth2.oauth2provider", | ||||
|  | ||||
| @ -1,14 +1,14 @@ | ||||
| """impersonation tests""" | ||||
| from json import loads | ||||
|  | ||||
| from django.test.testcases import TestCase | ||||
| from django.urls import reverse | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import User | ||||
| from authentik.core.tests.utils import create_test_admin_user | ||||
|  | ||||
|  | ||||
| class TestImpersonation(APITestCase): | ||||
| class TestImpersonation(TestCase): | ||||
|     """impersonation tests""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
| @ -23,10 +23,10 @@ class TestImpersonation(APITestCase): | ||||
|         self.other_user.save() | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|         self.client.post( | ||||
|         self.client.get( | ||||
|             reverse( | ||||
|                 "authentik_api:user-impersonate", | ||||
|                 kwargs={"pk": self.other_user.pk}, | ||||
|                 "authentik_core:impersonate-init", | ||||
|                 kwargs={"user_id": self.other_user.pk}, | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
| @ -35,7 +35,7 @@ class TestImpersonation(APITestCase): | ||||
|         self.assertEqual(response_body["user"]["username"], self.other_user.username) | ||||
|         self.assertEqual(response_body["original"]["username"], self.user.username) | ||||
|  | ||||
|         self.client.get(reverse("authentik_api:user-impersonate-end")) | ||||
|         self.client.get(reverse("authentik_core:impersonate-end")) | ||||
|  | ||||
|         response = self.client.get(reverse("authentik_api:user-me")) | ||||
|         response_body = loads(response.content.decode()) | ||||
| @ -46,7 +46,9 @@ class TestImpersonation(APITestCase): | ||||
|         """test impersonation without permissions""" | ||||
|         self.client.force_login(self.other_user) | ||||
|  | ||||
|         self.client.get(reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})) | ||||
|         self.client.get( | ||||
|             reverse("authentik_core:impersonate-init", kwargs={"user_id": self.user.pk}) | ||||
|         ) | ||||
|  | ||||
|         response = self.client.get(reverse("authentik_api:user-me")) | ||||
|         response_body = loads(response.content.decode()) | ||||
| @ -56,5 +58,5 @@ class TestImpersonation(APITestCase): | ||||
|         """test un-impersonation without impersonating first""" | ||||
|         self.client.force_login(self.other_user) | ||||
|  | ||||
|         response = self.client.get(reverse("authentik_api:user-impersonate-end")) | ||||
|         self.assertEqual(response.status_code, 204) | ||||
|         response = self.client.get(reverse("authentik_core:impersonate-end")) | ||||
|         self.assertRedirects(response, reverse("authentik_core:if-user")) | ||||
|  | ||||
| @ -4,10 +4,7 @@ from guardian.shortcuts import get_anonymous_user | ||||
|  | ||||
| from authentik.core.exceptions import PropertyMappingExpressionException | ||||
| from authentik.core.models import PropertyMapping | ||||
| from authentik.core.tests.utils import create_test_admin_user | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.policies.expression.models import ExpressionPolicy | ||||
|  | ||||
|  | ||||
| class TestPropertyMappings(TestCase): | ||||
| @ -15,24 +12,23 @@ class TestPropertyMappings(TestCase): | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         super().setUp() | ||||
|         self.user = create_test_admin_user() | ||||
|         self.factory = RequestFactory() | ||||
|  | ||||
|     def test_expression(self): | ||||
|         """Test expression""" | ||||
|         mapping = PropertyMapping.objects.create(name=generate_id(), expression="return 'test'") | ||||
|         mapping = PropertyMapping.objects.create(name="test", expression="return 'test'") | ||||
|         self.assertEqual(mapping.evaluate(None, None), "test") | ||||
|  | ||||
|     def test_expression_syntax(self): | ||||
|         """Test expression syntax error""" | ||||
|         mapping = PropertyMapping.objects.create(name=generate_id(), expression="-") | ||||
|         mapping = PropertyMapping.objects.create(name="test", expression="-") | ||||
|         with self.assertRaises(PropertyMappingExpressionException): | ||||
|             mapping.evaluate(None, None) | ||||
|  | ||||
|     def test_expression_error_general(self): | ||||
|         """Test expression error""" | ||||
|         expr = "return aaa" | ||||
|         mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr) | ||||
|         mapping = PropertyMapping.objects.create(name="test", expression=expr) | ||||
|         with self.assertRaises(PropertyMappingExpressionException): | ||||
|             mapping.evaluate(None, None) | ||||
|         events = Event.objects.filter( | ||||
| @ -45,7 +41,7 @@ class TestPropertyMappings(TestCase): | ||||
|         """Test expression error (with user and http request""" | ||||
|         expr = "return aaa" | ||||
|         request = self.factory.get("/") | ||||
|         mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr) | ||||
|         mapping = PropertyMapping.objects.create(name="test", expression=expr) | ||||
|         with self.assertRaises(PropertyMappingExpressionException): | ||||
|             mapping.evaluate(get_anonymous_user(), request) | ||||
|         events = Event.objects.filter( | ||||
| @ -56,23 +52,3 @@ class TestPropertyMappings(TestCase): | ||||
|         event = events.first() | ||||
|         self.assertEqual(event.user["username"], "AnonymousUser") | ||||
|         self.assertEqual(event.client_ip, "127.0.0.1") | ||||
|  | ||||
|     def test_call_policy(self): | ||||
|         """test ak_call_policy""" | ||||
|         expr = ExpressionPolicy.objects.create( | ||||
|             name=generate_id(), | ||||
|             execution_logging=True, | ||||
|             expression="return request.http_request.path", | ||||
|         ) | ||||
|         http_request = self.factory.get("/") | ||||
|         tmpl = ( | ||||
|             """ | ||||
|         res = ak_call_policy('%s') | ||||
|         result = [request.http_request.path, res.raw_result] | ||||
|         return result | ||||
|         """ | ||||
|             % expr.name | ||||
|         ) | ||||
|         evaluator = PropertyMapping(expression=tmpl, name=generate_id()) | ||||
|         res = evaluator.evaluate(self.user, http_request) | ||||
|         self.assertEqual(res, ["/", "/"]) | ||||
|  | ||||
| @ -5,7 +5,6 @@ from django.urls.base import reverse | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.api.tokens import TokenSerializer | ||||
| from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User | ||||
| from authentik.core.tests.utils import create_test_admin_user | ||||
| from authentik.lib.generators import generate_id | ||||
| @ -100,16 +99,3 @@ class TestTokenAPI(APITestCase): | ||||
|         self.assertEqual(len(body["results"]), 2) | ||||
|         self.assertEqual(body["results"][0]["identifier"], token_should.identifier) | ||||
|         self.assertEqual(body["results"][1]["identifier"], token_should_not.identifier) | ||||
|  | ||||
|     def test_serializer_no_request(self): | ||||
|         """Test serializer without request""" | ||||
|         self.assertTrue( | ||||
|             TokenSerializer( | ||||
|                 data={ | ||||
|                     "identifier": generate_id(), | ||||
|                     "intent": TokenIntents.INTENT_APP_PASSWORD, | ||||
|                     "key": generate_id(), | ||||
|                     "user": self.user.pk, | ||||
|                 } | ||||
|             ).is_valid(raise_exception=True) | ||||
|         ) | ||||
|  | ||||
| @ -1,19 +1,11 @@ | ||||
| """Test Users API""" | ||||
|  | ||||
| from datetime import datetime | ||||
|  | ||||
| from django.contrib.sessions.backends.cache import KEY_PREFIX | ||||
| from django.core.cache import cache | ||||
| from django.urls.base import reverse | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import ( | ||||
|     USER_ATTRIBUTE_SA, | ||||
|     USER_ATTRIBUTE_TOKEN_EXPIRING, | ||||
|     AuthenticatedSession, | ||||
|     Token, | ||||
|     User, | ||||
| ) | ||||
| from authentik.core.models import AuthenticatedSession, User | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant | ||||
| from authentik.flows.models import FlowDesignation | ||||
| from authentik.lib.generators import generate_id, generate_key | ||||
| @ -138,71 +130,7 @@ class TestUsersAPI(APITestCase): | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         user_filter = User.objects.filter( | ||||
|             username="test-sa", | ||||
|             attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True}, | ||||
|         ) | ||||
|         self.assertTrue(user_filter.exists()) | ||||
|         user: User = user_filter.first() | ||||
|         self.assertFalse(user.has_usable_password()) | ||||
|  | ||||
|         token_filter = Token.objects.filter(user=user) | ||||
|         self.assertTrue(token_filter.exists()) | ||||
|         self.assertTrue(token_filter.first().expiring) | ||||
|  | ||||
|     def test_service_account_no_expire(self): | ||||
|         """Service account creation without token expiration""" | ||||
|         self.client.force_login(self.admin) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:user-service-account"), | ||||
|             data={ | ||||
|                 "name": "test-sa", | ||||
|                 "create_group": True, | ||||
|                 "expiring": False, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         user_filter = User.objects.filter( | ||||
|             username="test-sa", | ||||
|             attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: False, USER_ATTRIBUTE_SA: True}, | ||||
|         ) | ||||
|         self.assertTrue(user_filter.exists()) | ||||
|         user: User = user_filter.first() | ||||
|         self.assertFalse(user.has_usable_password()) | ||||
|  | ||||
|         token_filter = Token.objects.filter(user=user) | ||||
|         self.assertTrue(token_filter.exists()) | ||||
|         self.assertFalse(token_filter.first().expiring) | ||||
|  | ||||
|     def test_service_account_with_custom_expire(self): | ||||
|         """Service account creation with custom token expiration date""" | ||||
|         self.client.force_login(self.admin) | ||||
|         expire_on = datetime(2050, 11, 11, 11, 11, 11).astimezone() | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:user-service-account"), | ||||
|             data={ | ||||
|                 "name": "test-sa", | ||||
|                 "create_group": True, | ||||
|                 "expires": expire_on.isoformat(), | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         user_filter = User.objects.filter( | ||||
|             username="test-sa", | ||||
|             attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True}, | ||||
|         ) | ||||
|         self.assertTrue(user_filter.exists()) | ||||
|         user: User = user_filter.first() | ||||
|         self.assertFalse(user.has_usable_password()) | ||||
|  | ||||
|         token_filter = Token.objects.filter(user=user) | ||||
|         self.assertTrue(token_filter.exists()) | ||||
|         token = token_filter.first() | ||||
|         self.assertTrue(token.expiring) | ||||
|         self.assertEqual(token.expires, expire_on) | ||||
|         self.assertTrue(User.objects.filter(username="test-sa").exists()) | ||||
|  | ||||
|     def test_service_account_invalid(self): | ||||
|         """Service account creation (twice with same name, expect error)""" | ||||
| @ -215,19 +143,7 @@ class TestUsersAPI(APITestCase): | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         user_filter = User.objects.filter( | ||||
|             username="test-sa", | ||||
|             attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True}, | ||||
|         ) | ||||
|         self.assertTrue(user_filter.exists()) | ||||
|         user: User = user_filter.first() | ||||
|         self.assertFalse(user.has_usable_password()) | ||||
|  | ||||
|         token_filter = Token.objects.filter(user=user) | ||||
|         self.assertTrue(token_filter.exists()) | ||||
|         self.assertTrue(token_filter.first().expiring) | ||||
|  | ||||
|         self.assertTrue(User.objects.filter(username="test-sa").exists()) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:user-service-account"), | ||||
|             data={ | ||||
|  | ||||
| @ -27,6 +27,6 @@ class UserSettingSerializer(PassiveSerializer): | ||||
|  | ||||
|     object_uid = CharField() | ||||
|     component = CharField() | ||||
|     title = CharField(required=True) | ||||
|     title = CharField() | ||||
|     configure_url = CharField(required=False) | ||||
|     icon_url = CharField(required=False) | ||||
|  | ||||
| @ -1,18 +1,14 @@ | ||||
| """authentik URL Configuration""" | ||||
| from channels.auth import AuthMiddleware | ||||
| from channels.sessions import CookieMiddleware | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.urls import path | ||||
| from django.views.decorators.csrf import ensure_csrf_cookie | ||||
| from django.views.generic import RedirectView | ||||
|  | ||||
| from authentik.core.views import apps | ||||
| from authentik.core.views import apps, impersonate | ||||
| from authentik.core.views.debug import AccessDeniedView | ||||
| from authentik.core.views.interface import FlowInterfaceView, InterfaceView | ||||
| from authentik.core.views.session import EndSessionView | ||||
| from authentik.root.asgi_middleware import SessionMiddleware | ||||
| from authentik.root.messages.consumer import MessageConsumer | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path( | ||||
| @ -28,6 +24,17 @@ urlpatterns = [ | ||||
|         apps.RedirectToAppLaunch.as_view(), | ||||
|         name="application-launch", | ||||
|     ), | ||||
|     # Impersonation | ||||
|     path( | ||||
|         "-/impersonation/<int:user_id>/", | ||||
|         impersonate.ImpersonateInitView.as_view(), | ||||
|         name="impersonate-init", | ||||
|     ), | ||||
|     path( | ||||
|         "-/impersonation/end/", | ||||
|         impersonate.ImpersonateEndView.as_view(), | ||||
|         name="impersonate-end", | ||||
|     ), | ||||
|     # Interfaces | ||||
|     path( | ||||
|         "if/admin/", | ||||
| @ -57,12 +64,6 @@ urlpatterns = [ | ||||
|     ), | ||||
| ] | ||||
|  | ||||
| websocket_urlpatterns = [ | ||||
|     path( | ||||
|         "ws/client/", CookieMiddleware(SessionMiddleware(AuthMiddleware(MessageConsumer.as_asgi()))) | ||||
|     ), | ||||
| ] | ||||
|  | ||||
| if settings.DEBUG: | ||||
|     urlpatterns += [ | ||||
|         path("debug/policy/deny/", AccessDeniedView.as_view(), name="debug-policy-deny"), | ||||
|  | ||||
| @ -11,20 +11,16 @@ from authentik.flows.challenge import ( | ||||
|     HttpChallengeResponse, | ||||
|     RedirectChallenge, | ||||
| ) | ||||
| from authentik.flows.exceptions import FlowNonApplicableException | ||||
| from authentik.flows.models import FlowDesignation, in_memory_stage | ||||
| from authentik.flows.models import in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.flows.views.executor import ( | ||||
|     SESSION_KEY_APPLICATION_PRE, | ||||
|     SESSION_KEY_PLAN, | ||||
|     ToDefaultFlow, | ||||
| ) | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.stages.consent.stage import ( | ||||
|     PLAN_CONTEXT_CONSENT_HEADER, | ||||
|     PLAN_CONTEXT_CONSENT_PERMISSIONS, | ||||
| ) | ||||
| from authentik.tenants.models import Tenant | ||||
|  | ||||
|  | ||||
| class RedirectToAppLaunch(View): | ||||
| @ -39,13 +35,12 @@ class RedirectToAppLaunch(View): | ||||
|         # Check if we're authenticated already, saves us the flow run | ||||
|         if request.user.is_authenticated: | ||||
|             return HttpResponseRedirect(app.get_launch_url(request.user)) | ||||
|         self.request.session[SESSION_KEY_APPLICATION_PRE] = app | ||||
|         # otherwise, do a custom flow plan that includes the application that's | ||||
|         # being accessed, to improve usability | ||||
|         flow = ToDefaultFlow(request=request, designation=FlowDesignation.AUTHENTICATION).get_flow() | ||||
|         tenant: Tenant = request.tenant | ||||
|         flow = tenant.flow_authentication | ||||
|         planner = FlowPlanner(flow) | ||||
|         planner.allow_empty_flows = True | ||||
|         try: | ||||
|         plan = planner.plan( | ||||
|             request, | ||||
|             { | ||||
| @ -55,8 +50,6 @@ class RedirectToAppLaunch(View): | ||||
|                 PLAN_CONTEXT_CONSENT_PERMISSIONS: [], | ||||
|             }, | ||||
|         ) | ||||
|         except FlowNonApplicableException: | ||||
|             raise Http404 | ||||
|         plan.insert_stage(in_memory_stage(RedirectToAppStage)) | ||||
|         request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug) | ||||
|  | ||||
							
								
								
									
										60
									
								
								authentik/core/views/impersonate.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								authentik/core/views/impersonate.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,60 @@ | ||||
| """authentik impersonation views""" | ||||
|  | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.shortcuts import get_object_or_404, redirect | ||||
| from django.views import View | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.middleware import ( | ||||
|     SESSION_KEY_IMPERSONATE_ORIGINAL_USER, | ||||
|     SESSION_KEY_IMPERSONATE_USER, | ||||
| ) | ||||
| from authentik.core.models import User | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.config import CONFIG | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| class ImpersonateInitView(View): | ||||
|     """Initiate Impersonation""" | ||||
|  | ||||
|     def get(self, request: HttpRequest, user_id: int) -> HttpResponse: | ||||
|         """Impersonation handler, checks permissions""" | ||||
|         if not CONFIG.y_bool("impersonation"): | ||||
|             LOGGER.debug("User attempted to impersonate", user=request.user) | ||||
|             return HttpResponse("Unauthorized", status=401) | ||||
|         if not request.user.has_perm("impersonate"): | ||||
|             LOGGER.debug("User attempted to impersonate without permissions", user=request.user) | ||||
|             return HttpResponse("Unauthorized", status=401) | ||||
|  | ||||
|         user_to_be = get_object_or_404(User, pk=user_id) | ||||
|  | ||||
|         request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user | ||||
|         request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be | ||||
|  | ||||
|         Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) | ||||
|  | ||||
|         return redirect("authentik_core:if-user") | ||||
|  | ||||
|  | ||||
| class ImpersonateEndView(View): | ||||
|     """End User impersonation""" | ||||
|  | ||||
|     def get(self, request: HttpRequest) -> HttpResponse: | ||||
|         """End Impersonation handler""" | ||||
|         if ( | ||||
|             SESSION_KEY_IMPERSONATE_USER not in request.session | ||||
|             or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session | ||||
|         ): | ||||
|             LOGGER.debug("Can't end impersonation", user=request.user) | ||||
|             return redirect("authentik_core:if-user") | ||||
|  | ||||
|         original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] | ||||
|  | ||||
|         del request.session[SESSION_KEY_IMPERSONATE_USER] | ||||
|         del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] | ||||
|  | ||||
|         Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user) | ||||
|  | ||||
|         return redirect("authentik_core:root-redirect") | ||||
| @ -7,14 +7,13 @@ from django.conf import settings | ||||
| from django.contrib.sessions.models import Session | ||||
| from django.core.exceptions import SuspiciousOperation | ||||
| from django.db.models import Model | ||||
| from django.db.models.signals import m2m_changed, post_save, pre_delete | ||||
| from django.db.models.signals import post_save, pre_delete | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django_otp.plugins.otp_static.models import StaticToken | ||||
| from guardian.models import UserObjectPermission | ||||
|  | ||||
| from authentik.core.models import ( | ||||
|     AuthenticatedSession, | ||||
|     Group, | ||||
|     PropertyMapping, | ||||
|     Provider, | ||||
|     Source, | ||||
| @ -29,7 +28,6 @@ from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.outposts.models import OutpostServiceConnection | ||||
| from authentik.policies.models import Policy, PolicyBindingModel | ||||
| from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken | ||||
| from authentik.providers.scim.models import SCIMGroup, SCIMUser | ||||
|  | ||||
| IGNORED_MODELS = ( | ||||
|     Event, | ||||
| @ -50,8 +48,6 @@ IGNORED_MODELS = ( | ||||
|     AuthorizationCode, | ||||
|     AccessToken, | ||||
|     RefreshToken, | ||||
|     SCIMUser, | ||||
|     SCIMGroup, | ||||
| ) | ||||
|  | ||||
|  | ||||
| @ -62,13 +58,6 @@ def should_log_model(model: Model) -> bool: | ||||
|     return model.__class__ not in IGNORED_MODELS | ||||
|  | ||||
|  | ||||
| def should_log_m2m(model: Model) -> bool: | ||||
|     """Return true if m2m operation should be logged""" | ||||
|     if model.__class__ in [User, Group]: | ||||
|         return True | ||||
|     return False | ||||
|  | ||||
|  | ||||
| class EventNewThread(Thread): | ||||
|     """Create Event in background thread""" | ||||
|  | ||||
| @ -107,7 +96,6 @@ class AuditMiddleware: | ||||
|             return | ||||
|         post_save_handler = partial(self.post_save_handler, user=request.user, request=request) | ||||
|         pre_delete_handler = partial(self.pre_delete_handler, user=request.user, request=request) | ||||
|         m2m_changed_handler = partial(self.m2m_changed_handler, user=request.user, request=request) | ||||
|         post_save.connect( | ||||
|             post_save_handler, | ||||
|             dispatch_uid=request.request_id, | ||||
| @ -118,11 +106,6 @@ class AuditMiddleware: | ||||
|             dispatch_uid=request.request_id, | ||||
|             weak=False, | ||||
|         ) | ||||
|         m2m_changed.connect( | ||||
|             m2m_changed_handler, | ||||
|             dispatch_uid=request.request_id, | ||||
|             weak=False, | ||||
|         ) | ||||
|  | ||||
|     def disconnect(self, request: HttpRequest): | ||||
|         """Disconnect signals""" | ||||
| @ -130,7 +113,6 @@ class AuditMiddleware: | ||||
|             return | ||||
|         post_save.disconnect(dispatch_uid=request.request_id) | ||||
|         pre_delete.disconnect(dispatch_uid=request.request_id) | ||||
|         m2m_changed.disconnect(dispatch_uid=request.request_id) | ||||
|  | ||||
|     def __call__(self, request: HttpRequest) -> HttpResponse: | ||||
|         self.connect(request) | ||||
| @ -185,20 +167,3 @@ class AuditMiddleware: | ||||
|             user=user, | ||||
|             model=model_to_dict(instance), | ||||
|         ).run() | ||||
|  | ||||
|     @staticmethod | ||||
|     def m2m_changed_handler( | ||||
|         user: User, request: HttpRequest, sender, instance: Model, action: str, **_ | ||||
|     ): | ||||
|         """Signal handler for all object's m2m_changed""" | ||||
|         if action not in ["pre_add", "pre_remove", "post_clear"]: | ||||
|             return | ||||
|         if not should_log_m2m(instance): | ||||
|             return | ||||
|  | ||||
|         EventNewThread( | ||||
|             EventAction.MODEL_UPDATED, | ||||
|             request, | ||||
|             user=user, | ||||
|             model=model_to_dict(instance), | ||||
|         ).run() | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|  | ||||
| import uuid | ||||
| from datetime import timedelta | ||||
| from typing import Iterable | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.apps.registry import Apps | ||||
| @ -12,7 +13,6 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||
| import authentik.events.models | ||||
| import authentik.lib.models | ||||
| from authentik.events.models import EventAction, NotificationSeverity, TransportMode | ||||
| from authentik.lib.migrations import progress_bar | ||||
|  | ||||
|  | ||||
| def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
| @ -43,6 +43,49 @@ def token_view_to_secret_view(apps: Apps, schema_editor: BaseDatabaseSchemaEdito | ||||
|     Event.objects.using(db_alias).bulk_update(events, ["context", "action"]) | ||||
|  | ||||
|  | ||||
| # Taken from https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console | ||||
| def progress_bar( | ||||
|     iterable: Iterable, | ||||
|     prefix="Writing: ", | ||||
|     suffix=" finished", | ||||
|     decimals=1, | ||||
|     length=100, | ||||
|     fill="█", | ||||
|     print_end="\r", | ||||
| ): | ||||
|     """ | ||||
|     Call in a loop to create terminal progress bar | ||||
|     @params: | ||||
|         iteration   - Required  : current iteration (Int) | ||||
|         total       - Required  : total iterations (Int) | ||||
|         prefix      - Optional  : prefix string (Str) | ||||
|         suffix      - Optional  : suffix string (Str) | ||||
|         decimals    - Optional  : positive number of decimals in percent complete (Int) | ||||
|         length      - Optional  : character length of bar (Int) | ||||
|         fill        - Optional  : bar fill character (Str) | ||||
|         print_end   - Optional  : end character (e.g. "\r", "\r\n") (Str) | ||||
|     """ | ||||
|     total = len(iterable) | ||||
|     if total < 1: | ||||
|         return | ||||
|  | ||||
|     def print_progress_bar(iteration): | ||||
|         """Progress Bar Printing Function""" | ||||
|         percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total))) | ||||
|         filledLength = int(length * iteration // total) | ||||
|         bar = fill * filledLength + "-" * (length - filledLength) | ||||
|         print(f"\r{prefix} |{bar}| {percent}% {suffix}", end=print_end) | ||||
|  | ||||
|     # Initial Call | ||||
|     print_progress_bar(0) | ||||
|     # Update Progress Bar | ||||
|     for i, item in enumerate(iterable): | ||||
|         yield item | ||||
|         print_progress_bar(i + 1) | ||||
|     # Print New Line on Complete | ||||
|     print() | ||||
|  | ||||
|  | ||||
| def update_expires(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     Event = apps.get_model("authentik_events", "event") | ||||
|  | ||||
| @ -214,18 +214,11 @@ class Event(SerializerModel, ExpiringModel): | ||||
|         Events independently from requests. | ||||
|         `user` arguments optionally overrides user from requests.""" | ||||
|         if request: | ||||
|             from authentik.flows.views.executor import QS_QUERY | ||||
|  | ||||
|             self.context["http_request"] = { | ||||
|                 "path": request.path, | ||||
|                 "method": request.method, | ||||
|                 "args": QueryDict(request.META.get("QUERY_STRING", "")), | ||||
|             } | ||||
|             # Special case for events created during flow execution | ||||
|             # since they keep the http query within a wrapped query | ||||
|             if QS_QUERY in self.context["http_request"]["args"]: | ||||
|                 wrapped = self.context["http_request"]["args"][QS_QUERY] | ||||
|                 self.context["http_request"]["args"] = QueryDict(wrapped) | ||||
|         if hasattr(request, "tenant"): | ||||
|             tenant: Tenant = request.tenant | ||||
|             # Because self.created only gets set on save, we can't use it's value here | ||||
|  | ||||
| @ -41,7 +41,7 @@ class TaskResult: | ||||
|  | ||||
|     def with_error(self, exc: Exception) -> "TaskResult": | ||||
|         """Since errors might not always be pickle-able, set the traceback""" | ||||
|         self.messages.append(exception_to_string(exc)) | ||||
|         self.messages.append(str(exc)) | ||||
|         return self | ||||
|  | ||||
|  | ||||
| @ -111,7 +111,6 @@ class MonitoredTask(Task): | ||||
|     _result: Optional[TaskResult] | ||||
|  | ||||
|     _uid: Optional[str] | ||||
|     start: Optional[float] = None | ||||
|  | ||||
|     def __init__(self, *args, **kwargs) -> None: | ||||
|         super().__init__(*args, **kwargs) | ||||
| @ -119,6 +118,7 @@ class MonitoredTask(Task): | ||||
|         self._uid = None | ||||
|         self._result = None | ||||
|         self.result_timeout_hours = 6 | ||||
|         self.start = default_timer() | ||||
|  | ||||
|     def set_uid(self, uid: str): | ||||
|         """Set UID, so in the case of an unexpected error its saved correctly""" | ||||
| @ -128,10 +128,6 @@ class MonitoredTask(Task): | ||||
|         """Set result for current run, will overwrite previous result.""" | ||||
|         self._result = result | ||||
|  | ||||
|     def before_start(self, task_id, args, kwargs): | ||||
|         self.start = default_timer() | ||||
|         return super().before_start(task_id, args, kwargs) | ||||
|  | ||||
|     # pylint: disable=too-many-arguments | ||||
|     def after_return(self, status, retval, task_id, args: list[Any], kwargs: dict[str, Any], einfo): | ||||
|         super().after_return(status, retval, task_id, args, kwargs, einfo=einfo) | ||||
| @ -142,7 +138,7 @@ class MonitoredTask(Task): | ||||
|         info = TaskInfo( | ||||
|             task_name=self.__name__, | ||||
|             task_description=self.__doc__, | ||||
|             start_timestamp=self.start or default_timer(), | ||||
|             start_timestamp=self.start, | ||||
|             finish_timestamp=default_timer(), | ||||
|             finish_time=datetime.now(), | ||||
|             result=self._result, | ||||
| @ -166,7 +162,7 @@ class MonitoredTask(Task): | ||||
|         TaskInfo( | ||||
|             task_name=self.__name__, | ||||
|             task_description=self.__doc__, | ||||
|             start_timestamp=self.start or default_timer(), | ||||
|             start_timestamp=self.start, | ||||
|             finish_timestamp=default_timer(), | ||||
|             finish_time=datetime.now(), | ||||
|             result=self._result, | ||||
|  | ||||
| @ -1,7 +1,4 @@ | ||||
| """Flow Binding API Views""" | ||||
| from typing import Any | ||||
|  | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.serializers import ModelSerializer | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| @ -15,13 +12,6 @@ class FlowStageBindingSerializer(ModelSerializer): | ||||
|  | ||||
|     stage_obj = StageSerializer(read_only=True, source="stage") | ||||
|  | ||||
|     def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: | ||||
|         evaluate_on_plan = attrs.get("evaluate_on_plan", False) | ||||
|         re_evaluate_policies = attrs.get("re_evaluate_policies", True) | ||||
|         if not evaluate_on_plan and not re_evaluate_policies: | ||||
|             raise ValidationError("Either evaluation on plan or evaluation on run must be enabled") | ||||
|         return super().validate(attrs) | ||||
|  | ||||
|     class Meta: | ||||
|         model = FlowStageBinding | ||||
|         fields = [ | ||||
|  | ||||
| @ -23,8 +23,7 @@ class DiagramElement: | ||||
|     style: list[str] = field(default_factory=lambda: ["[", "]"]) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         description = self.description.replace('"', "#quot;") | ||||
|         element = f'{self.identifier}{self.style[0]}"{description}"{self.style[1]}' | ||||
|         element = f'{self.identifier}{self.style[0]}"{self.description}"{self.style[1]}' | ||||
|         if self.action is not None: | ||||
|             if self.action != "": | ||||
|                 element = f"--{self.action}--> {element}" | ||||
|  | ||||
| @ -1,26 +0,0 @@ | ||||
| # Generated by Django 4.1.7 on 2023-02-25 15:51 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_flows", "0024_flow_authentication"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="flowstagebinding", | ||||
|             name="evaluate_on_plan", | ||||
|             field=models.BooleanField( | ||||
|                 default=False, help_text="Evaluate policies during the Flow planning process." | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="flowstagebinding", | ||||
|             name="re_evaluate_policies", | ||||
|             field=models.BooleanField( | ||||
|                 default=True, help_text="Evaluate policies when the Stage is present to the user." | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -211,11 +211,14 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel): | ||||
|     stage = InheritanceForeignKey(Stage, on_delete=models.CASCADE) | ||||
|  | ||||
|     evaluate_on_plan = models.BooleanField( | ||||
|         default=False, | ||||
|         help_text=_("Evaluate policies during the Flow planning process."), | ||||
|         default=True, | ||||
|         help_text=_( | ||||
|             "Evaluate policies during the Flow planning process. " | ||||
|             "Disable this for input-based policies." | ||||
|         ), | ||||
|     ) | ||||
|     re_evaluate_policies = models.BooleanField( | ||||
|         default=True, | ||||
|         default=False, | ||||
|         help_text=_("Evaluate policies when the Stage is present to the user."), | ||||
|     ) | ||||
|  | ||||
| @ -271,15 +274,6 @@ class ConfigurableStage(models.Model): | ||||
|         abstract = True | ||||
|  | ||||
|  | ||||
| class FriendlyNamedStage(models.Model): | ||||
|     """Abstract base class for a Stage that can have a user friendly name configured.""" | ||||
|  | ||||
|     friendly_name = models.TextField(null=True) | ||||
|  | ||||
|     class Meta: | ||||
|         abstract = True | ||||
|  | ||||
|  | ||||
| class FlowToken(Token): | ||||
|     """Subclass of a standard Token, stores the currently active flow plan upon creation. | ||||
|     Can be used to later resume a flow.""" | ||||
|  | ||||
| @ -147,6 +147,7 @@ class FlowPlanner: | ||||
|     ) -> FlowPlan: | ||||
|         """Check each of the flows' policies, check policies for each stage with PolicyBinding | ||||
|         and return ordered list""" | ||||
|         self._check_authentication(request) | ||||
|         with Hub.current.start_span( | ||||
|             op="authentik.flow.planner.plan", description=self.flow.slug | ||||
|         ) as span: | ||||
| @ -164,12 +165,6 @@ class FlowPlanner: | ||||
|                 user = default_context[PLAN_CONTEXT_PENDING_USER] | ||||
|             else: | ||||
|                 user = request.user | ||||
|                 # We only need to check the flow authentication if it's planned without a user | ||||
|                 # in the context, as a user in the context can only be set via the explicit code API | ||||
|                 # or if a flow is restarted due to `invalid_response_action` being set to | ||||
|                 # `restart_with_context`, which can only happen if the user was already authorized | ||||
|                 # to use the flow | ||||
|                 self._check_authentication(request) | ||||
|             # First off, check the flow's direct policy bindings | ||||
|             # to make sure the user even has access to the flow | ||||
|             engine = PolicyEngine(self.flow, user, request) | ||||
| @ -266,6 +261,7 @@ class FlowPlanner: | ||||
|                     marker = ReevaluateMarker(binding=binding) | ||||
|                 if stage: | ||||
|                     plan.append(binding, marker) | ||||
|             HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug) | ||||
|         self._logger.debug( | ||||
|             "f(plan): finished building", | ||||
|         ) | ||||
|  | ||||
| @ -7,7 +7,6 @@ from django.http.request import QueryDict | ||||
| from django.http.response import HttpResponse | ||||
| from django.urls import reverse | ||||
| from django.views.generic.base import View | ||||
| from prometheus_client import Histogram | ||||
| from rest_framework.request import Request | ||||
| from sentry_sdk.hub import Hub | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
| @ -32,11 +31,6 @@ if TYPE_CHECKING: | ||||
|     from authentik.flows.views.executor import FlowExecutorView | ||||
|  | ||||
| PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier" | ||||
| HIST_FLOWS_STAGE_TIME = Histogram( | ||||
|     "authentik_flows_stage_time", | ||||
|     "Duration taken by different parts of stages", | ||||
|     ["stage_type", "method"], | ||||
| ) | ||||
|  | ||||
|  | ||||
| class StageView(View): | ||||
| @ -115,24 +109,14 @@ class ChallengeStageView(StageView): | ||||
|                     keep_context=keep_context, | ||||
|                 ) | ||||
|                 return self.executor.restart_flow(keep_context) | ||||
|             with ( | ||||
|                 Hub.current.start_span( | ||||
|             with Hub.current.start_span( | ||||
|                 op="authentik.flow.stage.challenge_invalid", | ||||
|                 description=self.__class__.__name__, | ||||
|                 ), | ||||
|                 HIST_FLOWS_STAGE_TIME.labels( | ||||
|                     stage_type=self.__class__.__name__, method="challenge_invalid" | ||||
|                 ).time(), | ||||
|             ): | ||||
|                 return self.challenge_invalid(challenge) | ||||
|         with ( | ||||
|             Hub.current.start_span( | ||||
|         with Hub.current.start_span( | ||||
|             op="authentik.flow.stage.challenge_valid", | ||||
|             description=self.__class__.__name__, | ||||
|             ), | ||||
|             HIST_FLOWS_STAGE_TIME.labels( | ||||
|                 stage_type=self.__class__.__name__, method="challenge_valid" | ||||
|             ).time(), | ||||
|         ): | ||||
|             return self.challenge_valid(challenge) | ||||
|  | ||||
| @ -151,14 +135,9 @@ class ChallengeStageView(StageView): | ||||
|             return self.executor.flow.title | ||||
|  | ||||
|     def _get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         with ( | ||||
|             Hub.current.start_span( | ||||
|         with Hub.current.start_span( | ||||
|             op="authentik.flow.stage.get_challenge", | ||||
|             description=self.__class__.__name__, | ||||
|             ), | ||||
|             HIST_FLOWS_STAGE_TIME.labels( | ||||
|                 stage_type=self.__class__.__name__, method="get_challenge" | ||||
|             ).time(), | ||||
|         ): | ||||
|             challenge = self.get_challenge(*args, **kwargs) | ||||
|         with Hub.current.start_span( | ||||
| @ -204,12 +183,12 @@ class ChallengeStageView(StageView): | ||||
|         for field, errors in response.errors.items(): | ||||
|             for error in errors: | ||||
|                 full_errors.setdefault(field, []) | ||||
|                 field_error = { | ||||
|                 full_errors[field].append( | ||||
|                     { | ||||
|                         "string": str(error), | ||||
|                         "code": error.code, | ||||
|                     } | ||||
|                 if hasattr(error, "code"): | ||||
|                     field_error["code"] = error.code | ||||
|                 full_errors[field].append(field_error) | ||||
|                 ) | ||||
|         challenge_response.initial_data["response_errors"] = full_errors | ||||
|         if not challenge_response.is_valid(): | ||||
|             self.logger.error( | ||||
| @ -231,7 +210,7 @@ class AccessDeniedChallengeView(ChallengeStageView): | ||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         return AccessDeniedChallenge( | ||||
|             data={ | ||||
|                 "error_message": str(self.error_message or "Unknown error"), | ||||
|                 "error_message": self.error_message or "Unknown error", | ||||
|                 "type": ChallengeTypes.NATIVE.value, | ||||
|                 "component": "ak-stage-access-denied", | ||||
|             } | ||||
|  | ||||
| @ -2,13 +2,10 @@ | ||||
| from django.test import TestCase | ||||
| from django.urls import reverse | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_flow | ||||
| 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_PLAN | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.providers.oauth2.models import OAuth2Provider | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
|  | ||||
|  | ||||
| class TestHelperView(TestCase): | ||||
| @ -25,41 +22,6 @@ class TestHelperView(TestCase): | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.url, expected_url) | ||||
|  | ||||
|     def test_default_view_app(self): | ||||
|         """Test that ToDefaultFlow returns the expected URL (when accessing an application)""" | ||||
|         Flow.objects.filter(designation=FlowDesignation.AUTHENTICATION).delete() | ||||
|         flow = create_test_flow(FlowDesignation.AUTHENTICATION) | ||||
|         self.client.session[SESSION_KEY_APPLICATION_PRE] = Application( | ||||
|             name=generate_id(), | ||||
|             slug=generate_id(), | ||||
|             provider=OAuth2Provider( | ||||
|                 name=generate_id(), | ||||
|                 authentication_flow=flow, | ||||
|             ), | ||||
|         ) | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_flows:default-authentication"), | ||||
|         ) | ||||
|         expected_url = reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.url, expected_url) | ||||
|  | ||||
|     def test_default_view_app_no_provider(self): | ||||
|         """Test that ToDefaultFlow returns the expected URL | ||||
|         (when accessing an application, without a provider)""" | ||||
|         Flow.objects.filter(designation=FlowDesignation.AUTHENTICATION).delete() | ||||
|         flow = create_test_flow(FlowDesignation.AUTHENTICATION) | ||||
|         self.client.session[SESSION_KEY_APPLICATION_PRE] = Application( | ||||
|             name=generate_id(), | ||||
|             slug=generate_id(), | ||||
|         ) | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_flows:default-authentication"), | ||||
|         ) | ||||
|         expected_url = reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertEqual(response.url, expected_url) | ||||
|  | ||||
|     def test_default_view_invalid_plan(self): | ||||
|         """Test that ToDefaultFlow returns the expected URL (with an invalid plan)""" | ||||
|         Flow.objects.filter(designation=FlowDesignation.INVALIDATION).delete() | ||||
|  | ||||
| @ -22,7 +22,6 @@ from sentry_sdk.api import set_tag | ||||
| from sentry_sdk.hub import Hub | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.events.models import Event, EventAction, cleanse_dict | ||||
| from authentik.flows.challenge import ( | ||||
|     Challenge, | ||||
| @ -69,7 +68,6 @@ SESSION_KEY_GET = "authentik/flows/get" | ||||
| SESSION_KEY_POST = "authentik/flows/post" | ||||
| SESSION_KEY_HISTORY = "authentik/flows/history" | ||||
| QS_KEY_TOKEN = "flow_token"  # nosec | ||||
| QS_QUERY = "query" | ||||
|  | ||||
|  | ||||
| def challenge_types(): | ||||
| @ -174,7 +172,7 @@ class FlowExecutorView(APIView): | ||||
|             op="authentik.flow.executor.dispatch", description=self.flow.slug | ||||
|         ) as span: | ||||
|             span.set_data("authentik Flow", self.flow.slug) | ||||
|             get_params = QueryDict(request.GET.get(QS_QUERY, "")) | ||||
|             get_params = QueryDict(request.GET.get("query", "")) | ||||
|             if QS_KEY_TOKEN in get_params: | ||||
|                 plan = self._check_flow_token(get_params[QS_KEY_TOKEN]) | ||||
|                 if plan: | ||||
| @ -477,32 +475,20 @@ class ToDefaultFlow(View): | ||||
|         LOGGER.debug("flow_by_policy: no flow found", filters=flow_filter) | ||||
|         return None | ||||
|  | ||||
|     def get_flow(self) -> Flow: | ||||
|         """Get a flow for the selected designation""" | ||||
|         tenant: Tenant = self.request.tenant | ||||
|     def dispatch(self, request: HttpRequest) -> HttpResponse: | ||||
|         tenant: Tenant = request.tenant | ||||
|         flow = None | ||||
|         # First, attempt to get default flow from tenant | ||||
|         if self.designation == FlowDesignation.AUTHENTICATION: | ||||
|             flow = tenant.flow_authentication | ||||
|             # Check if we have a default flow from application | ||||
|             application: Optional[Application] = self.request.session.get( | ||||
|                 SESSION_KEY_APPLICATION_PRE | ||||
|             ) | ||||
|             if application and application.provider and application.provider.authentication_flow: | ||||
|                 flow = application.provider.authentication_flow | ||||
|         elif self.designation == FlowDesignation.INVALIDATION: | ||||
|         if self.designation == FlowDesignation.INVALIDATION: | ||||
|             flow = tenant.flow_invalidation | ||||
|         if flow: | ||||
|             return flow | ||||
|         # If no flow was set, get the first based on slug and policy | ||||
|         flow = self.flow_by_policy(self.request, designation=self.designation) | ||||
|         if flow: | ||||
|             return flow | ||||
|         if not flow: | ||||
|             flow = self.flow_by_policy(request, designation=self.designation) | ||||
|         # If we still don't have a flow, 404 | ||||
|         if not flow: | ||||
|             raise Http404 | ||||
|  | ||||
|     def dispatch(self, request: HttpRequest) -> HttpResponse: | ||||
|         flow = self.get_flow() | ||||
|         # If user already has a pending plan, clear it so we don't have to later. | ||||
|         if SESSION_KEY_PLAN in self.request.session: | ||||
|             plan: FlowPlan = self.request.session[SESSION_KEY_PLAN] | ||||
| @ -575,13 +561,9 @@ class ConfigureFlowInitView(LoginRequiredMixin, View): | ||||
|             LOGGER.debug("Stage has no configure_flow set", stage=stage) | ||||
|             raise Http404 | ||||
|  | ||||
|         try: | ||||
|         plan = FlowPlanner(stage.configure_flow).plan( | ||||
|             request, {PLAN_CONTEXT_PENDING_USER: request.user} | ||||
|         ) | ||||
|         except FlowNonApplicableException: | ||||
|             LOGGER.warning("Flow not applicable to user") | ||||
|             raise Http404 | ||||
|         request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|  | ||||
| @ -1,11 +1,10 @@ | ||||
| """Avatar utils""" | ||||
| from base64 import b64encode | ||||
| from functools import cache as funccache | ||||
| from functools import cache | ||||
| from hashlib import md5 | ||||
| from typing import TYPE_CHECKING, Optional | ||||
| from urllib.parse import urlencode | ||||
|  | ||||
| from django.core.cache import cache | ||||
| from django.templatetags.static import static | ||||
| from lxml import etree  # nosec | ||||
| from lxml.etree import Element, SubElement  # nosec | ||||
| @ -16,7 +15,6 @@ from authentik.lib.utils.http import get_http_session | ||||
|  | ||||
| GRAVATAR_URL = "https://secure.gravatar.com" | ||||
| DEFAULT_AVATAR = static("dist/assets/images/user_default.png") | ||||
| CACHE_KEY_GRAVATAR = "goauthentik.io/lib/avatars/" | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.core.models import User | ||||
| @ -52,24 +50,22 @@ def avatar_mode_gravatar(user: "User", mode: str) -> Optional[str]: | ||||
|     parameters = [("size", "158"), ("rating", "g"), ("default", "404")] | ||||
|     gravatar_url = f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}" | ||||
|  | ||||
|     full_key = CACHE_KEY_GRAVATAR + mail_hash | ||||
|     if cache.has_key(full_key): | ||||
|         cache.touch(full_key) | ||||
|         return cache.get(full_key) | ||||
|  | ||||
|     @cache | ||||
|     def check_non_default(url: str): | ||||
|         """Cache HEAD check, based on URL""" | ||||
|         try: | ||||
|             # Since we specify a default of 404, do a HEAD request | ||||
|             # (HEAD since we don't need the body) | ||||
|             # so if that returns a 404, move onto the next mode | ||||
|         res = get_http_session().head(gravatar_url, timeout=5) | ||||
|             res = get_http_session().head(url, timeout=5) | ||||
|             if res.status_code == 404: | ||||
|             cache.set(full_key, None) | ||||
|                 return None | ||||
|             res.raise_for_status() | ||||
|         except RequestException: | ||||
|         return gravatar_url | ||||
|     cache.set(full_key, gravatar_url) | ||||
|     return gravatar_url | ||||
|             return url | ||||
|         return url | ||||
|  | ||||
|     return check_non_default(gravatar_url) | ||||
|  | ||||
|  | ||||
| def generate_colors(text: str) -> tuple[str, str]: | ||||
| @ -87,7 +83,7 @@ def generate_colors(text: str) -> tuple[str, str]: | ||||
|     return bg_hex, text_hex | ||||
|  | ||||
|  | ||||
| @funccache | ||||
| @cache | ||||
| # pylint: disable=too-many-arguments,too-many-locals | ||||
| def generate_avatar_from_name( | ||||
|     name: str, | ||||
| @ -154,7 +150,7 @@ def generate_avatar_from_name( | ||||
|  | ||||
| def avatar_mode_generated(user: "User", mode: str) -> Optional[str]: | ||||
|     """Wrapper that converts generated avatar to base64 svg""" | ||||
|     svg = generate_avatar_from_name(user.name if user.name.strip() != "" else "a k") | ||||
|     svg = generate_avatar_from_name(user.name if user.name != "" else "a k") | ||||
|     return f"data:image/svg+xml;base64,{b64encode(svg.encode('utf-8')).decode('utf-8')}" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -5,25 +5,18 @@ postgresql: | ||||
|   name: authentik | ||||
|   user: authentik | ||||
|   port: 5432 | ||||
|   password: "env://POSTGRES_PASSWORD" | ||||
|   password: 'env://POSTGRES_PASSWORD' | ||||
|   use_pgbouncer: false | ||||
|  | ||||
| listen: | ||||
|   listen_http: 0.0.0.0:9000 | ||||
|   listen_https: 0.0.0.0:9443 | ||||
|   listen_metrics: 0.0.0.0:9300 | ||||
|   trusted_proxy_cidrs: | ||||
|     - 127.0.0.0/8 | ||||
|     - 10.0.0.0/8 | ||||
|     - 172.16.0.0/12 | ||||
|     - 192.168.0.0/16 | ||||
|     - fe80::/10 | ||||
|     - ::1/128 | ||||
|  | ||||
| redis: | ||||
|   host: localhost | ||||
|   port: 6379 | ||||
|   password: "" | ||||
|   password: '' | ||||
|   tls: false | ||||
|   tls_reqs: "none" | ||||
|   db: 0 | ||||
|  | ||||
| @ -1,14 +1,11 @@ | ||||
| """authentik expression policy evaluator""" | ||||
| import re | ||||
| import socket | ||||
| from ipaddress import ip_address, ip_network | ||||
| from textwrap import indent | ||||
| from typing import Any, Iterable, Optional | ||||
|  | ||||
| from cachetools import TLRUCache, cached | ||||
| from django.core.exceptions import FieldError | ||||
| from django_otp import devices_for_user | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
| from rest_framework.serializers import ValidationError | ||||
| from sentry_sdk.hub import Hub | ||||
| from sentry_sdk.tracing import Span | ||||
| @ -17,9 +14,7 @@ from structlog.stdlib import get_logger | ||||
| from authentik.core.models import User | ||||
| from authentik.events.models import Event | ||||
| from authentik.lib.utils.http import get_http_session | ||||
| from authentik.policies.models import Policy, PolicyBinding | ||||
| from authentik.policies.process import PolicyProcess | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
| from authentik.policies.types import PolicyRequest | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -40,56 +35,20 @@ class BaseEvaluator: | ||||
|         # update website/docs/expressions/_objects.md | ||||
|         # update website/docs/expressions/_functions.md | ||||
|         self._globals = { | ||||
|             "ak_call_policy": self.expr_func_call_policy, | ||||
|             "ak_create_event": self.expr_event_create, | ||||
|             "ak_is_group_member": BaseEvaluator.expr_is_group_member, | ||||
|             "ak_logger": get_logger(self._filename).bind(), | ||||
|             "ak_user_by": BaseEvaluator.expr_user_by, | ||||
|             "ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator, | ||||
|             "ip_address": ip_address, | ||||
|             "ip_network": ip_network, | ||||
|             "list_flatten": BaseEvaluator.expr_flatten, | ||||
|             "regex_match": BaseEvaluator.expr_regex_match, | ||||
|             "regex_replace": BaseEvaluator.expr_regex_replace, | ||||
|             "list_flatten": BaseEvaluator.expr_flatten, | ||||
|             "ak_is_group_member": BaseEvaluator.expr_is_group_member, | ||||
|             "ak_user_by": BaseEvaluator.expr_user_by, | ||||
|             "ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator, | ||||
|             "ak_create_event": self.expr_event_create, | ||||
|             "ak_logger": get_logger(self._filename).bind(), | ||||
|             "requests": get_http_session(), | ||||
|             "resolve_dns": BaseEvaluator.expr_resolve_dns, | ||||
|             "reverse_dns": BaseEvaluator.expr_reverse_dns, | ||||
|             "ip_address": ip_address, | ||||
|             "ip_network": ip_network, | ||||
|         } | ||||
|         self._context = {} | ||||
|  | ||||
|     @cached(cache=TLRUCache(maxsize=32, ttu=lambda key, value, now: now + 180)) | ||||
|     @staticmethod | ||||
|     def expr_resolve_dns(host: str, ip_version: Optional[int] = None) -> list[str]: | ||||
|         """Resolve host to a list of IPv4 and/or IPv6 addresses.""" | ||||
|         # Although it seems to be fine (raising OSError), docs warn | ||||
|         # against passing `None` for both the host and the port | ||||
|         # https://docs.python.org/3/library/socket.html#socket.getaddrinfo | ||||
|         host = host or "" | ||||
|  | ||||
|         ip_list = [] | ||||
|  | ||||
|         family = 0 | ||||
|         if ip_version == 4: | ||||
|             family = socket.AF_INET | ||||
|         if ip_version == 6: | ||||
|             family = socket.AF_INET6 | ||||
|  | ||||
|         try: | ||||
|             for ip_addr in socket.getaddrinfo(host, None, family=family): | ||||
|                 ip_list.append(str(ip_addr[4][0])) | ||||
|         except OSError: | ||||
|             pass | ||||
|         return list(set(ip_list)) | ||||
|  | ||||
|     @cached(cache=TLRUCache(maxsize=32, ttu=lambda key, value, now: now + 180)) | ||||
|     @staticmethod | ||||
|     def expr_reverse_dns(ip_addr: str) -> str: | ||||
|         """Perform a reverse DNS lookup.""" | ||||
|         try: | ||||
|             return socket.getfqdn(ip_addr) | ||||
|         except OSError: | ||||
|             return ip_addr | ||||
|  | ||||
|     @staticmethod | ||||
|     def expr_flatten(value: list[Any] | Any) -> Optional[Any]: | ||||
|         """Flatten `value` if its a list""" | ||||
| @ -156,19 +115,6 @@ class BaseEvaluator: | ||||
|                 return | ||||
|         event.save() | ||||
|  | ||||
|     def expr_func_call_policy(self, name: str, **kwargs) -> PolicyResult: | ||||
|         """Call policy by name, with current request""" | ||||
|         policy = Policy.objects.filter(name=name).select_subclasses().first() | ||||
|         if not policy: | ||||
|             raise ValueError(f"Policy '{name}' not found.") | ||||
|         user = self._context.get("user", get_anonymous_user()) | ||||
|         req = PolicyRequest(user) | ||||
|         if "request" in self._context: | ||||
|             req = self._context["request"] | ||||
|         req.context.update(kwargs) | ||||
|         proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None) | ||||
|         return proc.profiling_wrapper() | ||||
|  | ||||
|     def wrap_expression(self, expression: str, params: Iterable[str]) -> str: | ||||
|         """Wrap expression in a function, call it, and save the result as `result`""" | ||||
|         handler_signature = ",".join(params) | ||||
|  | ||||
| @ -1,58 +0,0 @@ | ||||
| """Migration helpers""" | ||||
| from typing import Iterable | ||||
|  | ||||
| from django.apps.registry import Apps | ||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor | ||||
|  | ||||
|  | ||||
| def fallback_names(app: str, model: str, field: str): | ||||
|     """Factory function that checks all instances of `app`.`model` instance's `field` | ||||
|     to prevent any duplicates""" | ||||
|  | ||||
|     def migrator(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|         db_alias = schema_editor.connection.alias | ||||
|  | ||||
|         klass = apps.get_model(app, model) | ||||
|         seen_names = [] | ||||
|         for obj in klass.objects.using(db_alias).all(): | ||||
|             value = getattr(obj, field) | ||||
|             if value not in seen_names: | ||||
|                 seen_names.append(value) | ||||
|                 continue | ||||
|             new_value = value + "_2" | ||||
|             setattr(obj, field, new_value) | ||||
|             obj.save() | ||||
|  | ||||
|     return migrator | ||||
|  | ||||
|  | ||||
| def progress_bar(iterable: Iterable): | ||||
|     """Call in a loop to create terminal progress bar | ||||
|     https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console""" | ||||
|  | ||||
|     prefix = "Writing: " | ||||
|     suffix = " finished" | ||||
|     decimals = 1 | ||||
|     length = 100 | ||||
|     fill = "█" | ||||
|     print_end = "\r" | ||||
|  | ||||
|     total = len(iterable) | ||||
|     if total < 1: | ||||
|         return | ||||
|  | ||||
|     def print_progress_bar(iteration): | ||||
|         """Progress Bar Printing Function""" | ||||
|         percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total))) | ||||
|         filled_length = int(length * iteration // total) | ||||
|         bar = fill * filled_length + "-" * (length - filled_length) | ||||
|         print(f"\r{prefix} |{bar}| {percent}% {suffix}", end=print_end) | ||||
|  | ||||
|     # Initial Call | ||||
|     print_progress_bar(0) | ||||
|     # Update Progress Bar | ||||
|     for i, item in enumerate(iterable): | ||||
|         yield item | ||||
|         print_progress_bar(i + 1) | ||||
|     # Print New Line on Complete | ||||
|     print() | ||||
| @ -74,22 +74,3 @@ class DomainlessURLValidator(URLValidator): | ||||
|         if scheme not in self.schemes: | ||||
|             value = "default" + value | ||||
|         super().__call__(value) | ||||
|  | ||||
|  | ||||
| class DomainlessFormattedURLValidator(DomainlessURLValidator): | ||||
|     """URL validator which allows for python format strings""" | ||||
|  | ||||
|     def __init__(self, *args, **kwargs) -> None: | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.formatter_re = r"([%\(\)a-zA-Z])*" | ||||
|         self.host_re = "(" + self.formatter_re + self.hostname_re + self.domain_re + "|localhost)" | ||||
|         self.regex = _lazy_re_compile( | ||||
|             r"^(?:[a-z0-9.+-]*)://"  # scheme is validated separately | ||||
|             r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?"  # user:pass authentication | ||||
|             r"(?:" + self.ipv4_re + "|" + self.ipv6_re + "|" + self.host_re + ")" | ||||
|             r"(?::\d{2,5})?"  # port | ||||
|             r"(?:[/?#][^\s]*)?"  # resource path | ||||
|             r"\Z", | ||||
|             re.IGNORECASE, | ||||
|         ) | ||||
|         self.schemes = ["http", "https", "blank"] + list(self.schemes) | ||||
|  | ||||
| @ -4,6 +4,7 @@ from typing import Any, Optional | ||||
|  | ||||
| from billiard.exceptions import SoftTimeLimitExceeded, WorkerLostError | ||||
| from celery.exceptions import CeleryError | ||||
| from channels.middleware import BaseMiddleware | ||||
| from channels_redis.core import ChannelFull | ||||
| from django.conf import settings | ||||
| from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation, ValidationError | ||||
| @ -16,27 +17,37 @@ from ldap3.core.exceptions import LDAPException | ||||
| from redis.exceptions import ConnectionError as RedisConnectionError | ||||
| from redis.exceptions import RedisError, ResponseError | ||||
| from rest_framework.exceptions import APIException | ||||
| from sentry_sdk import HttpTransport | ||||
| from sentry_sdk import HttpTransport, Hub | ||||
| from sentry_sdk import init as sentry_sdk_init | ||||
| from sentry_sdk.api import set_tag | ||||
| from sentry_sdk.integrations.argv import ArgvIntegration | ||||
| from sentry_sdk.integrations.celery import CeleryIntegration | ||||
| from sentry_sdk.integrations.django import DjangoIntegration | ||||
| from sentry_sdk.integrations.redis import RedisIntegration | ||||
| from sentry_sdk.integrations.socket import SocketIntegration | ||||
| from sentry_sdk.integrations.stdlib import StdlibIntegration | ||||
| from sentry_sdk.integrations.threading import ThreadingIntegration | ||||
| from sentry_sdk.tracing import Transaction | ||||
| from structlog.stdlib import get_logger | ||||
| from websockets.exceptions import WebSocketException | ||||
|  | ||||
| from authentik import __version__, get_build_hash | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.http import authentik_user_agent | ||||
| from authentik.lib.utils.reflection import get_env | ||||
| from authentik.lib.utils.reflection import class_to_path, get_env | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| class SentryWSMiddleware(BaseMiddleware): | ||||
|     """Sentry Websocket middleweare to set the transaction name based on | ||||
|     consumer class path""" | ||||
|  | ||||
|     async def __call__(self, scope, receive, send): | ||||
|         transaction: Optional[Transaction] = Hub.current.scope.transaction | ||||
|         class_path = class_to_path(self.inner.consumer_class) | ||||
|         if transaction: | ||||
|             transaction.name = class_path | ||||
|         return await self.inner(scope, receive, send) | ||||
|  | ||||
|  | ||||
| class SentryIgnoredException(Exception): | ||||
|     """Base Class for all errors that are suppressed, and not sent to sentry.""" | ||||
|  | ||||
| @ -64,13 +75,10 @@ def sentry_init(**sentry_init_kwargs): | ||||
|     sentry_sdk_init( | ||||
|         dsn=CONFIG.y("error_reporting.sentry_dsn"), | ||||
|         integrations=[ | ||||
|             ArgvIntegration(), | ||||
|             StdlibIntegration(), | ||||
|             DjangoIntegration(transaction_style="function_name"), | ||||
|             CeleryIntegration(monitor_beat_tasks=True), | ||||
|             CeleryIntegration(), | ||||
|             RedisIntegration(), | ||||
|             ThreadingIntegration(propagate_hub=True), | ||||
|             SocketIntegration(), | ||||
|         ], | ||||
|         before_send=before_send, | ||||
|         traces_sampler=traces_sampler, | ||||
| @ -86,12 +94,9 @@ def sentry_init(**sentry_init_kwargs): | ||||
| def traces_sampler(sampling_context: dict) -> float: | ||||
|     """Custom sampler to ignore certain routes""" | ||||
|     path = sampling_context.get("asgi_scope", {}).get("path", "") | ||||
|     _type = sampling_context.get("asgi_scope", {}).get("type", "") | ||||
|     # Ignore all healthcheck routes | ||||
|     if path.startswith("/-/health") or path.startswith("/-/metrics"): | ||||
|         return 0 | ||||
|     if _type == "websocket": | ||||
|         return 0 | ||||
|     return float(CONFIG.y("error_reporting.sample_rate", 0.1)) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,4 @@ | ||||
| """Test utils""" | ||||
| from inspect import currentframe | ||||
| from pathlib import Path | ||||
|  | ||||
| from django.contrib.messages.middleware import MessageMiddleware | ||||
| from django.contrib.sessions.middleware import SessionMiddleware | ||||
| from django.http import HttpRequest | ||||
| @ -14,21 +11,6 @@ def dummy_get_response(request: HttpRequest):  # pragma: no cover | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def load_fixture(path: str, **kwargs) -> str: | ||||
|     """Load fixture, optionally formatting it with kwargs""" | ||||
|     current = currentframe() | ||||
|     parent = current.f_back | ||||
|     calling_file_path = parent.f_globals["__file__"] | ||||
|     with open( | ||||
|         Path(calling_file_path).resolve().parent / Path(path), "r", encoding="utf-8" | ||||
|     ) as _fixture: | ||||
|         fixture = _fixture.read() | ||||
|         try: | ||||
|             return fixture % kwargs | ||||
|         except TypeError: | ||||
|             return fixture | ||||
|  | ||||
|  | ||||
| def get_request(*args, user=None, **kwargs): | ||||
|     """Get a request with usable session""" | ||||
|     request = RequestFactory().get(*args, **kwargs) | ||||
|  | ||||
| @ -16,12 +16,10 @@ LOGGER = get_logger() | ||||
|  | ||||
| def _get_client_ip_from_meta(meta: dict[str, Any]) -> str: | ||||
|     """Attempt to get the client's IP by checking common HTTP Headers. | ||||
|     Returns none if no IP Could be found | ||||
|  | ||||
|     No additional validation is done here as requests are expected to only arrive here | ||||
|     via the go proxy, which deals with validating these headers for us""" | ||||
|     Returns none if no IP Could be found""" | ||||
|     headers = ( | ||||
|         "HTTP_X_FORWARDED_FOR", | ||||
|         "HTTP_X_REAL_IP", | ||||
|         "REMOTE_ADDR", | ||||
|     ) | ||||
|     for _header in headers: | ||||
| @ -40,17 +38,13 @@ def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]: | ||||
|     if OUTPOST_REMOTE_IP_HEADER not in request.META or OUTPOST_TOKEN_HEADER not in request.META: | ||||
|         return None | ||||
|     fake_ip = request.META[OUTPOST_REMOTE_IP_HEADER] | ||||
|     token = ( | ||||
|         Token.filter_not_expired( | ||||
|     tokens = Token.filter_not_expired( | ||||
|         key=request.META.get(OUTPOST_TOKEN_HEADER), intent=TokenIntents.INTENT_API | ||||
|     ) | ||||
|         .select_related("user") | ||||
|         .first() | ||||
|     ) | ||||
|     if not token: | ||||
|     if not tokens.exists(): | ||||
|         LOGGER.warning("Attempted remote-ip override without token", fake_ip=fake_ip) | ||||
|         return None | ||||
|     user = token.user | ||||
|     user = tokens.first().user | ||||
|     if not user.group_attributes(request).get(USER_ATTRIBUTE_CAN_OVERRIDE_IP, False): | ||||
|         LOGGER.warning( | ||||
|             "Remote-IP override: user doesn't have permission", | ||||
|  | ||||
| @ -9,4 +9,4 @@ def get_lxml_parser(): | ||||
|  | ||||
| def lxml_from_string(text: str): | ||||
|     """Wrapper around fromstring""" | ||||
|     return fromstring(text, parser=get_lxml_parser())  # nosec | ||||
|     return fromstring(text, parser=get_lxml_parser()) | ||||
|  | ||||
| @ -28,7 +28,6 @@ from authentik.outposts.models import ( | ||||
| ) | ||||
| from authentik.providers.ldap.models import LDAPProvider | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
| from authentik.providers.radius.models import RadiusProvider | ||||
|  | ||||
|  | ||||
| class OutpostSerializer(ModelSerializer): | ||||
| @ -52,7 +51,6 @@ class OutpostSerializer(ModelSerializer): | ||||
|         type_map = { | ||||
|             OutpostType.LDAP: LDAPProvider, | ||||
|             OutpostType.PROXY: ProxyProvider, | ||||
|             OutpostType.RADIUS: RadiusProvider, | ||||
|             None: Provider, | ||||
|         } | ||||
|         for provider in providers: | ||||
|  | ||||
| @ -24,7 +24,6 @@ class AuthentikOutpostConfig(ManagedAppConfig): | ||||
|     label = "authentik_outposts" | ||||
|     verbose_name = "authentik Outpost" | ||||
|     default = True | ||||
|     ws_mountpoint = "authentik.outposts.urls" | ||||
|  | ||||
|     def reconcile_load_outposts_signals(self): | ||||
|         """Load outposts signals""" | ||||
|  | ||||
| @ -16,6 +16,7 @@ from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpda | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.outposts.controllers.kubernetes import KubernetesController | ||||
|  | ||||
| # pylint: disable=invalid-name | ||||
| T = TypeVar("T", V1Pod, V1Deployment) | ||||
|  | ||||
|  | ||||
| @ -55,7 +56,6 @@ class KubernetesObjectReconciler(Generic[T]): | ||||
|             } | ||||
|         ).lower() | ||||
|  | ||||
|     # pylint: disable=invalid-name | ||||
|     def up(self): | ||||
|         """Create object if it doesn't exist, update if needed or recreate if needed.""" | ||||
|         current = None | ||||
|  | ||||
| @ -4,7 +4,6 @@ from typing import TYPE_CHECKING | ||||
| from django.utils.text import slugify | ||||
| from kubernetes.client import ( | ||||
|     AppsV1Api, | ||||
|     V1Capabilities, | ||||
|     V1Container, | ||||
|     V1ContainerPort, | ||||
|     V1Deployment, | ||||
| @ -14,12 +13,9 @@ from kubernetes.client import ( | ||||
|     V1LabelSelector, | ||||
|     V1ObjectMeta, | ||||
|     V1ObjectReference, | ||||
|     V1PodSecurityContext, | ||||
|     V1PodSpec, | ||||
|     V1PodTemplateSpec, | ||||
|     V1SeccompProfile, | ||||
|     V1SecretKeySelector, | ||||
|     V1SecurityContext, | ||||
| ) | ||||
|  | ||||
| from authentik import __version__, get_full_version | ||||
| @ -107,11 +103,6 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|                         image_pull_secrets=[ | ||||
|                             V1ObjectReference(name=secret) for secret in image_pull_secrets | ||||
|                         ], | ||||
|                         security_context=V1PodSecurityContext( | ||||
|                             seccomp_profile=V1SeccompProfile( | ||||
|                                 type="RuntimeDefault", | ||||
|                             ), | ||||
|                         ), | ||||
|                         containers=[ | ||||
|                             V1Container( | ||||
|                                 name=str(self.outpost.type), | ||||
| @ -155,13 +146,6 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): | ||||
|                                         ), | ||||
|                                     ), | ||||
|                                 ], | ||||
|                                 security_context=V1SecurityContext( | ||||
|                                     run_as_non_root=True, | ||||
|                                     allow_privilege_escalation=False, | ||||
|                                     capabilities=V1Capabilities( | ||||
|                                         drop=["ALL"], | ||||
|                                     ), | ||||
|                                 ), | ||||
|                             ) | ||||
|                         ], | ||||
|                     ), | ||||
|  | ||||
| @ -1,28 +0,0 @@ | ||||
| # Generated by Django 4.1.7 on 2023-03-07 13:41 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
| from authentik.lib.migrations import fallback_names | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_outposts", "0018_kubernetesserviceconnection_verify_ssl"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(fallback_names("authentik_outposts", "outpost", "name")), | ||||
|         migrations.RunPython( | ||||
|             fallback_names("authentik_outposts", "outpostserviceconnection", "name") | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="outpost", | ||||
|             name="name", | ||||
|             field=models.TextField(unique=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="outpostserviceconnection", | ||||
|             name="name", | ||||
|             field=models.TextField(unique=True), | ||||
|         ), | ||||
|     ] | ||||
| @ -1,20 +0,0 @@ | ||||
| # Generated by Django 4.1.7 on 2023-03-20 10:58 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_outposts", "0019_alter_outpost_name_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="outpost", | ||||
|             name="type", | ||||
|             field=models.TextField( | ||||
|                 choices=[("proxy", "Proxy"), ("ldap", "Ldap"), ("radius", "Radius")], | ||||
|                 default="proxy", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	