Compare commits
11 Commits
version/20
...
version-20
Author | SHA1 | Date | |
---|---|---|---|
fb0a88f2cf | |||
4d8d405e70 | |||
1d5f399b61 | |||
bb575fcc10 | |||
13fd1afbb9 | |||
f059b998cc | |||
3f48202dfe | |||
2a3ebb616b | |||
ceab1f732d | |||
01d2cce9ca | |||
72f85defb8 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2023.4.2
|
||||
current_version = 2023.3.1
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||
|
@ -15,6 +15,3 @@ indent_size = 2
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
6
.github/actions/setup/action.yml
vendored
6
.github/actions/setup/action.yml
vendored
@ -1,11 +1,6 @@
|
||||
name: 'Setup authentik testing environment'
|
||||
description: 'Setup authentik testing environment'
|
||||
|
||||
inputs:
|
||||
postgresql_tag:
|
||||
description: "Optional postgresql image tag"
|
||||
default: "12"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@ -29,7 +24,6 @@ runs:
|
||||
- name: Setup dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
export PSQL_TAG=${{ inputs.postgresql_tag }}
|
||||
docker-compose -f .github/actions/setup/docker-compose.yml up -d
|
||||
poetry env use python3.11
|
||||
poetry install
|
||||
|
2
.github/actions/setup/docker-compose.yml
vendored
2
.github/actions/setup/docker-compose.yml
vendored
@ -3,7 +3,7 @@ version: '3.7'
|
||||
services:
|
||||
postgresql:
|
||||
container_name: postgres
|
||||
image: library/postgres:${PSQL_TAG:-12}
|
||||
image: library/postgres:12
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
|
11
.github/codecov.yml
vendored
11
.github/codecov.yml
vendored
@ -1,10 +1,3 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
# adjust accordingly based on how flaky your tests are
|
||||
# this allows a 1% drop from the previous base commit coverage
|
||||
threshold: 1%
|
||||
notify:
|
||||
after_n_builds: 3
|
||||
precision: 2
|
||||
round: up
|
||||
|
1
.github/codespell-dictionary.txt
vendored
1
.github/codespell-dictionary.txt
vendored
@ -1 +0,0 @@
|
||||
authentic->authentik
|
1
.github/stale.yml
vendored
1
.github/stale.yml
vendored
@ -16,4 +16,3 @@ markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
only: issues
|
||||
|
14
.github/workflows/ci-main.yml
vendored
14
.github/workflows/ci-main.yml
vendored
@ -29,7 +29,6 @@ jobs:
|
||||
- bandit
|
||||
- pyright
|
||||
- pending-migrations
|
||||
- codespell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@ -60,7 +59,7 @@ jobs:
|
||||
cp authentik/lib/default.yml local.env.yml
|
||||
cp -R .github ..
|
||||
cp -R scripts ..
|
||||
git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
|
||||
git checkout $(git describe --abbrev=0 --match 'version/*')
|
||||
rm -rf .github/ scripts/
|
||||
mv ../.github ../scripts .
|
||||
- name: Setup authentik env (ensure stable deps are installed)
|
||||
@ -80,21 +79,12 @@ jobs:
|
||||
- name: migrate to latest
|
||||
run: poetry run python -m lifecycle.migrate
|
||||
test-unittest:
|
||||
name: test-unittest - PostgreSQL ${{ matrix.psql }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
psql:
|
||||
- 11-alpine
|
||||
- 12-alpine
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
postgresql_tag: ${{ matrix.psql }}
|
||||
- name: run unittest
|
||||
run: |
|
||||
poetry run make test
|
||||
@ -138,8 +128,6 @@ jobs:
|
||||
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
|
||||
- name: ldap
|
||||
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
|
||||
- name: radius
|
||||
glob: tests/e2e/test_provider_radius*
|
||||
- name: flows
|
||||
glob: tests/e2e/test_flows*
|
||||
steps:
|
||||
|
14
.github/workflows/ci-outpost.yml
vendored
14
.github/workflows/ci-outpost.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- name: Prepare and generate API
|
||||
@ -34,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- name: Generate API
|
||||
@ -59,9 +59,8 @@ jobs:
|
||||
type:
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
arch:
|
||||
- "linux/amd64"
|
||||
- 'linux/amd64'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@ -107,18 +106,17 @@ jobs:
|
||||
type:
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: "npm"
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Generate API
|
||||
run: make gen-client-go
|
||||
|
22
.github/workflows/ci-website.yml
vendored
22
.github/workflows/ci-website.yml
vendored
@ -39,32 +39,10 @@ jobs:
|
||||
- name: test
|
||||
working-directory: website/
|
||||
run: npm test
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: ${{ matrix.job }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- build
|
||||
- build-docs-only
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
run: npm ci
|
||||
- name: build
|
||||
working-directory: website/
|
||||
run: npm run ${{ matrix.job }}
|
||||
ci-website-mark:
|
||||
needs:
|
||||
- lint-prettier
|
||||
- test
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo mark
|
||||
|
7
.github/workflows/ghcr-retention.yml
vendored
7
.github/workflows/ghcr-retention.yml
vendored
@ -10,11 +10,6 @@ jobs:
|
||||
name: Delete old unused container images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: tibdex/github-app-token@v1
|
||||
with:
|
||||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Delete 'dev' containers older than a week
|
||||
uses: snok/container-retention-policy@v2
|
||||
with:
|
||||
@ -23,5 +18,5 @@ jobs:
|
||||
account-type: org
|
||||
org-name: goauthentik
|
||||
untagged-only: false
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
skip-tags: gh-next,gh-main
|
||||
|
6
.github/workflows/release-publish.yml
vendored
6
.github/workflows/release-publish.yml
vendored
@ -52,10 +52,9 @@ jobs:
|
||||
type:
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- name: Set up QEMU
|
||||
@ -100,12 +99,11 @@ jobs:
|
||||
type:
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
goos: [linux, darwin]
|
||||
goarch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
|
9
.github/workflows/release-tag.yml
vendored
9
.github/workflows/release-tag.yml
vendored
@ -22,23 +22,18 @@ jobs:
|
||||
docker-compose up --no-start
|
||||
docker-compose start postgresql redis
|
||||
docker-compose run -u root server test-all
|
||||
- id: generate_token
|
||||
uses: tibdex/github-app-token@v1
|
||||
with:
|
||||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Extract version number
|
||||
id: get_version
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ steps.generate_token.outputs.token }}
|
||||
github-token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
script: |
|
||||
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1.1.4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ steps.get_version.outputs.result }}
|
||||
|
11
.github/workflows/translation-compile.yml
vendored
11
.github/workflows/translation-compile.yml
vendored
@ -18,23 +18,18 @@ jobs:
|
||||
compile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: tibdex/github-app-token@v1
|
||||
with:
|
||||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: run compile
|
||||
run: poetry run ./manage.py compilemessages
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
branch: compile-backend-translation
|
||||
commit-message: "core: compile backend translations"
|
||||
title: "core: compile backend translations"
|
||||
|
15
.github/workflows/web-api-publish.yml
vendored
15
.github/workflows/web-api-publish.yml
vendored
@ -9,14 +9,9 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: tibdex/github-app-token@v1
|
||||
with:
|
||||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: '18'
|
||||
@ -35,10 +30,10 @@ jobs:
|
||||
run: |
|
||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- uses: peter-evans/create-pull-request@v5
|
||||
- uses: peter-evans/create-pull-request@v4
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
branch: update-web-api-client
|
||||
commit-message: "web: bump API Client version"
|
||||
title: "web: bump API Client version"
|
||||
@ -47,8 +42,8 @@ jobs:
|
||||
signoff: true
|
||||
team-reviewers: "@goauthentik/core"
|
||||
author: authentik bot <github-bot@goauthentik.io>
|
||||
- uses: peter-evans/enable-pull-request-automerge@v3
|
||||
- uses: peter-evans/enable-pull-request-automerge@v2
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
merge-method: squash
|
||||
|
@ -20,7 +20,6 @@ The following is a set of guidelines for contributing to authentik and its compo
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Enhancements](#suggesting-enhancements)
|
||||
- [Your First Code Contribution](#your-first-code-contribution)
|
||||
- [Help with the Docs](#help-with-the-docs)
|
||||
- [Pull Requests](#pull-requests)
|
||||
|
||||
[Styleguides](#styleguides)
|
||||
@ -136,9 +135,6 @@ authentik can be run locally, all though depending on which part you want to wor
|
||||
|
||||
This is documented in the [developer docs](https://goauthentik.io/developer-docs/?utm_source=github)
|
||||
|
||||
### Help with the Docs
|
||||
Contributions to the technical documentation are greatly appreciated. Open a PR if you have improvements to make or new content to add. If you have questions or suggestions about the documentation, open an Issue. No contribution is too small.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
The process described here has several goals:
|
||||
|
10
Dockerfile
10
Dockerfile
@ -20,7 +20,7 @@ WORKDIR /work/web
|
||||
RUN npm ci && npm run build
|
||||
|
||||
# Stage 3: Poetry to requirements.txt export
|
||||
FROM docker.io/python:3.11.3-slim-bullseye AS poetry-locker
|
||||
FROM docker.io/python:3.11.2-slim-bullseye AS poetry-locker
|
||||
|
||||
WORKDIR /work
|
||||
COPY ./pyproject.toml /work
|
||||
@ -31,7 +31,7 @@ RUN pip install --no-cache-dir poetry && \
|
||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||
|
||||
# Stage 4: Build go proxy
|
||||
FROM docker.io/golang:1.20.3-bullseye AS go-builder
|
||||
FROM docker.io/golang:1.20.2-bullseye AS go-builder
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
@ -47,7 +47,7 @@ COPY ./go.sum /work/go.sum
|
||||
RUN go build -o /work/authentik ./cmd/server/
|
||||
|
||||
# Stage 5: MaxMind GeoIP
|
||||
FROM docker.io/maxmindinc/geoipupdate:v5.0 as geoip
|
||||
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
|
||||
|
||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
||||
ENV GEOIPUPDATE_VERBOSE="true"
|
||||
@ -62,7 +62,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
"
|
||||
|
||||
# Stage 6: Run
|
||||
FROM docker.io/python:3.11.3-slim-bullseye AS final-image
|
||||
FROM docker.io/python:3.11.2-slim-bullseye AS final-image
|
||||
|
||||
LABEL org.opencontainers.image.url https://goauthentik.io
|
||||
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
|
||||
@ -102,7 +102,7 @@ COPY ./tests /tests
|
||||
COPY ./manage.py /
|
||||
COPY ./blueprints /blueprints
|
||||
COPY ./lifecycle/ /lifecycle
|
||||
COPY --from=go-builder /work/authentik /bin/authentik
|
||||
COPY --from=go-builder /work/authentik /authentik-proxy
|
||||
COPY --from=web-builder /work/web/dist/ /web/dist/
|
||||
COPY --from=web-builder /work/web/authentik/ /web/authentik/
|
||||
COPY --from=website-builder /work/website/help/ /website/help/
|
||||
|
57
Makefile
57
Makefile
@ -4,20 +4,6 @@ UID = $(shell id -u)
|
||||
GID = $(shell id -g)
|
||||
NPM_VERSION = $(shell python -m scripts.npm_version)
|
||||
|
||||
CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
|
||||
-I .github/codespell-words.txt \
|
||||
-S 'web/src/locales/**' \
|
||||
authentik \
|
||||
internal \
|
||||
cmd \
|
||||
web/src \
|
||||
website/src \
|
||||
website/blog \
|
||||
website/developer-docs \
|
||||
website/docs \
|
||||
website/integrations \
|
||||
website/src
|
||||
|
||||
all: lint-fix lint test gen web
|
||||
|
||||
test-go:
|
||||
@ -40,7 +26,14 @@ test:
|
||||
lint-fix:
|
||||
isort authentik tests scripts lifecycle
|
||||
black authentik tests scripts lifecycle
|
||||
codespell -w $(CODESPELL_ARGS)
|
||||
codespell -I .github/codespell-words.txt -S 'web/src/locales/**' -w \
|
||||
authentik \
|
||||
internal \
|
||||
cmd \
|
||||
web/src \
|
||||
website/src \
|
||||
website/docs \
|
||||
website/developer-docs
|
||||
|
||||
lint:
|
||||
pylint authentik tests lifecycle
|
||||
@ -50,6 +43,9 @@ lint:
|
||||
migrate:
|
||||
python -m lifecycle.migrate
|
||||
|
||||
run:
|
||||
go run -v ./cmd/server/
|
||||
|
||||
i18n-extract: i18n-extract-core web-extract
|
||||
|
||||
i18n-extract-core:
|
||||
@ -63,20 +59,15 @@ gen-build:
|
||||
AUTHENTIK_DEBUG=true ak make_blueprint_schema > blueprints/schema.json
|
||||
AUTHENTIK_DEBUG=true ak spectacular --file schema.yml
|
||||
|
||||
gen-changelog:
|
||||
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
|
||||
npx prettier --write changelog.md
|
||||
|
||||
gen-diff:
|
||||
git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > old_schema.yml
|
||||
git show $(shell git describe --abbrev=0):schema.yml > old_schema.yml
|
||||
docker run \
|
||||
--rm -v ${PWD}:/local \
|
||||
--user ${UID}:${GID} \
|
||||
docker.io/openapitools/openapi-diff:2.1.0-beta.6 \
|
||||
docker.io/openapitools/openapi-diff:2.1.0-beta.3 \
|
||||
--markdown /local/diff.md \
|
||||
/local/old_schema.yml /local/schema.yml
|
||||
rm old_schema.yml
|
||||
npx prettier --write diff.md
|
||||
|
||||
gen-clean:
|
||||
rm -rf web/api/src/
|
||||
@ -86,7 +77,7 @@ gen-client-ts:
|
||||
docker run \
|
||||
--rm -v ${PWD}:/local \
|
||||
--user ${UID}:${GID} \
|
||||
docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \
|
||||
docker.io/openapitools/openapi-generator-cli:v6.0.0 generate \
|
||||
-i /local/schema.yml \
|
||||
-g typescript-fetch \
|
||||
-o /local/gen-ts-api \
|
||||
@ -99,21 +90,20 @@ gen-client-ts:
|
||||
\cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api
|
||||
|
||||
gen-client-go:
|
||||
mkdir -p ./gen-go-api ./gen-go-api/templates
|
||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./gen-go-api/config.yaml
|
||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./gen-go-api/templates/README.mustache
|
||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O ./gen-go-api/templates/go.mod.mustache
|
||||
cp schema.yml ./gen-go-api/
|
||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O config.yaml
|
||||
mkdir -p templates
|
||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O templates/README.mustache
|
||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O templates/go.mod.mustache
|
||||
docker run \
|
||||
--rm -v ${PWD}/gen-go-api:/local \
|
||||
--rm -v ${PWD}:/local \
|
||||
--user ${UID}:${GID} \
|
||||
docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \
|
||||
docker.io/openapitools/openapi-generator-cli:v6.0.0 generate \
|
||||
-i /local/schema.yml \
|
||||
-g go \
|
||||
-o /local/ \
|
||||
-o /local/gen-go-api \
|
||||
-c /local/config.yaml
|
||||
go mod edit -replace goauthentik.io/api/v3=./gen-go-api
|
||||
rm -rf ./gen-go-api/config.yaml ./gen-go-api/templates/
|
||||
rm -rf config.yaml ./templates/
|
||||
|
||||
gen-dev-config:
|
||||
python -m scripts.generate_config
|
||||
@ -182,9 +172,6 @@ ci-pylint: ci--meta-debug
|
||||
ci-black: ci--meta-debug
|
||||
black --check $(PY_SOURCES)
|
||||
|
||||
ci-codespell: ci--meta-debug
|
||||
codespell $(CODESPELL_ARGS) -s
|
||||
|
||||
ci-isort: ci--meta-debug
|
||||
isort --check $(PY_SOURCES)
|
||||
|
||||
|
12
README.md
12
README.md
@ -15,13 +15,13 @@
|
||||
|
||||
## What is authentik?
|
||||
|
||||
Authentik is an open-source Identity Provider that emphasizes flexibility and versatility. It can be seamlessly integrated into existing environments to support new protocols. Authentik is also a great solution for implementing sign-up, recovery, and other similar features in your application, saving you the hassle of dealing with them.
|
||||
authentik is an open-source Identity Provider focused on flexibility and versatility. You can use authentik in an existing environment to add support for new protocols. authentik is also a great solution for implementing signup/recovery/etc in your application, so you don't have to deal with it.
|
||||
|
||||
## Installation
|
||||
|
||||
For small/test setups it is recommended to use Docker Compose; refer to the [documentation](https://goauthentik.io/docs/installation/docker-compose/?utm_source=github).
|
||||
For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/?utm_source=github)
|
||||
|
||||
For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/?utm_source=github).
|
||||
For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/?utm_source=github)
|
||||
|
||||
## Screenshots
|
||||
|
||||
@ -32,15 +32,15 @@ For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/h
|
||||
|
||||
## Development
|
||||
|
||||
See [Developer Documentation](https://goauthentik.io/developer-docs/?utm_source=github)
|
||||
See [Development Documentation](https://goauthentik.io/developer-docs/?utm_source=github)
|
||||
|
||||
## Security
|
||||
|
||||
See [SECURITY.md](SECURITY.md)
|
||||
|
||||
## Adoption and Contributions
|
||||
## Support
|
||||
|
||||
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [CONTRIBUTING.md file](./CONTRIBUTING.md).
|
||||
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR!
|
||||
|
||||
## Sponsors
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2023.4.2"
|
||||
__version__ = "2023.3.1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""authentik administration overview"""
|
||||
import os
|
||||
import platform
|
||||
from datetime import datetime
|
||||
from sys import version as python_version
|
||||
@ -33,6 +34,7 @@ class RuntimeDict(TypedDict):
|
||||
class SystemSerializer(PassiveSerializer):
|
||||
"""Get system information."""
|
||||
|
||||
env = SerializerMethodField()
|
||||
http_headers = SerializerMethodField()
|
||||
http_host = SerializerMethodField()
|
||||
http_is_secure = SerializerMethodField()
|
||||
@ -41,6 +43,10 @@ class SystemSerializer(PassiveSerializer):
|
||||
server_time = SerializerMethodField()
|
||||
embedded_outpost_host = SerializerMethodField()
|
||||
|
||||
def get_env(self, request: Request) -> dict[str, str]:
|
||||
"""Get Environment"""
|
||||
return os.environ.copy()
|
||||
|
||||
def get_http_headers(self, request: Request) -> dict[str, str]:
|
||||
"""Get HTTP Request headers"""
|
||||
headers = {}
|
||||
|
@ -1,5 +1,4 @@
|
||||
"""API Authentication"""
|
||||
from hmac import compare_digest
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.conf import settings
|
||||
@ -79,7 +78,7 @@ def token_secret_key(value: str) -> Optional[User]:
|
||||
and return the service account for the managed outpost"""
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
|
||||
if not compare_digest(value, settings.SECRET_KEY):
|
||||
if value != settings.SECRET_KEY:
|
||||
return None
|
||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||
if not outposts:
|
||||
|
@ -7,13 +7,82 @@ API Browser - {{ tenant.branding_title }}
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script src="{% static 'dist/standalone/api-browser/index.js' %}?version={{ version }}" type="module"></script>
|
||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
|
||||
<link rel="icon" href="{{ tenant.branding_favicon }}">
|
||||
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
|
||||
<script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script>
|
||||
<script>
|
||||
function getCookie(name) {
|
||||
let cookieValue = "";
|
||||
if (document.cookie && document.cookie !== "") {
|
||||
const cookies = document.cookie.split(";");
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
// Does this cookie string begin with the name we want?
|
||||
if (cookie.substring(0, name.length + 1) === name + "=") {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
window.addEventListener('DOMContentLoaded', (event) => {
|
||||
const rapidocEl = document.querySelector('rapi-doc');
|
||||
rapidocEl.addEventListener('before-try', (e) => {
|
||||
e.detail.request.headers.append('X-authentik-CSRF', getCookie("authentik_csrf"));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
img.logo {
|
||||
width: 100%;
|
||||
padding: 1rem 0.5rem 1.5rem 0.5rem;
|
||||
min-height: 48px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<ak-api-browser schemaPath="{{ path }}"></ak-api-browser>
|
||||
<rapi-doc
|
||||
spec-url="{{ path }}"
|
||||
heading-text=""
|
||||
theme="light"
|
||||
render-style="read"
|
||||
default-schema-tab="schema"
|
||||
primary-color="#fd4b2d"
|
||||
nav-bg-color="#212427"
|
||||
bg-color="#000000"
|
||||
text-color="#000000"
|
||||
nav-text-color="#ffffff"
|
||||
nav-hover-bg-color="#3c3f42"
|
||||
nav-accent-color="#4f5255"
|
||||
nav-hover-text-color="#ffffff"
|
||||
use-path-in-nav-bar="true"
|
||||
nav-item-spacing="relaxed"
|
||||
allow-server-selection="false"
|
||||
show-header="false"
|
||||
allow-spec-url-load="false"
|
||||
allow-spec-file-load="false">
|
||||
<div slot="nav-logo">
|
||||
<img alt="authentik Logo" class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
||||
</div>
|
||||
</rapi-doc>
|
||||
<script>
|
||||
const rapidoc = document.querySelector("rapi-doc");
|
||||
const matcher = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const changer = (ev) => {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
let bg, text = "";
|
||||
if (matcher.matches) {
|
||||
bg = style.getPropertyValue('--pf-global--BackgroundColor--light-300');
|
||||
text = style.getPropertyValue('--pf-global--Color--300');
|
||||
} else {
|
||||
bg = style.getPropertyValue('--ak-dark-background');
|
||||
text = style.getPropertyValue('--ak-dark-foreground');
|
||||
}
|
||||
rapidoc.attributes.getNamedItem("bg-color").value = bg.trim();
|
||||
rapidoc.attributes.getNamedItem("text-color").value = text.trim();
|
||||
rapidoc.requestUpdate();
|
||||
};
|
||||
matcher.addEventListener("change", changer);
|
||||
window.addEventListener("load", changer);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -56,7 +56,6 @@ from authentik.providers.oauth2.api.tokens import (
|
||||
RefreshTokenViewSet,
|
||||
)
|
||||
from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet
|
||||
from authentik.providers.radius.api import RadiusOutpostConfigViewSet, RadiusProviderViewSet
|
||||
from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet
|
||||
from authentik.providers.saml.api.providers import SAMLProviderViewSet
|
||||
from authentik.providers.scim.api.property_mapping import SCIMMappingViewSet
|
||||
@ -129,7 +128,6 @@ router.register("outposts/service_connections/docker", DockerServiceConnectionVi
|
||||
router.register("outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet)
|
||||
router.register("outposts/proxy", ProxyOutpostConfigViewSet)
|
||||
router.register("outposts/ldap", LDAPOutpostConfigViewSet)
|
||||
router.register("outposts/radius", RadiusOutpostConfigViewSet)
|
||||
|
||||
router.register("flows/instances", FlowViewSet)
|
||||
router.register("flows/bindings", FlowStageBindingViewSet)
|
||||
@ -168,7 +166,6 @@ router.register("providers/proxy", ProxyProviderViewSet)
|
||||
router.register("providers/oauth2", OAuth2ProviderViewSet)
|
||||
router.register("providers/saml", SAMLProviderViewSet)
|
||||
router.register("providers/scim", SCIMProviderViewSet)
|
||||
router.register("providers/radius", RadiusProviderViewSet)
|
||||
|
||||
router.register("oauth2/authorization_codes", AuthorizationCodeViewSet)
|
||||
router.register("oauth2/refresh_tokens", RefreshTokenViewSet)
|
||||
|
@ -82,10 +82,7 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
def retrieve_file(self) -> str:
|
||||
"""Get blueprint from path"""
|
||||
try:
|
||||
base = Path(CONFIG.y("blueprints_dir"))
|
||||
full_path = base.joinpath(Path(self.path)).resolve()
|
||||
if not str(full_path).startswith(str(base.resolve())):
|
||||
raise BlueprintRetrievalFailed("Invalid blueprint path")
|
||||
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
|
||||
with full_path.open("r", encoding="utf-8") as _file:
|
||||
return _file.read()
|
||||
except (IOError, OSError) as exc:
|
||||
|
@ -1,15 +1,34 @@
|
||||
"""authentik managed models tests"""
|
||||
from typing import Callable, Type
|
||||
|
||||
from django.apps import apps
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.blueprints.v1.importer import is_model_allowed
|
||||
from authentik.lib.models import SerializerModel
|
||||
|
||||
|
||||
class TestModels(TestCase):
|
||||
"""Test Models"""
|
||||
|
||||
def test_retrieve_file(self):
|
||||
"""Test retrieve_file"""
|
||||
instance = BlueprintInstance.objects.create(name=generate_id(), path="../etc/hosts")
|
||||
with self.assertRaises(BlueprintRetrievalFailed):
|
||||
instance.retrieve()
|
||||
|
||||
def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
|
||||
"""Test serializer"""
|
||||
|
||||
def tester(self: TestModels):
|
||||
if test_model._meta.abstract: # pragma: no cover
|
||||
return
|
||||
model_class = test_model()
|
||||
self.assertTrue(isinstance(model_class, SerializerModel))
|
||||
self.assertIsNotNone(model_class.serializer)
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
for app in apps.get_app_configs():
|
||||
if not app.label.startswith("authentik"):
|
||||
continue
|
||||
for model in app.get_models():
|
||||
if not is_model_allowed(model):
|
||||
continue
|
||||
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))
|
||||
|
@ -1,34 +0,0 @@
|
||||
"""authentik managed models tests"""
|
||||
from typing import Callable, Type
|
||||
|
||||
from django.apps import apps
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.blueprints.v1.importer import is_model_allowed
|
||||
from authentik.lib.models import SerializerModel
|
||||
|
||||
|
||||
class TestModels(TestCase):
|
||||
"""Test Models"""
|
||||
|
||||
|
||||
def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
|
||||
"""Test serializer"""
|
||||
|
||||
def tester(self: TestModels):
|
||||
if test_model._meta.abstract: # pragma: no cover
|
||||
return
|
||||
model_class = test_model()
|
||||
self.assertTrue(isinstance(model_class, SerializerModel))
|
||||
self.assertIsNotNone(model_class.serializer)
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
for app in apps.get_app_configs():
|
||||
if not app.label.startswith("authentik"):
|
||||
continue
|
||||
for model in app.get_models():
|
||||
if not is_model_allowed(model):
|
||||
continue
|
||||
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))
|
@ -122,7 +122,7 @@ def blueprints_find():
|
||||
)
|
||||
blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None
|
||||
blueprints.append(blueprint)
|
||||
LOGGER.debug(
|
||||
LOGGER.info(
|
||||
"parsed & loaded blueprint",
|
||||
hash=file_hash,
|
||||
path=str(path),
|
||||
|
@ -35,7 +35,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"authentication_flow",
|
||||
"authorization_flow",
|
||||
"property_mappings",
|
||||
"component",
|
||||
|
@ -67,12 +67,11 @@ from authentik.core.models import (
|
||||
TokenIntents,
|
||||
User,
|
||||
)
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.models import EventAction
|
||||
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.lib.config import CONFIG
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.tasks import send_mails
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
@ -212,9 +211,8 @@ class UserMetricsSerializer(PassiveSerializer):
|
||||
def get_logins(self, _):
|
||||
"""Get successful logins per 8 hours for the last 7 days"""
|
||||
user = self.context["user"]
|
||||
request = self.context["request"]
|
||||
return (
|
||||
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||
action=EventAction.LOGIN, user__pk=user.pk
|
||||
)
|
||||
# 3 data points per day, so 8 hour spans
|
||||
@ -225,9 +223,8 @@ class UserMetricsSerializer(PassiveSerializer):
|
||||
def get_logins_failed(self, _):
|
||||
"""Get failed logins per 8 hours for the last 7 days"""
|
||||
user = self.context["user"]
|
||||
request = self.context["request"]
|
||||
return (
|
||||
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||
action=EventAction.LOGIN_FAILED, context__username=user.username
|
||||
)
|
||||
# 3 data points per day, so 8 hour spans
|
||||
@ -238,9 +235,8 @@ class UserMetricsSerializer(PassiveSerializer):
|
||||
def get_authorizations(self, _):
|
||||
"""Get failed logins per 8 hours for the last 7 days"""
|
||||
user = self.context["user"]
|
||||
request = self.context["request"]
|
||||
return (
|
||||
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||
action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
|
||||
)
|
||||
# 3 data points per day, so 8 hour spans
|
||||
@ -475,9 +471,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
def metrics(self, request: Request, pk: int) -> Response:
|
||||
"""User metrics per 1h"""
|
||||
user: User = self.get_object()
|
||||
serializer = UserMetricsSerializer(instance={})
|
||||
serializer = UserMetricsSerializer(True)
|
||||
serializer.context["user"] = user
|
||||
serializer.context["request"] = request
|
||||
return Response(serializer.data)
|
||||
|
||||
@permission_required("authentik_core.reset_user_password")
|
||||
@ -544,58 +539,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
send_mails(email_stage, message)
|
||||
return Response(status=204)
|
||||
|
||||
@permission_required("authentik_core.impersonate")
|
||||
@extend_schema(
|
||||
request=OpenApiTypes.NONE,
|
||||
responses={
|
||||
"204": OpenApiResponse(description="Successfully started impersonation"),
|
||||
"401": OpenApiResponse(description="Access denied"),
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["POST"])
|
||||
def impersonate(self, request: Request, pk: int) -> Response:
|
||||
"""Impersonate a user"""
|
||||
if not CONFIG.y_bool("impersonation"):
|
||||
LOGGER.debug("User attempted to impersonate", user=request.user)
|
||||
return Response(status=401)
|
||||
if not request.user.has_perm("impersonate"):
|
||||
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
||||
return Response(status=401)
|
||||
|
||||
user_to_be = self.get_object()
|
||||
|
||||
request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
|
||||
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be
|
||||
|
||||
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
||||
|
||||
return Response(status=201)
|
||||
|
||||
@extend_schema(
|
||||
request=OpenApiTypes.NONE,
|
||||
responses={
|
||||
"204": OpenApiResponse(description="Successfully started impersonation"),
|
||||
},
|
||||
)
|
||||
@action(detail=False, methods=["GET"])
|
||||
def impersonate_end(self, request: Request) -> Response:
|
||||
"""End Impersonation a user"""
|
||||
if (
|
||||
SESSION_KEY_IMPERSONATE_USER not in request.session
|
||||
or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session
|
||||
):
|
||||
LOGGER.debug("Can't end impersonation", user=request.user)
|
||||
return Response(status=204)
|
||||
|
||||
original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||
|
||||
del request.session[SESSION_KEY_IMPERSONATE_USER]
|
||||
del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||
|
||||
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
||||
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
|
||||
for backend in list(self.filter_backends):
|
||||
|
@ -11,7 +11,6 @@ class AuthentikCoreConfig(ManagedAppConfig):
|
||||
label = "authentik_core"
|
||||
verbose_name = "authentik Core"
|
||||
mountpoint = ""
|
||||
ws_mountpoint = "authentik.core.urls"
|
||||
default = True
|
||||
|
||||
def reconcile_load_core_signals(self):
|
||||
|
@ -21,14 +21,11 @@ PROPERTY_MAPPING_TIME = Histogram(
|
||||
class PropertyMappingEvaluator(BaseEvaluator):
|
||||
"""Custom Evaluator that adds some different context variables."""
|
||||
|
||||
dry_run: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: Model,
|
||||
user: Optional[User] = None,
|
||||
request: Optional[HttpRequest] = None,
|
||||
dry_run: Optional[bool] = False,
|
||||
**kwargs,
|
||||
):
|
||||
if hasattr(model, "name"):
|
||||
@ -45,13 +42,9 @@ class PropertyMappingEvaluator(BaseEvaluator):
|
||||
req.http_request = request
|
||||
self._context["request"] = req
|
||||
self._context.update(**kwargs)
|
||||
self.dry_run = dry_run
|
||||
|
||||
def handle_error(self, exc: Exception, expression_source: str):
|
||||
"""Exception Handler"""
|
||||
# For dry-run requests we don't save exceptions
|
||||
if self.dry_run:
|
||||
return
|
||||
error_string = exception_to_string(exc)
|
||||
event = Event.new(
|
||||
EventAction.PROPERTY_MAPPING_EXCEPTION,
|
||||
|
@ -1,19 +0,0 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-19 21:57
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_core", "0026_alter_propertymapping_name_alter_provider_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="uuid",
|
||||
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
]
|
@ -1,25 +0,0 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-23 21:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_flows", "0025_alter_flowstagebinding_evaluate_on_plan_and_more"),
|
||||
("authentik_core", "0027_alter_user_uuid"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="provider",
|
||||
name="authentication_flow",
|
||||
field=models.ForeignKey(
|
||||
help_text="Flow used for authentication when the associated application is accessed by an un-authenticated user.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="provider_authentication",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
]
|
@ -146,7 +146,7 @@ class UserManager(DjangoUserManager):
|
||||
class User(SerializerModel, GuardianUserMixin, AbstractUser):
|
||||
"""Custom User model to allow easier adding of user-based settings"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, unique=True)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False)
|
||||
name = models.TextField(help_text=_("User's display name."))
|
||||
path = models.TextField(default="users")
|
||||
|
||||
@ -249,17 +249,6 @@ class Provider(SerializerModel):
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
|
||||
authentication_flow = models.ForeignKey(
|
||||
"authentik_flows.Flow",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text=_(
|
||||
"Flow used for authentication when the associated application is accessed by an "
|
||||
"un-authenticated user."
|
||||
),
|
||||
related_name="provider_authentication",
|
||||
)
|
||||
|
||||
authorization_flow = models.ForeignKey(
|
||||
"authentik_flows.Flow",
|
||||
on_delete=models.CASCADE,
|
||||
|
@ -9,13 +9,16 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title>
|
||||
<link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}">
|
||||
{% block head_before %}
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)">
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
|
||||
<script src="{% static 'dist/poly.js' %}?version={{ version }}" type="module"></script>
|
||||
<script src="{% static 'dist/standalone/loading/index.js' %}?version={{ version }}" type="module"></script>
|
||||
<script src="{% static 'dist/poly.js' %}" type="module"></script>
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
<meta name="sentry-trace" content="{{ sentry_trace }}" />
|
||||
|
@ -1,6 +1,7 @@
|
||||
{% extends "base/skeleton.html" %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block head %}
|
||||
<script src="{% static 'dist/admin/AdminInterface.js' %}?version={{ version }}" type="module"></script>
|
||||
@ -14,6 +15,19 @@
|
||||
{% block body %}
|
||||
<ak-message-container></ak-message-container>
|
||||
<ak-interface-admin>
|
||||
<ak-loading></ak-loading>
|
||||
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
||||
<div class="pf-c-empty-state" style="height: 100vh;">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}">
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
<h1 class="pf-c-title pf-m-lg">
|
||||
{% trans "Loading..." %}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ak-interface-admin>
|
||||
{% endblock %}
|
||||
|
@ -1,6 +1,7 @@
|
||||
{% extends "base/skeleton.html" %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_before %}
|
||||
{{ block.super }}
|
||||
@ -30,6 +31,19 @@ window.authentik.flow = {
|
||||
{% block body %}
|
||||
<ak-message-container></ak-message-container>
|
||||
<ak-flow-executor>
|
||||
<ak-loading></ak-loading>
|
||||
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
||||
<div class="pf-c-empty-state" style="height: 100vh;">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}">
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
<h1 class="pf-c-title pf-m-lg">
|
||||
{% trans "Loading..." %}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ak-flow-executor>
|
||||
{% endblock %}
|
||||
|
@ -1,6 +1,7 @@
|
||||
{% extends "base/skeleton.html" %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block head %}
|
||||
<script src="{% static 'dist/user/UserInterface.js' %}?version={{ version }}" type="module"></script>
|
||||
@ -14,6 +15,19 @@
|
||||
{% block body %}
|
||||
<ak-message-container></ak-message-container>
|
||||
<ak-interface-user>
|
||||
<ak-loading></ak-loading>
|
||||
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
|
||||
<div class="pf-c-empty-state" style="height: 100vh;">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}">
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
<h1 class="pf-c-title pf-m-lg">
|
||||
{% trans "Loading..." %}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ak-interface-user>
|
||||
{% endblock %}
|
||||
|
@ -129,7 +129,6 @@ class TestApplicationsAPI(APITestCase):
|
||||
"provider_obj": {
|
||||
"assigned_application_name": "allowed",
|
||||
"assigned_application_slug": "allowed",
|
||||
"authentication_flow": None,
|
||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||
"component": "ak-provider-oauth2-form",
|
||||
"meta_model_name": "authentik_providers_oauth2.oauth2provider",
|
||||
@ -179,7 +178,6 @@ class TestApplicationsAPI(APITestCase):
|
||||
"provider_obj": {
|
||||
"assigned_application_name": "allowed",
|
||||
"assigned_application_slug": "allowed",
|
||||
"authentication_flow": None,
|
||||
"authorization_flow": str(self.provider.authorization_flow.pk),
|
||||
"component": "ak-provider-oauth2-form",
|
||||
"meta_model_name": "authentik_providers_oauth2.oauth2provider",
|
||||
|
@ -1,14 +1,14 @@
|
||||
"""impersonation tests"""
|
||||
from json import loads
|
||||
|
||||
from django.test.testcases import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
|
||||
|
||||
class TestImpersonation(APITestCase):
|
||||
class TestImpersonation(TestCase):
|
||||
"""impersonation tests"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
@ -23,10 +23,10 @@ class TestImpersonation(APITestCase):
|
||||
self.other_user.save()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.client.post(
|
||||
self.client.get(
|
||||
reverse(
|
||||
"authentik_api:user-impersonate",
|
||||
kwargs={"pk": self.other_user.pk},
|
||||
"authentik_core:impersonate-init",
|
||||
kwargs={"user_id": self.other_user.pk},
|
||||
)
|
||||
)
|
||||
|
||||
@ -35,7 +35,7 @@ class TestImpersonation(APITestCase):
|
||||
self.assertEqual(response_body["user"]["username"], self.other_user.username)
|
||||
self.assertEqual(response_body["original"]["username"], self.user.username)
|
||||
|
||||
self.client.get(reverse("authentik_api:user-impersonate-end"))
|
||||
self.client.get(reverse("authentik_core:impersonate-end"))
|
||||
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
response_body = loads(response.content.decode())
|
||||
@ -46,7 +46,9 @@ class TestImpersonation(APITestCase):
|
||||
"""test impersonation without permissions"""
|
||||
self.client.force_login(self.other_user)
|
||||
|
||||
self.client.get(reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}))
|
||||
self.client.get(
|
||||
reverse("authentik_core:impersonate-init", kwargs={"user_id": self.user.pk})
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
response_body = loads(response.content.decode())
|
||||
@ -56,5 +58,5 @@ class TestImpersonation(APITestCase):
|
||||
"""test un-impersonation without impersonating first"""
|
||||
self.client.force_login(self.other_user)
|
||||
|
||||
response = self.client.get(reverse("authentik_api:user-impersonate-end"))
|
||||
self.assertEqual(response.status_code, 204)
|
||||
response = self.client.get(reverse("authentik_core:impersonate-end"))
|
||||
self.assertRedirects(response, reverse("authentik_core:if-user"))
|
||||
|
@ -4,10 +4,7 @@ from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import PropertyMapping
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.expression.models import ExpressionPolicy
|
||||
|
||||
|
||||
class TestPropertyMappings(TestCase):
|
||||
@ -15,24 +12,23 @@ class TestPropertyMappings(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = create_test_admin_user()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_expression(self):
|
||||
"""Test expression"""
|
||||
mapping = PropertyMapping.objects.create(name=generate_id(), expression="return 'test'")
|
||||
mapping = PropertyMapping.objects.create(name="test", expression="return 'test'")
|
||||
self.assertEqual(mapping.evaluate(None, None), "test")
|
||||
|
||||
def test_expression_syntax(self):
|
||||
"""Test expression syntax error"""
|
||||
mapping = PropertyMapping.objects.create(name=generate_id(), expression="-")
|
||||
mapping = PropertyMapping.objects.create(name="test", expression="-")
|
||||
with self.assertRaises(PropertyMappingExpressionException):
|
||||
mapping.evaluate(None, None)
|
||||
|
||||
def test_expression_error_general(self):
|
||||
"""Test expression error"""
|
||||
expr = "return aaa"
|
||||
mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr)
|
||||
mapping = PropertyMapping.objects.create(name="test", expression=expr)
|
||||
with self.assertRaises(PropertyMappingExpressionException):
|
||||
mapping.evaluate(None, None)
|
||||
events = Event.objects.filter(
|
||||
@ -45,7 +41,7 @@ class TestPropertyMappings(TestCase):
|
||||
"""Test expression error (with user and http request"""
|
||||
expr = "return aaa"
|
||||
request = self.factory.get("/")
|
||||
mapping = PropertyMapping.objects.create(name=generate_id(), expression=expr)
|
||||
mapping = PropertyMapping.objects.create(name="test", expression=expr)
|
||||
with self.assertRaises(PropertyMappingExpressionException):
|
||||
mapping.evaluate(get_anonymous_user(), request)
|
||||
events = Event.objects.filter(
|
||||
@ -56,23 +52,3 @@ class TestPropertyMappings(TestCase):
|
||||
event = events.first()
|
||||
self.assertEqual(event.user["username"], "AnonymousUser")
|
||||
self.assertEqual(event.client_ip, "127.0.0.1")
|
||||
|
||||
def test_call_policy(self):
|
||||
"""test ak_call_policy"""
|
||||
expr = ExpressionPolicy.objects.create(
|
||||
name=generate_id(),
|
||||
execution_logging=True,
|
||||
expression="return request.http_request.path",
|
||||
)
|
||||
http_request = self.factory.get("/")
|
||||
tmpl = (
|
||||
"""
|
||||
res = ak_call_policy('%s')
|
||||
result = [request.http_request.path, res.raw_result]
|
||||
return result
|
||||
"""
|
||||
% expr.name
|
||||
)
|
||||
evaluator = PropertyMapping(expression=tmpl, name=generate_id())
|
||||
res = evaluator.evaluate(self.user, http_request)
|
||||
self.assertEqual(res, ["/", "/"])
|
||||
|
@ -27,6 +27,6 @@ class UserSettingSerializer(PassiveSerializer):
|
||||
|
||||
object_uid = CharField()
|
||||
component = CharField()
|
||||
title = CharField(required=True)
|
||||
title = CharField()
|
||||
configure_url = CharField(required=False)
|
||||
icon_url = CharField(required=False)
|
||||
|
@ -1,18 +1,14 @@
|
||||
"""authentik URL Configuration"""
|
||||
from channels.auth import AuthMiddleware
|
||||
from channels.sessions import CookieMiddleware
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from authentik.core.views import apps
|
||||
from authentik.core.views import apps, impersonate
|
||||
from authentik.core.views.debug import AccessDeniedView
|
||||
from authentik.core.views.interface import FlowInterfaceView, InterfaceView
|
||||
from authentik.core.views.session import EndSessionView
|
||||
from authentik.root.asgi_middleware import SessionMiddleware
|
||||
from authentik.root.messages.consumer import MessageConsumer
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
@ -28,6 +24,17 @@ urlpatterns = [
|
||||
apps.RedirectToAppLaunch.as_view(),
|
||||
name="application-launch",
|
||||
),
|
||||
# Impersonation
|
||||
path(
|
||||
"-/impersonation/<int:user_id>/",
|
||||
impersonate.ImpersonateInitView.as_view(),
|
||||
name="impersonate-init",
|
||||
),
|
||||
path(
|
||||
"-/impersonation/end/",
|
||||
impersonate.ImpersonateEndView.as_view(),
|
||||
name="impersonate-end",
|
||||
),
|
||||
# Interfaces
|
||||
path(
|
||||
"if/admin/",
|
||||
@ -57,12 +64,6 @@ urlpatterns = [
|
||||
),
|
||||
]
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path(
|
||||
"ws/client/", CookieMiddleware(SessionMiddleware(AuthMiddleware(MessageConsumer.as_asgi())))
|
||||
),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += [
|
||||
path("debug/policy/deny/", AccessDeniedView.as_view(), name="debug-policy-deny"),
|
||||
|
@ -12,19 +12,16 @@ from authentik.flows.challenge import (
|
||||
RedirectChallenge,
|
||||
)
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import FlowDesignation, in_memory_stage
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import (
|
||||
SESSION_KEY_APPLICATION_PRE,
|
||||
SESSION_KEY_PLAN,
|
||||
ToDefaultFlow,
|
||||
)
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.stages.consent.stage import (
|
||||
PLAN_CONTEXT_CONSENT_HEADER,
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS,
|
||||
)
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
class RedirectToAppLaunch(View):
|
||||
@ -39,10 +36,10 @@ class RedirectToAppLaunch(View):
|
||||
# Check if we're authenticated already, saves us the flow run
|
||||
if request.user.is_authenticated:
|
||||
return HttpResponseRedirect(app.get_launch_url(request.user))
|
||||
self.request.session[SESSION_KEY_APPLICATION_PRE] = app
|
||||
# otherwise, do a custom flow plan that includes the application that's
|
||||
# being accessed, to improve usability
|
||||
flow = ToDefaultFlow(request=request, designation=FlowDesignation.AUTHENTICATION).get_flow()
|
||||
tenant: Tenant = request.tenant
|
||||
flow = tenant.flow_authentication
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
|
60
authentik/core/views/impersonate.py
Normal file
60
authentik/core/views/impersonate.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""authentik impersonation views"""
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.views import View
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.middleware import (
|
||||
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
|
||||
SESSION_KEY_IMPERSONATE_USER,
|
||||
)
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class ImpersonateInitView(View):
|
||||
"""Initiate Impersonation"""
|
||||
|
||||
def get(self, request: HttpRequest, user_id: int) -> HttpResponse:
|
||||
"""Impersonation handler, checks permissions"""
|
||||
if not CONFIG.y_bool("impersonation"):
|
||||
LOGGER.debug("User attempted to impersonate", user=request.user)
|
||||
return HttpResponse("Unauthorized", status=401)
|
||||
if not request.user.has_perm("impersonate"):
|
||||
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
||||
return HttpResponse("Unauthorized", status=401)
|
||||
|
||||
user_to_be = get_object_or_404(User, pk=user_id)
|
||||
|
||||
request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
|
||||
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be
|
||||
|
||||
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
||||
|
||||
return redirect("authentik_core:if-user")
|
||||
|
||||
|
||||
class ImpersonateEndView(View):
|
||||
"""End User impersonation"""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""End Impersonation handler"""
|
||||
if (
|
||||
SESSION_KEY_IMPERSONATE_USER not in request.session
|
||||
or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session
|
||||
):
|
||||
LOGGER.debug("Can't end impersonation", user=request.user)
|
||||
return redirect("authentik_core:if-user")
|
||||
|
||||
original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||
|
||||
del request.session[SESSION_KEY_IMPERSONATE_USER]
|
||||
del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||
|
||||
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
|
||||
|
||||
return redirect("authentik_core:root-redirect")
|
@ -214,18 +214,11 @@ class Event(SerializerModel, ExpiringModel):
|
||||
Events independently from requests.
|
||||
`user` arguments optionally overrides user from requests."""
|
||||
if request:
|
||||
from authentik.flows.views.executor import QS_QUERY
|
||||
|
||||
self.context["http_request"] = {
|
||||
"path": request.path,
|
||||
"method": request.method,
|
||||
"args": QueryDict(request.META.get("QUERY_STRING", "")),
|
||||
}
|
||||
# Special case for events created during flow execution
|
||||
# since they keep the http query within a wrapped query
|
||||
if QS_QUERY in self.context["http_request"]["args"]:
|
||||
wrapped = self.context["http_request"]["args"][QS_QUERY]
|
||||
self.context["http_request"]["args"] = QueryDict(wrapped)
|
||||
if hasattr(request, "tenant"):
|
||||
tenant: Tenant = request.tenant
|
||||
# Because self.created only gets set on save, we can't use it's value here
|
||||
|
@ -23,8 +23,7 @@ class DiagramElement:
|
||||
style: list[str] = field(default_factory=lambda: ["[", "]"])
|
||||
|
||||
def __str__(self) -> str:
|
||||
description = self.description.replace('"', "#quot;")
|
||||
element = f'{self.identifier}{self.style[0]}"{description}"{self.style[1]}'
|
||||
element = f'{self.identifier}{self.style[0]}"{self.description}"{self.style[1]}'
|
||||
if self.action is not None:
|
||||
if self.action != "":
|
||||
element = f"--{self.action}--> {element}"
|
||||
|
@ -271,15 +271,6 @@ class ConfigurableStage(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
class FriendlyNamedStage(models.Model):
|
||||
"""Abstract base class for a Stage that can have a user friendly name configured."""
|
||||
|
||||
friendly_name = models.TextField(null=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class FlowToken(Token):
|
||||
"""Subclass of a standard Token, stores the currently active flow plan upon creation.
|
||||
Can be used to later resume a flow."""
|
||||
|
@ -204,12 +204,12 @@ class ChallengeStageView(StageView):
|
||||
for field, errors in response.errors.items():
|
||||
for error in errors:
|
||||
full_errors.setdefault(field, [])
|
||||
field_error = {
|
||||
"string": str(error),
|
||||
}
|
||||
if hasattr(error, "code"):
|
||||
field_error["code"] = error.code
|
||||
full_errors[field].append(field_error)
|
||||
full_errors[field].append(
|
||||
{
|
||||
"string": str(error),
|
||||
"code": error.code,
|
||||
}
|
||||
)
|
||||
challenge_response.initial_data["response_errors"] = full_errors
|
||||
if not challenge_response.is_valid():
|
||||
self.logger.error(
|
||||
|
@ -2,13 +2,10 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.flows.models import Flow, FlowDesignation
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
|
||||
|
||||
class TestHelperView(TestCase):
|
||||
@ -25,41 +22,6 @@ class TestHelperView(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, expected_url)
|
||||
|
||||
def test_default_view_app(self):
|
||||
"""Test that ToDefaultFlow returns the expected URL (when accessing an application)"""
|
||||
Flow.objects.filter(designation=FlowDesignation.AUTHENTICATION).delete()
|
||||
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||
self.client.session[SESSION_KEY_APPLICATION_PRE] = Application(
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
provider=OAuth2Provider(
|
||||
name=generate_id(),
|
||||
authentication_flow=flow,
|
||||
),
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("authentik_flows:default-authentication"),
|
||||
)
|
||||
expected_url = reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, expected_url)
|
||||
|
||||
def test_default_view_app_no_provider(self):
|
||||
"""Test that ToDefaultFlow returns the expected URL
|
||||
(when accessing an application, without a provider)"""
|
||||
Flow.objects.filter(designation=FlowDesignation.AUTHENTICATION).delete()
|
||||
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||
self.client.session[SESSION_KEY_APPLICATION_PRE] = Application(
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("authentik_flows:default-authentication"),
|
||||
)
|
||||
expected_url = reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, expected_url)
|
||||
|
||||
def test_default_view_invalid_plan(self):
|
||||
"""Test that ToDefaultFlow returns the expected URL (with an invalid plan)"""
|
||||
Flow.objects.filter(designation=FlowDesignation.INVALIDATION).delete()
|
||||
|
@ -22,7 +22,6 @@ from sentry_sdk.api import set_tag
|
||||
from sentry_sdk.hub import Hub
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction, cleanse_dict
|
||||
from authentik.flows.challenge import (
|
||||
Challenge,
|
||||
@ -69,7 +68,6 @@ SESSION_KEY_GET = "authentik/flows/get"
|
||||
SESSION_KEY_POST = "authentik/flows/post"
|
||||
SESSION_KEY_HISTORY = "authentik/flows/history"
|
||||
QS_KEY_TOKEN = "flow_token" # nosec
|
||||
QS_QUERY = "query"
|
||||
|
||||
|
||||
def challenge_types():
|
||||
@ -174,7 +172,7 @@ class FlowExecutorView(APIView):
|
||||
op="authentik.flow.executor.dispatch", description=self.flow.slug
|
||||
) as span:
|
||||
span.set_data("authentik Flow", self.flow.slug)
|
||||
get_params = QueryDict(request.GET.get(QS_QUERY, ""))
|
||||
get_params = QueryDict(request.GET.get("query", ""))
|
||||
if QS_KEY_TOKEN in get_params:
|
||||
plan = self._check_flow_token(get_params[QS_KEY_TOKEN])
|
||||
if plan:
|
||||
@ -477,32 +475,20 @@ class ToDefaultFlow(View):
|
||||
LOGGER.debug("flow_by_policy: no flow found", filters=flow_filter)
|
||||
return None
|
||||
|
||||
def get_flow(self) -> Flow:
|
||||
"""Get a flow for the selected designation"""
|
||||
tenant: Tenant = self.request.tenant
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
tenant: Tenant = request.tenant
|
||||
flow = None
|
||||
# First, attempt to get default flow from tenant
|
||||
if self.designation == FlowDesignation.AUTHENTICATION:
|
||||
flow = tenant.flow_authentication
|
||||
# Check if we have a default flow from application
|
||||
application: Optional[Application] = self.request.session.get(
|
||||
SESSION_KEY_APPLICATION_PRE
|
||||
)
|
||||
if application and application.provider and application.provider.authentication_flow:
|
||||
flow = application.provider.authentication_flow
|
||||
elif self.designation == FlowDesignation.INVALIDATION:
|
||||
if self.designation == FlowDesignation.INVALIDATION:
|
||||
flow = tenant.flow_invalidation
|
||||
if flow:
|
||||
return flow
|
||||
# If no flow was set, get the first based on slug and policy
|
||||
flow = self.flow_by_policy(self.request, designation=self.designation)
|
||||
if flow:
|
||||
return flow
|
||||
if not flow:
|
||||
flow = self.flow_by_policy(request, designation=self.designation)
|
||||
# If we still don't have a flow, 404
|
||||
raise Http404
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
flow = self.get_flow()
|
||||
if not flow:
|
||||
raise Http404
|
||||
# If user already has a pending plan, clear it so we don't have to later.
|
||||
if SESSION_KEY_PLAN in self.request.session:
|
||||
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
||||
|
@ -8,7 +8,6 @@ from typing import Any, Iterable, Optional
|
||||
from cachetools import TLRUCache, cached
|
||||
from django.core.exceptions import FieldError
|
||||
from django_otp import devices_for_user
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.serializers import ValidationError
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.tracing import Span
|
||||
@ -17,9 +16,7 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.policies.models import Policy, PolicyBinding
|
||||
from authentik.policies.process import PolicyProcess
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
from authentik.policies.types import PolicyRequest
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -40,20 +37,19 @@ class BaseEvaluator:
|
||||
# update website/docs/expressions/_objects.md
|
||||
# update website/docs/expressions/_functions.md
|
||||
self._globals = {
|
||||
"ak_call_policy": self.expr_func_call_policy,
|
||||
"ak_create_event": self.expr_event_create,
|
||||
"ak_is_group_member": BaseEvaluator.expr_is_group_member,
|
||||
"ak_logger": get_logger(self._filename).bind(),
|
||||
"ak_user_by": BaseEvaluator.expr_user_by,
|
||||
"ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator,
|
||||
"ip_address": ip_address,
|
||||
"ip_network": ip_network,
|
||||
"list_flatten": BaseEvaluator.expr_flatten,
|
||||
"regex_match": BaseEvaluator.expr_regex_match,
|
||||
"regex_replace": BaseEvaluator.expr_regex_replace,
|
||||
"requests": get_http_session(),
|
||||
"list_flatten": BaseEvaluator.expr_flatten,
|
||||
"ak_is_group_member": BaseEvaluator.expr_is_group_member,
|
||||
"ak_user_by": BaseEvaluator.expr_user_by,
|
||||
"ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator,
|
||||
"resolve_dns": BaseEvaluator.expr_resolve_dns,
|
||||
"reverse_dns": BaseEvaluator.expr_reverse_dns,
|
||||
"ak_create_event": self.expr_event_create,
|
||||
"ak_logger": get_logger(self._filename).bind(),
|
||||
"requests": get_http_session(),
|
||||
"ip_address": ip_address,
|
||||
"ip_network": ip_network,
|
||||
}
|
||||
self._context = {}
|
||||
|
||||
@ -156,19 +152,6 @@ class BaseEvaluator:
|
||||
return
|
||||
event.save()
|
||||
|
||||
def expr_func_call_policy(self, name: str, **kwargs) -> PolicyResult:
|
||||
"""Call policy by name, with current request"""
|
||||
policy = Policy.objects.filter(name=name).select_subclasses().first()
|
||||
if not policy:
|
||||
raise ValueError(f"Policy '{name}' not found.")
|
||||
user = self._context.get("user", get_anonymous_user())
|
||||
req = PolicyRequest(user)
|
||||
if "request" in self._context:
|
||||
req = self._context["request"]
|
||||
req.context.update(kwargs)
|
||||
proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None)
|
||||
return proc.profiling_wrapper()
|
||||
|
||||
def wrap_expression(self, expression: str, params: Iterable[str]) -> str:
|
||||
"""Wrap expression in a function, call it, and save the result as `result`"""
|
||||
handler_signature = ",".join(params)
|
||||
|
@ -19,12 +19,9 @@ from rest_framework.exceptions import APIException
|
||||
from sentry_sdk import HttpTransport
|
||||
from sentry_sdk import init as sentry_sdk_init
|
||||
from sentry_sdk.api import set_tag
|
||||
from sentry_sdk.integrations.argv import ArgvIntegration
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from sentry_sdk.integrations.socket import SocketIntegration
|
||||
from sentry_sdk.integrations.stdlib import StdlibIntegration
|
||||
from sentry_sdk.integrations.threading import ThreadingIntegration
|
||||
from structlog.stdlib import get_logger
|
||||
from websockets.exceptions import WebSocketException
|
||||
@ -64,13 +61,10 @@ def sentry_init(**sentry_init_kwargs):
|
||||
sentry_sdk_init(
|
||||
dsn=CONFIG.y("error_reporting.sentry_dsn"),
|
||||
integrations=[
|
||||
ArgvIntegration(),
|
||||
StdlibIntegration(),
|
||||
DjangoIntegration(transaction_style="function_name"),
|
||||
CeleryIntegration(monitor_beat_tasks=True),
|
||||
CeleryIntegration(),
|
||||
RedisIntegration(),
|
||||
ThreadingIntegration(propagate_hub=True),
|
||||
SocketIntegration(),
|
||||
],
|
||||
before_send=before_send,
|
||||
traces_sampler=traces_sampler,
|
||||
|
@ -28,7 +28,6 @@ from authentik.outposts.models import (
|
||||
)
|
||||
from authentik.providers.ldap.models import LDAPProvider
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.providers.radius.models import RadiusProvider
|
||||
|
||||
|
||||
class OutpostSerializer(ModelSerializer):
|
||||
@ -52,7 +51,6 @@ class OutpostSerializer(ModelSerializer):
|
||||
type_map = {
|
||||
OutpostType.LDAP: LDAPProvider,
|
||||
OutpostType.PROXY: ProxyProvider,
|
||||
OutpostType.RADIUS: RadiusProvider,
|
||||
None: Provider,
|
||||
}
|
||||
for provider in providers:
|
||||
|
@ -24,7 +24,6 @@ class AuthentikOutpostConfig(ManagedAppConfig):
|
||||
label = "authentik_outposts"
|
||||
verbose_name = "authentik Outpost"
|
||||
default = True
|
||||
ws_mountpoint = "authentik.outposts.urls"
|
||||
|
||||
def reconcile_load_outposts_signals(self):
|
||||
"""Load outposts signals"""
|
||||
|
@ -4,7 +4,6 @@ from typing import TYPE_CHECKING
|
||||
from django.utils.text import slugify
|
||||
from kubernetes.client import (
|
||||
AppsV1Api,
|
||||
V1Capabilities,
|
||||
V1Container,
|
||||
V1ContainerPort,
|
||||
V1Deployment,
|
||||
@ -14,12 +13,9 @@ from kubernetes.client import (
|
||||
V1LabelSelector,
|
||||
V1ObjectMeta,
|
||||
V1ObjectReference,
|
||||
V1PodSecurityContext,
|
||||
V1PodSpec,
|
||||
V1PodTemplateSpec,
|
||||
V1SeccompProfile,
|
||||
V1SecretKeySelector,
|
||||
V1SecurityContext,
|
||||
)
|
||||
|
||||
from authentik import __version__, get_full_version
|
||||
@ -107,11 +103,6 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
|
||||
image_pull_secrets=[
|
||||
V1ObjectReference(name=secret) for secret in image_pull_secrets
|
||||
],
|
||||
security_context=V1PodSecurityContext(
|
||||
seccomp_profile=V1SeccompProfile(
|
||||
type="RuntimeDefault",
|
||||
),
|
||||
),
|
||||
containers=[
|
||||
V1Container(
|
||||
name=str(self.outpost.type),
|
||||
@ -155,13 +146,6 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
|
||||
),
|
||||
),
|
||||
],
|
||||
security_context=V1SecurityContext(
|
||||
run_as_non_root=True,
|
||||
allow_privilege_escalation=False,
|
||||
capabilities=V1Capabilities(
|
||||
drop=["ALL"],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
@ -1,20 +0,0 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-20 10:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_outposts", "0019_alter_outpost_name_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="outpost",
|
||||
name="type",
|
||||
field=models.TextField(
|
||||
choices=[("proxy", "Proxy"), ("ldap", "Ldap"), ("radius", "Radius")],
|
||||
default="proxy",
|
||||
),
|
||||
),
|
||||
]
|
@ -94,7 +94,6 @@ class OutpostType(models.TextChoices):
|
||||
|
||||
PROXY = "proxy"
|
||||
LDAP = "ldap"
|
||||
RADIUS = "radius"
|
||||
|
||||
|
||||
def default_outpost_config(host: Optional[str] = None):
|
||||
|
@ -7,7 +7,6 @@ from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.core.cache import cache
|
||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from django.db.models.base import Model
|
||||
@ -43,6 +42,7 @@ from authentik.providers.ldap.controllers.kubernetes import LDAPKubernetesContro
|
||||
from authentik.providers.proxy.controllers.docker import ProxyDockerController
|
||||
from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.root.messages.storage import closing_send
|
||||
|
||||
LOGGER = get_logger()
|
||||
CACHE_KEY_OUTPOST_DOWN = "outpost_teardown_%s"
|
||||
@ -214,29 +214,26 @@ def outpost_post_save(model_class: str, model_pk: Any):
|
||||
outpost_send_update(reverse)
|
||||
|
||||
|
||||
def outpost_send_update(model_instance: Model):
|
||||
def outpost_send_update(model_instace: Model):
|
||||
"""Send outpost update to all registered outposts, regardless to which authentik
|
||||
instance they are connected"""
|
||||
channel_layer = get_channel_layer()
|
||||
if isinstance(model_instance, OutpostModel):
|
||||
for outpost in model_instance.outpost_set.all():
|
||||
_outpost_single_update(outpost, channel_layer)
|
||||
elif isinstance(model_instance, Outpost):
|
||||
_outpost_single_update(model_instance, channel_layer)
|
||||
if isinstance(model_instace, OutpostModel):
|
||||
for outpost in model_instace.outpost_set.all():
|
||||
_outpost_single_update(outpost)
|
||||
elif isinstance(model_instace, Outpost):
|
||||
_outpost_single_update(model_instace)
|
||||
|
||||
|
||||
def _outpost_single_update(outpost: Outpost, layer=None):
|
||||
def _outpost_single_update(outpost: Outpost):
|
||||
"""Update outpost instances connected to a single outpost"""
|
||||
# Ensure token again, because this function is called when anything related to an
|
||||
# OutpostModel is saved, so we can be sure permissions are right
|
||||
_ = outpost.token
|
||||
outpost.build_user_permissions(outpost.user)
|
||||
if not layer: # pragma: no cover
|
||||
layer = get_channel_layer()
|
||||
for state in OutpostState.for_outpost(outpost):
|
||||
for channel in state.channel_ids:
|
||||
LOGGER.debug("sending update", channel=channel, instance=state.uid, outpost=outpost)
|
||||
async_to_sync(layer.send)(channel, {"type": "event.update"})
|
||||
async_to_sync(closing_send)(channel, {"type": "event.update"})
|
||||
|
||||
|
||||
@CELERY_APP.task(
|
||||
|
@ -1,8 +0,0 @@
|
||||
"""Outpost Websocket URLS"""
|
||||
from django.urls import path
|
||||
|
||||
from authentik.outposts.channels import OutpostConsumer
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path("ws/outpost/<uuid:pk>/", OutpostConsumer.as_asgi()),
|
||||
]
|
@ -9,6 +9,8 @@ from authentik.flows.planner import PLAN_CONTEXT_SSO
|
||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.policies.exceptions import PolicyException
|
||||
from authentik.policies.models import Policy, PolicyBinding
|
||||
from authentik.policies.process import PolicyProcess
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -30,11 +32,22 @@ class PolicyEvaluator(BaseEvaluator):
|
||||
# update website/docs/expressions/_functions.md
|
||||
self._context["ak_message"] = self.expr_func_message
|
||||
self._context["ak_user_has_authenticator"] = self.expr_func_user_has_authenticator
|
||||
self._context["ak_call_policy"] = self.expr_func_call_policy
|
||||
|
||||
def expr_func_message(self, message: str):
|
||||
"""Wrapper to append to messages list, which is returned with PolicyResult"""
|
||||
self._messages.append(message)
|
||||
|
||||
def expr_func_call_policy(self, name: str, **kwargs) -> PolicyResult:
|
||||
"""Call policy by name, with current request"""
|
||||
policy = Policy.objects.filter(name=name).select_subclasses().first()
|
||||
if not policy:
|
||||
raise ValueError(f"Policy '{name}' not found.")
|
||||
req: PolicyRequest = self._context["request"]
|
||||
req.context.update(kwargs)
|
||||
proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None)
|
||||
return proc.profiling_wrapper()
|
||||
|
||||
def set_policy_request(self, request: PolicyRequest):
|
||||
"""Update context based on policy request (if http request is given, update that too)"""
|
||||
# update website/docs/expressions/_objects.md
|
||||
@ -70,7 +83,6 @@ class PolicyEvaluator(BaseEvaluator):
|
||||
return PolicyResult(False, str(exc))
|
||||
else:
|
||||
policy_result = PolicyResult(False, *self._messages)
|
||||
policy_result.raw_result = result
|
||||
if result is None:
|
||||
LOGGER.warning(
|
||||
"Expression policy returned None",
|
||||
|
@ -54,7 +54,6 @@ class TestPasswordPolicyFlow(FlowTestCase):
|
||||
component="ak-stage-prompt",
|
||||
fields=[
|
||||
{
|
||||
"choices": None,
|
||||
"field_key": "password",
|
||||
"label": "PASSWORD_LABEL",
|
||||
"order": 0,
|
||||
|
@ -132,9 +132,9 @@ class TestPolicyProcess(TestCase):
|
||||
)
|
||||
binding = PolicyBinding(policy=policy, target=Application.objects.create(name="test"))
|
||||
|
||||
http_request = self.factory.get(reverse("authentik_api:user-impersonate-end"))
|
||||
http_request = self.factory.get(reverse("authentik_core:impersonate-end"))
|
||||
http_request.user = self.user
|
||||
http_request.resolver_match = resolve(reverse("authentik_api:user-impersonate-end"))
|
||||
http_request.resolver_match = resolve(reverse("authentik_core:impersonate-end"))
|
||||
|
||||
request = PolicyRequest(self.user)
|
||||
request.set_http_request(http_request)
|
||||
|
@ -69,11 +69,10 @@ class PolicyRequest:
|
||||
|
||||
@dataclass
|
||||
class PolicyResult:
|
||||
"""Result from evaluating a policy."""
|
||||
"""Small data-class to hold policy results"""
|
||||
|
||||
passing: bool
|
||||
messages: tuple[str, ...]
|
||||
raw_result: Any
|
||||
|
||||
source_binding: Optional["PolicyBinding"]
|
||||
source_results: Optional[list["PolicyResult"]]
|
||||
@ -84,7 +83,6 @@ class PolicyResult:
|
||||
super().__init__()
|
||||
self.passing = passing
|
||||
self.messages = messages
|
||||
self.raw_result = None
|
||||
self.source_binding = None
|
||||
self.source_results = []
|
||||
self.log_messages = []
|
||||
|
@ -3,8 +3,6 @@
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
import authentik.providers.oauth2.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
@ -39,14 +37,4 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="client_secret",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default=authentik.providers.oauth2.models.generate_client_secret,
|
||||
max_length=255,
|
||||
verbose_name="Client Secret",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@ -27,11 +27,6 @@ from authentik.providers.oauth2.id_token import IDToken, SubModes
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
|
||||
def generate_client_secret() -> str:
|
||||
"""Generate client secret with adequate length"""
|
||||
return generate_id(128)
|
||||
|
||||
|
||||
class ClientTypes(models.TextChoices):
|
||||
"""Confidential clients are capable of maintaining the confidentiality
|
||||
of their credentials. Public clients are incapable."""
|
||||
@ -137,7 +132,7 @@ class OAuth2Provider(Provider):
|
||||
max_length=255,
|
||||
blank=True,
|
||||
verbose_name=_("Client Secret"),
|
||||
default=generate_client_secret,
|
||||
default=generate_key,
|
||||
)
|
||||
redirect_uris = models.TextField(
|
||||
default="",
|
||||
|
@ -7,6 +7,7 @@ from rest_framework.test import APITestCase
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
|
||||
|
||||
@ -17,6 +18,8 @@ class TestAPI(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
)
|
||||
|
@ -9,7 +9,7 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.challenge import ChallengeTypes
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.constants import TOKEN_TYPE
|
||||
from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, RedirectUriError
|
||||
@ -298,6 +298,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
signing_key=self.keypair,
|
||||
@ -360,6 +361,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
signing_key=self.keypair,
|
||||
@ -415,6 +417,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
signing_key=self.keypair,
|
||||
@ -464,6 +467,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
signing_key=self.keypair,
|
||||
|
@ -8,7 +8,7 @@ from django.utils import timezone
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
@ -21,6 +21,8 @@ class TesOAuth2Introspection(OAuthTestCase):
|
||||
super().setUp()
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
signing_key=create_test_cert(),
|
||||
|
@ -8,7 +8,7 @@ from django.utils import timezone
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
@ -20,6 +20,8 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
super().setUp()
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
signing_key=create_test_cert(),
|
||||
|
@ -38,6 +38,8 @@ class TestToken(OAuthTestCase):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://TestServer",
|
||||
signing_key=self.keypair,
|
||||
@ -65,6 +67,8 @@ class TestToken(OAuthTestCase):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=self.keypair,
|
||||
@ -86,6 +90,8 @@ class TestToken(OAuthTestCase):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
signing_key=self.keypair,
|
||||
@ -114,6 +120,8 @@ class TestToken(OAuthTestCase):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
signing_key=self.keypair,
|
||||
@ -155,6 +163,8 @@ class TestToken(OAuthTestCase):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
signing_key=self.keypair,
|
||||
@ -205,6 +215,8 @@ class TestToken(OAuthTestCase):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
signing_key=self.keypair,
|
||||
@ -251,6 +263,8 @@ class TestToken(OAuthTestCase):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=self.keypair,
|
||||
|
@ -8,6 +8,7 @@ from jwt import decode
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import USER_ATTRIBUTE_SA, Application, Group, Token, TokenIntents
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.constants import (
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
@ -30,6 +31,8 @@ class TestTokenClientCredentials(OAuthTestCase):
|
||||
self.factory = RequestFactory()
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=create_test_cert(),
|
||||
|
@ -9,7 +9,7 @@ from jwt import decode
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.constants import (
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
@ -39,7 +39,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
slug=generate_id(),
|
||||
provider_type="openidconnect",
|
||||
consumer_key=generate_id(),
|
||||
consumer_secret=generate_id(),
|
||||
consumer_secret=generate_key(),
|
||||
authorization_url="http://foo",
|
||||
access_token_url=f"http://{generate_id()}",
|
||||
profile_url="http://foo",
|
||||
@ -52,6 +52,8 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=self.cert,
|
||||
|
@ -7,7 +7,7 @@ from django.urls import reverse
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_code_fixed_length, generate_id
|
||||
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
|
||||
from authentik.providers.oauth2.constants import GRANT_TYPE_DEVICE_CODE
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
@ -22,6 +22,8 @@ class TestTokenDeviceCode(OAuthTestCase):
|
||||
self.factory = RequestFactory()
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=create_test_cert(),
|
||||
|
@ -9,7 +9,7 @@ from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
@ -23,6 +23,8 @@ class TestUserinfo(OAuthTestCase):
|
||||
self.app = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
signing_key=create_test_cert(),
|
||||
|
@ -1,65 +0,0 @@
|
||||
"""RadiusProvider API Views"""
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.providers.radius.models import RadiusProvider
|
||||
|
||||
|
||||
class RadiusProviderSerializer(ProviderSerializer):
|
||||
"""RadiusProvider Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = RadiusProvider
|
||||
fields = ProviderSerializer.Meta.fields + [
|
||||
"client_networks",
|
||||
# Shared secret is not a write-only field, as
|
||||
# an admin might have to view it
|
||||
"shared_secret",
|
||||
]
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
||||
|
||||
|
||||
class RadiusProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"""RadiusProvider Viewset"""
|
||||
|
||||
queryset = RadiusProvider.objects.all()
|
||||
serializer_class = RadiusProviderSerializer
|
||||
ordering = ["name"]
|
||||
search_fields = ["name", "client_networks"]
|
||||
filterset_fields = {
|
||||
"application": ["isnull"],
|
||||
"name": ["iexact"],
|
||||
"authorization_flow__slug": ["iexact"],
|
||||
"client_networks": ["iexact"],
|
||||
}
|
||||
|
||||
|
||||
class RadiusOutpostConfigSerializer(ModelSerializer):
|
||||
"""RadiusProvider Serializer"""
|
||||
|
||||
application_slug = CharField(source="application.slug")
|
||||
auth_flow_slug = CharField(source="authorization_flow.slug")
|
||||
|
||||
class Meta:
|
||||
model = RadiusProvider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"application_slug",
|
||||
"auth_flow_slug",
|
||||
"client_networks",
|
||||
"shared_secret",
|
||||
]
|
||||
|
||||
|
||||
class RadiusOutpostConfigViewSet(ReadOnlyModelViewSet):
|
||||
"""RadiusProvider Viewset"""
|
||||
|
||||
queryset = RadiusProvider.objects.filter(application__isnull=False)
|
||||
serializer_class = RadiusOutpostConfigSerializer
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
@ -1,10 +0,0 @@
|
||||
"""authentik radius provider app config"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthentikProviderRadiusConfig(AppConfig):
|
||||
"""authentik radius provider app config"""
|
||||
|
||||
name = "authentik.providers.radius"
|
||||
label = "authentik_providers_radius"
|
||||
verbose_name = "authentik Providers.Radius"
|
@ -1,14 +0,0 @@
|
||||
"""Radius Provider Docker Controller"""
|
||||
from authentik.outposts.controllers.base import DeploymentPort
|
||||
from authentik.outposts.controllers.docker import DockerController
|
||||
from authentik.outposts.models import DockerServiceConnection, Outpost
|
||||
|
||||
|
||||
class RadiusDockerController(DockerController):
|
||||
"""Radius Provider Docker Controller"""
|
||||
|
||||
def __init__(self, outpost: Outpost, connection: DockerServiceConnection):
|
||||
super().__init__(outpost, connection)
|
||||
self.deployment_ports = [
|
||||
DeploymentPort(1812, "radius", "udp", 1812),
|
||||
]
|
@ -1,14 +0,0 @@
|
||||
"""Radius Provider Kubernetes Controller"""
|
||||
from authentik.outposts.controllers.base import DeploymentPort
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
from authentik.outposts.models import KubernetesServiceConnection, Outpost
|
||||
|
||||
|
||||
class RadiusKubernetesController(KubernetesController):
|
||||
"""Radius Provider Kubernetes Controller"""
|
||||
|
||||
def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection):
|
||||
super().__init__(outpost, connection)
|
||||
self.deployment_ports = [
|
||||
DeploymentPort(1812, "radius", "udp", 1812),
|
||||
]
|
@ -1,52 +0,0 @@
|
||||
# Generated by Django 4.1.7 on 2023-03-20 10:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import authentik.lib.generators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0027_alter_user_uuid"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="RadiusProvider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_core.provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"shared_secret",
|
||||
models.TextField(
|
||||
default=authentik.lib.generators.generate_id,
|
||||
help_text="Shared secret between clients and server to hash packets.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_networks",
|
||||
models.TextField(
|
||||
default="0.0.0.0/0, ::/0",
|
||||
help_text="List of CIDRs (comma-separated) that clients can connect from. A more specific CIDR will match before a looser one. Clients connecting from a non-specified CIDR will be dropped.",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Radius Provider",
|
||||
"verbose_name_plural": "Radius Providers",
|
||||
},
|
||||
bases=("authentik_core.provider", models.Model),
|
||||
),
|
||||
]
|
@ -1,50 +0,0 @@
|
||||
"""Radius Provider"""
|
||||
from typing import Optional, Type
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import Provider
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.models import OutpostModel
|
||||
|
||||
|
||||
class RadiusProvider(OutpostModel, Provider):
|
||||
"""Allow applications to authenticate against authentik's users using Radius."""
|
||||
|
||||
shared_secret = models.TextField(
|
||||
default=generate_id,
|
||||
help_text=_("Shared secret between clients and server to hash packets."),
|
||||
)
|
||||
|
||||
client_networks = models.TextField(
|
||||
default="0.0.0.0/0, ::/0",
|
||||
help_text=_(
|
||||
"List of CIDRs (comma-separated) that clients can connect from. A more specific "
|
||||
"CIDR will match before a looser one. Clients connecting from a non-specified CIDR "
|
||||
"will be dropped."
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def launch_url(self) -> Optional[str]:
|
||||
"""Radius never has a launch URL"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
return "ak-provider-radius-form"
|
||||
|
||||
@property
|
||||
def serializer(self) -> Type[Serializer]:
|
||||
from authentik.providers.radius.api import RadiusProviderSerializer
|
||||
|
||||
return RadiusProviderSerializer
|
||||
|
||||
def __str__(self):
|
||||
return f"Radius Provider {self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Radius Provider")
|
||||
verbose_name_plural = _("Radius Providers")
|
@ -34,16 +34,8 @@ def pre_delete_scim(sender: type[Model], instance: User | Group, **_):
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=User.ak_groups.through)
|
||||
def m2m_changed_scim(
|
||||
sender: type[Model], instance, action: str, pk_set: set, reverse: bool, **kwargs
|
||||
):
|
||||
def m2m_changed_scim(sender: type[Model], instance, action: str, pk_set: set, **kwargs):
|
||||
"""Sync group membership"""
|
||||
if action not in ["post_add", "post_remove"]:
|
||||
return
|
||||
# reverse: instance is a Group, pk_set is a list of user pks
|
||||
# non-reverse: instance is a User, pk_set is a list of groups
|
||||
if reverse:
|
||||
scim_signal_m2m.delay(str(instance.pk), action, list(pk_set))
|
||||
else:
|
||||
for group_pk in pk_set:
|
||||
scim_signal_m2m.delay(group_pk, action, [instance.pk])
|
||||
scim_signal_m2m.delay(str(instance.pk), action, list(pk_set))
|
||||
|
@ -151,7 +151,7 @@ def scim_signal_direct(model: str, pk: Any, raw_op: str):
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def scim_signal_m2m(group_pk: str, action: str, pk_set: list[int]):
|
||||
def scim_signal_m2m(group_pk: str, action: str, pk_set: set[int]):
|
||||
"""Update m2m (group membership)"""
|
||||
group = Group.objects.filter(pk=group_pk).first()
|
||||
if not group:
|
||||
|
@ -82,11 +82,9 @@ class SCIMMembershipTests(TestCase):
|
||||
mocker.request_history[3].body,
|
||||
{
|
||||
"emails": [],
|
||||
"active": True,
|
||||
"externalId": user.uid,
|
||||
"name": {"familyName": "", "formatted": "", "givenName": ""},
|
||||
"photos": [],
|
||||
"displayName": "",
|
||||
"userName": user.username,
|
||||
},
|
||||
)
|
||||
@ -165,8 +163,6 @@ class SCIMMembershipTests(TestCase):
|
||||
self.assertJSONEqual(
|
||||
mocker.request_history[3].body,
|
||||
{
|
||||
"active": True,
|
||||
"displayName": "",
|
||||
"emails": [],
|
||||
"externalId": user.uid,
|
||||
"name": {"familyName": "", "formatted": "", "givenName": ""},
|
||||
|
@ -61,7 +61,6 @@ class SCIMUserTests(TestCase):
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[1].body,
|
||||
{
|
||||
"active": True,
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
@ -75,7 +74,6 @@ class SCIMUserTests(TestCase):
|
||||
"formatted": uid,
|
||||
"givenName": uid,
|
||||
},
|
||||
"displayName": uid,
|
||||
"photos": [],
|
||||
"userName": uid,
|
||||
},
|
||||
@ -117,7 +115,6 @@ class SCIMUserTests(TestCase):
|
||||
self.assertEqual(
|
||||
body,
|
||||
{
|
||||
"active": True,
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
@ -125,7 +122,6 @@ class SCIMUserTests(TestCase):
|
||||
"value": f"{uid}@goauthentik.io",
|
||||
}
|
||||
],
|
||||
"displayName": uid,
|
||||
"externalId": user.uid,
|
||||
"name": {
|
||||
"familyName": "",
|
||||
@ -170,7 +166,6 @@ class SCIMUserTests(TestCase):
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[1].body,
|
||||
{
|
||||
"active": True,
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
@ -184,7 +179,6 @@ class SCIMUserTests(TestCase):
|
||||
"formatted": uid,
|
||||
"givenName": uid,
|
||||
},
|
||||
"displayName": uid,
|
||||
"photos": [],
|
||||
"userName": uid,
|
||||
},
|
||||
@ -238,7 +232,6 @@ class SCIMUserTests(TestCase):
|
||||
self.assertJSONEqual(
|
||||
mock.request_history[1].body,
|
||||
{
|
||||
"active": True,
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
@ -252,7 +245,6 @@ class SCIMUserTests(TestCase):
|
||||
"formatted": uid,
|
||||
"givenName": uid,
|
||||
},
|
||||
"displayName": uid,
|
||||
"photos": [],
|
||||
"userName": uid,
|
||||
},
|
||||
|
@ -2,12 +2,9 @@
|
||||
import os
|
||||
from contextvars import ContextVar
|
||||
from logging.config import dictConfig
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
from typing import Callable
|
||||
|
||||
from celery import Celery, bootsteps
|
||||
from celery.apps.worker import Worker
|
||||
from celery import Celery
|
||||
from celery.signals import (
|
||||
after_task_publish,
|
||||
setup_logging,
|
||||
@ -31,7 +28,6 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
|
||||
LOGGER = get_logger()
|
||||
CELERY_APP = Celery("authentik")
|
||||
CTX_TASK_ID = ContextVar(STRUCTLOG_KEY_PREFIX + "task_id", default=Ellipsis)
|
||||
HEARTBEAT_FILE = Path(gettempdir() + "/authentik-worker")
|
||||
|
||||
|
||||
@setup_logging.connect
|
||||
@ -103,33 +99,6 @@ def worker_ready_hook(*args, **kwargs):
|
||||
start_blueprint_watcher()
|
||||
|
||||
|
||||
class LivenessProbe(bootsteps.StartStopStep):
|
||||
"""Add a timed task to touch a temporary file for healthchecking reasons"""
|
||||
|
||||
requires = {"celery.worker.components:Timer"}
|
||||
|
||||
def __init__(self, parent, **kwargs):
|
||||
super().__init__(parent, **kwargs)
|
||||
self.requests = []
|
||||
self.tref = None
|
||||
|
||||
def start(self, parent: Worker):
|
||||
self.tref = parent.timer.call_repeatedly(
|
||||
10.0,
|
||||
self.update_heartbeat_file,
|
||||
(parent,),
|
||||
priority=10,
|
||||
)
|
||||
self.update_heartbeat_file(parent)
|
||||
|
||||
def stop(self, parent: Worker):
|
||||
HEARTBEAT_FILE.unlink(missing_ok=True)
|
||||
|
||||
def update_heartbeat_file(self, worker: Worker):
|
||||
"""Touch heartbeat file"""
|
||||
HEARTBEAT_FILE.touch()
|
||||
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
@ -138,4 +107,3 @@ CELERY_APP.config_from_object(settings, namespace="CELERY")
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
CELERY_APP.autodiscover_tasks()
|
||||
CELERY_APP.steps["worker"].add(LivenessProbe)
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Channels Messages storage"""
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from channels import DEFAULT_CHANNEL_LAYER
|
||||
from channels.layers import channel_layers
|
||||
from django.contrib.messages.storage.base import Message
|
||||
from django.contrib.messages.storage.session import SessionStorage
|
||||
from django.core.cache import cache
|
||||
@ -10,13 +11,21 @@ SESSION_KEY = "_messages"
|
||||
CACHE_PREFIX = "goauthentik.io/root/messages_"
|
||||
|
||||
|
||||
async def closing_send(channel, message):
|
||||
"""Wrapper around layer send that closes the connection"""
|
||||
# See https://github.com/django/channels_redis/issues/332
|
||||
# TODO: Remove this after channels_redis 4.1 is released
|
||||
channel_layer = channel_layers.make_backend(DEFAULT_CHANNEL_LAYER)
|
||||
await channel_layer.send(channel, message)
|
||||
await channel_layer.close_pools()
|
||||
|
||||
|
||||
class ChannelsStorage(SessionStorage):
|
||||
"""Send contrib.messages over websocket"""
|
||||
|
||||
def __init__(self, request: HttpRequest) -> None:
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
super().__init__(request)
|
||||
self.channel = get_channel_layer()
|
||||
|
||||
def _store(self, messages: list[Message], response, *args, **kwargs):
|
||||
prefix = f"{CACHE_PREFIX}{self.request.session.session_key}_messages_"
|
||||
@ -28,7 +37,7 @@ class ChannelsStorage(SessionStorage):
|
||||
for key in keys:
|
||||
uid = key.replace(prefix, "")
|
||||
for message in messages:
|
||||
async_to_sync(self.channel.send)(
|
||||
async_to_sync(closing_send)(
|
||||
uid,
|
||||
{
|
||||
"type": "event.update",
|
||||
|
@ -79,7 +79,6 @@ INSTALLED_APPS = [
|
||||
"authentik.providers.ldap",
|
||||
"authentik.providers.oauth2",
|
||||
"authentik.providers.proxy",
|
||||
"authentik.providers.radius",
|
||||
"authentik.providers.saml",
|
||||
"authentik.providers.scim",
|
||||
"authentik.recovery",
|
||||
@ -276,10 +275,6 @@ DATABASES = {
|
||||
"USER": CONFIG.y("postgresql.user"),
|
||||
"PASSWORD": CONFIG.y("postgresql.password"),
|
||||
"PORT": int(CONFIG.y("postgresql.port")),
|
||||
"SSLMODE": CONFIG.y("postgresql.sslmode"),
|
||||
"SSLROOTCERT": CONFIG.y("postgresql.sslrootcert"),
|
||||
"SSLCERT": CONFIG.y("postgresql.sslcert"),
|
||||
"SSLKEY": CONFIG.y("postgresql.sslkey"),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@ class PytestTestRunner: # pragma: no cover
|
||||
self.failfast = failfast
|
||||
self.keepdb = keepdb
|
||||
|
||||
self.args = ["-vv", "--full-trace"]
|
||||
self.args = ["-vv"]
|
||||
if self.failfast:
|
||||
self.args.append("--exitfirst")
|
||||
if self.keepdb:
|
||||
|
@ -1,21 +1,15 @@
|
||||
"""root Websocket URLS"""
|
||||
from importlib import import_module
|
||||
from channels.auth import AuthMiddleware
|
||||
from channels.sessions import CookieMiddleware
|
||||
from django.urls import path
|
||||
|
||||
from structlog.stdlib import get_logger
|
||||
from authentik.outposts.channels import OutpostConsumer
|
||||
from authentik.root.asgi_middleware import SessionMiddleware
|
||||
from authentik.root.messages.consumer import MessageConsumer
|
||||
|
||||
from authentik.lib.utils.reflection import get_apps
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
websocket_urlpatterns = []
|
||||
for _authentik_app in get_apps():
|
||||
mountpoint = getattr(_authentik_app, "ws_mountpoint", None)
|
||||
if not mountpoint:
|
||||
continue
|
||||
ws_paths = import_module(mountpoint)
|
||||
websocket_urlpatterns.extend(getattr(ws_paths, "websocket_urlpatterns"))
|
||||
LOGGER.debug(
|
||||
"Mounted URLs",
|
||||
app_name=_authentik_app.name,
|
||||
app_mountpoint=mountpoint,
|
||||
)
|
||||
websocket_urlpatterns = [
|
||||
path("ws/outpost/<uuid:pk>/", OutpostConsumer.as_asgi()),
|
||||
path(
|
||||
"ws/client/", CookieMiddleware(SessionMiddleware(AuthMiddleware(MessageConsumer.as_asgi())))
|
||||
),
|
||||
]
|
||||
|
@ -2,12 +2,13 @@
|
||||
from typing import Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from ldap3 import Connection
|
||||
from ldap3.core.exceptions import LDAPException, LDAPInvalidCredentialsResult
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.auth import InbuiltBackend
|
||||
from authentik.core.models import User
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
from authentik.sources.ldap.models import LDAP_TIMEOUT, LDAPSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
LDAP_DISTINGUISHED_NAME = "distinguishedName"
|
||||
@ -57,11 +58,12 @@ class LDAPBackend(InbuiltBackend):
|
||||
# Try to bind as new user
|
||||
LOGGER.debug("Attempting Binding as user", user=user)
|
||||
try:
|
||||
temp_connection = source.connection(
|
||||
connection_kwargs={
|
||||
"user": user.attributes.get(LDAP_DISTINGUISHED_NAME),
|
||||
"password": password,
|
||||
}
|
||||
temp_connection = Connection(
|
||||
source.server,
|
||||
user=user.attributes.get(LDAP_DISTINGUISHED_NAME),
|
||||
password=password,
|
||||
raise_exceptions=True,
|
||||
receive_timeout=LDAP_TIMEOUT,
|
||||
)
|
||||
temp_connection.bind()
|
||||
return user
|
||||
|
@ -1,11 +1,9 @@
|
||||
"""authentik LDAP Models"""
|
||||
from ssl import CERT_REQUIRED
|
||||
from typing import Optional
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls
|
||||
from ldap3.core.exceptions import LDAPSchemaError
|
||||
from ldap3 import ALL, RANDOM, Connection, Server, ServerPool, Tls
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import Group, PropertyMapping, Source
|
||||
@ -105,7 +103,8 @@ class LDAPSource(Source):
|
||||
|
||||
return LDAPSourceSerializer
|
||||
|
||||
def server(self, **kwargs) -> Server:
|
||||
@property
|
||||
def server(self) -> Server:
|
||||
"""Get LDAP Server/ServerPool"""
|
||||
servers = []
|
||||
tls_kwargs = {}
|
||||
@ -114,45 +113,32 @@ class LDAPSource(Source):
|
||||
tls_kwargs["validate"] = CERT_REQUIRED
|
||||
if ciphers := CONFIG.y("ldap.tls.ciphers", None):
|
||||
tls_kwargs["ciphers"] = ciphers.strip()
|
||||
server_kwargs = {
|
||||
kwargs = {
|
||||
"get_info": ALL,
|
||||
"connect_timeout": LDAP_TIMEOUT,
|
||||
"tls": Tls(**tls_kwargs),
|
||||
}
|
||||
server_kwargs.update(kwargs)
|
||||
if "," in self.server_uri:
|
||||
for server in self.server_uri.split(","):
|
||||
servers.append(Server(server, **server_kwargs))
|
||||
servers.append(Server(server, **kwargs))
|
||||
else:
|
||||
servers = [Server(self.server_uri, **server_kwargs)]
|
||||
servers = [Server(self.server_uri, **kwargs)]
|
||||
return ServerPool(servers, RANDOM, active=True, exhaust=True)
|
||||
|
||||
def connection(
|
||||
self, server_kwargs: Optional[dict] = None, connection_kwargs: Optional[dict] = None
|
||||
) -> Connection:
|
||||
@property
|
||||
def connection(self) -> Connection:
|
||||
"""Get a fully connected and bound LDAP Connection"""
|
||||
server_kwargs = server_kwargs or {}
|
||||
connection_kwargs = connection_kwargs or {}
|
||||
connection_kwargs.setdefault("user", self.bind_cn)
|
||||
connection_kwargs.setdefault("password", self.bind_password)
|
||||
connection = Connection(
|
||||
self.server(**server_kwargs),
|
||||
self.server,
|
||||
raise_exceptions=True,
|
||||
user=self.bind_cn,
|
||||
password=self.bind_password,
|
||||
receive_timeout=LDAP_TIMEOUT,
|
||||
**connection_kwargs,
|
||||
)
|
||||
|
||||
if self.start_tls:
|
||||
connection.start_tls(read_server_info=False)
|
||||
try:
|
||||
connection.bind()
|
||||
except LDAPSchemaError as exc:
|
||||
# Schema error, so try connecting without schema info
|
||||
# See https://github.com/goauthentik/authentik/issues/4590
|
||||
if server_kwargs.get("get_info", ALL) == NONE:
|
||||
raise exc
|
||||
server_kwargs["get_info"] = NONE
|
||||
return self.connection(server_kwargs, connection_kwargs)
|
||||
connection.bind()
|
||||
return connection
|
||||
|
||||
class Meta:
|
||||
|
@ -47,11 +47,10 @@ class LDAPPasswordChanger:
|
||||
|
||||
def __init__(self, source: LDAPSource) -> None:
|
||||
self._source = source
|
||||
self._connection = source.connection()
|
||||
|
||||
def get_domain_root_dn(self) -> str:
|
||||
"""Attempt to get root DN via MS specific fields or generic LDAP fields"""
|
||||
info = self._connection.server.info
|
||||
info = self._source.connection.server.info
|
||||
if "rootDomainNamingContext" in info.other:
|
||||
return info.other["rootDomainNamingContext"][0]
|
||||
naming_contexts = info.naming_contexts
|
||||
@ -62,7 +61,7 @@ class LDAPPasswordChanger:
|
||||
"""Check if DOMAIN_PASSWORD_COMPLEX is enabled"""
|
||||
root_dn = self.get_domain_root_dn()
|
||||
try:
|
||||
root_attrs = self._connection.extend.standard.paged_search(
|
||||
root_attrs = self._source.connection.extend.standard.paged_search(
|
||||
search_base=root_dn,
|
||||
search_filter="(objectClass=*)",
|
||||
search_scope=BASE,
|
||||
@ -91,14 +90,14 @@ class LDAPPasswordChanger:
|
||||
LOGGER.info(f"User has no {LDAP_DISTINGUISHED_NAME} set.")
|
||||
return
|
||||
try:
|
||||
self._connection.extend.microsoft.modify_password(user_dn, password)
|
||||
self._source.connection.extend.microsoft.modify_password(user_dn, password)
|
||||
except LDAPAttributeError:
|
||||
self._connection.extend.standard.modify_password(user_dn, new_password=password)
|
||||
self._source.connection.extend.standard.modify_password(user_dn, new_password=password)
|
||||
|
||||
def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
|
||||
"""Check if a password contains sAMAccount or displayName"""
|
||||
users = list(
|
||||
self._connection.extend.standard.paged_search(
|
||||
self._source.connection.extend.standard.paged_search(
|
||||
search_base=user_dn,
|
||||
search_filter=self._source.user_object_filter,
|
||||
search_scope=BASE,
|
||||
|
@ -63,8 +63,8 @@ def ldap_sync_password(sender, user: User, password: str, **_):
|
||||
if not sources.exists():
|
||||
return
|
||||
source = sources.first()
|
||||
changer = LDAPPasswordChanger(source)
|
||||
try:
|
||||
changer = LDAPPasswordChanger(source)
|
||||
changer.change_password(user, password)
|
||||
except LDAPOperationResult as exc:
|
||||
Event.new(
|
||||
|
@ -3,7 +3,6 @@ from typing import Any, Generator
|
||||
|
||||
from django.db.models.base import Model
|
||||
from django.db.models.query import QuerySet
|
||||
from ldap3 import Connection
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
@ -20,12 +19,10 @@ class BaseLDAPSynchronizer:
|
||||
|
||||
_source: LDAPSource
|
||||
_logger: BoundLogger
|
||||
_connection: Connection
|
||||
_messages: list[str]
|
||||
|
||||
def __init__(self, source: LDAPSource):
|
||||
self._source = source
|
||||
self._connection = source.connection()
|
||||
self._messages = []
|
||||
self._logger = get_logger().bind(source=source, syncer=self.__class__.__name__)
|
||||
|
||||
|
@ -14,7 +14,7 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
"""Sync LDAP Users and groups into authentik"""
|
||||
|
||||
def get_objects(self, **kwargs) -> Generator:
|
||||
return self._connection.extend.standard.paged_search(
|
||||
return self._source.connection.extend.standard.paged_search(
|
||||
search_base=self.base_dn_groups,
|
||||
search_filter=self._source.group_object_filter,
|
||||
search_scope=SUBTREE,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user