Compare commits
54 Commits
version/20
...
interfaces
Author | SHA1 | Date | |
---|---|---|---|
a1c1c3a27c | |||
c0262f0802 | |||
c6f8290ca1 | |||
905ae00e02 | |||
3ec477d58d | |||
ff996f798f | |||
1889e82309 | |||
48a4080699 | |||
246a6c7384 | |||
e39c460e3a | |||
bb92c4a967 | |||
b40caf12df | |||
8ebd2d14b4 | |||
445bc05b67 | |||
7538b2f860 | |||
367f86ecfb | |||
055ead54b5 | |||
df0232358b | |||
baa3ea6585 | |||
e75e2cf324 | |||
948b83a2b2 | |||
34e9af57fe | |||
94ae490284 | |||
690f263bac | |||
6280446450 | |||
7d87f86410 | |||
0d1201f972 | |||
78b23c4bd4 | |||
7fcfc48af2 | |||
611fd96e3a | |||
4671d4afb4 | |||
07c4ef986b | |||
7d64ec5066 | |||
ee6edec1d8 | |||
04cc7817ee | |||
9ac6511548 | |||
2eee53806a | |||
c5af79f176 | |||
0477862b73 | |||
5ef5213fae | |||
6a554ef45a | |||
f44175303b | |||
dfa80543b5 | |||
5f99887b50 | |||
2502a7cece | |||
77025cdb79 | |||
ce5f6d5d43 | |||
1893626e04 | |||
edb2aa2db5 | |||
9e539d0a0e | |||
a3088b7f79 | |||
b186e35b61 | |||
2a3933f141 | |||
2f2eec0d21 |
@ -6,3 +6,4 @@ dist/**
|
||||
build/**
|
||||
build_docs/**
|
||||
Dockerfile
|
||||
authentik/enterprise
|
||||
|
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
@ -12,6 +11,7 @@ A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
@ -27,8 +27,9 @@ If applicable, add screenshots to help explain your problem.
|
||||
Output of docker-compose logs or kubectl logs respectively
|
||||
|
||||
**Version and Deployment (please complete the following information):**
|
||||
- authentik version: [e.g. 2021.8.5]
|
||||
- Deployment: [e.g. docker-compose, helm]
|
||||
|
||||
- authentik version: [e.g. 2021.8.5]
|
||||
- Deployment: [e.g. docker-compose, helm]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
5
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
|
10
.github/ISSUE_TEMPLATE/question.md
vendored
10
.github/ISSUE_TEMPLATE/question.md
vendored
@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask a question about a feature or specific configuration
|
||||
title: ''
|
||||
title: ""
|
||||
labels: question
|
||||
assignees: ''
|
||||
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe your question/**
|
||||
@ -20,8 +19,9 @@ If applicable, add screenshots to help explain your problem.
|
||||
Output of docker-compose logs or kubectl logs respectively
|
||||
|
||||
**Version and Deployment (please complete the following information):**
|
||||
- authentik version: [e.g. 2021.8.5]
|
||||
- Deployment: [e.g. docker-compose, helm]
|
||||
|
||||
- authentik version: [e.g. 2021.8.5]
|
||||
- Deployment: [e.g. docker-compose, helm]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
@ -1,5 +1,5 @@
|
||||
name: 'Comment usage instructions on PRs'
|
||||
description: 'Comment usage instructions on PRs'
|
||||
name: "Comment usage instructions on PRs"
|
||||
description: "Comment usage instructions on PRs"
|
||||
|
||||
inputs:
|
||||
tag:
|
||||
@ -17,7 +17,7 @@ runs:
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
comment-author: "github-actions[bot]"
|
||||
body-includes: authentik PR Installation instructions
|
||||
- name: Create or update comment
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
|
@ -1,5 +1,5 @@
|
||||
name: 'Prepare docker environment variables'
|
||||
description: 'Prepare docker environment variables'
|
||||
name: "Prepare docker environment variables"
|
||||
description: "Prepare docker environment variables"
|
||||
|
||||
outputs:
|
||||
shouldBuild:
|
||||
|
12
.github/actions/setup/action.yml
vendored
12
.github/actions/setup/action.yml
vendored
@ -1,5 +1,5 @@
|
||||
name: 'Setup authentik testing environment'
|
||||
description: 'Setup authentik testing environment'
|
||||
name: "Setup authentik testing environment"
|
||||
description: "Setup authentik testing environment"
|
||||
|
||||
inputs:
|
||||
postgresql_tag:
|
||||
@ -18,13 +18,13 @@ runs:
|
||||
- name: Setup python and restore poetry
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'poetry'
|
||||
python-version: "3.11"
|
||||
cache: "poetry"
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3.1.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Setup dependencies
|
||||
shell: bash
|
||||
|
8
.github/actions/setup/docker-compose.yml
vendored
8
.github/actions/setup/docker-compose.yml
vendored
@ -1,23 +1,23 @@
|
||||
version: '3.7'
|
||||
version: "3.7"
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
container_name: postgres
|
||||
image: library/postgres:${PSQL_TAG:-12}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
- db-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||
POSTGRES_DB: authentik
|
||||
ports:
|
||||
- 5432:5432
|
||||
- 5432:5432
|
||||
restart: always
|
||||
redis:
|
||||
container_name: redis
|
||||
image: library/redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
- 6379:6379
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
|
120
.github/dependabot.yml
vendored
120
.github/dependabot.yml
vendored
@ -1,62 +1,62 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "ci:"
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "core:"
|
||||
- package-ecosystem: npm
|
||||
directory: "/web"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "web:"
|
||||
- package-ecosystem: npm
|
||||
directory: "/website"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "website:"
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "core:"
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "core:"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "ci:"
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "core:"
|
||||
- package-ecosystem: npm
|
||||
directory: "/web"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "web:"
|
||||
- package-ecosystem: npm
|
||||
directory: "/website"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "website:"
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "core:"
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "core:"
|
||||
|
13
.github/pull_request_template.md
vendored
13
.github/pull_request_template.md
vendored
@ -5,15 +5,20 @@ Please check the [Contributing guidelines](https://github.com/goauthentik/authen
|
||||
-->
|
||||
|
||||
# Details
|
||||
* **Does this resolve an issue?**
|
||||
Resolves #
|
||||
|
||||
- **Does this resolve an issue?**
|
||||
Resolves #
|
||||
|
||||
## Changes
|
||||
|
||||
### New Features
|
||||
* Adds feature which does x, y, and z.
|
||||
|
||||
- Adds feature which does x, y, and z.
|
||||
|
||||
### Breaking Changes
|
||||
* Adds breaking change which causes \<issue\>.
|
||||
|
||||
- Adds breaking change which causes \<issue\>.
|
||||
|
||||
## Additional
|
||||
|
||||
Any further notes or comments you want to make.
|
||||
|
4
.github/transifex.yml
vendored
4
.github/transifex.yml
vendored
@ -6,11 +6,11 @@ git:
|
||||
source_language: en
|
||||
source_file: web/src/locales/en.po
|
||||
# path expression to translation files, must contain <lang> placeholder
|
||||
translation_files_expression: 'web/src/locales/<lang>.po'
|
||||
translation_files_expression: "web/src/locales/<lang>.po"
|
||||
- filter_type: file
|
||||
# all supported i18n types: https://docs.transifex.com/formats
|
||||
file_format: PO
|
||||
source_language: en
|
||||
source_file: locale/en/LC_MESSAGES/django.po
|
||||
# path expression to translation files, must contain <lang> placeholder
|
||||
translation_files_expression: 'locale/<lang>/LC_MESSAGES/django.po'
|
||||
translation_files_expression: "locale/<lang>/LC_MESSAGES/django.po"
|
||||
|
13
.github/workflows/ci-main.yml
vendored
13
.github/workflows/ci-main.yml
vendored
@ -23,13 +23,14 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- pylint
|
||||
- black
|
||||
- isort
|
||||
- bandit
|
||||
- pyright
|
||||
- pending-migrations
|
||||
- black
|
||||
- codespell
|
||||
- isort
|
||||
- pending-migrations
|
||||
- pylint
|
||||
- pyright
|
||||
- ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@ -211,6 +212,7 @@ jobs:
|
||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
tags: |
|
||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}
|
||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.sha }}
|
||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}
|
||||
build-args: |
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
@ -252,6 +254,7 @@ jobs:
|
||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
tags: |
|
||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-arm64
|
||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.sha }}-arm64
|
||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.shortHash }}-arm64
|
||||
build-args: |
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
|
16
.github/workflows/ci-outpost.yml
vendored
16
.github/workflows/ci-outpost.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
go-version-file: "go.mod"
|
||||
- name: Prepare and generate API
|
||||
run: |
|
||||
# Create folder structure for go embeds
|
||||
@ -36,7 +36,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
go-version-file: "go.mod"
|
||||
- name: Generate API
|
||||
run: make gen-client-go
|
||||
- name: Go unittests
|
||||
@ -60,8 +60,6 @@ jobs:
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
arch:
|
||||
- "linux/amd64"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@ -94,7 +92,7 @@ jobs:
|
||||
build-args: |
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||
platforms: ${{ matrix.arch }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
build-binary:
|
||||
timeout-minutes: 120
|
||||
@ -114,10 +112,10 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Generate API
|
||||
@ -133,7 +131,3 @@ jobs:
|
||||
export GOOS=${{ matrix.goos }}
|
||||
export GOARCH=${{ matrix.goarch }}
|
||||
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
path: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
|
20
.github/workflows/ci-web.yml
vendored
20
.github/workflows/ci-web.yml
vendored
@ -17,8 +17,8 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
@ -33,8 +33,8 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
@ -49,8 +49,8 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
@ -65,8 +65,8 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
run: |
|
||||
@ -97,8 +97,8 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
|
12
.github/workflows/ci-website.yml
vendored
12
.github/workflows/ci-website.yml
vendored
@ -17,8 +17,8 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
run: npm ci
|
||||
@ -31,8 +31,8 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
run: npm ci
|
||||
@ -52,8 +52,8 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
run: npm ci
|
||||
|
58
.github/workflows/codeql-analysis.yml
vendored
58
.github/workflows/codeql-analysis.yml
vendored
@ -2,12 +2,12 @@ name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, '*', next, version* ]
|
||||
branches: [main, "*", next, version*]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '30 6 * * 5'
|
||||
- cron: "30 6 * * 5"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@ -21,40 +21,40 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go', 'javascript', 'python' ]
|
||||
language: ["go", "javascript", "python"]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
2
.github/workflows/ghcr-retention.yml
vendored
2
.github/workflows/ghcr-retention.yml
vendored
@ -2,7 +2,7 @@ name: ghcr-retention
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # every day at midnight
|
||||
- cron: "0 0 * * *" # every day at midnight
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
12
.github/workflows/release-publish.yml
vendored
12
.github/workflows/release-publish.yml
vendored
@ -57,7 +57,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
go-version-file: "go.mod"
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2.1.0
|
||||
- name: Set up Docker Buildx
|
||||
@ -107,11 +107,11 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build web
|
||||
working-directory: web/
|
||||
@ -173,5 +173,5 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
with:
|
||||
version: authentik@${{ steps.ev.outputs.version }}
|
||||
sourcemaps: './web/dist'
|
||||
url_prefix: '~/static/dist'
|
||||
sourcemaps: "./web/dist"
|
||||
url_prefix: "~/static/dist"
|
||||
|
2
.github/workflows/release-tag.yml
vendored
2
.github/workflows/release-tag.yml
vendored
@ -3,7 +3,7 @@ name: authentik-on-tag
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'version/*'
|
||||
- "version/*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
6
.github/workflows/translation-compile.yml
vendored
6
.github/workflows/translation-compile.yml
vendored
@ -1,12 +1,12 @@
|
||||
name: authentik-backend-translate-compile
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- '/locale/'
|
||||
- "/locale/"
|
||||
pull_request:
|
||||
paths:
|
||||
- '/locale/'
|
||||
- "/locale/"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
|
8
.github/workflows/web-api-publish.yml
vendored
8
.github/workflows/web-api-publish.yml
vendored
@ -1,9 +1,9 @@
|
||||
name: authentik-web-api-publish
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'schema.yml'
|
||||
- "schema.yml"
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
build:
|
||||
@ -14,8 +14,8 @@ jobs:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Generate API Client
|
||||
run: make gen-client-ts
|
||||
- name: Publish package
|
||||
|
@ -1,5 +1,5 @@
|
||||
# Stage 1: Build website
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/node:18 as website-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/node:20 as website-builder
|
||||
|
||||
COPY ./website /work/website/
|
||||
COPY ./blueprints /work/blueprints/
|
||||
@ -10,7 +10,7 @@ WORKDIR /work/website
|
||||
RUN npm ci && npm run build-docs-only
|
||||
|
||||
# Stage 2: Build webui
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/node:18 as web-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/node:20 as web-builder
|
||||
|
||||
COPY ./web /work/web/
|
||||
COPY ./website /work/website/
|
||||
@ -83,7 +83,7 @@ RUN apt-get update && \
|
||||
# Required for runtime
|
||||
apt-get install -y --no-install-recommends libxmlsec1-openssl libmaxminddb0 && \
|
||||
# Required for bootstrap & healtcheck
|
||||
apt-get install -y --no-install-recommends curl runit && \
|
||||
apt-get install -y --no-install-recommends runit && \
|
||||
pip install --no-cache-dir -r /requirements.txt && \
|
||||
apt-get remove --purge -y build-essential pkg-config libxmlsec1-dev && \
|
||||
apt-get autoremove --purge -y && \
|
||||
|
9
LICENSE
9
LICENSE
@ -1,6 +1,11 @@
|
||||
MIT License
|
||||
Copyright (c) 2023 Jens Langhammer
|
||||
|
||||
Copyright (c) 2022 Jens Langhammer
|
||||
Portions of this software are licensed as follows:
|
||||
* All content residing under the "website/" directory of this repository is licensed under "Creative Commons: CC BY-SA 4.0 license".
|
||||
* All content that resides under the "authentik/enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "authentik/enterprise/LICENSE".
|
||||
* All client-side JavaScript (when served directly or after being compiled, arranged, augmented, or combined), is licensed under the "MIT Expat" license.
|
||||
* All third party components incorporated into the authentik are licensed under the original license provided by the owner of the applicable component.
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the "MIT" license as defined below.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
14
Makefile
14
Makefile
@ -3,6 +3,7 @@ PWD = $(shell pwd)
|
||||
UID = $(shell id -u)
|
||||
GID = $(shell id -g)
|
||||
NPM_VERSION = $(shell python -m scripts.npm_version)
|
||||
PY_SOURCES = authentik tests scripts lifecycle
|
||||
|
||||
CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
|
||||
-I .github/codespell-words.txt \
|
||||
@ -38,13 +39,14 @@ test:
|
||||
coverage report
|
||||
|
||||
lint-fix:
|
||||
isort authentik tests scripts lifecycle
|
||||
black authentik tests scripts lifecycle
|
||||
isort authentik $(PY_SOURCES)
|
||||
black authentik $(PY_SOURCES)
|
||||
ruff authentik $(PY_SOURCES)
|
||||
codespell -w $(CODESPELL_ARGS)
|
||||
|
||||
lint:
|
||||
pylint authentik tests lifecycle
|
||||
bandit -r authentik tests lifecycle -x node_modules
|
||||
pylint $(PY_SOURCES)
|
||||
bandit -r $(PY_SOURCES) -x node_modules
|
||||
golangci-lint run -v
|
||||
|
||||
migrate:
|
||||
@ -171,7 +173,6 @@ website-watch:
|
||||
|
||||
# These targets are use by GitHub actions to allow usage of matrix
|
||||
# which makes the YAML File a lot smaller
|
||||
PY_SOURCES=authentik tests lifecycle
|
||||
ci--meta-debug:
|
||||
python -V
|
||||
node --version
|
||||
@ -182,6 +183,9 @@ ci-pylint: ci--meta-debug
|
||||
ci-black: ci--meta-debug
|
||||
black --check $(PY_SOURCES)
|
||||
|
||||
ci-ruff: ci--meta-debug
|
||||
ruff check $(PY_SOURCES)
|
||||
|
||||
ci-codespell: ci--meta-debug
|
||||
codespell $(CODESPELL_ARGS) -s
|
||||
|
||||
|
@ -18,6 +18,7 @@ from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.lib.utils.reflection import get_env
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
from authentik.outposts.models import Outpost
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
|
||||
class RuntimeDict(TypedDict):
|
||||
@ -77,7 +78,7 @@ class SystemSerializer(PassiveSerializer):
|
||||
|
||||
def get_tenant(self, request: Request) -> str:
|
||||
"""Currently active tenant"""
|
||||
return str(request._request.tenant)
|
||||
return str(get_tenant(request))
|
||||
|
||||
def get_server_time(self, request: Request) -> datetime:
|
||||
"""Current server time"""
|
||||
|
@ -29,6 +29,7 @@ class Capabilities(models.TextChoices):
|
||||
CAN_GEO_IP = "can_geo_ip"
|
||||
CAN_IMPERSONATE = "can_impersonate"
|
||||
CAN_DEBUG = "can_debug"
|
||||
IS_ENTERPRISE = "is_enterprise"
|
||||
|
||||
|
||||
class ErrorReportingConfigSerializer(PassiveSerializer):
|
||||
@ -70,6 +71,8 @@ class ConfigView(APIView):
|
||||
caps.append(Capabilities.CAN_IMPERSONATE)
|
||||
if settings.DEBUG: # pragma: no cover
|
||||
caps.append(Capabilities.CAN_DEBUG)
|
||||
if "authentik.enterprise" in settings.INSTALLED_APPS:
|
||||
caps.append(Capabilities.IS_ENTERPRISE)
|
||||
return caps
|
||||
|
||||
def get_config(self) -> ConfigSerializer:
|
||||
|
@ -33,6 +33,7 @@ from authentik.flows.api.flows import FlowViewSet
|
||||
from authentik.flows.api.stages import StageViewSet
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
from authentik.flows.views.inspector import FlowInspectorView
|
||||
from authentik.interfaces.api import InterfaceViewSet
|
||||
from authentik.outposts.api.outposts import OutpostViewSet
|
||||
from authentik.outposts.api.service_connections import (
|
||||
DockerServiceConnectionViewSet,
|
||||
@ -123,6 +124,8 @@ router.register("core/user_consent", UserConsentViewSet)
|
||||
router.register("core/tokens", TokenViewSet)
|
||||
router.register("core/tenants", TenantViewSet)
|
||||
|
||||
router.register("interfaces", InterfaceViewSet)
|
||||
|
||||
router.register("outposts/instances", OutpostViewSet)
|
||||
router.register("outposts/service_connections/all", ServiceConnectionViewSet)
|
||||
router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet)
|
||||
|
@ -6,7 +6,6 @@ from pathlib import Path
|
||||
import django.contrib.postgres.fields
|
||||
from dacite.core import from_dict
|
||||
from django.apps.registry import Apps
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
from yaml import load
|
||||
@ -15,7 +14,7 @@ from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_SYSTEM
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
def check_blueprint_v1_file(BlueprintInstance: type["BlueprintInstance"], path: Path):
|
||||
def check_blueprint_v1_file(BlueprintInstance: type, path: Path):
|
||||
"""Check if blueprint should be imported"""
|
||||
from authentik.blueprints.models import BlueprintInstanceStatus
|
||||
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata
|
||||
|
@ -10,7 +10,6 @@ from django.db.models.functions import ExtractHour
|
||||
from django.db.models.query import QuerySet
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
@ -72,10 +71,12 @@ from authentik.flows.exceptions import FlowNonApplicableException
|
||||
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.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import reverse_interface
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.tasks import send_mails
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.tenants.models import Tenant
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -321,7 +322,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]:
|
||||
"""Create a recovery link (when the current tenant has a recovery flow set),
|
||||
that can either be shown to an admin or sent to the user directly"""
|
||||
tenant: Tenant = self.request._request.tenant
|
||||
tenant = get_tenant(self.request)
|
||||
# Check that there is a recovery flow, if not return an error
|
||||
flow = tenant.flow_recovery
|
||||
if not flow:
|
||||
@ -350,8 +351,12 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
querystring = urlencode({QS_KEY_TOKEN: token.key})
|
||||
link = self.request.build_absolute_uri(
|
||||
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
+ f"?{querystring}"
|
||||
reverse_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=flow.slug,
|
||||
),
|
||||
+f"?{querystring}",
|
||||
)
|
||||
return link, token
|
||||
|
||||
|
@ -33,6 +33,7 @@ from authentik.lib.models import (
|
||||
)
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
|
||||
@ -168,7 +169,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
|
||||
including the users attributes"""
|
||||
final_attributes = {}
|
||||
if request and hasattr(request, "tenant"):
|
||||
always_merger.merge(final_attributes, request.tenant.attributes)
|
||||
always_merger.merge(final_attributes, get_tenant(request).attributes)
|
||||
for group in self.ak_groups.all().order_by("name"):
|
||||
always_merger.merge(final_attributes, group.attributes)
|
||||
always_merger.merge(final_attributes, self.attributes)
|
||||
@ -227,7 +228,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Failed to get default locale", exc=exc)
|
||||
if request:
|
||||
return request.tenant.locale
|
||||
return get_tenant(request).default_locale
|
||||
return ""
|
||||
|
||||
@property
|
||||
|
@ -25,7 +25,8 @@ from authentik.flows.planner import (
|
||||
)
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import redirect_to_default_interface
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
from authentik.policies.utils import delete_none_keys
|
||||
@ -226,7 +227,7 @@ class SourceFlowManager:
|
||||
# Ensure redirect is carried through when user was trying to
|
||||
# authorize application
|
||||
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
|
||||
NEXT_ARG_NAME, "authentik_core:if-user"
|
||||
NEXT_ARG_NAME, "authentik_core:root-redirect"
|
||||
)
|
||||
kwargs.update(
|
||||
{
|
||||
@ -253,9 +254,9 @@ class SourceFlowManager:
|
||||
for stage in stages:
|
||||
plan.append_stage(stage)
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
self.request.GET,
|
||||
return redirect_to_default_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=flow.slug,
|
||||
)
|
||||
|
||||
@ -299,8 +300,9 @@ class SourceFlowManager:
|
||||
_("Successfully linked %(source)s!" % {"source": self.source.name}),
|
||||
)
|
||||
return redirect(
|
||||
# Not ideal that we don't directly redirect to the configured user interface
|
||||
reverse(
|
||||
"authentik_core:if-user",
|
||||
"authentik_core:root-redirect",
|
||||
)
|
||||
+ f"#/settings;page-{self.source.slug}"
|
||||
)
|
||||
|
@ -59,4 +59,6 @@ class TestImpersonation(TestCase):
|
||||
self.client.force_login(self.other_user)
|
||||
|
||||
response = self.client.get(reverse("authentik_core:impersonate-end"))
|
||||
self.assertRedirects(response, reverse("authentik_core:if-user"))
|
||||
self.assertRedirects(
|
||||
response, reverse("authentik_interfaces:if", kwargs={"if_name": "user"})
|
||||
)
|
||||
|
@ -3,23 +3,30 @@ 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.http import HttpRequest, HttpResponse
|
||||
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, 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.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import RedirectToInterface
|
||||
from authentik.root.asgi_middleware import SessionMiddleware
|
||||
from authentik.root.messages.consumer import MessageConsumer
|
||||
|
||||
|
||||
def placeholder_view(request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Empty view used as placeholder
|
||||
|
||||
(Mounted to websocket endpoints and used by e2e tests)"""
|
||||
return HttpResponse(status_code=200)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"",
|
||||
login_required(
|
||||
RedirectView.as_view(pattern_name="authentik_core:if-user", query_string=True)
|
||||
),
|
||||
login_required(RedirectToInterface.as_view(type=InterfaceType.USER)),
|
||||
name="root-redirect",
|
||||
),
|
||||
path(
|
||||
@ -40,31 +47,16 @@ urlpatterns = [
|
||||
name="impersonate-end",
|
||||
),
|
||||
# Interfaces
|
||||
path(
|
||||
"if/admin/",
|
||||
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/admin.html")),
|
||||
name="if-admin",
|
||||
),
|
||||
path(
|
||||
"if/user/",
|
||||
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/user.html")),
|
||||
name="if-user",
|
||||
),
|
||||
path(
|
||||
"if/flow/<slug:flow_slug>/",
|
||||
ensure_csrf_cookie(FlowInterfaceView.as_view()),
|
||||
name="if-flow",
|
||||
),
|
||||
path(
|
||||
"if/session-end/<slug:application_slug>/",
|
||||
ensure_csrf_cookie(EndSessionView.as_view()),
|
||||
name="if-session-end",
|
||||
),
|
||||
# Fallback for WS
|
||||
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
|
||||
path("ws/outpost/<uuid:pk>/", placeholder_view),
|
||||
path(
|
||||
"ws/client/",
|
||||
InterfaceView.as_view(template_name="if/admin.html"),
|
||||
placeholder_view,
|
||||
),
|
||||
]
|
||||
|
||||
|
@ -20,11 +20,13 @@ from authentik.flows.views.executor import (
|
||||
SESSION_KEY_PLAN,
|
||||
ToDefaultFlow,
|
||||
)
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import redirect_to_default_interface
|
||||
from authentik.stages.consent.stage import (
|
||||
PLAN_CONTEXT_CONSENT_HEADER,
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS,
|
||||
)
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
|
||||
class RedirectToAppLaunch(View):
|
||||
@ -59,7 +61,7 @@ class RedirectToAppLaunch(View):
|
||||
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)
|
||||
return redirect_to_default_interface(request, InterfaceType.FLOW, flow_slug=flow.slug)
|
||||
|
||||
|
||||
class RedirectToAppStage(ChallengeStageView):
|
||||
|
@ -35,7 +35,7 @@ class ImpersonateInitView(View):
|
||||
|
||||
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
||||
|
||||
return redirect("authentik_core:if-user")
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
|
||||
class ImpersonateEndView(View):
|
||||
@ -48,7 +48,7 @@ class ImpersonateEndView(View):
|
||||
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")
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||
|
||||
|
@ -1,36 +0,0 @@
|
||||
"""Interface views"""
|
||||
from json import dumps
|
||||
from typing import Any
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.generic.base import TemplateView
|
||||
from rest_framework.request import Request
|
||||
|
||||
from authentik import get_build_hash
|
||||
from authentik.admin.tasks import LOCAL_VERSION
|
||||
from authentik.api.v3.config import ConfigView
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.tenants.api import CurrentTenantSerializer
|
||||
|
||||
|
||||
class InterfaceView(TemplateView):
|
||||
"""Base interface view"""
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data)
|
||||
kwargs["tenant_json"] = dumps(CurrentTenantSerializer(self.request.tenant).data)
|
||||
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
|
||||
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
|
||||
kwargs["build"] = get_build_hash()
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class FlowInterfaceView(InterfaceView):
|
||||
"""Flow interface"""
|
||||
|
||||
template_name = "if/flow.html"
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
||||
kwargs["inspector"] = "inspector" in self.request.GET
|
||||
return super().get_context_data(**kwargs)
|
@ -2,8 +2,6 @@
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
|
45
authentik/enterprise/LICENSE
Normal file
45
authentik/enterprise/LICENSE
Normal file
@ -0,0 +1,45 @@
|
||||
The authentik Enterprise Edition (EE) license (the “EE License”)
|
||||
Copyright (c) 2022-present Authentik Security Inc.
|
||||
|
||||
With regard to the authentik Software:
|
||||
|
||||
This software and associated documentation files (the "Software") may only be
|
||||
used in production, if you (and any entity that you represent) have agreed to,
|
||||
and are in compliance with, the Authentik Subscription Terms of Service, available
|
||||
at https://goauthentik.io/legal/terms (the "EE Terms"), or other
|
||||
agreement governing the use of the Software, as agreed by you and authentik Security Inc,
|
||||
and otherwise have a valid authentik Enterprise Edition subscription for the
|
||||
correct number of user seats. Subject to the foregoing sentence, you are free to
|
||||
modify this Software and publish patches to the Software. You agree that Authentik
|
||||
Security Inc. and/or its licensors (as applicable) retain all right, title and interest
|
||||
in and to all such modifications and/or patches, and all such modifications and/or
|
||||
patches may only be used, copied, modified, displayed, distributed, or otherwise
|
||||
exploited with a valid authentik Enterprise Edition subscription for the correct
|
||||
number of user seats. Notwithstanding the foregoing, you may copy and modify
|
||||
the Software for development and testing purposes, without requiring a
|
||||
subscription. You agree that Authentik Security Inc. and/or its
|
||||
licensors (as applicable) retain all right, title and interest in
|
||||
and to all such modifications. You are not granted any other rights
|
||||
beyond what is expressly stated herein. Subject to the
|
||||
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
|
||||
and/or sell the Software.
|
||||
|
||||
This EE License applies only to the part of this Software that is not
|
||||
distributed as part of authentik Open Source (OSS). Any part of this Software
|
||||
distributed as part of authentik OSS or is served client-side as an image, font,
|
||||
cascading stylesheet (CSS), file which produces or is compiled, arranged,
|
||||
augmented, or combined into client-side JavaScript, in whole or in part, is
|
||||
copyrighted under the MIT license. The full text of this EE License shall
|
||||
be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
For all third party components incorporated into the authentik Software, those
|
||||
components are licensed under the original license provided by the owner of the
|
||||
applicable component.
|
11
authentik/enterprise/apps.py
Normal file
11
authentik/enterprise/apps.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""Enterprise app config"""
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
|
||||
class AuthentikEnterpriseConfig(ManagedAppConfig):
|
||||
"""Enterprise app config"""
|
||||
|
||||
name = "authentik.enterprise"
|
||||
label = "authentik_enterprise"
|
||||
verbose_name = "authentik Enterprise"
|
||||
default = True
|
1
authentik/enterprise/settings.py
Normal file
1
authentik/enterprise/settings.py
Normal file
@ -0,0 +1 @@
|
||||
"""Enterprise additional settings"""
|
@ -11,7 +11,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
|
||||
|
||||
|
||||
|
@ -41,8 +41,7 @@ from authentik.lib.utils.http import get_client_ip, get_http_session
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.tenants.models import Tenant
|
||||
from authentik.tenants.utils import DEFAULT_TENANT
|
||||
from authentik.tenants.utils import get_fallback_tenant, get_tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
if TYPE_CHECKING:
|
||||
@ -57,7 +56,7 @@ def default_event_duration():
|
||||
|
||||
def default_tenant():
|
||||
"""Get a default value for tenant"""
|
||||
return sanitize_dict(model_to_dict(DEFAULT_TENANT))
|
||||
return sanitize_dict(model_to_dict(get_fallback_tenant()))
|
||||
|
||||
|
||||
class NotificationTransportError(SentryIgnoredException):
|
||||
@ -227,7 +226,7 @@ class Event(SerializerModel, ExpiringModel):
|
||||
wrapped = self.context["http_request"]["args"][QS_QUERY]
|
||||
self.context["http_request"]["args"] = QueryDict(wrapped)
|
||||
if hasattr(request, "tenant"):
|
||||
tenant: Tenant = request.tenant
|
||||
tenant = get_tenant(request)
|
||||
# Because self.created only gets set on save, we can't use it's value here
|
||||
# hence we set self.created to now and then use it
|
||||
self.created = now()
|
||||
|
@ -25,6 +25,8 @@ from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.flows.planner import CACHE_PREFIX, PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
|
||||
from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import reverse_interface
|
||||
from authentik.lib.utils.file import (
|
||||
FilePathSerializer,
|
||||
FileUploadSerializer,
|
||||
@ -294,7 +296,11 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
||||
return Response(
|
||||
{
|
||||
"link": request._request.build_absolute_uri(
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
reverse_interface(
|
||||
request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=flow.slug,
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -7,6 +7,8 @@ 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.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.tests import reverse_interface
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
|
||||
@ -21,7 +23,10 @@ class TestHelperView(TestCase):
|
||||
response = self.client.get(
|
||||
reverse("authentik_flows:default-invalidation"),
|
||||
)
|
||||
expected_url = reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
expected_url = reverse_interface(
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=flow.slug,
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, expected_url)
|
||||
|
||||
@ -72,6 +77,9 @@ class TestHelperView(TestCase):
|
||||
response = self.client.get(
|
||||
reverse("authentik_flows:default-invalidation"),
|
||||
)
|
||||
expected_url = reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
expected_url = reverse_interface(
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=flow.slug,
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, expected_url)
|
||||
|
@ -53,12 +53,14 @@ from authentik.flows.planner import (
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.stage import AccessDeniedChallengeView, StageView
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import redirect_to_default_interface
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.reflection import all_subclasses, class_to_path
|
||||
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.tenants.models import Tenant
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
# Argument used to redirect user after login
|
||||
@ -479,7 +481,7 @@ class ToDefaultFlow(View):
|
||||
|
||||
def get_flow(self) -> Flow:
|
||||
"""Get a flow for the selected designation"""
|
||||
tenant: Tenant = self.request.tenant
|
||||
tenant = get_tenant(self.request)
|
||||
flow = None
|
||||
# First, attempt to get default flow from tenant
|
||||
if self.designation == FlowDesignation.AUTHENTICATION:
|
||||
@ -512,7 +514,7 @@ class ToDefaultFlow(View):
|
||||
flow_slug=flow.slug,
|
||||
)
|
||||
del self.request.session[SESSION_KEY_PLAN]
|
||||
return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug)
|
||||
return redirect_to_default_interface(request, InterfaceType.FLOW, flow_slug=flow.slug)
|
||||
|
||||
|
||||
def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse:
|
||||
@ -583,8 +585,8 @@ class ConfigureFlowInitView(LoginRequiredMixin, View):
|
||||
LOGGER.warning("Flow not applicable to user")
|
||||
raise Http404
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
self.request.GET,
|
||||
return redirect_to_default_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=stage.configure_flow.slug,
|
||||
)
|
||||
|
0
authentik/interfaces/__init__.py
Normal file
0
authentik/interfaces/__init__.py
Normal file
28
authentik/interfaces/api.py
Normal file
28
authentik/interfaces/api.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""interfaces API"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.interfaces.models import Interface
|
||||
|
||||
|
||||
class InterfaceSerializer(ModelSerializer):
|
||||
"""Interface serializer"""
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
"interface_uuid",
|
||||
"url_name",
|
||||
"type",
|
||||
"template",
|
||||
]
|
||||
|
||||
|
||||
class InterfaceViewSet(UsedByMixin, ModelViewSet):
|
||||
"""Interface serializer"""
|
||||
|
||||
queryset = Interface.objects.all()
|
||||
serializer_class = InterfaceSerializer
|
||||
filterset_fields = ["url_name", "type", "template"]
|
||||
search_fields = ["url_name", "type", "template"]
|
12
authentik/interfaces/apps.py
Normal file
12
authentik/interfaces/apps.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""authentik interfaces app config"""
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
|
||||
class AuthentikInterfacesConfig(ManagedAppConfig):
|
||||
"""authentik interfaces app config"""
|
||||
|
||||
name = "authentik.interfaces"
|
||||
label = "authentik_interfaces"
|
||||
verbose_name = "authentik Interfaces"
|
||||
mountpoint = "if/"
|
||||
default = True
|
36
authentik/interfaces/migrations/0001_initial.py
Normal file
36
authentik/interfaces/migrations/0001_initial.py
Normal file
@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.1.7 on 2023-02-16 11:01
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Interface",
|
||||
fields=[
|
||||
(
|
||||
"interface_uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("url_name", models.SlugField(unique=True)),
|
||||
(
|
||||
"type",
|
||||
models.TextField(
|
||||
choices=[("user", "User"), ("admin", "Admin"), ("flow", "Flow")]
|
||||
),
|
||||
),
|
||||
("template", models.TextField()),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
0
authentik/interfaces/migrations/__init__.py
Normal file
0
authentik/interfaces/migrations/__init__.py
Normal file
33
authentik/interfaces/models.py
Normal file
33
authentik/interfaces/models.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Interface models"""
|
||||
from typing import Type
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from rest_framework.serializers import BaseSerializer
|
||||
|
||||
from authentik.lib.models import SerializerModel
|
||||
|
||||
|
||||
class InterfaceType(models.TextChoices):
|
||||
"""Interface types"""
|
||||
|
||||
USER = "user"
|
||||
ADMIN = "admin"
|
||||
FLOW = "flow"
|
||||
|
||||
|
||||
class Interface(SerializerModel):
|
||||
"""Interface"""
|
||||
|
||||
interface_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
url_name = models.SlugField(unique=True)
|
||||
|
||||
type = models.TextField(choices=InterfaceType.choices)
|
||||
template = models.TextField()
|
||||
|
||||
@property
|
||||
def serializer(self) -> Type[BaseSerializer]:
|
||||
from authentik.interfaces.api import InterfaceSerializer
|
||||
|
||||
return InterfaceSerializer
|
12
authentik/interfaces/tests.py
Normal file
12
authentik/interfaces/tests.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Interface tests"""
|
||||
from django.test import RequestFactory
|
||||
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import reverse_interface as full_reverse_interface
|
||||
|
||||
|
||||
def reverse_interface(interface_type: InterfaceType, **kwargs):
|
||||
"""reverse_interface wrapper for tests"""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
return full_reverse_interface(request, interface_type, **kwargs)
|
14
authentik/interfaces/urls.py
Normal file
14
authentik/interfaces/urls.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Interface urls"""
|
||||
from django.urls import path
|
||||
|
||||
from authentik.interfaces.views import InterfaceView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"<slug:if_name>/",
|
||||
InterfaceView.as_view(),
|
||||
kwargs={"flow_slug": None},
|
||||
name="if",
|
||||
),
|
||||
path("<slug:if_name>/<slug:flow_slug>/", InterfaceView.as_view(), name="if"),
|
||||
]
|
113
authentik/interfaces/views.py
Normal file
113
authentik/interfaces/views.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""Interface views"""
|
||||
from json import dumps
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse, QueryDict
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template import Template, TemplateSyntaxError, engines
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from rest_framework.request import Request
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import get_build_hash
|
||||
from authentik.admin.tasks import LOCAL_VERSION
|
||||
from authentik.api.v3.config import ConfigView
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.interfaces.models import Interface, InterfaceType
|
||||
from authentik.lib.utils.urls import reverse_with_qs
|
||||
from authentik.tenants.api import CurrentTenantSerializer
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def template_from_string(template_string: str) -> Template:
|
||||
"""Render template from string"""
|
||||
chain = []
|
||||
engine_list = engines.all()
|
||||
for engine in engine_list:
|
||||
try:
|
||||
return engine.from_string(template_string)
|
||||
except TemplateSyntaxError as exc:
|
||||
chain.append(exc)
|
||||
raise TemplateSyntaxError(template_string, chain=chain)
|
||||
|
||||
|
||||
def redirect_to_default_interface(request: HttpRequest, interface_type: InterfaceType, **kwargs):
|
||||
"""Shortcut to inline redirect to default interface,
|
||||
keeping GET parameters of the passed request"""
|
||||
return RedirectToInterface.as_view(type=interface_type)(request, **kwargs)
|
||||
|
||||
|
||||
def reverse_interface(
|
||||
request: HttpRequest, interface_type: InterfaceType, query: Optional[QueryDict] = None, **kwargs
|
||||
):
|
||||
"""Reverse URL to configured default interface"""
|
||||
tenant = get_tenant(request)
|
||||
interface: Interface = None
|
||||
|
||||
if interface_type == InterfaceType.USER:
|
||||
interface = tenant.interface_user
|
||||
if interface_type == InterfaceType.ADMIN:
|
||||
interface = tenant.interface_admin
|
||||
if interface_type == InterfaceType.FLOW:
|
||||
interface = tenant.interface_flow
|
||||
|
||||
if not interface:
|
||||
LOGGER.warning("No interface found", type=interface_type, tenant=tenant)
|
||||
raise Http404()
|
||||
kwargs["if_name"] = interface.url_name
|
||||
return reverse_with_qs(
|
||||
"authentik_interfaces:if",
|
||||
query=query or request.GET,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
|
||||
class RedirectToInterface(View):
|
||||
"""Redirect to tenant's configured view for specified type"""
|
||||
|
||||
type: Optional[InterfaceType] = None
|
||||
|
||||
def dispatch(self, request: HttpRequest, **kwargs: Any) -> HttpResponse:
|
||||
target = reverse_interface(request, self.type, **kwargs)
|
||||
if self.request.GET:
|
||||
target += "?" + urlencode(self.request.GET.items())
|
||||
return redirect(target)
|
||||
|
||||
|
||||
@method_decorator(ensure_csrf_cookie, name="dispatch")
|
||||
@method_decorator(cache_page(60 * 10), name="dispatch")
|
||||
class InterfaceView(View):
|
||||
"""General interface view"""
|
||||
|
||||
def get_context_data(self) -> dict[str, Any]:
|
||||
"""Get template context"""
|
||||
return {
|
||||
"config_json": dumps(ConfigView(request=Request(self.request)).get_config().data),
|
||||
"tenant_json": dumps(CurrentTenantSerializer(get_tenant(self.request)).data),
|
||||
"version_family": f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}",
|
||||
"version_subdomain": f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}",
|
||||
"build": get_build_hash(),
|
||||
}
|
||||
|
||||
def type_flow(self, context: dict[str, Any]):
|
||||
"""Special handling for flow interfaces"""
|
||||
if self.kwargs.get("flow_slug", None) is None:
|
||||
raise Http404()
|
||||
context["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
||||
context["inspector"] = "inspector" in self.request.GET
|
||||
|
||||
def dispatch(self, request: HttpRequest, if_name: str, **kwargs: Any) -> HttpResponse:
|
||||
context = self.get_context_data()
|
||||
# TODO: Cache
|
||||
interface: Interface = get_object_or_404(Interface, url_name=if_name)
|
||||
if interface.type == InterfaceType.FLOW:
|
||||
self.type_flow(context)
|
||||
template = template_from_string(interface.template)
|
||||
return TemplateResponse(request, template, context)
|
@ -13,7 +13,6 @@ from paramiko.ssh_exception import SSHException
|
||||
from structlog.stdlib import get_logger
|
||||
from yaml import safe_dump
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
|
||||
from authentik.outposts.docker_ssh import DockerInlineSSH, SSHManagedExternallyException
|
||||
|
@ -22,7 +22,7 @@ from kubernetes.client import (
|
||||
V1SecurityContext,
|
||||
)
|
||||
|
||||
from authentik import __version__, get_full_version
|
||||
from authentik import get_full_version
|
||||
from authentik.outposts.controllers.base import FIELD_MANAGER
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||
from authentik.outposts.controllers.k8s.triggers import NeedsUpdate
|
||||
|
@ -12,6 +12,7 @@ from authentik.lib.utils.http import get_http_session
|
||||
from authentik.policies.models import Policy
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
RE_LOWER = re.compile("[a-z]")
|
||||
@ -143,7 +144,8 @@ class PasswordPolicy(Policy):
|
||||
user_inputs.append(request.user.name)
|
||||
user_inputs.append(request.user.email)
|
||||
if request.http_request:
|
||||
user_inputs.append(request.http_request.tenant.branding_title)
|
||||
tenant = get_tenant(request.http_request)
|
||||
user_inputs.append(tenant.branding_title)
|
||||
# Only calculate result for the first 100 characters, as with over 100 char
|
||||
# long passwords we can be reasonably sure that they'll surpass the score anyways
|
||||
# See https://github.com/dropbox/zxcvbn#runtime-latency
|
||||
|
@ -59,6 +59,7 @@ class TestPasswordPolicyFlow(FlowTestCase):
|
||||
"label": "PASSWORD_LABEL",
|
||||
"order": 0,
|
||||
"placeholder": "PASSWORD_PLACEHOLDER",
|
||||
"initial_value": "",
|
||||
"required": True,
|
||||
"type": "password",
|
||||
"sub_text": "",
|
||||
|
@ -1,10 +1,8 @@
|
||||
# Generated by Django 3.1 on 2020-08-18 15:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.apps.registry import Apps
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
import authentik.core.models
|
||||
import authentik.lib.generators
|
||||
|
@ -39,8 +39,9 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
self.assertEqual(
|
||||
res.url,
|
||||
reverse(
|
||||
"authentik_core:if-flow",
|
||||
"authentik_interfaces:if",
|
||||
kwargs={
|
||||
"if_name": "flow",
|
||||
"flow_slug": self.device_flow.slug,
|
||||
},
|
||||
),
|
||||
@ -68,8 +69,9 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
self.assertEqual(
|
||||
res.url,
|
||||
reverse(
|
||||
"authentik_core:if-flow",
|
||||
"authentik_interfaces:if",
|
||||
kwargs={
|
||||
"if_name": "flow",
|
||||
"flow_slug": self.provider.authorization_flow.slug,
|
||||
},
|
||||
)
|
||||
|
@ -29,8 +29,9 @@ from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import redirect_to_default_interface
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.types import PolicyRequest
|
||||
from authentik.policies.views import PolicyAccessView, RequestValidationError
|
||||
@ -68,7 +69,7 @@ from authentik.stages.consent.stage import (
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
PLAN_CONTEXT_PARAMS = "goauthentik.io/providers/oauth2/params"
|
||||
SESSION_KEY_LAST_LOGIN_UID = "authentik/providers/oauth2/last_login_uid"
|
||||
|
||||
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSENT, PROMPT_LOGIN}
|
||||
@ -404,9 +405,9 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
plan.append_stage(in_memory_stage(OAuthFulfillmentStage))
|
||||
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
self.request.GET,
|
||||
return redirect_to_default_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=self.provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
|
@ -8,7 +8,7 @@ from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.providers.oauth2.models import DeviceToken
|
||||
|
||||
PLAN_CONTEXT_DEVICE = "device"
|
||||
PLAN_CONTEXT_DEVICE = "goauthentik.io/providers/oauth2/device"
|
||||
|
||||
|
||||
class OAuthDeviceCodeFinishChallenge(Challenge):
|
||||
|
@ -15,7 +15,8 @@ from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import redirect_to_default_interface
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.views.device_finish import (
|
||||
PLAN_CONTEXT_DEVICE,
|
||||
@ -26,7 +27,7 @@ from authentik.stages.consent.stage import (
|
||||
PLAN_CONTEXT_CONSENT_HEADER,
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS,
|
||||
)
|
||||
from authentik.tenants.models import Tenant
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
QS_KEY_CODE = "code" # nosec
|
||||
@ -77,9 +78,9 @@ def validate_code(code: int, request: HttpRequest) -> Optional[HttpResponse]:
|
||||
return None
|
||||
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
request.GET,
|
||||
return redirect_to_default_interface(
|
||||
request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=token.provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
@ -88,7 +89,7 @@ class DeviceEntryView(View):
|
||||
"""View used to initiate the device-code flow, url entered by endusers"""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
tenant: Tenant = request.tenant
|
||||
tenant = get_tenant(request)
|
||||
device_flow = tenant.flow_device_code
|
||||
if not device_flow:
|
||||
LOGGER.info("Tenant has no device code flow configured", tenant=tenant)
|
||||
@ -110,9 +111,9 @@ class DeviceEntryView(View):
|
||||
plan.append_stage(in_memory_stage(OAuthDeviceCodeStage))
|
||||
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
self.request.GET,
|
||||
return redirect_to_default_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=device_flow.slug,
|
||||
)
|
||||
|
||||
|
@ -9,6 +9,7 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from authentik.providers.oauth2.constants import SCOPE_GITHUB_ORG_READ, SCOPE_GITHUB_USER_EMAIL
|
||||
from authentik.providers.oauth2.models import RefreshToken
|
||||
from authentik.providers.oauth2.utils import protected_resource_view
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
@ -76,6 +77,7 @@ class GitHubUserTeamsView(View):
|
||||
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
|
||||
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||
user = token.user
|
||||
tenant = get_tenant(request)
|
||||
|
||||
orgs_response = []
|
||||
for org in user.ak_groups.all():
|
||||
@ -97,7 +99,7 @@ class GitHubUserTeamsView(View):
|
||||
"created_at": "",
|
||||
"updated_at": "",
|
||||
"organization": {
|
||||
"login": slugify(request.tenant.branding_title),
|
||||
"login": slugify(tenant.branding_title),
|
||||
"id": 1,
|
||||
"node_id": "",
|
||||
"url": "",
|
||||
@ -109,7 +111,7 @@ class GitHubUserTeamsView(View):
|
||||
"public_members_url": "",
|
||||
"avatar_url": "",
|
||||
"description": "",
|
||||
"name": request.tenant.branding_title,
|
||||
"name": tenant.branding_title,
|
||||
"company": "",
|
||||
"blog": "",
|
||||
"location": "",
|
||||
|
@ -29,9 +29,6 @@ from authentik.providers.oauth2.utils import cors_allow
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
PLAN_CONTEXT_SCOPES = "scopes"
|
||||
|
||||
|
||||
class ProviderInfoView(View):
|
||||
"""OpenID-compliant Provider Info"""
|
||||
|
@ -15,7 +15,8 @@ from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN, SESSION_KEY_POST
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import redirect_to_default_interface
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
@ -76,9 +77,9 @@ class SAMLSSOView(PolicyAccessView):
|
||||
raise Http404
|
||||
plan.append_stage(in_memory_stage(SAMLFlowFinalView))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
request.GET,
|
||||
return redirect_to_default_interface(
|
||||
request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=self.provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
|
@ -22,4 +22,4 @@ class UseTokenView(View):
|
||||
login(request, token.user, backend=BACKEND_INBUILT)
|
||||
token.delete()
|
||||
messages.warning(request, _("Used recovery-link to authenticate."))
|
||||
return redirect("authentik_core:if-user")
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
@ -65,6 +65,7 @@ INSTALLED_APPS = [
|
||||
"authentik.admin",
|
||||
"authentik.api",
|
||||
"authentik.crypto",
|
||||
"authentik.interfaces",
|
||||
"authentik.events",
|
||||
"authentik.flows",
|
||||
"authentik.lib",
|
||||
@ -492,3 +493,12 @@ if DEBUG:
|
||||
INSTALLED_APPS.append("authentik.core")
|
||||
|
||||
CONFIG.log("info", "Booting authentik", version=__version__)
|
||||
|
||||
# Attempt to load enterprise app, if available
|
||||
try:
|
||||
importlib.import_module("authentik.enterprise.apps")
|
||||
CONFIG.log("info", "Enabled authentik enterprise")
|
||||
INSTALLED_APPS.append("authentik.enterprise")
|
||||
_update_settings("authentik.enterprise.settings")
|
||||
except ImportError:
|
||||
pass
|
||||
|
@ -3,8 +3,6 @@
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
import authentik.lib.generators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
|
@ -32,7 +32,8 @@ from authentik.flows.planner import (
|
||||
)
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import redirect_to_default_interface
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.saml.utils.encoding import nice64
|
||||
from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat
|
||||
@ -40,11 +41,7 @@ from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
|
||||
from authentik.sources.saml.processors.metadata import MetadataProcessor
|
||||
from authentik.sources.saml.processors.request import RequestProcessor
|
||||
from authentik.sources.saml.processors.response import ResponseProcessor
|
||||
from authentik.stages.consent.stage import (
|
||||
PLAN_CONTEXT_CONSENT_HEADER,
|
||||
PLAN_CONTEXT_CONSENT_TITLE,
|
||||
ConsentStageView,
|
||||
)
|
||||
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_HEADER, ConsentStageView
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -76,7 +73,7 @@ class InitiateView(View):
|
||||
# Ensure redirect is carried through when user was trying to
|
||||
# authorize application
|
||||
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
|
||||
NEXT_ARG_NAME, "authentik_core:if-user"
|
||||
NEXT_ARG_NAME, "authentik_core:root-redirect"
|
||||
)
|
||||
kwargs.update(
|
||||
{
|
||||
@ -95,9 +92,9 @@ class InitiateView(View):
|
||||
for stage in stages_to_append:
|
||||
plan.append_stage(stage)
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
self.request.GET,
|
||||
return redirect_to_default_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=source.pre_authentication_flow.slug,
|
||||
)
|
||||
|
||||
@ -128,7 +125,6 @@ class InitiateView(View):
|
||||
injected_stages = []
|
||||
plan_kwargs = {
|
||||
PLAN_CONTEXT_TITLE: f"Redirecting to {source.name}...",
|
||||
PLAN_CONTEXT_CONSENT_TITLE: f"Redirecting to {source.name}...",
|
||||
PLAN_CONTEXT_ATTRS: {
|
||||
"SAMLRequest": saml_request,
|
||||
"RelayState": relay_state,
|
||||
|
@ -10,7 +10,6 @@ from duo_client.admin import Admin
|
||||
from duo_client.auth import Auth
|
||||
from rest_framework.serializers import BaseSerializer, Serializer
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||
from authentik.lib.models import SerializerModel
|
||||
|
@ -17,6 +17,7 @@ from authentik.flows.challenge import (
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
|
||||
from authentik.stages.authenticator_totp.settings import OTP_TOTP_ISSUER
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
SESSION_TOTP_DEVICE = "totp_device"
|
||||
|
||||
@ -57,7 +58,7 @@ class AuthenticatorTOTPStageView(ChallengeStageView):
|
||||
data={
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"config_url": device.config_url.replace(
|
||||
OTP_TOTP_ISSUER, quote(self.request.tenant.branding_title)
|
||||
OTP_TOTP_ISSUER, quote(get_tenant(self.request).branding_title)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
@ -33,7 +33,7 @@ from authentik.stages.authenticator_validate.models import AuthenticatorValidate
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
|
||||
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
|
||||
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TITLE
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -175,8 +175,6 @@ def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) ->
|
||||
pushinfo = {
|
||||
__("Domain"): stage_view.request.get_host(),
|
||||
}
|
||||
if PLAN_CONTEXT_CONSENT_TITLE in stage_view.executor.plan.context:
|
||||
pushinfo[__("Title")] = stage_view.executor.plan.context[PLAN_CONTEXT_CONSENT_TITLE]
|
||||
if SESSION_KEY_APPLICATION_PRE in stage_view.request.session:
|
||||
pushinfo[__("Application")] = stage_view.request.session.get(
|
||||
SESSION_KEY_APPLICATION_PRE, Application()
|
||||
@ -190,7 +188,7 @@ def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) ->
|
||||
type=__(
|
||||
"%(brand_name)s Login request"
|
||||
% {
|
||||
"brand_name": stage_view.request.tenant.branding_title,
|
||||
"brand_name": get_tenant(stage_view.request).branding_title,
|
||||
}
|
||||
),
|
||||
display_username=user.username,
|
||||
|
@ -14,7 +14,6 @@ from rest_framework.serializers import ValidationError
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import cleanse_dict, sanitize_dict
|
||||
from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUserInfoChallenge
|
||||
from authentik.flows.exceptions import FlowSkipStageException
|
||||
from authentik.flows.models import FlowDesignation, NotConfiguredAction, Stage
|
||||
@ -382,13 +381,9 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
self.logger.debug("Set user from user-less flow", user=webauthn_device.user)
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = webauthn_device.user
|
||||
self.executor.plan.context[PLAN_CONTEXT_METHOD] = "auth_webauthn_pwl"
|
||||
self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(
|
||||
sanitize_dict(
|
||||
{
|
||||
"device": webauthn_device,
|
||||
}
|
||||
)
|
||||
)
|
||||
self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS] = {
|
||||
"device": webauthn_device,
|
||||
}
|
||||
return self.set_valid_mfa_cookie(response.device)
|
||||
|
||||
def cleanup(self):
|
||||
|
@ -19,7 +19,7 @@ from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, Duo
|
||||
from authentik.stages.authenticator_validate.challenge import validate_challenge_duo
|
||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||
from authentik.stages.user_login.models import UserLoginStage
|
||||
from authentik.tenants.utils import get_tenant_for_request
|
||||
from authentik.tenants.utils import lookup_tenant_for_request
|
||||
|
||||
|
||||
class AuthenticatorValidateStageDuoTests(FlowTestCase):
|
||||
@ -36,7 +36,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase):
|
||||
middleware = SessionMiddleware(dummy_get_response)
|
||||
middleware.process_request(request)
|
||||
request.session.save()
|
||||
setattr(request, "tenant", get_tenant_for_request(request))
|
||||
setattr(request, "tenant", lookup_tenant_for_request(request))
|
||||
|
||||
stage = AuthenticatorDuoStage.objects.create(
|
||||
name=generate_id(),
|
||||
|
@ -29,6 +29,7 @@ from authentik.flows.challenge import (
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.stages.authenticator_webauthn.models import AuthenticateWebAuthnStage, WebAuthnDevice
|
||||
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge"
|
||||
|
||||
@ -92,7 +93,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||
|
||||
registration_options: PublicKeyCredentialCreationOptions = generate_registration_options(
|
||||
rp_id=get_rp_id(self.request),
|
||||
rp_name=self.request.tenant.branding_title,
|
||||
rp_name=get_tenant(self.request).branding_title,
|
||||
user_id=user.uid,
|
||||
user_name=user.username,
|
||||
user_display_name=user.name,
|
||||
|
@ -19,7 +19,6 @@ from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent
|
||||
|
||||
PLAN_CONTEXT_CONSENT = "consent"
|
||||
PLAN_CONTEXT_CONSENT_TITLE = "consent_title"
|
||||
PLAN_CONTEXT_CONSENT_HEADER = "consent_header"
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS = "consent_permissions"
|
||||
PLAN_CONTEXT_CONSENT_EXTRA_PERMISSIONS = "consent_additional_permissions"
|
||||
@ -59,8 +58,6 @@ class ConsentStageView(ChallengeStageView):
|
||||
),
|
||||
"token": token,
|
||||
}
|
||||
if PLAN_CONTEXT_CONSENT_TITLE in self.executor.plan.context:
|
||||
data["title"] = self.executor.plan.context[PLAN_CONTEXT_CONSENT_TITLE]
|
||||
if PLAN_CONTEXT_CONSENT_HEADER in self.executor.plan.context:
|
||||
data["header_text"] = self.executor.plan.context[PLAN_CONTEXT_CONSENT_HEADER]
|
||||
challenge = ConsentChallenge(data=data)
|
||||
|
@ -3,7 +3,6 @@ from datetime import timedelta
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
@ -16,6 +15,8 @@ from authentik.flows.models import FlowToken
|
||||
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import reverse_interface
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.tasks import send_mails
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
@ -47,9 +48,10 @@ class EmailStageView(ChallengeStageView):
|
||||
|
||||
def get_full_url(self, **kwargs) -> str:
|
||||
"""Get full URL to be used in template"""
|
||||
base_url = reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={"flow_slug": self.executor.flow.slug},
|
||||
base_url = reverse_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=self.executor.flow.slug,
|
||||
)
|
||||
relative_url = f"{base_url}?{urlencode(kwargs)}"
|
||||
return self.request.build_absolute_uri(relative_url)
|
||||
|
@ -7,6 +7,7 @@ from django.core.mail.backends.locmem import EmailBackend
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.markers import StageMarker
|
||||
@ -29,6 +30,7 @@ class TestEmailStageSending(APITestCase):
|
||||
)
|
||||
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
|
||||
|
||||
@apply_blueprint("system/interfaces.yaml")
|
||||
def test_pending_user(self):
|
||||
"""Test with pending user"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
@ -54,6 +56,7 @@ class TestEmailStageSending(APITestCase):
|
||||
self.assertEqual(event.context["to_email"], [self.user.email])
|
||||
self.assertEqual(event.context["from_email"], "system@authentik.local")
|
||||
|
||||
@apply_blueprint("system/interfaces.yaml")
|
||||
def test_send_error(self):
|
||||
"""Test error during sending (sending will be retried)"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
|
@ -7,6 +7,7 @@ from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.flows.markers import StageMarker
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
|
||||
@ -74,6 +75,7 @@ class TestEmailStage(FlowTestCase):
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@apply_blueprint("system/interfaces.yaml")
|
||||
@patch(
|
||||
"authentik.stages.email.models.EmailStage.backend_class",
|
||||
PropertyMock(return_value=EmailBackend),
|
||||
@ -123,6 +125,7 @@ class TestEmailStage(FlowTestCase):
|
||||
with self.settings(EMAIL_HOST=host):
|
||||
self.assertEqual(EmailStage(use_global_settings=True).backend.host, host)
|
||||
|
||||
@apply_blueprint("system/interfaces.yaml")
|
||||
def test_token(self):
|
||||
"""Test with token"""
|
||||
# Make sure token exists
|
||||
|
@ -26,8 +26,9 @@ from authentik.flows.models import FlowDesignation
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView
|
||||
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_GET
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import reverse_interface
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.lib.utils.urls import reverse_with_qs
|
||||
from authentik.sources.oauth.types.apple import AppleLoginChallenge
|
||||
from authentik.sources.plex.models import PlexAuthenticationChallenge
|
||||
from authentik.stages.identification.models import IdentificationStage
|
||||
@ -205,22 +206,25 @@ class IdentificationStageView(ChallengeStageView):
|
||||
get_qs = self.request.session.get(SESSION_KEY_GET, self.request.GET)
|
||||
# Check for related enrollment and recovery flow, add URL to view
|
||||
if current_stage.enrollment_flow:
|
||||
challenge.initial_data["enroll_url"] = reverse_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
challenge.initial_data["enroll_url"] = reverse_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
query=get_qs,
|
||||
kwargs={"flow_slug": current_stage.enrollment_flow.slug},
|
||||
flow_slug=current_stage.enrollment_flow.slug,
|
||||
)
|
||||
if current_stage.recovery_flow:
|
||||
challenge.initial_data["recovery_url"] = reverse_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
challenge.initial_data["recovery_url"] = reverse_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
query=get_qs,
|
||||
kwargs={"flow_slug": current_stage.recovery_flow.slug},
|
||||
flow_slug=current_stage.recovery_flow.slug,
|
||||
)
|
||||
if current_stage.passwordless_flow:
|
||||
challenge.initial_data["passwordless_url"] = reverse_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
challenge.initial_data["passwordless_url"] = reverse_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
query=get_qs,
|
||||
kwargs={"flow_slug": current_stage.passwordless_flow.slug},
|
||||
flow_slug=current_stage.passwordless_flow.slug,
|
||||
)
|
||||
|
||||
# Check all enabled source, add them if they have a UI Login button.
|
||||
|
@ -5,6 +5,8 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.flows.challenge import ChallengeTypes
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.tests import reverse_interface
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
from authentik.stages.password import BACKEND_INBUILT
|
||||
@ -166,9 +168,9 @@ class TestIdentificationStage(FlowTestCase):
|
||||
component="ak-stage-identification",
|
||||
user_fields=["email"],
|
||||
password_fields=False,
|
||||
enroll_url=reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={"flow_slug": flow.slug},
|
||||
enroll_url=reverse_interface(
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=flow.slug,
|
||||
),
|
||||
show_source_labels=False,
|
||||
primary_action="Log in",
|
||||
@ -204,9 +206,9 @@ class TestIdentificationStage(FlowTestCase):
|
||||
component="ak-stage-identification",
|
||||
user_fields=["email"],
|
||||
password_fields=False,
|
||||
recovery_url=reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={"flow_slug": flow.slug},
|
||||
recovery_url=reverse_interface(
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=flow.slug,
|
||||
),
|
||||
show_source_labels=False,
|
||||
primary_action="Log in",
|
||||
|
@ -4,7 +4,7 @@ from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT
|
||||
from authentik.stages.password import BACKEND_APP_PASSWORD
|
||||
|
||||
|
||||
def update_default_backends(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
|
@ -1,7 +1,6 @@
|
||||
# Generated by Django 3.2.6 on 2021-08-23 14:34
|
||||
import django.contrib.postgres.fields
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db import migrations
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from authentik.stages.password import BACKEND_INBUILT
|
||||
|
@ -5,7 +5,6 @@ from django.contrib.auth import _clean_credentials
|
||||
from django.contrib.auth.backends import BaseBackend
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.exceptions import ErrorDetail, ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
@ -23,6 +22,8 @@ from authentik.flows.challenge import (
|
||||
from authentik.flows.models import Flow, FlowDesignation, Stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
from authentik.interfaces.views import reverse_interface
|
||||
from authentik.lib.utils.reflection import path_to_class
|
||||
from authentik.stages.password.models import PasswordStage
|
||||
|
||||
@ -95,11 +96,12 @@ class PasswordStageView(ChallengeStageView):
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
}
|
||||
)
|
||||
recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY)
|
||||
if recovery_flow.exists():
|
||||
recover_url = reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={"flow_slug": recovery_flow.first().slug},
|
||||
recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY).first()
|
||||
if recovery_flow:
|
||||
recover_url = reverse_interface(
|
||||
self.request,
|
||||
InterfaceType.FLOW,
|
||||
flow_slug=recovery_flow.slug,
|
||||
)
|
||||
challenge.initial_data["recovery_url"] = self.request.build_absolute_uri(recover_url)
|
||||
return challenge
|
||||
|
@ -57,10 +57,12 @@ class PromptSerializer(ModelSerializer):
|
||||
"type",
|
||||
"required",
|
||||
"placeholder",
|
||||
"initial_value",
|
||||
"order",
|
||||
"promptstage_set",
|
||||
"sub_text",
|
||||
"placeholder_expression",
|
||||
"initial_value_expression",
|
||||
]
|
||||
|
||||
|
||||
|
@ -0,0 +1,53 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-24 17:32
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def migrate_placeholder_expressions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from authentik.stages.prompt.models import CHOICE_FIELDS
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
Prompt = apps.get_model("authentik_stages_prompt", "prompt")
|
||||
|
||||
for prompt in Prompt.objects.using(db_alias).all():
|
||||
if not prompt.placeholder_expression or prompt.type in CHOICE_FIELDS:
|
||||
continue
|
||||
|
||||
prompt.initial_value = prompt.placeholder
|
||||
prompt.initial_value_expression = True
|
||||
prompt.placeholder = ""
|
||||
prompt.placeholder_expression = False
|
||||
prompt.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_stages_prompt", "0010_alter_prompt_placeholder_alter_prompt_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="prompt",
|
||||
name="initial_value",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Optionally pre-fill the input with an initial value. When creating a fixed choice field, enable interpreting as expression and return a list to return multiple default choices.",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="prompt",
|
||||
name="initial_value_expression",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="prompt",
|
||||
name="placeholder",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Optionally provide a short hint that describes the expected input value. When creating a fixed choice field, enable interpreting as expression and return a list to return multiple choices.",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(code=migrate_placeholder_expressions),
|
||||
]
|
@ -29,6 +29,8 @@ from authentik.flows.models import Stage
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.policies.models import Policy
|
||||
|
||||
CHOICES_CONTEXT_SUFFIX = "__choices"
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@ -119,15 +121,25 @@ class Prompt(SerializerModel):
|
||||
placeholder = models.TextField(
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"When creating a Radio Button Group or Dropdown, enable interpreting as "
|
||||
"Optionally provide a short hint that describes the expected input value. "
|
||||
"When creating a fixed choice field, enable interpreting as "
|
||||
"expression and return a list to return multiple choices."
|
||||
),
|
||||
)
|
||||
initial_value = models.TextField(
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Optionally pre-fill the input with an initial value. "
|
||||
"When creating a fixed choice field, enable interpreting as "
|
||||
"expression and return a list to return multiple default choices."
|
||||
),
|
||||
)
|
||||
sub_text = models.TextField(blank=True, default="")
|
||||
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
placeholder_expression = models.BooleanField(default=False)
|
||||
initial_value_expression = models.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def serializer(self) -> Type[BaseSerializer]:
|
||||
@ -148,8 +160,8 @@ class Prompt(SerializerModel):
|
||||
|
||||
raw_choices = self.placeholder
|
||||
|
||||
if self.field_key in prompt_context:
|
||||
raw_choices = prompt_context[self.field_key]
|
||||
if self.field_key + CHOICES_CONTEXT_SUFFIX in prompt_context:
|
||||
raw_choices = prompt_context[self.field_key + CHOICES_CONTEXT_SUFFIX]
|
||||
elif self.placeholder_expression:
|
||||
evaluator = PropertyMappingEvaluator(
|
||||
self, user, request, prompt_context=prompt_context, dry_run=dry_run
|
||||
@ -184,16 +196,9 @@ class Prompt(SerializerModel):
|
||||
) -> str:
|
||||
"""Get fully interpolated placeholder"""
|
||||
if self.type in CHOICE_FIELDS:
|
||||
# Make sure to return a valid choice as placeholder
|
||||
choices = self.get_choices(prompt_context, user, request, dry_run=dry_run)
|
||||
if not choices:
|
||||
return ""
|
||||
return choices[0]
|
||||
|
||||
if self.field_key in prompt_context:
|
||||
# We don't want to parse this as an expression since a user will
|
||||
# be able to control the input
|
||||
return prompt_context[self.field_key]
|
||||
# Choice fields use the placeholder to define all valid choices.
|
||||
# Therefore their actual placeholder is always blank
|
||||
return ""
|
||||
|
||||
if self.placeholder_expression:
|
||||
evaluator = PropertyMappingEvaluator(
|
||||
@ -211,6 +216,47 @@ class Prompt(SerializerModel):
|
||||
raise wrapped from exc
|
||||
return self.placeholder
|
||||
|
||||
def get_initial_value(
|
||||
self,
|
||||
prompt_context: dict,
|
||||
user: User,
|
||||
request: HttpRequest,
|
||||
dry_run: Optional[bool] = False,
|
||||
) -> str:
|
||||
"""Get fully interpolated initial value"""
|
||||
|
||||
if self.field_key in prompt_context:
|
||||
# We don't want to parse this as an expression since a user will
|
||||
# be able to control the input
|
||||
value = prompt_context[self.field_key]
|
||||
elif self.initial_value_expression:
|
||||
evaluator = PropertyMappingEvaluator(
|
||||
self, user, request, prompt_context=prompt_context, dry_run=dry_run
|
||||
)
|
||||
try:
|
||||
value = evaluator.evaluate(self.initial_value)
|
||||
except Exception as exc: # pylint:disable=broad-except
|
||||
wrapped = PropertyMappingExpressionException(str(exc))
|
||||
LOGGER.warning(
|
||||
"failed to evaluate prompt initial value",
|
||||
exc=wrapped,
|
||||
)
|
||||
if dry_run:
|
||||
raise wrapped from exc
|
||||
value = self.initial_value
|
||||
else:
|
||||
value = self.initial_value
|
||||
|
||||
if self.type in CHOICE_FIELDS:
|
||||
# Ensure returned value is a valid choice
|
||||
choices = self.get_choices(prompt_context, user, request)
|
||||
if not choices:
|
||||
return ""
|
||||
if value not in choices:
|
||||
return choices[0]
|
||||
|
||||
return value
|
||||
|
||||
def field(self, default: Optional[Any], choices: Optional[list[Any]] = None) -> CharField:
|
||||
"""Get field type for Challenge and response. Choices are only valid for CHOICE_FIELDS."""
|
||||
field_class = CharField
|
||||
|
@ -38,6 +38,7 @@ class StagePromptSerializer(PassiveSerializer):
|
||||
type = ChoiceField(choices=FieldTypes.choices)
|
||||
required = BooleanField()
|
||||
placeholder = CharField(allow_blank=True)
|
||||
initial_value = CharField(allow_blank=True)
|
||||
order = IntegerField()
|
||||
sub_text = CharField(allow_blank=True)
|
||||
choices = ListField(child=CharField(allow_blank=True), allow_empty=True, allow_null=True)
|
||||
@ -76,7 +77,7 @@ class PromptChallengeResponse(ChallengeResponse):
|
||||
choices = field.get_choices(
|
||||
plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request
|
||||
)
|
||||
current = field.get_placeholder(
|
||||
current = field.get_initial_value(
|
||||
plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request
|
||||
)
|
||||
self.fields[field.field_key] = field.field(current, choices)
|
||||
@ -197,8 +198,9 @@ class PromptStageView(ChallengeStageView):
|
||||
serializers = []
|
||||
for field in fields:
|
||||
data = StagePromptSerializer(field).data
|
||||
# Ensure all choices and placeholders are str, as otherwise further in
|
||||
# we can fail serializer validation if we return some types such as bool
|
||||
# Ensure all choices, placeholders and initial values are str, as
|
||||
# otherwise further in we can fail serializer validation if we return
|
||||
# some types such as bool
|
||||
choices = field.get_choices(context, self.get_pending_user(), self.request, dry_run)
|
||||
if choices:
|
||||
data["choices"] = [str(choice) for choice in choices]
|
||||
@ -207,6 +209,9 @@ class PromptStageView(ChallengeStageView):
|
||||
data["placeholder"] = str(
|
||||
field.get_placeholder(context, self.get_pending_user(), self.request, dry_run)
|
||||
)
|
||||
data["initial_value"] = str(
|
||||
field.get_initial_value(context, self.get_pending_user(), self.request, dry_run)
|
||||
)
|
||||
serializers.append(data)
|
||||
return serializers
|
||||
|
||||
|
@ -22,6 +22,7 @@ from authentik.stages.prompt.stage import (
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
class TestPromptStage(FlowTestCase):
|
||||
"""Prompt tests"""
|
||||
|
||||
@ -37,6 +38,7 @@ class TestPromptStage(FlowTestCase):
|
||||
type=FieldTypes.USERNAME,
|
||||
required=True,
|
||||
placeholder="USERNAME_PLACEHOLDER",
|
||||
initial_value="akuser",
|
||||
)
|
||||
text_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
@ -45,6 +47,7 @@ class TestPromptStage(FlowTestCase):
|
||||
type=FieldTypes.TEXT,
|
||||
required=True,
|
||||
placeholder="TEXT_PLACEHOLDER",
|
||||
initial_value="some text",
|
||||
)
|
||||
text_area_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
@ -53,6 +56,7 @@ class TestPromptStage(FlowTestCase):
|
||||
type=FieldTypes.TEXT_AREA,
|
||||
required=True,
|
||||
placeholder="TEXT_AREA_PLACEHOLDER",
|
||||
initial_value="some text",
|
||||
)
|
||||
email_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
@ -61,6 +65,7 @@ class TestPromptStage(FlowTestCase):
|
||||
type=FieldTypes.EMAIL,
|
||||
required=True,
|
||||
placeholder="EMAIL_PLACEHOLDER",
|
||||
initial_value="email@example.com",
|
||||
)
|
||||
password_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
@ -69,6 +74,7 @@ class TestPromptStage(FlowTestCase):
|
||||
type=FieldTypes.PASSWORD,
|
||||
required=True,
|
||||
placeholder="PASSWORD_PLACEHOLDER",
|
||||
initial_value="supersecurepassword",
|
||||
)
|
||||
password2_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
@ -77,6 +83,7 @@ class TestPromptStage(FlowTestCase):
|
||||
type=FieldTypes.PASSWORD,
|
||||
required=True,
|
||||
placeholder="PASSWORD_PLACEHOLDER",
|
||||
initial_value="supersecurepassword",
|
||||
)
|
||||
number_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
@ -85,6 +92,7 @@ class TestPromptStage(FlowTestCase):
|
||||
type=FieldTypes.NUMBER,
|
||||
required=True,
|
||||
placeholder="NUMBER_PLACEHOLDER",
|
||||
initial_value="42",
|
||||
)
|
||||
hidden_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
@ -92,6 +100,7 @@ class TestPromptStage(FlowTestCase):
|
||||
type=FieldTypes.HIDDEN,
|
||||
required=True,
|
||||
placeholder="HIDDEN_PLACEHOLDER",
|
||||
initial_value="something idk",
|
||||
)
|
||||
static_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
@ -99,6 +108,7 @@ class TestPromptStage(FlowTestCase):
|
||||
type=FieldTypes.STATIC,
|
||||
required=True,
|
||||
placeholder="static",
|
||||
initial_value="something idk",
|
||||
)
|
||||
radio_button_group = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
@ -106,6 +116,7 @@ class TestPromptStage(FlowTestCase):
|
||||
type=FieldTypes.RADIO_BUTTON_GROUP,
|
||||
required=True,
|
||||
placeholder="test",
|
||||
initial_value="test",
|
||||
)
|
||||
dropdown = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
@ -137,9 +148,9 @@ class TestPromptStage(FlowTestCase):
|
||||
password_prompt.field_key: "test",
|
||||
password2_prompt.field_key: "test",
|
||||
number_prompt.field_key: 3,
|
||||
hidden_prompt.field_key: hidden_prompt.placeholder,
|
||||
static_prompt.field_key: static_prompt.placeholder,
|
||||
radio_button_group.field_key: radio_button_group.placeholder,
|
||||
hidden_prompt.field_key: hidden_prompt.initial_value,
|
||||
static_prompt.field_key: static_prompt.initial_value,
|
||||
radio_button_group.field_key: radio_button_group.initial_value,
|
||||
dropdown.field_key: "",
|
||||
}
|
||||
|
||||
@ -335,106 +346,176 @@ class TestPromptStage(FlowTestCase):
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
context["text_prompt_expression"] = generate_id()
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")),
|
||||
context["text_prompt_expression"],
|
||||
|
||||
def test_prompt_placeholder_does_not_take_value_from_context(self):
|
||||
"""Test placeholder does not automatically take value from context"""
|
||||
context = {
|
||||
"foo": generate_id(),
|
||||
}
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="text_prompt_expression",
|
||||
label="TEXT_LABEL",
|
||||
type=FieldTypes.TEXT,
|
||||
placeholder="return prompt_context['foo']",
|
||||
placeholder_expression=True,
|
||||
)
|
||||
self.assertNotEqual(
|
||||
context["text_prompt_expression"] = generate_id()
|
||||
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
|
||||
def test_choice_prompts_placeholders(self):
|
||||
"""Test placeholders and expression of choice fields"""
|
||||
context = {"foo": generate_id()}
|
||||
def test_prompt_initial_value(self):
|
||||
"""Test initial_value and expression"""
|
||||
context = {
|
||||
"foo": generate_id(),
|
||||
}
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="text_prompt_expression",
|
||||
label="TEXT_LABEL",
|
||||
type=FieldTypes.TEXT,
|
||||
initial_value="return prompt_context['foo']",
|
||||
initial_value_expression=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_initial_value(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
context["text_prompt_expression"] = generate_id()
|
||||
self.assertEqual(
|
||||
prompt.get_initial_value(context, self.user, self.factory.get("/")),
|
||||
context["text_prompt_expression"],
|
||||
)
|
||||
self.assertNotEqual(
|
||||
prompt.get_initial_value(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
|
||||
def test_choice_prompts_placeholder_and_initial_value_no_choices(self):
|
||||
"""Test placeholder and initial value of choice fields with 0 choices"""
|
||||
context = {}
|
||||
|
||||
# No choices - unusable (in the sense it creates an unsubmittable form)
|
||||
# but valid behaviour
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="fixed_choice_prompt_expression",
|
||||
label="LABEL",
|
||||
type=FieldTypes.RADIO_BUTTON_GROUP,
|
||||
placeholder="return []",
|
||||
placeholder_expression=True,
|
||||
initial_value="Invalid choice",
|
||||
initial_value_expression=False,
|
||||
)
|
||||
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
|
||||
self.assertEqual(prompt.get_initial_value(context, self.user, self.factory.get("/")), "")
|
||||
self.assertEqual(prompt.get_choices(context, self.user, self.factory.get("/")), tuple())
|
||||
context["fixed_choice_prompt_expression"] = generate_id()
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")),
|
||||
context["fixed_choice_prompt_expression"],
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
(context["fixed_choice_prompt_expression"],),
|
||||
)
|
||||
self.assertNotEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
|
||||
self.assertNotEqual(prompt.get_choices(context, self.user, self.factory.get("/")), tuple())
|
||||
|
||||
del context["fixed_choice_prompt_expression"]
|
||||
def test_choice_prompts_placeholder_and_initial_value_single_choice(self):
|
||||
"""Test placeholder and initial value of choice fields with 1 choice"""
|
||||
context = {"foo": generate_id()}
|
||||
|
||||
# Single choice
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="fixed_choice_prompt_expression",
|
||||
label="LABEL",
|
||||
type=FieldTypes.RADIO_BUTTON_GROUP,
|
||||
placeholder="return prompt_context['foo']",
|
||||
placeholder_expression=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],)
|
||||
)
|
||||
context["fixed_choice_prompt_expression"] = generate_id()
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")),
|
||||
context["fixed_choice_prompt_expression"],
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
(context["fixed_choice_prompt_expression"],),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
self.assertNotEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],)
|
||||
)
|
||||
|
||||
del context["fixed_choice_prompt_expression"]
|
||||
|
||||
# Multi choice
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="fixed_choice_prompt_expression",
|
||||
label="LABEL",
|
||||
type=FieldTypes.DROPDOWN,
|
||||
placeholder="return [prompt_context['foo'], True, 'text']",
|
||||
placeholder=context["foo"],
|
||||
placeholder_expression=False,
|
||||
initial_value=context["foo"],
|
||||
initial_value_expression=False,
|
||||
)
|
||||
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
|
||||
self.assertEqual(
|
||||
prompt.get_initial_value(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],)
|
||||
)
|
||||
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="fixed_choice_prompt_expression",
|
||||
label="LABEL",
|
||||
type=FieldTypes.DROPDOWN,
|
||||
placeholder="return [prompt_context['foo']]",
|
||||
placeholder_expression=True,
|
||||
initial_value="return prompt_context['foo']",
|
||||
initial_value_expression=True,
|
||||
)
|
||||
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
|
||||
self.assertEqual(
|
||||
prompt.get_initial_value(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],)
|
||||
)
|
||||
|
||||
def test_choice_prompts_placeholder_and_initial_value_multiple_choices(self):
|
||||
"""Test placeholder and initial value of choice fields with multiple choices"""
|
||||
context = {}
|
||||
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="fixed_choice_prompt_expression",
|
||||
label="LABEL",
|
||||
type=FieldTypes.RADIO_BUTTON_GROUP,
|
||||
placeholder="return ['test', True, 42]",
|
||||
placeholder_expression=True,
|
||||
)
|
||||
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
prompt.get_initial_value(context, self.user, self.factory.get("/")), "test"
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")), ("test", True, 42)
|
||||
)
|
||||
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="fixed_choice_prompt_expression",
|
||||
label="LABEL",
|
||||
type=FieldTypes.RADIO_BUTTON_GROUP,
|
||||
placeholder="return ['test', True, 42]",
|
||||
placeholder_expression=True,
|
||||
initial_value="return True",
|
||||
initial_value_expression=True,
|
||||
)
|
||||
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
|
||||
self.assertEqual(prompt.get_initial_value(context, self.user, self.factory.get("/")), True)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")), ("test", True, 42)
|
||||
)
|
||||
|
||||
def test_choice_prompts_placeholder_and_initial_value_from_context(self):
|
||||
"""Test placeholder and initial value of choice fields with values from context"""
|
||||
rand_value = generate_id()
|
||||
context = {
|
||||
"fixed_choice_prompt_expression": rand_value,
|
||||
"fixed_choice_prompt_expression__choices": ["test", 42, rand_value],
|
||||
}
|
||||
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="fixed_choice_prompt_expression",
|
||||
label="LABEL",
|
||||
type=FieldTypes.RADIO_BUTTON_GROUP,
|
||||
)
|
||||
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
|
||||
self.assertEqual(
|
||||
prompt.get_initial_value(context, self.user, self.factory.get("/")), rand_value
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")), ("test", 42, rand_value)
|
||||
)
|
||||
|
||||
def test_initial_value_not_valid_choice(self):
|
||||
"""Test initial_value not a valid choice"""
|
||||
context = {}
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="choice_prompt",
|
||||
label="TEXT_LABEL",
|
||||
type=FieldTypes.DROPDOWN,
|
||||
placeholder="choice",
|
||||
initial_value="another_choice",
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
(context["foo"], True, "text"),
|
||||
)
|
||||
context["fixed_choice_prompt_expression"] = tuple(["text", generate_id(), 2])
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")),
|
||||
"text",
|
||||
("choice",),
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
context["fixed_choice_prompt_expression"],
|
||||
)
|
||||
self.assertNotEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
self.assertNotEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
(context["foo"], True, "text"),
|
||||
prompt.get_initial_value(context, self.user, self.factory.get("/")),
|
||||
"choice",
|
||||
)
|
||||
|
||||
def test_choices_are_none_for_non_choice_fields(self):
|
||||
@ -505,6 +586,8 @@ class TestPromptStage(FlowTestCase):
|
||||
"type": FieldTypes.TEXT,
|
||||
"placeholder": 'return "Hello world"',
|
||||
"placeholder_expression": True,
|
||||
"initial_value": 'return "Hello Hello world"',
|
||||
"initial_value_expression": True,
|
||||
"sub_text": "test",
|
||||
"order": 123,
|
||||
},
|
||||
@ -522,6 +605,7 @@ class TestPromptStage(FlowTestCase):
|
||||
"type": "text",
|
||||
"required": True,
|
||||
"placeholder": "Hello world",
|
||||
"initial_value": "Hello Hello world",
|
||||
"order": 123,
|
||||
"sub_text": "test",
|
||||
"choices": None,
|
||||
|
@ -18,6 +18,7 @@ from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.tenants.models import Tenant
|
||||
from authentik.tenants.utils import get_tenant
|
||||
|
||||
|
||||
class FooterLinkSerializer(PassiveSerializer):
|
||||
@ -54,6 +55,9 @@ class TenantSerializer(ModelSerializer):
|
||||
"flow_unenrollment",
|
||||
"flow_user_settings",
|
||||
"flow_device_code",
|
||||
"interface_admin",
|
||||
"interface_user",
|
||||
"interface_flow",
|
||||
"event_retention",
|
||||
"web_certificate",
|
||||
"attributes",
|
||||
@ -120,6 +124,9 @@ class TenantViewSet(UsedByMixin, ModelViewSet):
|
||||
"flow_unenrollment",
|
||||
"flow_user_settings",
|
||||
"flow_device_code",
|
||||
"interface_admin",
|
||||
"interface_user",
|
||||
"interface_flow",
|
||||
"event_retention",
|
||||
"web_certificate",
|
||||
]
|
||||
@ -133,5 +140,4 @@ class TenantViewSet(UsedByMixin, ModelViewSet):
|
||||
@action(methods=["GET"], detail=False, permission_classes=[AllowAny])
|
||||
def current(self, request: Request) -> Response:
|
||||
"""Get current tenant"""
|
||||
tenant: Tenant = request._request.tenant
|
||||
return Response(CurrentTenantSerializer(tenant).data)
|
||||
return Response(CurrentTenantSerializer(get_tenant(request)).data)
|
||||
|
@ -6,7 +6,7 @@ from django.http.response import HttpResponse
|
||||
from django.utils.translation import activate
|
||||
from sentry_sdk.api import set_tag
|
||||
|
||||
from authentik.tenants.utils import get_tenant_for_request
|
||||
from authentik.tenants.utils import lookup_tenant_for_request
|
||||
|
||||
|
||||
class TenantMiddleware:
|
||||
@ -19,7 +19,7 @@ class TenantMiddleware:
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
if not hasattr(request, "tenant"):
|
||||
tenant = get_tenant_for_request(request)
|
||||
tenant = lookup_tenant_for_request(request)
|
||||
setattr(request, "tenant", tenant)
|
||||
set_tag("authentik.tenant_uuid", tenant.tenant_uuid.hex)
|
||||
set_tag("authentik.tenant_domain", tenant.domain)
|
||||
|
@ -3,9 +3,7 @@
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
import authentik.lib.utils.time
|
||||
|
||||
|
@ -0,0 +1,78 @@
|
||||
# Generated by Django 4.1.7 on 2023-02-21 14:18
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def migrate_set_default(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
Tenant = apps.get_model("authentik_tenants", "tenant")
|
||||
Interface = apps.get_model("authentik_interfaces", "Interface")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
from authentik.blueprints.models import BlueprintInstance
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.blueprints.v1.tasks import blueprints_discovery
|
||||
from authentik.interfaces.models import InterfaceType
|
||||
|
||||
# If we don't have any tenants yet, we don't need wait for the default interface blueprint
|
||||
if not Tenant.objects.using(db_alias).exists():
|
||||
return
|
||||
|
||||
interface_blueprint = BlueprintInstance.objects.filter(path="system/interfaces.yaml").first()
|
||||
if not interface_blueprint:
|
||||
blueprints_discovery.delay().get()
|
||||
interface_blueprint = BlueprintInstance.objects.filter(
|
||||
path="system/interfaces.yaml"
|
||||
).first()
|
||||
if not interface_blueprint:
|
||||
raise ValueError("Failed to apply system/interfaces.yaml blueprint")
|
||||
Importer(interface_blueprint.retrieve()).apply()
|
||||
|
||||
for tenant in Tenant.objects.using(db_alias).all():
|
||||
tenant.interface_admin = Interface.objects.filter(type=InterfaceType.ADMIN).first()
|
||||
tenant.interface_user = Interface.objects.filter(type=InterfaceType.USER).first()
|
||||
tenant.interface_flow = Interface.objects.filter(type=InterfaceType.FLOW).first()
|
||||
tenant.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_interfaces", "0001_initial"),
|
||||
("authentik_tenants", "0004_tenant_flow_device_code"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tenant",
|
||||
name="interface_admin",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="tenant_admin",
|
||||
to="authentik_interfaces.interface",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tenant",
|
||||
name="interface_flow",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="tenant_flow",
|
||||
to="authentik_interfaces.interface",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tenant",
|
||||
name="interface_user",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="tenant_user",
|
||||
to="authentik_interfaces.interface",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_set_default),
|
||||
]
|
@ -7,7 +7,6 @@ from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
@ -33,22 +32,59 @@ class Tenant(SerializerModel):
|
||||
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
|
||||
|
||||
flow_authentication = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_authentication"
|
||||
"authentik_flows.Flow",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="tenant_authentication",
|
||||
)
|
||||
flow_invalidation = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_invalidation"
|
||||
"authentik_flows.Flow",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="tenant_invalidation",
|
||||
)
|
||||
flow_recovery = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_recovery"
|
||||
"authentik_flows.Flow", null=True, on_delete=models.SET_NULL, related_name="tenant_recovery"
|
||||
)
|
||||
flow_unenrollment = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_unenrollment"
|
||||
"authentik_flows.Flow",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="tenant_unenrollment",
|
||||
)
|
||||
flow_user_settings = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_user_settings"
|
||||
"authentik_flows.Flow",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="tenant_user_settings",
|
||||
)
|
||||
flow_device_code = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_device_code"
|
||||
"authentik_flows.Flow",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="tenant_device_code",
|
||||
)
|
||||
|
||||
interface_flow = models.ForeignKey(
|
||||
"authentik_interfaces.Interface",
|
||||
default=None,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="tenant_flow",
|
||||
)
|
||||
interface_user = models.ForeignKey(
|
||||
"authentik_interfaces.Interface",
|
||||
default=None,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="tenant_user",
|
||||
)
|
||||
interface_admin = models.ForeignKey(
|
||||
"authentik_interfaces.Interface",
|
||||
default=None,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="tenant_admin",
|
||||
)
|
||||
|
||||
event_retention = models.TextField(
|
||||
|
@ -75,7 +75,7 @@ class TestTenants(APITestCase):
|
||||
)
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
request.tenant = tenant
|
||||
setattr(request, "tenant", tenant)
|
||||
event = Event.new(action=EventAction.SYSTEM_EXCEPTION, message="test").from_http(request)
|
||||
self.assertEqual(event.expires.day, (event.created + timedelta_from_string("weeks=3")).day)
|
||||
self.assertEqual(
|
||||
|
@ -4,17 +4,41 @@ from typing import Any
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Value as V
|
||||
from django.http.request import HttpRequest
|
||||
from rest_framework.request import Request
|
||||
from sentry_sdk.hub import Hub
|
||||
|
||||
from authentik import get_full_version
|
||||
from authentik.interfaces.models import Interface, InterfaceType
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
_q_default = Q(default=True)
|
||||
DEFAULT_TENANT = Tenant(domain="fallback")
|
||||
|
||||
|
||||
def get_tenant_for_request(request: HttpRequest) -> Tenant:
|
||||
def get_fallback_tenant():
|
||||
"""Get fallback tenant"""
|
||||
|
||||
fallback_interface = Interface(
|
||||
url_name="fallback",
|
||||
type=InterfaceType.FLOW,
|
||||
template="Fallback interface",
|
||||
)
|
||||
return Tenant(
|
||||
domain="fallback",
|
||||
interface_flow=fallback_interface,
|
||||
interface_user=fallback_interface,
|
||||
interface_admin=fallback_interface,
|
||||
)
|
||||
|
||||
|
||||
def get_tenant(request: HttpRequest | Request) -> "Tenant":
|
||||
"""Get the request's tenant, falls back to a fallback tenant object"""
|
||||
if isinstance(request, Request):
|
||||
request = request._request
|
||||
return getattr(request, "tenant", get_fallback_tenant())
|
||||
|
||||
|
||||
def lookup_tenant_for_request(request: HttpRequest) -> "Tenant":
|
||||
"""Get tenant object for current request"""
|
||||
db_tenants = (
|
||||
Tenant.objects.annotate(host_domain=V(request.get_host()))
|
||||
@ -23,13 +47,13 @@ def get_tenant_for_request(request: HttpRequest) -> Tenant:
|
||||
)
|
||||
tenants = list(db_tenants.all())
|
||||
if len(tenants) < 1:
|
||||
return DEFAULT_TENANT
|
||||
return get_fallback_tenant()
|
||||
return tenants[0]
|
||||
|
||||
|
||||
def context_processor(request: HttpRequest) -> dict[str, Any]:
|
||||
"""Context Processor that injects tenant object into every template"""
|
||||
tenant = getattr(request, "tenant", DEFAULT_TENANT)
|
||||
tenant = getattr(request, "tenant", get_fallback_tenant())
|
||||
trace = ""
|
||||
span = Hub.current.scope.span
|
||||
if span:
|
||||
|
@ -2,6 +2,11 @@ metadata:
|
||||
name: Default - Tenant
|
||||
version: 1
|
||||
entries:
|
||||
- model: authentik_blueprints.metaapplyblueprint
|
||||
attrs:
|
||||
identifiers:
|
||||
name: System - Interfaces
|
||||
required: false
|
||||
- model: authentik_blueprints.metaapplyblueprint
|
||||
attrs:
|
||||
identifiers:
|
||||
@ -21,6 +26,9 @@ entries:
|
||||
flow_authentication: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
|
||||
flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
|
||||
flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]]
|
||||
interface_admin: !Find [authentik_interfaces.Interface, [type, admin]]
|
||||
interface_user: !Find [authentik_interfaces.Interface, [type, user]]
|
||||
interface_flow: !Find [authentik_interfaces.Interface, [type, flow]]
|
||||
identifiers:
|
||||
domain: authentik-default
|
||||
default: True
|
||||
|
@ -13,12 +13,14 @@ entries:
|
||||
id: flow
|
||||
- attrs:
|
||||
order: 200
|
||||
placeholder: |
|
||||
placeholder: Username
|
||||
placeholder_expression: false
|
||||
initial_value: |
|
||||
try:
|
||||
return user.username
|
||||
except:
|
||||
return ''
|
||||
placeholder_expression: true
|
||||
initial_value_expression: true
|
||||
required: true
|
||||
type: text
|
||||
field_key: username
|
||||
@ -29,12 +31,14 @@ entries:
|
||||
model: authentik_stages_prompt.prompt
|
||||
- attrs:
|
||||
order: 201
|
||||
placeholder: |
|
||||
placeholder: Name
|
||||
placeholder_expression: false
|
||||
initial_value: |
|
||||
try:
|
||||
return user.name
|
||||
except:
|
||||
return ''
|
||||
placeholder_expression: true
|
||||
initial_value_expression: true
|
||||
required: true
|
||||
type: text
|
||||
field_key: name
|
||||
@ -45,12 +49,14 @@ entries:
|
||||
model: authentik_stages_prompt.prompt
|
||||
- attrs:
|
||||
order: 202
|
||||
placeholder: |
|
||||
placeholder: Email
|
||||
placeholder_expression: false
|
||||
initial_value: |
|
||||
try:
|
||||
return user.email
|
||||
except:
|
||||
return ''
|
||||
placeholder_expression: true
|
||||
initial_value_expression: true
|
||||
required: true
|
||||
type: email
|
||||
field_key: email
|
||||
@ -61,12 +67,14 @@ entries:
|
||||
model: authentik_stages_prompt.prompt
|
||||
- attrs:
|
||||
order: 203
|
||||
placeholder: |
|
||||
placeholder: Locale
|
||||
placeholder_expression: false
|
||||
initial_value: |
|
||||
try:
|
||||
return user.attributes.get("settings", {}).get("locale", "")
|
||||
except:
|
||||
return ''
|
||||
placeholder_expression: true
|
||||
initial_value_expression: true
|
||||
required: true
|
||||
type: ak-locale
|
||||
field_key: attributes.settings.locale
|
||||
|
@ -61,6 +61,7 @@
|
||||
"authentik_events.notificationwebhookmapping",
|
||||
"authentik_flows.flow",
|
||||
"authentik_flows.flowstagebinding",
|
||||
"authentik_interfaces.interface",
|
||||
"authentik_outposts.dockerserviceconnection",
|
||||
"authentik_outposts.kubernetesserviceconnection",
|
||||
"authentik_outposts.outpost",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user