Compare commits

..

1 Commits

Author SHA1 Message Date
86c1d60093 web: Flesh out static config exports. 2025-04-10 17:16:18 +02:00
449 changed files with 40852 additions and 67202 deletions

View File

@ -10,9 +10,6 @@ insert_final_newline = true
[*.html] [*.html]
indent_size = 2 indent_size = 2
[schemas/*.json]
indent_size = 2
[*.{yaml,yml}] [*.{yaml,yml}]
indent_size = 2 indent_size = 2

View File

@ -28,9 +28,9 @@ runs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: package.json node-version-file: web/package.json
cache: "npm" cache: "npm"
cache-dependency-path: package-lock.json cache-dependency-path: web/package-lock.json
- name: Setup go - name: Setup go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
@ -44,7 +44,7 @@ runs:
run: | run: |
export PSQL_TAG=${{ inputs.postgresql_version }} export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/docker-compose.yml up -d docker compose -f .github/actions/setup/docker-compose.yml up -d
npm ci cd web && npm ci
- name: Generate config - name: Generate config
shell: uv run python {0} shell: uv run python {0}
run: | run: |

View File

@ -1,5 +1,5 @@
# Re-usable workflow for a single-architecture build # Re-usable workflow for a single-architecture build
name: "Single-arch Container build" name: Single-arch Container build
on: on:
workflow_call: workflow_call:
@ -42,7 +42,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3.6.0 - uses: docker/setup-qemu-action@v3.6.0
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: Prepare variables - name: prepare variables
uses: ./.github/actions/docker-push-variables uses: ./.github/actions/docker-push-variables
id: ev id: ev
env: env:
@ -64,12 +64,12 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Make empty clients - name: make empty clients
if: ${{ inputs.release }} if: ${{ inputs.release }}
run: | run: |
mkdir -p ./gen-ts-api mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api mkdir -p ./gen-go-api
- name: Generate TypeScript API Client - name: generate ts client
if: ${{ !inputs.release }} if: ${{ !inputs.release }}
run: make gen-client-ts run: make gen-client-ts
- name: Build Docker Image - name: Build Docker Image

View File

@ -1,5 +1,5 @@
# Re-usable workflow for a multi-architecture build # Re-usable workflow for a multi-architecture build
name: "Multi-arch container build" name: Multi-arch container build
on: on:
workflow_call: workflow_call:
@ -49,7 +49,7 @@ jobs:
shouldPush: ${{ steps.ev.outputs.shouldPush }} shouldPush: ${{ steps.ev.outputs.shouldPush }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Prepare variables - name: prepare variables
uses: ./.github/actions/docker-push-variables uses: ./.github/actions/docker-push-variables
id: ev id: ev
env: env:
@ -69,7 +69,7 @@ jobs:
tag: ${{ fromJson(needs.get-tags.outputs.tags) }} tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Prepare variables - name: prepare variables
uses: ./.github/actions/docker-push-variables uses: ./.github/actions/docker-push-variables
id: ev id: ev
env: env:

View File

@ -1,5 +1,4 @@
name: "Python API Publish" name: authentik-api-py-publish
on: on:
push: push:
branches: [main] branches: [main]
@ -8,7 +7,6 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
name: "Build and Publish"
if: ${{ github.repository != 'goauthentik/authentik-internal' }} if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@ -32,7 +30,8 @@ jobs:
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"
- name: Generate Python API Client cache: "poetry"
- name: Generate API Client
run: make gen-client-py run: make gen-client-py
- name: Publish package - name: Publish package
working-directory: gen-py-api/ working-directory: gen-py-api/

View File

@ -1,4 +1,4 @@
name: "TypeScript API Publish" name: authentik-api-ts-publish
on: on:
push: push:
branches: [main] branches: [main]
@ -7,7 +7,6 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: build:
name: "Build and Publish"
if: ${{ github.repository != 'goauthentik/authentik-internal' }} if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -21,9 +20,9 @@ jobs:
token: ${{ steps.generate_token.outputs.token }} token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: package.json node-version-file: web/package.json
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- name: Generate TypeScript API Client - name: Generate API Client
run: make gen-client-ts run: make gen-client-ts
- name: Publish package - name: Publish package
working-directory: gen-ts-api/ working-directory: gen-ts-api/

View File

@ -1,4 +1,4 @@
name: "authentik CI AWS CloudFormation" name: authentik-ci-aws-cfn
on: on:
push: push:
@ -18,7 +18,6 @@ env:
jobs: jobs:
check-changes-applied: check-changes-applied:
name: "Check changes applied"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -37,7 +36,6 @@ jobs:
uv run make aws-cfn uv run make aws-cfn
git diff --exit-code git diff --exit-code
ci-aws-cfn-mark: ci-aws-cfn-mark:
name: "CI AWS CloudFormation Mark"
if: always() if: always()
needs: needs:
- check-changes-applied - check-changes-applied

View File

@ -1,5 +1,5 @@
--- ---
name: "authentik CI Main Daily" name: authentik-ci-main-daily
on: on:
workflow_dispatch: workflow_dispatch:
@ -9,7 +9,6 @@ on:
jobs: jobs:
test-container: test-container:
name: "Test Container ${{ matrix.version }}"
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false

View File

@ -1,5 +1,5 @@
--- ---
name: "authentik CI Main" name: authentik-ci-main
on: on:
push: push:
@ -19,7 +19,6 @@ env:
jobs: jobs:
lint: lint:
name: "Lint"
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -34,10 +33,9 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup authentik env - name: Setup authentik env
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Run job ${{ matrix.job }} - name: run job
run: uv run make ci-${{ matrix.job }} run: uv run make ci-${{ matrix.job }}
test-migrations: test-migrations:
name: "Test Migrations"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -46,7 +44,6 @@ jobs:
- name: run migrations - name: run migrations
run: uv run python -m lifecycle.migrate run: uv run python -m lifecycle.migrate
test-make-seed: test-make-seed:
name: "Test Make Seed"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: seed - id: seed
@ -55,7 +52,7 @@ jobs:
outputs: outputs:
seed: ${{ steps.seed.outputs.seed }} seed: ${{ steps.seed.outputs.seed }}
test-migrations-from-stable: test-migrations-from-stable:
name: "Test Migrations From Stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5" name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
needs: test-make-seed needs: test-make-seed
@ -70,7 +67,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Checkout Stable - name: checkout stable
run: | run: |
# Copy current, latest config to local # Copy current, latest config to local
# Temporarly comment the .github backup while migrating to uv # Temporarly comment the .github backup while migrating to uv
@ -87,9 +84,9 @@ jobs:
with: with:
postgresql_version: ${{ matrix.psql }} postgresql_version: ${{ matrix.psql }}
continue-on-error: true continue-on-error: true
- name: Run migrations to stable - name: run migrations to stable
run: poetry run python -m lifecycle.migrate run: poetry run python -m lifecycle.migrate
- name: Checkout current code - name: checkout current code
run: | run: |
set -x set -x
git fetch git fetch
@ -100,10 +97,10 @@ jobs:
uses: ./.github/actions/setup uses: ./.github/actions/setup
with: with:
postgresql_version: ${{ matrix.psql }} postgresql_version: ${{ matrix.psql }}
- name: Migrate to latest - name: migrate to latest
run: | run: |
uv run python -m lifecycle.migrate uv run python -m lifecycle.migrate
- name: Run tests - name: run tests
env: env:
# Test in the main database that we just migrated from the previous stable version # Test in the main database that we just migrated from the previous stable version
AUTHENTIK_POSTGRESQL__TEST__NAME: authentik AUTHENTIK_POSTGRESQL__TEST__NAME: authentik
@ -113,7 +110,7 @@ jobs:
run: | run: |
uv run make ci-test uv run make ci-test
test-unittest: test-unittest:
name: "Unit tests - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5" name: test-unittest - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
needs: test-make-seed needs: test-make-seed
@ -149,7 +146,6 @@ jobs:
file: unittest.xml file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
test-integration: test-integration:
name: "Integration tests"
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
@ -158,7 +154,7 @@ jobs:
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Create k8s Kind Cluster - name: Create k8s Kind Cluster
uses: helm/kind-action@v1.12.0 uses: helm/kind-action@v1.12.0
- name: Run integration - name: run integration
run: | run: |
uv run coverage run manage.py test tests/integration uv run coverage run manage.py test tests/integration
uv run coverage xml uv run coverage xml
@ -174,50 +170,49 @@ jobs:
file: unittest.xml file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
test-e2e: test-e2e:
name: "Test E2E (${{ matrix.job.name }})" name: test-e2e (${{ matrix.job.name }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
job: job:
- name: Proxy Provider - name: proxy
glob: tests/e2e/test_provider_proxy* glob: tests/e2e/test_provider_proxy*
- name: OAuth2 Provider - name: oauth
glob: tests/e2e/test_provider_oauth2* tests/e2e/test_source_oauth* glob: tests/e2e/test_provider_oauth2* tests/e2e/test_source_oauth*
- name: OIDC Provider - name: oauth-oidc
glob: tests/e2e/test_provider_oidc* glob: tests/e2e/test_provider_oidc*
- name: SAML Provider - name: saml
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml* glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
- name: LDAP Provider - name: ldap
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap* glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
- name: RADIUS Provider - name: radius
glob: tests/e2e/test_provider_radius* glob: tests/e2e/test_provider_radius*
- name: SCIM Source - name: scim
glob: tests/e2e/test_source_scim* glob: tests/e2e/test_source_scim*
- name: Flows - name: flows
glob: tests/e2e/test_flows* glob: tests/e2e/test_flows*
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup authentik env - name: Setup authentik env
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Setup E2E env (chrome, etc) - name: Setup e2e env (chrome, etc)
run: | run: |
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
- id: cache-web - id: cache-web
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: web/dist path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('./package-lock.json', 'web/src/**') }} key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
- name: Prepare Web UI - name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true' if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web
run: | run: |
npm ci npm ci
make gen-client-ts make -C .. gen-client-ts
npm run build -w @goauthentik/web npm run build
- name: run e2e
npm run typecheck
- name: Run E2E tests
run: | run: |
uv run coverage run manage.py test ${{ matrix.job.glob }} uv run coverage run manage.py test ${{ matrix.job.glob }}
uv run coverage xml uv run coverage xml
@ -233,7 +228,6 @@ jobs:
file: unittest.xml file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
ci-core-mark: ci-core-mark:
name: "CI Core Mark"
if: always() if: always()
needs: needs:
- lint - lint
@ -248,7 +242,6 @@ jobs:
with: with:
jobs: ${{ toJSON(needs) }} jobs: ${{ toJSON(needs) }}
build: build:
name: "Build"
permissions: permissions:
# Needed to upload container images to ghcr.io # Needed to upload container images to ghcr.io
packages: write packages: write
@ -262,7 +255,6 @@ jobs:
image_name: ghcr.io/goauthentik/dev-server image_name: ghcr.io/goauthentik/dev-server
release: false release: false
pr-comment: pr-comment:
name: "PR Comment"
needs: needs:
- build - build
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -275,7 +267,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Prepare variables - name: prepare variables
uses: ./.github/actions/docker-push-variables uses: ./.github/actions/docker-push-variables
id: ev id: ev
env: env:

View File

@ -1,5 +1,5 @@
--- ---
name: "authentik CI Outpost" name: authentik-ci-outpost
on: on:
push: push:
@ -14,7 +14,6 @@ on:
jobs: jobs:
lint-golint: lint-golint:
name: "Lint Go"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -27,7 +26,7 @@ jobs:
mkdir -p web/dist mkdir -p web/dist
mkdir -p website/help mkdir -p website/help
touch web/dist/test website/help/test touch web/dist/test website/help/test
- name: Generate Go API Client - name: Generate API
run: make gen-client-go run: make gen-client-go
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v7 uses: golangci/golangci-lint-action@v7
@ -36,7 +35,6 @@ jobs:
args: --timeout 5000s --verbose args: --timeout 5000s --verbose
skip-cache: true skip-cache: true
test-unittest: test-unittest:
name: "Unit Test Go"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -45,13 +43,12 @@ jobs:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: Setup authentik env - name: Setup authentik env
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Generate Go API Client - name: Generate API
run: make gen-client-go run: make gen-client-go
- name: Go unittests - name: Go unittests
run: | run: |
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./... go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./...
ci-outpost-mark: ci-outpost-mark:
name: "CI Outpost Mark"
if: always() if: always()
needs: needs:
- lint-golint - lint-golint
@ -62,7 +59,6 @@ jobs:
with: with:
jobs: ${{ toJSON(needs) }} jobs: ${{ toJSON(needs) }}
build-container: build-container:
name: "Build Container"
timeout-minutes: 120 timeout-minutes: 120
needs: needs:
- ci-outpost-mark - ci-outpost-mark
@ -89,7 +85,7 @@ jobs:
uses: docker/setup-qemu-action@v3.6.0 uses: docker/setup-qemu-action@v3.6.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Prepare variables - name: prepare variables
uses: ./.github/actions/docker-push-variables uses: ./.github/actions/docker-push-variables
id: ev id: ev
env: env:
@ -103,7 +99,7 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate Go API Client - name: Generate API
run: make gen-client-go run: make gen-client-go
- name: Build Docker Image - name: Build Docker Image
id: push id: push
@ -126,7 +122,6 @@ jobs:
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true push-to-registry: true
build-binary: build-binary:
name: "Build Binary"
timeout-minutes: 120 timeout-minutes: 120
needs: needs:
- ci-outpost-mark - ci-outpost-mark
@ -145,22 +140,21 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version-file: package.json
cache: "npm"
cache-dependency-path: package-lock.json
- name: Install Node.js dependencies
run: npm ci
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: Generate Go API Client - uses: actions/setup-node@v4
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Generate API
run: make gen-client-go run: make gen-client-go
- name: Build web - name: Build web
working-directory: web/
run: | run: |
npm ci npm ci
npm run build-proxy -w @goauthentik/web npm run build-proxy
- name: Build outpost - name: Build outpost
run: | run: |
set -x set -x

View File

@ -1,4 +1,4 @@
name: CI Web UI name: authentik-ci-web
on: on:
push: push:
@ -13,50 +13,54 @@ on:
jobs: jobs:
lint: lint:
name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- lint
- lint:lockfile
- tsc
- prettier-check
project:
- web
include:
- command: tsc
project: web
- command: lit-analyse
project: web
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: package.json node-version-file: ${{ matrix.project }}/package.json
cache: "npm" cache: "npm"
cache-dependency-path: package-lock.json cache-dependency-path: ${{ matrix.project }}/package-lock.json
- name: Install Node.js dependencies - working-directory: ${{ matrix.project }}/
run: npm ci run: |
- name: Generate TypeScript API npm ci
- name: Generate API
run: make gen-client-ts run: make gen-client-ts
- name: Build
run: |
npm run build -w @goauthentik/web
- name: Type check
run: |
npm run typecheck
- name: Lint - name: Lint
run: | working-directory: ${{ matrix.project }}/
npm run lint -w @goauthentik/web run: npm run ${{ matrix.command }}
npm run lint:lockfile -w @goauthentik/web
npm run lit-analyse -w @goauthentik/web
build: build:
name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: package.json node-version-file: web/package.json
cache: "npm" cache: "npm"
cache-dependency-path: package-lock.json cache-dependency-path: web/package-lock.json
- name: Install Node.js dependencies - working-directory: web/
run: npm ci run: npm ci
- name: Generate TypeScript API - name: Generate API
run: make gen-client-ts run: make gen-client-ts
- name: build - name: build
run: | working-directory: web/
npm run build -w @goauthentik/web run: npm run build
npm run typecheck
ci-web-mark: ci-web-mark:
name: CI Web Mark
if: always() if: always()
needs: needs:
- build - build
@ -67,7 +71,6 @@ jobs:
with: with:
jobs: ${{ toJSON(needs) }} jobs: ${{ toJSON(needs) }}
test: test:
name: Test
needs: needs:
- ci-web-mark - ci-web-mark
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -75,12 +78,13 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: package.json node-version-file: web/package.json
cache: "npm" cache: "npm"
cache-dependency-path: package-lock.json cache-dependency-path: web/package-lock.json
- name: Install Node.js dependencies - working-directory: web/
run: npm ci run: npm ci
- name: Generate TypeScript API - name: Generate API
run: make gen-client-ts run: make gen-client-ts
- name: Test Web UI - name: test
run: npm run test -w @goauthentik/web || exit 0 working-directory: web/
run: npm run test || exit 0

View File

@ -1,4 +1,4 @@
name: CI Docs Website name: authentik-ci-website
on: on:
push: push:
@ -13,59 +13,55 @@ on:
jobs: jobs:
lint: lint:
name: "Lint"
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- lint:lockfile
- prettier-check
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - working-directory: website/
with:
node-version-file: package.json
cache: "npm"
cache-dependency-path: package-lock.json
- name: Install Node.js dependencies
run: |
npm ci
- name: Generate TypeScript API
run: make gen-client-ts
- name: Lint Docs
run: |
npm run lint:prettier:check
npm run lint:lockfile -w @goauthentik/docs
test:
name: "Test Docs"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: package.json
cache: "npm"
cache-dependency-path: package-lock.json
- name: Install Node.js dependencies
run: |
npm ci
- name: Generate TypeScript API
run: make gen-client-ts
- name: Test Docs
run: |
npm run test -w @goauthentik/docs
build:
name: "Build Docs"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: package.json
cache: "npm"
cache-dependency-path: package-lock.json
- name: Install Node.js dependencies
run: npm ci run: npm ci
- name: Build - name: Lint
run: | working-directory: website/
npm run build -w @goauthentik/docs run: npm run ${{ matrix.command }}
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
run: npm ci
- name: test
working-directory: website/
run: npm test
build:
runs-on: ubuntu-latest
name: ${{ matrix.job }}
strategy:
fail-fast: false
matrix:
job:
- build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
run: npm ci
- name: build
working-directory: website/
run: npm run ${{ matrix.job }}
ci-website-mark: ci-website-mark:
name: "CI Website Mark"
if: always() if: always()
needs: needs:
- lint - lint

View File

@ -10,7 +10,7 @@ on:
jobs: jobs:
analyze: analyze:
name: "Analyze" name: Analyze
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
actions: read actions: read

View File

@ -1,4 +1,4 @@
name: "authentik CI Update WebAuthn MDS" name: authentik-gen-update-webauthn-mds
on: on:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
@ -11,7 +11,6 @@ env:
jobs: jobs:
build: build:
name: "Update WebAuthn MDS"
if: ${{ github.repository != 'goauthentik/authentik-internal' }} if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@ -1,6 +1,6 @@
--- ---
# See https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries # See https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
name: "Post-PR Closed Cache Cleanup" name: Cleanup cache after PR is closed
on: on:
pull_request: pull_request:
types: types:
@ -12,7 +12,6 @@ permissions:
jobs: jobs:
cleanup: cleanup:
name: "Cleanup Cache"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code - name: Check out code

View File

@ -1,4 +1,4 @@
name: "authentik GHCR Retention Policy" name: ghcr-retention
on: on:
# schedule: # schedule:
@ -8,7 +8,7 @@ on:
jobs: jobs:
clean-ghcr: clean-ghcr:
if: ${{ github.repository != 'goauthentik/authentik-internal' }} if: ${{ github.repository != 'goauthentik/authentik-internal' }}
name: "Delete old unused container images" name: Delete old unused container images
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: generate_token - id: generate_token

View File

@ -1,5 +1,5 @@
--- ---
name: "authentik CI Image Compression" name: authentik-compress-images
on: on:
push: push:
@ -20,7 +20,7 @@ on:
jobs: jobs:
compress: compress:
name: "Compress Docker images" name: compress
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Don't run on forks. Token will not be available. Will run on main and open a PR anyway # Don't run on forks. Token will not be available. Will run on main and open a PR anyway
if: | if: |

View File

@ -1,45 +0,0 @@
name: authentik-packages-npm-publish
on:
push:
branches: [main]
paths:
- packages/docusaurus-config
- packages/eslint-config
- packages/prettier-config
- packages/tsconfig
workflow_dispatch:
jobs:
publish:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- docusaurus-config
- eslint-config
- prettier-config
- tsconfig
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version-file: packages/${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c
with:
files: |
packages/${{ matrix.package }}/package.json
- name: Publish package
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: packages/${{ matrix.package}}
run: |
npm ci
npm run build
npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}

View File

@ -1,4 +1,4 @@
name: "authentik Publish Source Docs" name: authentik-publish-source-docs
on: on:
push: push:
@ -12,7 +12,6 @@ env:
jobs: jobs:
publish-source-docs: publish-source-docs:
name: "Publish"
if: ${{ github.repository != 'goauthentik/authentik-internal' }} if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 120 timeout-minutes: 120
@ -20,11 +19,11 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup authentik env - name: Setup authentik env
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Generate docs - name: generate docs
run: | run: |
uv run make migrate uv run make migrate
uv run ak build_source_docs uv run ak build_source_docs
- name: Deploy to Netlify - name: Publish
uses: netlify/actions/cli@master uses: netlify/actions/cli@master
with: with:
args: deploy --dir=source_docs --prod args: deploy --dir=source_docs --prod

View File

@ -1,4 +1,4 @@
name: "authentik on Release Next Branch" name: authentik-on-release-next-branch
on: on:
schedule: schedule:
@ -11,7 +11,6 @@ permissions:
jobs: jobs:
update-next: update-next:
name: "Update Next Branch"
if: ${{ github.repository != 'goauthentik/authentik-internal' }} if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: internal-production environment: internal-production

View File

@ -1,5 +1,5 @@
--- ---
name: "Release publish" name: authentik-on-release
on: on:
release: release:
@ -7,7 +7,6 @@ on:
jobs: jobs:
build-server: build-server:
name: "Build server"
uses: ./.github/workflows/_reusable-docker-build.yaml uses: ./.github/workflows/_reusable-docker-build.yaml
secrets: inherit secrets: inherit
permissions: permissions:
@ -22,7 +21,6 @@ jobs:
registry_dockerhub: true registry_dockerhub: true
registry_ghcr: true registry_ghcr: true
build-outpost: build-outpost:
name: "Build outpost"
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
# Needed to upload container images to ghcr.io # Needed to upload container images to ghcr.io
@ -47,14 +45,14 @@ jobs:
uses: docker/setup-qemu-action@v3.6.0 uses: docker/setup-qemu-action@v3.6.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Prepare variables - name: prepare variables
uses: ./.github/actions/docker-push-variables uses: ./.github/actions/docker-push-variables
id: ev id: ev
env: env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with: with:
image-name: ghcr.io/goauthentik/${{ matrix.type }},beryju/authentik-${{ matrix.type }} image-name: ghcr.io/goauthentik/${{ matrix.type }},beryju/authentik-${{ matrix.type }}
- name: Make empty clients - name: make empty clients
run: | run: |
mkdir -p ./gen-ts-api mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api mkdir -p ./gen-go-api
@ -87,7 +85,6 @@ jobs:
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true push-to-registry: true
build-outpost-binary: build-outpost-binary:
name: "Build outpost binary"
timeout-minutes: 120 timeout-minutes: 120
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@ -109,13 +106,14 @@ jobs:
go-version-file: "go.mod" go-version-file: "go.mod"
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: package.json node-version-file: web/package.json
cache: "npm" cache: "npm"
cache-dependency-path: package-lock.json cache-dependency-path: web/package-lock.json
- name: Build web - name: Build web
working-directory: web/
run: | run: |
npm ci npm ci
npm run build-proxy -w @goauthentik/web npm run build-proxy
- name: Build outpost - name: Build outpost
run: | run: |
set -x set -x
@ -131,7 +129,6 @@ jobs:
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
tag: ${{ github.ref }} tag: ${{ github.ref }}
upload-aws-cfn-template: upload-aws-cfn-template:
name: "Upload AWS CloudFormation template"
permissions: permissions:
# Needed for AWS login # Needed for AWS login
id-token: write id-token: write
@ -153,7 +150,6 @@ jobs:
aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml
aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml
test-release: test-release:
name: "Test release"
needs: needs:
- build-server - build-server
- build-outpost - build-outpost
@ -170,7 +166,6 @@ jobs:
docker compose start postgresql redis docker compose start postgresql redis
docker compose run -u root server test-all docker compose run -u root server test-all
sentry-release: sentry-release:
name: "Sentry release"
needs: needs:
- build-server - build-server
- build-outpost - build-outpost
@ -178,7 +173,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Prepare variables - name: prepare variables
uses: ./.github/actions/docker-push-variables uses: ./.github/actions/docker-push-variables
id: ev id: ev
env: env:

View File

@ -1,5 +1,5 @@
--- ---
name: "authentik on Tag Release" name: authentik-on-tag
on: on:
push: push:
@ -8,7 +8,7 @@ on:
jobs: jobs:
build: build:
name: "Create Release from Tag" name: Create Release from Tag
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -20,7 +20,7 @@ jobs:
with: with:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Prepare variables - name: prepare variables
uses: ./.github/actions/docker-push-variables uses: ./.github/actions/docker-push-variables
id: ev id: ev
env: env:

View File

@ -1,15 +1,13 @@
name: "authentik Repository Mirror" name: "authentik-repo-mirror"
on: [push, delete] on: [push, delete]
jobs: jobs:
to_internal: to_internal:
name: "Mirror to internal repository"
if: ${{ github.repository != 'goauthentik/authentik-internal' }} if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
name: "Checkout repository"
with: with:
fetch-depth: 0 fetch-depth: 0
- if: ${{ env.MIRROR_KEY != '' }} - if: ${{ env.MIRROR_KEY != '' }}

View File

@ -1,4 +1,4 @@
name: "authentik Repository Stale Issues" name: "authentik-repo-stale"
on: on:
schedule: schedule:
@ -11,7 +11,6 @@ permissions:
jobs: jobs:
stale: stale:
name: "Stale Issues"
if: ${{ github.repository != 'goauthentik/authentik-internal' }} if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@ -1,4 +1,4 @@
name: "authentik CI Semgrep" name: authentik-semgrep
on: on:
workflow_dispatch: {} workflow_dispatch: {}
pull_request: {} pull_request: {}
@ -13,7 +13,7 @@ on:
- cron: '12 15 * * *' - cron: '12 15 * * *'
jobs: jobs:
semgrep: semgrep:
name: "semgrep/ci" name: semgrep/ci
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read

View File

@ -1,4 +1,4 @@
name: "authentik Translations Advice" name: authentik-translation-advice
on: on:
pull_request: pull_request:
@ -16,7 +16,6 @@ permissions:
jobs: jobs:
post-comment: post-comment:
name: "Post Comment"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Find Comment - name: Find Comment

View File

@ -1,5 +1,5 @@
--- ---
name: "authentik Extract & Compile Translations" name: authentik-translate-extract-compile
on: on:
schedule: schedule:
- cron: "0 0 * * *" # every day at midnight - cron: "0 0 * * *" # every day at midnight
@ -16,7 +16,6 @@ env:
jobs: jobs:
compile: compile:
name: "Compile Translations"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: generate_token - id: generate_token
@ -33,20 +32,15 @@ jobs:
if: ${{ github.event_name == 'pull_request' }} if: ${{ github.event_name == 'pull_request' }}
- name: Setup authentik env - name: Setup authentik env
uses: ./.github/actions/setup uses: ./.github/actions/setup
- name: Generate TypeScript API - name: Generate API
run: make gen-client-ts run: make gen-client-ts
- name: Extract Translations - name: run extract
run: | run: |
uv run make i18n-extract uv run make i18n-extract
- name: Build Docs Site - name: run compile
run: npm run build-bundled -w @goauthentik/docs
- name: Build Web UI
run: npm run build -w @goauthentik/web
- name: Type check
run: npm run typecheck
- name: Compile Messages
run: | run: |
uv run ak compilemessages uv run ak compilemessages
make web-check-compile
- name: Create Pull Request - name: Create Pull Request
if: ${{ github.event_name != 'pull_request' }} if: ${{ github.event_name != 'pull_request' }}
uses: peter-evans/create-pull-request@v7 uses: peter-evans/create-pull-request@v7

View File

@ -1,6 +1,6 @@
# Rename transifex pull requests to have a correct naming # Rename transifex pull requests to have a correct naming
# Also enables auto squash-merge # Also enables auto squash-merge
name: "authentik Translations Transifex PR Rename" name: authentik-translation-transifex-rename
on: on:
pull_request: pull_request:
@ -12,7 +12,6 @@ permissions:
jobs: jobs:
rename_pr: rename_pr:
name: "Rename PR"
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}} if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}}
steps: steps:

27
.gitignore vendored
View File

@ -11,10 +11,6 @@ local_settings.py
db.sqlite3 db.sqlite3
media media
# Node
node_modules
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly. # in your Git repository. Update and uncomment the following line accordingly.
# <django-project-name>/staticfiles/ # <django-project-name>/staticfiles/
@ -217,26 +213,3 @@ source_docs/
### Docker ### ### Docker ###
docker-compose.override.yml docker-compose.override.yml
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
node_modules/
tsconfig.tsbuildinfo
# Wireit's cache
.wireit
custom-elements.json
### Development ###
.drafts

View File

@ -1,52 +0,0 @@
# Prettier Ignorefile
## Static Files
**/LICENSE
authentik/stages/**/*
authentik/sources/**/*
schemas/**/*
blueprints/**/*
## Build asset directories
coverage
dist
out
.docusaurus
.wireit
website/docs/developer-docs/api/**/*
## Environment
*.env
## Secrets
*.secrets
## Yarn
.yarn/**/*
## Node
node_modules
coverage
## Configs
*.log
*.yaml
*.yml
# Templates
# TODO: Rename affected files to *.template.* or similar.
authentik/**/*.html
*.html
*.mdx
*.md
## Import order matters
web/src/poly.ts
web/src/locale-codes.ts
web/src/locales/
# Storybook
storybook-static/
.storybook/css-import-maps*

View File

@ -17,6 +17,6 @@
"ms-python.vscode-pylance", "ms-python.vscode-pylance",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"Tobermory.es6-string-html", "Tobermory.es6-string-html",
"unifiedjs.vscode-mdx" "unifiedjs.vscode-mdx",
] ]
} }

72
.vscode/settings.json vendored
View File

@ -16,7 +16,7 @@
], ],
"typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index", "typescript.preferences.importModuleSpecifierEnding": "index",
"typescript.tsdk": "./node_modules/typescript/lib", "typescript.tsdk": "./web/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true, "typescript.enablePromptUseWorkspaceTsdk": true,
"yaml.schemas": { "yaml.schemas": {
"./blueprints/schema.json": "blueprints/**/*.yaml" "./blueprints/schema.json": "blueprints/**/*.yaml"
@ -30,71 +30,7 @@
} }
], ],
"go.testFlags": ["-count=1"], "go.testFlags": ["-count=1"],
"github-actions.workflows.pinned.workflows": [".github/workflows/ci-main.yml"], "github-actions.workflows.pinned.workflows": [
".github/workflows/ci-main.yml"
"eslint.useFlatConfig": true, ]
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.mjs": "*.d.mts",
"*.cjs": "*.d.cts",
"package.json": "package-lock.json, yarn.lock, .yarnrc, .yarnrc.yml, .yarn, .nvmrc, .node-version",
"tsconfig.json": "tsconfig.*.json, jsconfig.json",
"Dockerfile": "*.Dockerfile"
},
"search.exclude": {
"**/node_modules": true,
"**/*.code-search": true,
"**/dist": true,
"**/out": true,
"**/package-lock.json": true
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[shellscript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[django-html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.codeActionsOnSave": {
"source.removeUnusedImports": "explicit"
},
// We use Prettier for formatting, but specifying these settings
// will ensure that VS Code's IntelliSense doesn't autocomplete unformatted code.
"javascript.format.semicolons": "insert",
"typescript.format.semicolons": "insert",
"javascript.preferences.quoteStyle": "double",
"typescript.preferences.quoteStyle": "double",
"github.copilot.enable": {
"*": true,
"plaintext": true,
"markdown": true,
"scminput": false,
"csv": false,
"json": true,
"yaml": true
}
} }

40
.vscode/tasks.json vendored
View File

@ -4,7 +4,12 @@
{ {
"label": "authentik/core: make", "label": "authentik/core: make",
"command": "uv", "command": "uv",
"args": ["run", "make", "lint-fix", "lint"], "args": [
"run",
"make",
"lint-fix",
"lint"
],
"presentation": { "presentation": {
"panel": "new" "panel": "new"
}, },
@ -13,7 +18,11 @@
{ {
"label": "authentik/core: run", "label": "authentik/core: run",
"command": "uv", "command": "uv",
"args": ["run", "ak", "server"], "args": [
"run",
"ak",
"server"
],
"group": "build", "group": "build",
"presentation": { "presentation": {
"panel": "dedicated", "panel": "dedicated",
@ -23,13 +32,17 @@
{ {
"label": "authentik/web: make", "label": "authentik/web: make",
"command": "make", "command": "make",
"args": ["web"], "args": [
"web"
],
"group": "build" "group": "build"
}, },
{ {
"label": "authentik/web: watch", "label": "authentik/web: watch",
"command": "make", "command": "make",
"args": ["web-watch"], "args": [
"web-watch"
],
"group": "build", "group": "build",
"presentation": { "presentation": {
"panel": "dedicated", "panel": "dedicated",
@ -39,19 +52,26 @@
{ {
"label": "authentik: install", "label": "authentik: install",
"command": "make", "command": "make",
"args": ["install", "-j4"], "args": [
"install",
"-j4"
],
"group": "build" "group": "build"
}, },
{ {
"label": "authentik/website: make", "label": "authentik/website: make",
"command": "make", "command": "make",
"args": ["website"], "args": [
"website"
],
"group": "build" "group": "build"
}, },
{ {
"label": "authentik/website: watch", "label": "authentik/website: watch",
"command": "make", "command": "make",
"args": ["website-watch"], "args": [
"website-watch"
],
"group": "build", "group": "build",
"presentation": { "presentation": {
"panel": "dedicated", "panel": "dedicated",
@ -61,7 +81,11 @@
{ {
"label": "authentik/api: generate", "label": "authentik/api: generate",
"command": "uv", "command": "uv",
"args": ["run", "make", "gen"], "args": [
"run",
"make",
"gen"
],
"group": "build" "group": "build"
} }
] ]

View File

@ -23,8 +23,6 @@ docker-compose.yml @goauthentik/infrastructure
Makefile @goauthentik/infrastructure Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure .editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure CODEOWNERS @goauthentik/infrastructure
# Web packages
packages/ @goauthentik/frontend
# Web # Web
web/ @goauthentik/frontend web/ @goauthentik/frontend
tests/wdio/ @goauthentik/frontend tests/wdio/ @goauthentik/frontend

View File

@ -1,31 +1,48 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Stage 1 Web UI and Documentation build # Stage 1: Build website
FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS website-builder
FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS web-builder
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /work WORKDIR /work/website
COPY ./package.json ./package.json RUN --mount=type=bind,target=/work/website/package.json,src=./website/package.json \
COPY ./package-lock.json ./package-lock.json --mount=type=bind,target=/work/website/package-lock.json,src=./website/package-lock.json \
COPY ./packages ./packages --mount=type=cache,id=npm-website,sharing=shared,target=/root/.npm \
COPY ./web ./web npm ci --include=dev
COPY ./website ./website
COPY ./gen-ts-api ./gen-ts-api COPY ./website /work/website/
COPY ./blueprints ./blueprints COPY ./blueprints /work/blueprints/
COPY ./schema.yml ./schema.yml COPY ./schema.yml /work/
COPY ./SECURITY.md ./SECURITY.md COPY ./SECURITY.md /work/
RUN --mount=type=cache,target=/root/.npm npm ci --include=dev RUN npm run build-bundled
RUN npm run build-bundled -w @goauthentik/docs # Stage 2: Build webui
RUN npm run build -w @goauthentik/web FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS web-builder
# Stage 2: Build go proxy ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
ENV NODE_ENV=production
WORKDIR /work/web
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
npm ci --include=dev
COPY ./package.json /work
COPY ./web /work/web/
COPY ./website /work/website/
COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build
# Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
ARG TARGETOS ARG TARGETOS
@ -62,8 +79,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server go build -o /go/authentik ./cmd/server
# Stage 3: MaxMind GeoIP # Stage 4: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
@ -77,11 +93,10 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
mkdir -p /usr/share/GeoIP && \ mkdir -p /usr/share/GeoIP && \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv # Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.6.14 AS uv FROM ghcr.io/astral-sh/uv:0.6.14 AS uv
# Stage 6: Base python image
# Stage 5: Base python image FROM ghcr.io/goauthentik/fips-python:3.12.9-slim-bookworm-fips AS python-base
FROM ghcr.io/goauthentik/fips-python:3.12.10-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \ ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \ PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
@ -94,7 +109,7 @@ WORKDIR /ak-root/
COPY --from=uv /uv /uvx /bin/ COPY --from=uv /uv /uvx /bin/
# Stage 6: Python dependencies # Stage 7: Python dependencies
FROM python-base AS python-deps FROM python-base AS python-deps
ARG TARGETARCH ARG TARGETARCH
@ -129,7 +144,7 @@ RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
--mount=type=cache,target=/root/.cache/uv \ --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev uv sync --frozen --no-install-project --no-dev
# Stage 7: Run # Stage 8: Run
FROM python-base AS final-image FROM python-base AS final-image
ARG VERSION ARG VERSION
@ -174,7 +189,7 @@ COPY --from=go-builder /go/authentik /bin/authentik
COPY --from=python-deps /ak-root/.venv /ak-root/.venv COPY --from=python-deps /ak-root/.venv /ak-root/.venv
COPY --from=web-builder /work/web/dist/ /web/dist/ COPY --from=web-builder /work/web/dist/ /web/dist/
COPY --from=web-builder /work/web/authentik/ /web/authentik/ COPY --from=web-builder /work/web/authentik/ /web/authentik/
COPY --from=web-builder /work/website/build/ /website/help/ COPY --from=website-builder /work/website/build/ /website/help/
COPY --from=geoip /usr/share/GeoIP /geoip COPY --from=geoip /usr/share/GeoIP /geoip
USER 1000 USER 1000

107
Makefile
View File

@ -36,13 +36,6 @@ test: ## Run the server tests and produce a coverage report (locally)
uv run coverage html uv run coverage html
uv run coverage report uv run coverage report
node-check-compile: ## Check and compile the TypeScript source code
npm run typecheck
node-lint-fix: ## Lint and automatically fix errors in the javascript source code
lint-codespell
npm run lint:fix
lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors. lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors.
uv run black $(PY_SOURCES) uv run black $(PY_SOURCES)
uv run ruff check --fix $(PY_SOURCES) uv run ruff check --fix $(PY_SOURCES)
@ -54,6 +47,9 @@ lint: ## Lint the python and golang sources
uv run bandit -c pyproject.toml -r $(PY_SOURCES) uv run bandit -c pyproject.toml -r $(PY_SOURCES)
golangci-lint run -v golangci-lint run -v
core-install:
uv sync --frozen
migrate: ## Run the Authentik Django server's migrations migrate: ## Run the Authentik Django server's migrations
uv run python -m lifecycle.migrate uv run python -m lifecycle.migrate
@ -76,9 +72,7 @@ core-i18n-extract:
--ignore website \ --ignore website \
-l en -l en
install: ## Install all requires dependencies for `web`, `website` and `core` install: web-install website-install core-install ## Install all requires dependencies for `web`, `website` and `core`
npm ci
uv sync --frozen
dev-drop-db: dev-drop-db:
dropdb -U ${pg_user} -h ${pg_host} ${pg_name} dropdb -U ${pg_user} -h ${pg_host} ${pg_name}
@ -100,7 +94,6 @@ gen-build: ## Extract the schema from the database
AUTHENTIK_TENANTS__ENABLED=true \ AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
uv run ak make_blueprint_schema > blueprints/schema.json uv run ak make_blueprint_schema > blueprints/schema.json
AUTHENTIK_DEBUG=true \ AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \ AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \ AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
@ -108,24 +101,19 @@ gen-build: ## Extract the schema from the database
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md 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 npx prettier --write changelog.md
gen-diff: ## (Release) generate the changelog diff between the current schema and the last tag gen-diff: ## (Release) generate the changelog diff between the current schema and the last tag
git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > old_schema.yml git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > old_schema.yml
docker run \ docker run \
--rm -v ${PWD}:/local \ --rm -v ${PWD}:/local \
--user ${UID}:${GID} \ --user ${UID}:${GID} \
docker.io/openapitools/openapi-diff:2.1.0-beta.8 \ docker.io/openapitools/openapi-diff:2.1.0-beta.8 \
--markdown /local/diff.md \ --markdown /local/diff.md \
/local/old_schema.yml /local/schema.yml /local/old_schema.yml /local/schema.yml
rm old_schema.yml rm old_schema.yml
sed -i 's/{/&#123;/g' diff.md sed -i 's/{/&#123;/g' diff.md
sed -i 's/}/&#125;/g' diff.md sed -i 's/}/&#125;/g' diff.md
npx prettier --write diff.md npx prettier --write diff.md
gen-clean-ts: ## Remove generated API client for Typescript gen-clean-ts: ## Remove generated API client for Typescript
@ -145,57 +133,46 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
--rm -v ${PWD}:/local \ --rm -v ${PWD}:/local \
--user ${UID}:${GID} \ --user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v7.11.0 generate \ docker.io/openapitools/openapi-generator-cli:v7.11.0 generate \
--input-spec /local/schema.yml \ -i /local/schema.yml \
--generator-name typescript-fetch \ -g typescript-fetch \
--output /local/${GEN_API_TS} \ -o /local/${GEN_API_TS} \
--config /local/scripts/api-ts-config.yaml \ -c /local/scripts/api-ts-config.yaml \
--additional-properties=npmVersion=${NPM_VERSION} \ --additional-properties=npmVersion=${NPM_VERSION} \
--git-repo-id authentik \ --git-repo-id authentik \
--git-user-id goauthentik --git-user-id goauthentik
mkdir -p web/node_modules/@goauthentik/api
npm install cd ./${GEN_API_TS} && npm i
\cp -rf ./${GEN_API_TS}/* web/node_modules/@goauthentik/api
gen-client-py: gen-clean-py ## Build and install the authentik API for Python gen-client-py: gen-clean-py ## Build and install the authentik API for Python
docker run \ docker run \
--rm -v ${PWD}:/local \ --rm -v ${PWD}:/local \
--user ${UID}:${GID} \ --user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v7.11.0 generate \ docker.io/openapitools/openapi-generator-cli:v7.11.0 generate \
--input-spec /local/schema.yml \ -i /local/schema.yml \
--generator-name python \ -g python \
--output /local/${GEN_API_PY} \ -o /local/${GEN_API_PY} \
--config /local/scripts/api-py-config.yaml \ -c /local/scripts/api-py-config.yaml \
--additional-properties=packageVersion=${NPM_VERSION} \ --additional-properties=packageVersion=${NPM_VERSION} \
--git-repo-id authentik \ --git-repo-id authentik \
--git-user-id goauthentik --git-user-id goauthentik
pip install ./${GEN_API_PY} pip install ./${GEN_API_PY}
gen-client-go: gen-clean-go ## Build and install the authentik API for Golang gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
mkdir -p ./${GEN_API_GO} ./${GEN_API_GO}/templates mkdir -p ./${GEN_API_GO} ./${GEN_API_GO}/templates
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O ./${GEN_API_GO}/config.yaml
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml \ wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O ./${GEN_API_GO}/templates/README.mustache
-O ./${GEN_API_GO}/config.yaml wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O ./${GEN_API_GO}/templates/go.mod.mustache
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache \
-O ./${GEN_API_GO}/templates/README.mustache
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache \
-O ./${GEN_API_GO}/templates/go.mod.mustache
cp schema.yml ./${GEN_API_GO}/ cp schema.yml ./${GEN_API_GO}/
docker run \ docker run \
--rm -v ${PWD}/${GEN_API_GO}:/local \ --rm -v ${PWD}/${GEN_API_GO}:/local \
--user ${UID}:${GID} \ --user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \ docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \
--input-spec /local/schema.yml \ -i /local/schema.yml \
--generator-name go \ -g go \
--output /local/ \ -o /local/ \
--config /local/config.yaml -c /local/config.yaml
go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO} go mod edit -replace goauthentik.io/api/v3=./${GEN_API_GO}
rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/ rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/
gen-dev-config: ## Generate a local development config file gen-dev-config: ## Generate a local development config file
@ -207,38 +184,56 @@ gen: gen-build gen-client-ts
## Web ## Web
######################### #########################
web: web-lint-fix web-lint node-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it web-build: web-install ## Build the Authentik UI
cd web && npm run build
web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
web-install: ## Install the necessary libraries to build the Authentik UI
cd web && npm ci
web-test: ## Run tests for the Authentik UI web-test: ## Run tests for the Authentik UI
npm run test -w @goauthentik/web cd web && npm run test
web-watch: ## Build and watch the Authentik UI for changes, updating automatically web-watch: ## Build and watch the Authentik UI for changes, updating automatically
npm run watch -w @goauthentik/web rm -rf web/dist/
mkdir web/dist/
touch web/dist/.gitkeep
cd web && npm run watch
web-storybook-watch: ## Build and run the storybook documentation server web-storybook-watch: ## Build and run the storybook documentation server
npm run storybook -w @goauthentik/web cd web && npm run storybook
web-lint-fix: web-lint-fix:
npm run prettier -w @goauthentik/web cd web && npm run prettier
web-lint: web-lint:
npm run lint -w @goauthentik/web cd web && npm run lint
npm run lit-analyse -w @goauthentik/web cd web && npm run lit-analyse
web-check-compile:
cd web && npm run tsc
web-i18n-extract: web-i18n-extract:
npm run extract-locales -w @goauthentik/web cd web && npm run extract-locales
######################### #########################
## Website ## Website
######################### #########################
website: node-lint-fix website-build ## Automatically fix formatting issues in the Authentik website/docs source code, lint the code, and compile it website: website-lint-fix website-build ## Automatically fix formatting issues in the Authentik website/docs source code, lint the code, and compile it
website-install:
cd website && npm ci
website-lint-fix: lint-codespell
cd website && npm run prettier
website-build: website-build:
npm run build -w @goauthentik/docs cd website && npm run build
website-watch: ## Build and watch the documentation website, updating automatically website-watch: ## Build and watch the documentation website, updating automatically
npm run watch -w @goauthentik/docs cd website && npm run watch
######################### #########################
## Docker ## Docker

View File

@ -7,7 +7,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, DateTimeField from rest_framework.fields import CharField, DateTimeField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ListSerializer from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.blueprints.models import BlueprintInstance from authentik.blueprints.models import BlueprintInstance
@ -15,7 +15,7 @@ from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.oci import OCI_PREFIX from authentik.blueprints.v1.oci import OCI_PREFIX
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer from authentik.core.api.utils import JSONDictField, PassiveSerializer
from authentik.rbac.decorators import permission_required from authentik.rbac.decorators import permission_required

View File

@ -36,7 +36,6 @@ from authentik.core.models import (
GroupSourceConnection, GroupSourceConnection,
PropertyMapping, PropertyMapping,
Provider, Provider,
Session,
Source, Source,
User, User,
UserSourceConnection, UserSourceConnection,
@ -109,7 +108,6 @@ def excluded_models() -> list[type[Model]]:
Policy, Policy,
PolicyBindingModel, PolicyBindingModel,
# Classes that have other dependencies # Classes that have other dependencies
Session,
AuthenticatedSession, AuthenticatedSession,
# Classes which are only internally managed # Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin # FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin

View File

@ -5,7 +5,6 @@ from typing import TypedDict
from rest_framework import mixins from rest_framework import mixins
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.serializers import CharField, DateTimeField, IPAddressField
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from ua_parser import user_agent_parser from ua_parser import user_agent_parser
@ -55,11 +54,6 @@ class UserAgentDict(TypedDict):
class AuthenticatedSessionSerializer(ModelSerializer): class AuthenticatedSessionSerializer(ModelSerializer):
"""AuthenticatedSession Serializer""" """AuthenticatedSession Serializer"""
expires = DateTimeField(source="session.expires", read_only=True)
last_ip = IPAddressField(source="session.last_ip", read_only=True)
last_user_agent = CharField(source="session.last_user_agent", read_only=True)
last_used = DateTimeField(source="session.last_used", read_only=True)
current = SerializerMethodField() current = SerializerMethodField()
user_agent = SerializerMethodField() user_agent = SerializerMethodField()
geo_ip = SerializerMethodField() geo_ip = SerializerMethodField()
@ -68,19 +62,19 @@ class AuthenticatedSessionSerializer(ModelSerializer):
def get_current(self, instance: AuthenticatedSession) -> bool: def get_current(self, instance: AuthenticatedSession) -> bool:
"""Check if session is currently active session""" """Check if session is currently active session"""
request: Request = self.context["request"] request: Request = self.context["request"]
return request._request.session.session_key == instance.session.session_key return request._request.session.session_key == instance.session_key
def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict: def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict:
"""Get parsed user agent""" """Get parsed user agent"""
return user_agent_parser.Parse(instance.session.last_user_agent) return user_agent_parser.Parse(instance.last_user_agent)
def get_geo_ip(self, instance: AuthenticatedSession) -> GeoIPDict | None: # pragma: no cover def get_geo_ip(self, instance: AuthenticatedSession) -> GeoIPDict | None: # pragma: no cover
"""Get GeoIP Data""" """Get GeoIP Data"""
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.session.last_ip) return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip)
def get_asn(self, instance: AuthenticatedSession) -> ASNDict | None: # pragma: no cover def get_asn(self, instance: AuthenticatedSession) -> ASNDict | None: # pragma: no cover
"""Get ASN Data""" """Get ASN Data"""
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.session.last_ip) return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip)
class Meta: class Meta:
model = AuthenticatedSession model = AuthenticatedSession
@ -96,7 +90,6 @@ class AuthenticatedSessionSerializer(ModelSerializer):
"last_used", "last_used",
"expires", "expires",
] ]
extra_args = {"uuid": {"read_only": True}}
class AuthenticatedSessionViewSet( class AuthenticatedSessionViewSet(
@ -108,10 +101,9 @@ class AuthenticatedSessionViewSet(
): ):
"""AuthenticatedSession Viewset""" """AuthenticatedSession Viewset"""
lookup_field = "uuid" queryset = AuthenticatedSession.objects.all()
queryset = AuthenticatedSession.objects.select_related("session").all()
serializer_class = AuthenticatedSessionSerializer serializer_class = AuthenticatedSessionSerializer
search_fields = ["user__username", "session__last_ip", "session__last_user_agent"] search_fields = ["user__username", "last_ip", "last_user_agent"]
filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"] filterset_fields = ["user__username", "last_ip", "last_user_agent"]
ordering = ["user__username"] ordering = ["user__username"]
owner_field = "user" owner_field = "user"

View File

@ -1,11 +1,14 @@
"""User API Views""" """User API Views"""
from datetime import timedelta from datetime import timedelta
from importlib import import_module
from json import loads from json import loads
from typing import Any from typing import Any
from django.conf import settings
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.sessions.backends.base import SessionBase
from django.db.models.functions import ExtractHour from django.db.models.functions import ExtractHour
from django.db.transaction import atomic from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -69,8 +72,8 @@ from authentik.core.middleware import (
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_PATH_SERVICE_ACCOUNT, USER_PATH_SERVICE_ACCOUNT,
AuthenticatedSession,
Group, Group,
Session,
Token, Token,
TokenIntents, TokenIntents,
User, User,
@ -89,6 +92,7 @@ from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger() LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
class UserGroupSerializer(ModelSerializer): class UserGroupSerializer(ModelSerializer):
@ -772,6 +776,10 @@ class UserViewSet(UsedByMixin, ModelViewSet):
response = super().partial_update(request, *args, **kwargs) response = super().partial_update(request, *args, **kwargs)
instance: User = self.get_object() instance: User = self.get_object()
if not instance.is_active: if not instance.is_active:
Session.objects.filter(authenticatedsession__user=instance).delete() sessions = AuthenticatedSession.objects.filter(user=instance)
session_ids = sessions.values_list("session_key", flat=True)
for session in session_ids:
SessionStore(session).delete()
sessions.delete()
LOGGER.debug("Deleted user's sessions", user=instance.username) LOGGER.debug("Deleted user's sessions", user=instance.username)
return response return response

View File

@ -20,8 +20,6 @@ from rest_framework.serializers import (
raise_errors_on_nested_writes, raise_errors_on_nested_writes,
) )
from authentik.rbac.permissions import assign_initial_permissions
def is_dict(value: Any): def is_dict(value: Any):
"""Ensure a value is a dictionary, useful for JSONFields""" """Ensure a value is a dictionary, useful for JSONFields"""
@ -31,14 +29,6 @@ def is_dict(value: Any):
class ModelSerializer(BaseModelSerializer): class ModelSerializer(BaseModelSerializer):
def create(self, validated_data):
instance = super().create(validated_data)
request = self.context.get("request")
if request and hasattr(request, "user") and not request.user.is_anonymous:
assign_initial_permissions(request.user, instance)
return instance
def update(self, instance: Model, validated_data): def update(self, instance: Model, validated_data):
raise_errors_on_nested_writes("update", self, validated_data) raise_errors_on_nested_writes("update", self, validated_data)

View File

@ -24,15 +24,6 @@ class InbuiltBackend(ModelBackend):
self.set_method("password", request) self.set_method("password", request)
return user return user
async def aauthenticate(
self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
) -> User | None:
user = await super().aauthenticate(request, username=username, password=password, **kwargs)
if not user:
return None
self.set_method("password", request)
return user
def set_method(self, method: str, request: HttpRequest | None, **kwargs): def set_method(self, method: str, request: HttpRequest | None, **kwargs):
"""Set method data on current flow, if possbiel""" """Set method data on current flow, if possbiel"""
if not request: if not request:

View File

@ -1,15 +0,0 @@
"""Change user type"""
from importlib import import_module
from django.conf import settings
from authentik.tenants.management import TenantCommand
class Command(TenantCommand):
"""Delete all sessions"""
def handle_per_tenant(self, **options):
engine = import_module(settings.SESSION_ENGINE)
engine.SessionStore.clear_expired()

View File

@ -2,14 +2,9 @@
from collections.abc import Callable from collections.abc import Callable
from contextvars import ContextVar from contextvars import ContextVar
from functools import partial
from uuid import uuid4 from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import SimpleLazyObject
from django.utils.translation import override from django.utils.translation import override
from sentry_sdk.api import set_tag from sentry_sdk.api import set_tag
from structlog.contextvars import STRUCTLOG_KEY_PREFIX from structlog.contextvars import STRUCTLOG_KEY_PREFIX
@ -25,40 +20,6 @@ CTX_HOST = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + "host", default=None)
CTX_AUTH_VIA = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + KEY_AUTH_VIA, default=None) CTX_AUTH_VIA = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + KEY_AUTH_VIA, default=None)
def get_user(request):
if not hasattr(request, "_cached_user"):
user = None
if (authenticated_session := request.session.get("authenticatedsession", None)) is not None:
user = authenticated_session.user
request._cached_user = user or AnonymousUser()
return request._cached_user
async def aget_user(request):
if not hasattr(request, "_cached_user"):
user = None
if (
authenticated_session := await request.session.aget("authenticatedsession", None)
) is not None:
user = authenticated_session.user
request._cached_user = user or AnonymousUser()
return request._cached_user
class AuthenticationMiddleware(MiddlewareMixin):
def process_request(self, request):
if not hasattr(request, "session"):
raise ImproperlyConfigured(
"The Django authentication middleware requires session "
"middleware to be installed. Edit your MIDDLEWARE setting to "
"insert "
"'authentik.root.middleware.SessionMiddleware' before "
"'authentik.core.middleware.AuthenticationMiddleware'."
)
request.user = SimpleLazyObject(lambda: get_user(request))
request.auser = partial(aget_user, request)
class ImpersonateMiddleware: class ImpersonateMiddleware:
"""Middleware to impersonate users""" """Middleware to impersonate users"""

View File

@ -1,238 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-27 12:58
import uuid
import pickle # nosec
from django.core import signing
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.utils.timezone import now, timedelta
from authentik.lib.migrations import progress_bar
from authentik.root.middleware import ClientIPMiddleware
SESSION_CACHE_ALIAS = "default"
class PickleSerializer:
"""
Simple wrapper around pickle to be used in signing.dumps()/loads() and
cache backends.
"""
def __init__(self, protocol=None):
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
def dumps(self, obj):
"""Pickle data to be stored in redis"""
return pickle.dumps(obj, self.protocol)
def loads(self, data):
"""Unpickle data to be loaded from redis"""
return pickle.loads(data) # nosec
def _migrate_session(
apps,
db_alias,
session_key,
session_data,
expires,
):
Session = apps.get_model("authentik_core", "Session")
OldAuthenticatedSession = apps.get_model("authentik_core", "OldAuthenticatedSession")
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
old_auth_session = (
OldAuthenticatedSession.objects.using(db_alias).filter(session_key=session_key).first()
)
args = {
"session_key": session_key,
"expires": expires,
"last_ip": ClientIPMiddleware.default_ip,
"last_user_agent": "",
"session_data": {},
}
for k, v in session_data.items():
if k == "authentik/stages/user_login/last_ip":
args["last_ip"] = v
elif k in ["last_user_agent", "last_used"]:
args[k] = v
elif args in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY]:
pass
else:
args["session_data"][k] = v
if old_auth_session:
args["last_user_agent"] = old_auth_session.last_user_agent
args["last_used"] = old_auth_session.last_used
args["session_data"] = pickle.dumps(args["session_data"])
session = Session.objects.using(db_alias).create(**args)
if old_auth_session:
AuthenticatedSession.objects.using(db_alias).create(
session=session,
user=old_auth_session.user,
)
def migrate_redis_sessions(apps, schema_editor):
from django.core.cache import caches
db_alias = schema_editor.connection.alias
cache = caches[SESSION_CACHE_ALIAS]
# Not a redis cache, skipping
if not hasattr(cache, "keys"):
return
print("\nMigrating Redis sessions to database, this might take a couple of minutes...")
for key, session_data in progress_bar(cache.get_many(cache.keys(f"{KEY_PREFIX}*")).items()):
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=key.removeprefix(KEY_PREFIX),
session_data=session_data,
expires=now() + timedelta(seconds=cache.ttl(key)),
)
def migrate_database_sessions(apps, schema_editor):
DjangoSession = apps.get_model("sessions", "Session")
db_alias = schema_editor.connection.alias
print("\nMigration database sessions, this might take a couple of minutes...")
for django_session in progress_bar(DjangoSession.objects.using(db_alias).all()):
session_data = signing.loads(
django_session.session_data,
salt="django.contrib.sessions.SessionStore",
serializer=PickleSerializer,
)
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=django_session.session_key,
session_data=session_data,
expires=django_session.expire_date,
)
class Migration(migrations.Migration):
dependencies = [
("sessions", "0001_initial"),
("authentik_core", "0045_rename_new_identifier_usersourceconnection_identifier_and_more"),
("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"),
("authentik_providers_rac", "0006_connectiontoken_authentik_p_expires_91f148_idx_and_more"),
]
operations = [
# Rename AuthenticatedSession to OldAuthenticatedSession
migrations.RenameModel(
old_name="AuthenticatedSession",
new_name="OldAuthenticatedSession",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expires_cf4f72_idx",
old_name="authentik_c_expires_08251d_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expirin_c1f17f_idx",
old_name="authentik_c_expirin_9cd839_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expirin_e04f5d_idx",
old_name="authentik_c_expirin_195a84_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_session_a44819_idx",
old_name="authentik_c_session_d0f005_idx",
),
migrations.RunSQL(
sql="ALTER INDEX authentik_core_authenticatedsession_user_id_5055b6cf RENAME TO authentik_core_oldauthenticatedsession_user_id_5055b6cf",
reverse_sql="ALTER INDEX authentik_core_oldauthenticatedsession_user_id_5055b6cf RENAME TO authentik_core_authenticatedsession_user_id_5055b6cf",
),
# Create new Session and AuthenticatedSession models
migrations.CreateModel(
name="Session",
fields=[
(
"session_key",
models.CharField(
max_length=40, primary_key=True, serialize=False, verbose_name="session key"
),
),
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
("session_data", models.BinaryField(verbose_name="session data")),
("last_ip", models.GenericIPAddressField()),
("last_user_agent", models.TextField(blank=True)),
("last_used", models.DateTimeField(auto_now=True)),
],
options={
"default_permissions": [],
"verbose_name": "Session",
"verbose_name_plural": "Sessions",
},
),
migrations.AddIndex(
model_name="session",
index=models.Index(fields=["expires"], name="authentik_c_expires_d2f607_idx"),
),
migrations.AddIndex(
model_name="session",
index=models.Index(fields=["expiring"], name="authentik_c_expirin_7c2cfb_idx"),
),
migrations.AddIndex(
model_name="session",
index=models.Index(
fields=["expiring", "expires"], name="authentik_c_expirin_1ab2e4_idx"
),
),
migrations.AddIndex(
model_name="session",
index=models.Index(
fields=["expires", "session_key"], name="authentik_c_expires_c49143_idx"
),
),
migrations.CreateModel(
name="AuthenticatedSession",
fields=[
(
"session",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
serialize=False,
to="authentik_core.session",
),
),
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Authenticated Session",
"verbose_name_plural": "Authenticated Sessions",
},
),
migrations.RunPython(
code=migrate_redis_sessions,
reverse_code=migrations.RunPython.noop,
),
migrations.RunPython(
code=migrate_database_sessions,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-27 13:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0046_session_and_more"),
("authentik_providers_rac", "0007_migrate_session"),
("authentik_providers_oauth2", "0028_migrate_session"),
]
operations = [
migrations.DeleteModel(
name="OldAuthenticatedSession",
),
]

View File

@ -1,7 +1,6 @@
"""authentik core models""" """authentik core models"""
from datetime import datetime from datetime import datetime
from enum import StrEnum
from hashlib import sha256 from hashlib import sha256
from typing import Any, Optional, Self from typing import Any, Optional, Self
from uuid import uuid4 from uuid import uuid4
@ -10,7 +9,6 @@ from deepmerge import always_merger
from django.contrib.auth.hashers import check_password from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
from django.db import models from django.db import models
from django.db.models import Q, QuerySet, options from django.db.models import Q, QuerySet, options
from django.db.models.constants import LOOKUP_SEP from django.db.models.constants import LOOKUP_SEP
@ -648,30 +646,19 @@ class SourceUserMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning users""" """Different modes a source can handle new/returning users"""
IDENTIFIER = "identifier", _("Use the source-specific identifier") IDENTIFIER = "identifier", _("Use the source-specific identifier")
EMAIL_LINK = ( EMAIL_LINK = "email_link", _(
"email_link",
_(
"Link to a user with identical email address. Can have security implications " "Link to a user with identical email address. Can have security implications "
"when a source doesn't validate email addresses." "when a source doesn't validate email addresses."
),
) )
EMAIL_DENY = ( EMAIL_DENY = "email_deny", _(
"email_deny", "Use the user's email address, but deny enrollment when the email address already exists."
_(
"Use the user's email address, but deny enrollment when the email address already "
"exists."
),
) )
USERNAME_LINK = ( USERNAME_LINK = "username_link", _(
"username_link",
_(
"Link to a user with identical username. Can have security implications " "Link to a user with identical username. Can have security implications "
"when a username is used with another source." "when a username is used with another source."
),
) )
USERNAME_DENY = ( USERNAME_DENY = "username_deny", _(
"username_deny", "Use the user's username, but deny enrollment when the username already exists."
_("Use the user's username, but deny enrollment when the username already exists."),
) )
@ -679,16 +666,12 @@ class SourceGroupMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning groups""" """Different modes a source can handle new/returning groups"""
IDENTIFIER = "identifier", _("Use the source-specific identifier") IDENTIFIER = "identifier", _("Use the source-specific identifier")
NAME_LINK = ( NAME_LINK = "name_link", _(
"name_link",
_(
"Link to a group with identical name. Can have security implications " "Link to a group with identical name. Can have security implications "
"when a group name is used with another source." "when a group name is used with another source."
),
) )
NAME_DENY = ( NAME_DENY = "name_deny", _(
"name_deny", "Use the group name, but deny enrollment when the name already exists."
_("Use the group name, but deny enrollment when the name already exists."),
) )
@ -747,7 +730,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
choices=SourceGroupMatchingModes.choices, choices=SourceGroupMatchingModes.choices,
default=SourceGroupMatchingModes.IDENTIFIER, default=SourceGroupMatchingModes.IDENTIFIER,
help_text=_( help_text=_(
"How the source determines if an existing group should be used or a new group created." "How the source determines if an existing group should be used or "
"a new group created."
), ),
) )
@ -1028,75 +1012,45 @@ class PropertyMapping(SerializerModel, ManagedModel):
verbose_name_plural = _("Property Mappings") verbose_name_plural = _("Property Mappings")
class Session(ExpiringModel, AbstractBaseSession): class AuthenticatedSession(ExpiringModel):
"""User session with extra fields for fast access""" """Additional session class for authenticated users. Augments the standard django session
to achieve the following:
- Make it queryable by user
- Have a direct connection to user objects
- Allow users to view their own sessions and terminate them
- Save structured and well-defined information.
"""
# Remove upstream field because we're using our own ExpiringModel uuid = models.UUIDField(default=uuid4, primary_key=True)
expire_date = None
session_data = models.BinaryField(_("session data"))
# Keep in sync with Session.Keys session_key = models.CharField(max_length=40)
last_ip = models.GenericIPAddressField() user = models.ForeignKey(User, on_delete=models.CASCADE)
last_ip = models.TextField()
last_user_agent = models.TextField(blank=True) last_user_agent = models.TextField(blank=True)
last_used = models.DateTimeField(auto_now=True) last_used = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("Session")
verbose_name_plural = _("Sessions")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["expires", "session_key"]),
]
default_permissions = []
def __str__(self):
return self.session_key
class Keys(StrEnum):
"""
Keys to be set with the session interface for the fields above to be updated.
If a field is added here that needs to be initialized when the session is initialized,
it must also be reflected in authentik.root.middleware.SessionMiddleware.process_request
and in authentik.core.sessions.SessionStore.__init__
"""
LAST_IP = "last_ip"
LAST_USER_AGENT = "last_user_agent"
LAST_USED = "last_used"
@classmethod
def get_session_store_class(cls):
from authentik.core.sessions import SessionStore
return SessionStore
def get_decoded(self):
raise NotImplementedError
class AuthenticatedSession(SerializerModel):
session = models.OneToOneField(Session, on_delete=models.CASCADE, primary_key=True)
# We use the session as primary key, but we need the API to be able to reference
# this object uniquely without exposing the session key
uuid = models.UUIDField(default=uuid4, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta: class Meta:
verbose_name = _("Authenticated Session") verbose_name = _("Authenticated Session")
verbose_name_plural = _("Authenticated Sessions") verbose_name_plural = _("Authenticated Sessions")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["session_key"]),
]
def __str__(self) -> str: def __str__(self) -> str:
return f"Authenticated Session {str(self.pk)[:10]}" return f"Authenticated Session {self.session_key[:10]}"
@staticmethod @staticmethod
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]: def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
"""Create a new session from a http request""" """Create a new session from a http request"""
if not hasattr(request, "session") or not request.session.exists( from authentik.root.middleware import ClientIPMiddleware
request.session.session_key
): if not hasattr(request, "session") or not request.session.session_key:
return None return None
return AuthenticatedSession( return AuthenticatedSession(
session=Session.objects.filter(session_key=request.session.session_key).first(), session_key=request.session.session_key,
user=user, user=user,
last_ip=ClientIPMiddleware.get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
expires=request.session.get_expiry_date(),
) )

View File

@ -1,168 +0,0 @@
"""authentik sessions engine"""
import pickle # nosec
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.contrib.sessions.backends.db import SessionStore as SessionBase
from django.core.exceptions import SuspiciousOperation
from django.utils import timezone
from django.utils.functional import cached_property
from structlog.stdlib import get_logger
from authentik.root.middleware import ClientIPMiddleware
LOGGER = get_logger()
class SessionStore(SessionBase):
def __init__(self, session_key=None, last_ip=None, last_user_agent=""):
super().__init__(session_key)
self._create_kwargs = {
"last_ip": last_ip or ClientIPMiddleware.default_ip,
"last_user_agent": last_user_agent,
}
@classmethod
def get_model_class(cls):
from authentik.core.models import Session
return Session
@cached_property
def model_fields(self):
return [k.value for k in self.model.Keys]
def _get_session_from_db(self):
try:
return (
self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.get(
session_key=self.session_key,
expires__gt=timezone.now(),
)
)
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
if isinstance(exc, SuspiciousOperation):
LOGGER.warning(str(exc))
self._session_key = None
async def _aget_session_from_db(self):
try:
return (
await self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.aget(
session_key=self.session_key,
expires__gt=timezone.now(),
)
)
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
if isinstance(exc, SuspiciousOperation):
LOGGER.warning(str(exc))
self._session_key = None
def encode(self, session_dict):
return pickle.dumps(session_dict, protocol=pickle.HIGHEST_PROTOCOL)
def decode(self, session_data):
try:
return pickle.loads(session_data) # nosec
except pickle.PickleError:
# ValueError, unpickling exceptions. If any of these happen, just return an empty
# dictionary (an empty session)
pass
return {}
def load(self):
s = self._get_session_from_db()
if s:
return {
"authenticatedsession": getattr(s, "authenticatedsession", None),
**{k: getattr(s, k) for k in self.model_fields},
**self.decode(s.session_data),
}
else:
return {}
async def aload(self):
s = await self._aget_session_from_db()
if s:
return {
"authenticatedsession": getattr(s, "authenticatedsession", None),
**{k: getattr(s, k) for k in self.model_fields},
**self.decode(s.session_data),
}
else:
return {}
def create_model_instance(self, data):
args = {
"session_key": self._get_or_create_session_key(),
"expires": self.get_expiry_date(),
"session_data": {},
**self._create_kwargs,
}
for k, v in data.items():
# Don't save:
# - unused auth data
# - related models
if k in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY, "authenticatedsession"]:
pass
elif k in self.model_fields:
args[k] = v
else:
args["session_data"][k] = v
args["session_data"] = self.encode(args["session_data"])
return self.model(**args)
async def acreate_model_instance(self, data):
args = {
"session_key": await self._aget_or_create_session_key(),
"expires": await self.aget_expiry_date(),
"session_data": {},
**self._create_kwargs,
}
for k, v in data.items():
# Don't save:
# - unused auth data
# - related models
if k in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY, "authenticatedsession"]:
pass
elif k in self.model_fields:
args[k] = v
else:
args["session_data"][k] = v
args["session_data"] = self.encode(args["session_data"])
return self.model(**args)
@classmethod
def clear_expired(cls):
cls.get_model_class().objects.filter(expires__lt=timezone.now()).delete()
@classmethod
async def aclear_expired(cls):
await cls.get_model_class().objects.filter(expires__lt=timezone.now()).adelete()
def cycle_key(self):
data = self._session
key = self.session_key
self.create()
self._session_cache = data
if key:
self.delete(key)
if (authenticated_session := data.get("authenticatedsession")) is not None:
authenticated_session.session_id = self.session_key
authenticated_session.save(force_insert=True)

View File

@ -1,10 +1,14 @@
"""authentik core signals""" """authentik core signals"""
from django.contrib.auth.signals import user_logged_in from importlib import import_module
from django.conf import settings
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.base import SessionBase
from django.core.cache import cache from django.core.cache import cache
from django.core.signals import Signal from django.core.signals import Signal
from django.db.models import Model from django.db.models import Model
from django.db.models.signals import post_delete, post_save, pre_save from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.http.request import HttpRequest from django.http.request import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -14,7 +18,6 @@ from authentik.core.models import (
AuthenticatedSession, AuthenticatedSession,
BackchannelProvider, BackchannelProvider,
ExpiringModel, ExpiringModel,
Session,
User, User,
default_token_duration, default_token_duration,
) )
@ -25,6 +28,7 @@ password_changed = Signal()
login_failed = Signal() login_failed = Signal()
LOGGER = get_logger() LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
@receiver(post_save, sender=Application) @receiver(post_save, sender=Application)
@ -49,10 +53,18 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
session.save() session.save()
@receiver(post_delete, sender=AuthenticatedSession) @receiver(user_logged_out)
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
"""Delete AuthenticatedSession if it exists"""
if not request.session or not request.session.session_key:
return
AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete()
@receiver(pre_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted""" """Delete session when authenticated session is deleted"""
Session.objects.filter(session_key=instance.pk).delete() SessionStore(instance.session_key).delete()
@receiver(pre_save) @receiver(pre_save)

View File

@ -2,16 +2,22 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import ImproperlyConfigured
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
from django.core.cache import cache
from django.utils.timezone import now from django.utils.timezone import now
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_EXPIRES, USER_ATTRIBUTE_EXPIRES,
USER_ATTRIBUTE_GENERATED, USER_ATTRIBUTE_GENERATED,
AuthenticatedSession,
ExpiringModel, ExpiringModel,
User, User,
) )
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
LOGGER = get_logger() LOGGER = get_logger()
@ -32,6 +38,40 @@ def clean_expired_models(self: SystemTask):
obj.expire_action() obj.expire_action()
LOGGER.debug("Expired models", model=cls, amount=amount) LOGGER.debug("Expired models", model=cls, amount=amount)
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}") messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
# Special case
amount = 0
for session in AuthenticatedSession.objects.all():
match CONFIG.get("session_storage", "cache"):
case "cache":
cache_key = f"{KEY_PREFIX}{session.session_key}"
value = None
try:
value = cache.get(cache_key)
except Exception as exc:
LOGGER.debug("Failed to get session from cache", exc=exc)
if not value:
session.delete()
amount += 1
case "db":
if not (
DBSessionStore.get_model_class()
.objects.filter(session_key=session.session_key, expire_date__gt=now())
.exists()
):
session.delete()
amount += 1
case _:
# Should never happen, as we check for other values in authentik/root/settings.py
raise ImproperlyConfigured(
"Invalid session_storage setting, allowed values are db and cache"
)
if CONFIG.get("session_storage", "cache") == "db":
DBSessionStore.clear_expired()
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
self.set_status(TaskStatus.SUCCESSFUL, *messages) self.set_status(TaskStatus.SUCCESSFUL, *messages)

View File

@ -4,8 +4,8 @@
<script> <script>
window.authentik = { window.authentik = {
locale: "{{ LANGUAGE_CODE }}", locale: "{{ LANGUAGE_CODE }}",
config: JSON.parse("{{ config_json|escapejs }}" || "{}"), config: JSON.parse('{{ config_json|escapejs }}'),
brand: JSON.parse("{{ brand_json|escapejs }}" || "{}"), brand: JSON.parse('{{ brand_json|escapejs }}'),
versionFamily: "{{ version_family }}", versionFamily: "{{ version_family }}",
versionSubdomain: "{{ version_subdomain }}", versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}", build: "{{ build }}",
@ -14,8 +14,6 @@
relBase: "{{ base_url_rel }}", relBase: "{{ base_url_rel }}",
}, },
}; };
{% if messages %}
window.addEventListener("DOMContentLoaded", function () { window.addEventListener("DOMContentLoaded", function () {
{% for message in messages %} {% for message in messages %}
window.dispatchEvent( window.dispatchEvent(
@ -30,5 +28,4 @@
); );
{% endfor %} {% endfor %}
}); });
{% endif %}
</script> </script>

View File

@ -2,79 +2,31 @@
{% load i18n %} {% load i18n %}
{% load authentik_core %} {% load authentik_core %}
<!doctype html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #}
{% comment %} <meta name="darkreader-lock">
Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we
default to a dark theme based on preferred colour-scheme
{% endcomment %}
<meta name="darkreader-lock" />
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}">
<link rel="icon" href="{{ brand.branding_favicon_url }}" /> <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}" />
{% block head_before %} {% block head_before %}
{% endblock %} {% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}" /> <style>{{ brand.branding_custom_css }}</style>
<style data-test-id="color-scheme">
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark light;
}
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light dark;
}
}
</style>
<style data-test-id="custom-branding-css">
{{ brand.branding_custom_css }}
</style>
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script> <script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
<script <script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}"
type="module"
></script>
{% block head %} {% block head %}
{% endblock %} {% endblock %}
<meta name="sentry-trace" content="{{ sentry_trace }}" /> <meta name="sentry-trace" content="{{ sentry_trace }}" />
</head> </head>
<body> <body>
{% block body %}{% endblock %} {% block body %}
{% block scripts %}{% endblock %} {% endblock %}
{% block scripts %}
<noscript> {% endblock %}
<style>
body {
font-family: var(--ak-font-family-base), sans-serif;
}
</style>
<h1>
JavaScript is required to use
{% trans title|default:brand.branding_title %}
</h1>
<p>
Please enable JavaScript in your browser settings and reload the page. If you are using a
browser extension that blocks JavaScript, please disable it for this site.
</p>
</noscript>
</body> </body>
</html> </html>

View File

@ -4,15 +4,13 @@
{% block head %} {% block head %}
<script src="{% versioned_script 'dist/admin/AdminInterface-%v.js' %}" type="module"></script> <script src="{% versioned_script 'dist/admin/AdminInterface-%v.js' %}" type="module"></script>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
{% include "base/header_js.html" %} {% include "base/header_js.html" %}
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<ak-message-container></ak-message-container> <ak-message-container></ak-message-container>
<ak-interface-admin> <ak-interface-admin>
<ak-loading></ak-loading> <ak-loading></ak-loading>
</ak-interface-admin> </ak-interface-admin>

View File

@ -14,12 +14,7 @@
{% block card %} {% block card %}
<form method="POST" class="pf-c-form"> <form method="POST" class="pf-c-form">
<p>{% trans message %}</p> <p>{% trans message %}</p>
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary">
<a
id="ak-back-home"
href="{% url 'authentik_core:root-redirect' %}"
class="pf-c-button pf-m-primary"
>
{% trans 'Go home' %} {% trans 'Go home' %}
</a> </a>
</form> </form>

View File

@ -4,16 +4,13 @@
{% block head %} {% block head %}
<script src="{% versioned_script 'dist/user/UserInterface-%v.js' %}" type="module"></script> <script src="{% versioned_script 'dist/user/UserInterface-%v.js' %}" type="module"></script>
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)" />
{% include "base/header_js.html" %} {% include "base/header_js.html" %}
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<ak-message-container></ak-message-container> <ak-message-container></ak-message-container>
<ak-interface-user> <ak-interface-user>
<ak-loading></ak-loading> <ak-loading></ak-loading>
</ak-interface-user> </ak-interface-user>

View File

@ -5,18 +5,13 @@
{% block head_before %} {% block head_before %}
<link rel="prefetch" href="{{ request.brand.branding_default_flow_background_url }}" /> <link rel="prefetch" href="{{ request.brand.branding_default_flow_background_url }}" />
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}" /> <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
<link <link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)">
rel="stylesheet"
type="text/css"
href="{% static 'dist/theme-dark.css' %}"
media="(prefers-color-scheme: dark)"
/>
{% include "base/header_js.html" %} {% include "base/header_js.html" %}
{% endblock %} {% endblock %}
{% block head %} {% block head %}
<style data-test-id="base-full-root-styles"> <style>
:root { :root {
--ak-flow-background: url("{{ request.brand.branding_default_flow_background_url }}"); --ak-flow-background: url("{{ request.brand.branding_default_flow_background_url }}");
--pf-c-background-image--BackgroundImage: var(--ak-flow-background); --pf-c-background-image--BackgroundImage: var(--ak-flow-background);
@ -48,7 +43,8 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="pf-c-background-image"></div> <div class="pf-c-background-image">
</div>
<ak-message-container></ak-message-container> <ak-message-container></ak-message-container>
<div class="pf-c-login stacked"> <div class="pf-c-login stacked">
<div class="ak-login-container"> <div class="ak-login-container">

View File

@ -1,17 +1,9 @@
"""Test API Utils""" """Test API Utils"""
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.serializers import (
HyperlinkedModelSerializer,
)
from rest_framework.serializers import (
ModelSerializer as BaseModelSerializer,
)
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.api.utils import ModelSerializer as CustomModelSerializer
from authentik.core.api.utils import is_dict from authentik.core.api.utils import is_dict
from authentik.lib.utils.reflection import all_subclasses
class TestAPIUtils(APITestCase): class TestAPIUtils(APITestCase):
@ -22,14 +14,3 @@ class TestAPIUtils(APITestCase):
self.assertIsNone(is_dict({})) self.assertIsNone(is_dict({}))
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
is_dict("foo") is_dict("foo")
def test_all_serializers_descend_from_custom(self):
"""Test that every serializer we define descends from our own ModelSerializer"""
# Weirdly, there's only one serializer in `rest_framework` which descends from
# ModelSerializer: HyperlinkedModelSerializer
expected = {CustomModelSerializer, HyperlinkedModelSerializer}
actual = set(all_subclasses(BaseModelSerializer)) - set(
all_subclasses(CustomModelSerializer)
)
self.assertEqual(expected, actual)

View File

@ -5,7 +5,7 @@ from json import loads
from django.urls.base import reverse from django.urls.base import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import AuthenticatedSession, Session, User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
@ -30,18 +30,3 @@ class TestAuthenticatedSessionsAPI(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = loads(response.content.decode()) body = loads(response.content.decode())
self.assertEqual(body["pagination"]["count"], 1) self.assertEqual(body["pagination"]["count"], 1)
def test_delete(self):
"""Test deletion"""
self.client.force_login(self.user)
self.assertEqual(AuthenticatedSession.objects.all().count(), 1)
self.assertEqual(Session.objects.all().count(), 1)
response = self.client.delete(
reverse(
"authentik_api:authenticatedsession-detail",
kwargs={"uuid": AuthenticatedSession.objects.first().uuid},
)
)
self.assertEqual(response.status_code, 204)
self.assertEqual(AuthenticatedSession.objects.all().count(), 0)
self.assertEqual(Session.objects.all().count(), 0)

View File

@ -3,6 +3,8 @@
from datetime import datetime from datetime import datetime
from json import loads from json import loads
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.urls.base import reverse from django.urls.base import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@ -10,7 +12,6 @@ from authentik.brands.models import Brand
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_EXPIRING,
AuthenticatedSession, AuthenticatedSession,
Session,
Token, Token,
User, User,
UserTypes, UserTypes,
@ -380,15 +381,12 @@ class TestUsersAPI(APITestCase):
"""Ensure sessions are deleted when a user is deactivated""" """Ensure sessions are deleted when a user is deactivated"""
user = create_test_admin_user() user = create_test_admin_user()
session_id = generate_id() session_id = generate_id()
session = Session.objects.create(
session_key=session_id,
last_ip="255.255.255.255",
last_user_agent="",
)
AuthenticatedSession.objects.create( AuthenticatedSession.objects.create(
session=session,
user=user, user=user,
session_key=session_id,
last_ip="",
) )
cache.set(KEY_PREFIX + session_id, "foo")
self.client.force_login(self.admin) self.client.force_login(self.admin)
response = self.client.patch( response = self.client.patch(
@ -399,7 +397,5 @@ class TestUsersAPI(APITestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertFalse(Session.objects.filter(session_key=session_id).exists()) self.assertIsNone(cache.get(KEY_PREFIX + session_id))
self.assertFalse( self.assertFalse(AuthenticatedSession.objects.filter(session_key=session_id).exists())
AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
)

View File

@ -1,5 +1,7 @@
"""authentik URL Configuration""" """authentik URL Configuration"""
from channels.auth import AuthMiddleware
from channels.sessions import CookieMiddleware
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.urls import path from django.urls import path
@ -27,7 +29,7 @@ from authentik.core.views.interface import (
RootRedirectView, RootRedirectView,
) )
from authentik.flows.views.interface import FlowInterfaceView from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import AuthMiddlewareStack from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer from authentik.root.messages.consumer import MessageConsumer
from authentik.root.middleware import ChannelsLoggingMiddleware from authentik.root.middleware import ChannelsLoggingMiddleware
@ -97,7 +99,9 @@ api_urlpatterns = [
websocket_urlpatterns = [ websocket_urlpatterns = [
path( path(
"ws/client/", "ws/client/",
ChannelsLoggingMiddleware(AuthMiddlewareStack(MessageConsumer.as_asgi())), ChannelsLoggingMiddleware(
CookieMiddleware(SessionMiddleware(AuthMiddleware(MessageConsumer.as_asgi())))
),
), ),
] ]

View File

@ -102,7 +102,7 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi
"format": "complex", "format": "complex",
"session": { "session": {
"format": "opaque", "format": "opaque",
"id": sha256(instance.session.session_key.encode("ascii")).hexdigest(), "id": sha256(instance.session_key.encode("ascii")).hexdigest(),
}, },
"user": { "user": {
"format": "email", "format": "email",

View File

@ -4,9 +4,10 @@ from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.providers.ssf.models import ( from authentik.enterprise.providers.ssf.models import (
DeliveryMethods, DeliveryMethods,
EventTypes, EventTypes,

View File

@ -2,11 +2,11 @@
from rest_framework import mixins from rest_framework import mixins
from rest_framework.permissions import IsAdminUser from rest_framework.permissions import IsAdminUser
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet, ModelViewSet from rest_framework.viewsets import GenericViewSet, ModelViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.enterprise.api import EnterpriseRequiredMixin from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import ( from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
AuthenticatorEndpointGDTCStage, AuthenticatorEndpointGDTCStage,

View File

@ -59,7 +59,7 @@ def get_login_event(request_or_session: HttpRequest | AuthenticatedSession | Non
session = request_or_session.session session = request_or_session.session
if isinstance(request_or_session, AuthenticatedSession): if isinstance(request_or_session, AuthenticatedSession):
SessionStore = _session_engine.SessionStore SessionStore = _session_engine.SessionStore
session = SessionStore(request_or_session.session.session_key) session = SessionStore(request_or_session.session_key)
return session.get(SESSION_LOGIN_EVENT, None) return session.get(SESSION_LOGIN_EVENT, None)

View File

@ -2,18 +2,18 @@
{% load i18n %} {% load i18n %}
{% load authentik_core %} {% load authentik_core %}
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}" /> <link rel="icon" href="{{ brand.branding_favicon_url }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}" /> <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
{% block head_before %} {% block head_before %}
{% endblock %} {% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}" /> <link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}">
<meta name="sentry-trace" content="{{ sentry_trace }}" /> <meta name="sentry-trace" content="{{ sentry_trace }}" />
{% include "base/header_js.html" %} {% include "base/header_js.html" %}
<style> <style>
@ -45,7 +45,8 @@
</head> </head>
<body class="d-flex align-items-center py-4 bg-body-tertiary"> <body class="d-flex align-items-center py-4 bg-body-tertiary">
<div class="card m-auto"> <div class="card m-auto">
<main class="form-signin w-100 m-auto" id="flow-sfe-container"></main> <main class="form-signin w-100 m-auto" id="flow-sfe-container">
</main>
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span> <span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
</div> </div>
<script src="{% static 'dist/sfe/index.js' %}"></script> <script src="{% static 'dist/sfe/index.js' %}"></script>

View File

@ -1,31 +1,25 @@
{% extends "base/skeleton.html" %} {% extends "base/skeleton.html" %}
{% load static %} {% load static %}
{% load authentik_core %} {% load authentik_core %}
{% block head_before %} {% block head_before %}
{{ block.super }} {{ block.super }}
<link rel="prefetch" href="{{ flow.background_url }}" /> <link rel="prefetch" href="{{ flow.background_url }}" />
{% if flow.compatibility_mode and not inspector %} {% if flow.compatibility_mode and not inspector %}
<script> <script>ShadyDOM = { force: !navigator.webdriver };</script>
ShadyDOM = { force: !navigator.webdriver };
</script>
{% endif %} {% endif %}
{% include "base/header_js.html" %} {% include "base/header_js.html" %}
<script> <script>
window.authentik.flow = { window.authentik.flow = {
layout: "{{ flow.layout }}", "layout": "{{ flow.layout }}",
}; };
</script> </script>
{% endblock %} {% endblock %}
{% block head %} {% block head %}
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script> <script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
<style>
<style data-test-id="flow-root-styles">
:root { :root {
--ak-flow-background: url("{{ flow.background_url }}"); --ak-flow-background: url("{{ flow.background_url }}");
} }

View File

@ -356,14 +356,6 @@ def redis_url(db: int) -> str:
def django_db_config(config: ConfigLoader | None = None) -> dict: def django_db_config(config: ConfigLoader | None = None) -> dict:
if not config: if not config:
config = CONFIG config = CONFIG
pool_options = False
use_pool = config.get_bool("postgresql.use_pool", False)
if use_pool:
pool_options = config.get_dict_from_b64_json("postgresql.pool_options", True)
if not pool_options:
pool_options = True
db = { db = {
"default": { "default": {
"ENGINE": "authentik.root.db", "ENGINE": "authentik.root.db",
@ -377,7 +369,6 @@ def django_db_config(config: ConfigLoader | None = None) -> dict:
"sslrootcert": config.get("postgresql.sslrootcert"), "sslrootcert": config.get("postgresql.sslrootcert"),
"sslcert": config.get("postgresql.sslcert"), "sslcert": config.get("postgresql.sslcert"),
"sslkey": config.get("postgresql.sslkey"), "sslkey": config.get("postgresql.sslkey"),
"pool": pool_options,
}, },
"CONN_MAX_AGE": config.get_optional_int("postgresql.conn_max_age", 0), "CONN_MAX_AGE": config.get_optional_int("postgresql.conn_max_age", 0),
"CONN_HEALTH_CHECKS": config.get_bool("postgresql.conn_health_checks", False), "CONN_HEALTH_CHECKS": config.get_bool("postgresql.conn_health_checks", False),

View File

@ -21,7 +21,6 @@ postgresql:
user: authentik user: authentik
port: 5432 port: 5432
password: "env://POSTGRES_PASSWORD" password: "env://POSTGRES_PASSWORD"
use_pool: False
test: test:
name: test_authentik name: test_authentik
default_schema: public default_schema: public

View File

@ -18,7 +18,7 @@ from sentry_sdk import start_span
from sentry_sdk.tracing import Span from sentry_sdk.tracing import Span
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import User from authentik.core.models import AuthenticatedSession, User
from authentik.events.models import Event from authentik.events.models import Event
from authentik.lib.expression.exceptions import ControlFlowException from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.utils.http import get_http_session from authentik.lib.utils.http import get_http_session
@ -203,7 +203,9 @@ class BaseEvaluator:
provider = OAuth2Provider.objects.get(name=provider) provider = OAuth2Provider.objects.get(name=provider)
session = None session = None
if hasattr(request, "session") and request.session.session_key: if hasattr(request, "session") and request.session.session_key:
session = request.session["authenticatedsession"] session = AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).first()
access_token = AccessToken( access_token = AccessToken(
provider=provider, provider=provider,
user=user, user=user,

View File

@ -217,7 +217,6 @@ class TestConfig(TestCase):
"HOST": "foo", "HOST": "foo",
"NAME": "foo", "NAME": "foo",
"OPTIONS": { "OPTIONS": {
"pool": False,
"sslcert": "foo", "sslcert": "foo",
"sslkey": "foo", "sslkey": "foo",
"sslmode": "foo", "sslmode": "foo",
@ -268,7 +267,6 @@ class TestConfig(TestCase):
"HOST": "foo", "HOST": "foo",
"NAME": "foo", "NAME": "foo",
"OPTIONS": { "OPTIONS": {
"pool": False,
"sslcert": "foo", "sslcert": "foo",
"sslkey": "foo", "sslkey": "foo",
"sslmode": "foo", "sslmode": "foo",
@ -287,7 +285,6 @@ class TestConfig(TestCase):
"HOST": "bar", "HOST": "bar",
"NAME": "foo", "NAME": "foo",
"OPTIONS": { "OPTIONS": {
"pool": False,
"sslcert": "foo", "sslcert": "foo",
"sslkey": "foo", "sslkey": "foo",
"sslmode": "foo", "sslmode": "foo",
@ -336,7 +333,6 @@ class TestConfig(TestCase):
"HOST": "foo", "HOST": "foo",
"NAME": "foo", "NAME": "foo",
"OPTIONS": { "OPTIONS": {
"pool": False,
"sslcert": "foo", "sslcert": "foo",
"sslkey": "foo", "sslkey": "foo",
"sslmode": "foo", "sslmode": "foo",
@ -355,7 +351,6 @@ class TestConfig(TestCase):
"HOST": "bar", "HOST": "bar",
"NAME": "foo", "NAME": "foo",
"OPTIONS": { "OPTIONS": {
"pool": False,
"sslcert": "foo", "sslcert": "foo",
"sslkey": "foo", "sslkey": "foo",
"sslmode": "foo", "sslmode": "foo",
@ -399,7 +394,6 @@ class TestConfig(TestCase):
"HOST": "foo", "HOST": "foo",
"NAME": "foo", "NAME": "foo",
"OPTIONS": { "OPTIONS": {
"pool": False,
"sslcert": "foo", "sslcert": "foo",
"sslkey": "foo", "sslkey": "foo",
"sslmode": "foo", "sslmode": "foo",
@ -418,7 +412,6 @@ class TestConfig(TestCase):
"HOST": "bar", "HOST": "bar",
"NAME": "foo", "NAME": "foo",
"OPTIONS": { "OPTIONS": {
"pool": False,
"sslcert": "foo", "sslcert": "foo",
"sslkey": "foo", "sslkey": "foo",
"sslmode": "foo", "sslmode": "foo",
@ -458,7 +451,6 @@ class TestConfig(TestCase):
"HOST": "foo", "HOST": "foo",
"NAME": "foo", "NAME": "foo",
"OPTIONS": { "OPTIONS": {
"pool": False,
"sslcert": "foo", "sslcert": "foo",
"sslkey": "foo", "sslkey": "foo",
"sslmode": "foo", "sslmode": "foo",
@ -477,7 +469,6 @@ class TestConfig(TestCase):
"HOST": "bar", "HOST": "bar",
"NAME": "foo", "NAME": "foo",
"OPTIONS": { "OPTIONS": {
"pool": False,
"sslcert": "bar", "sslcert": "bar",
"sslkey": "foo", "sslkey": "foo",
"sslmode": "foo", "sslmode": "foo",
@ -493,87 +484,3 @@ class TestConfig(TestCase):
}, },
}, },
) )
def test_db_pool(self):
"""Test DB Config with pool"""
config = ConfigLoader()
config.set("postgresql.host", "foo")
config.set("postgresql.name", "foo")
config.set("postgresql.user", "foo")
config.set("postgresql.password", "foo")
config.set("postgresql.port", "foo")
config.set("postgresql.test.name", "foo")
config.set("postgresql.use_pool", True)
conf = django_db_config(config)
self.assertEqual(
conf,
{
"default": {
"ENGINE": "authentik.root.db",
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"pool": True,
"sslcert": None,
"sslkey": None,
"sslmode": None,
"sslrootcert": None,
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": False,
"DISABLE_SERVER_SIDE_CURSORS": False,
}
},
)
def test_db_pool_options(self):
"""Test DB Config with pool"""
config = ConfigLoader()
config.set("postgresql.host", "foo")
config.set("postgresql.name", "foo")
config.set("postgresql.user", "foo")
config.set("postgresql.password", "foo")
config.set("postgresql.port", "foo")
config.set("postgresql.test.name", "foo")
config.set("postgresql.use_pool", True)
config.set(
"postgresql.pool_options",
base64.b64encode(
dumps(
{
"max_size": 15,
}
).encode()
).decode(),
)
conf = django_db_config(config)
self.assertEqual(
conf,
{
"default": {
"ENGINE": "authentik.root.db",
"HOST": "foo",
"NAME": "foo",
"OPTIONS": {
"pool": {
"max_size": 15,
},
"sslcert": None,
"sslkey": None,
"sslmode": None,
"sslrootcert": None,
},
"PASSWORD": "foo",
"PORT": "foo",
"TEST": {"NAME": "foo"},
"USER": "foo",
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": False,
"DISABLE_SERVER_SIDE_CURSORS": False,
}
},
)

View File

@ -66,9 +66,7 @@ class GeoIPPolicy(Policy):
if not static_results and not dynamic_results: if not static_results and not dynamic_results:
return PolicyResult(True) return PolicyResult(True)
static_passing = any(r.passing for r in static_results) if static_results else True passing = any(r.passing for r in static_results) and all(r.passing for r in dynamic_results)
dynamic_passing = all(r.passing for r in dynamic_results)
passing = static_passing and dynamic_passing
messages = chain( messages = chain(
*[r.messages for r in static_results], *[r.messages for r in dynamic_results] *[r.messages for r in static_results], *[r.messages for r in dynamic_results]
) )
@ -115,19 +113,13 @@ class GeoIPPolicy(Policy):
to previous authentication requests""" to previous authentication requests"""
# Get previous login event and GeoIP data # Get previous login event and GeoIP data
previous_logins = Event.objects.filter( previous_logins = Event.objects.filter(
action=EventAction.LOGIN, action=EventAction.LOGIN, user__pk=request.user.pk, context__geo__isnull=False
user__pk=request.user.pk, # context__geo__isnull=False
).order_by("-created")[: self.history_login_count] ).order_by("-created")[: self.history_login_count]
_now = now() _now = now()
geoip_data: GeoIPDict | None = request.context.get("geoip") geoip_data: GeoIPDict | None = request.context.get("geoip")
if not geoip_data: if not geoip_data:
return PolicyResult(False) return PolicyResult(False)
if not previous_logins.exists():
return PolicyResult(True)
result = False
for previous_login in previous_logins: for previous_login in previous_logins:
if "geo" not in previous_login.context:
continue
previous_login_geoip: GeoIPDict = previous_login.context["geo"] previous_login_geoip: GeoIPDict = previous_login.context["geo"]
# Figure out distance # Figure out distance
@ -150,8 +142,7 @@ class GeoIPPolicy(Policy):
(MAX_DISTANCE_HOUR_KM * rel_time_hours) + self.distance_tolerance_km (MAX_DISTANCE_HOUR_KM * rel_time_hours) + self.distance_tolerance_km
): ):
return PolicyResult(False, _("Distance is further than possible.")) return PolicyResult(False, _("Distance is further than possible."))
result = True return PolicyResult(True)
return PolicyResult(result)
class Meta(Policy.PolicyMeta): class Meta(Policy.PolicyMeta):
verbose_name = _("GeoIP Policy") verbose_name = _("GeoIP Policy")

View File

@ -163,7 +163,7 @@ class TestGeoIPPolicy(TestCase):
result: PolicyResult = policy.passes(self.request) result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing) self.assertFalse(result.passing)
def test_history_impossible_travel_failing(self): def test_history_impossible_travel(self):
"""Test history checks""" """Test history checks"""
Event.objects.create( Event.objects.create(
action=EventAction.LOGIN, action=EventAction.LOGIN,
@ -181,24 +181,6 @@ class TestGeoIPPolicy(TestCase):
result: PolicyResult = policy.passes(self.request) result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing) self.assertFalse(result.passing)
def test_history_impossible_travel_passing(self):
"""Test history checks"""
Event.objects.create(
action=EventAction.LOGIN,
user=get_user(self.user),
context={
# Random location in Canada
"geo": {"lat": 55.868351, "long": -104.441011},
},
)
# Same location
self.request.context["geoip"] = {"lat": 55.868351, "long": -104.441011}
policy = GeoIPPolicy.objects.create(check_impossible_travel=True)
result: PolicyResult = policy.passes(self.request)
self.assertTrue(result.passing)
def test_history_no_geoip(self): def test_history_no_geoip(self):
"""Test history checks (previous login with no geoip data)""" """Test history checks (previous login with no geoip data)"""
Event.objects.create( Event.objects.create(
@ -213,18 +195,3 @@ class TestGeoIPPolicy(TestCase):
result: PolicyResult = policy.passes(self.request) result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing) self.assertFalse(result.passing)
def test_impossible_travel_no_geoip(self):
"""Test impossible travel checks (previous login with no geoip data)"""
Event.objects.create(
action=EventAction.LOGIN,
user=get_user(self.user),
context={},
)
# Random location in Poland
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}
policy = GeoIPPolicy.objects.create(check_impossible_travel=True)
result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)

View File

@ -2,6 +2,7 @@
from django.contrib.auth.signals import user_logged_in from django.contrib.auth.signals import user_logged_in
from django.db import transaction from django.db import transaction
from django.db.models import F
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest from django.http import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -12,29 +13,20 @@ from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR
from authentik.policies.reputation.models import Reputation, reputation_expiry from authentik.policies.reputation.models import Reputation, reputation_expiry
from authentik.root.middleware import ClientIPMiddleware from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.identification.signals import identification_failed from authentik.stages.identification.signals import identification_failed
from authentik.tenants.utils import get_current_tenant
LOGGER = get_logger() LOGGER = get_logger()
def clamp(value, min, max):
return sorted([min, value, max])[1]
def update_score(request: HttpRequest, identifier: str, amount: int): def update_score(request: HttpRequest, identifier: str, amount: int):
"""Update score for IP and User""" """Update score for IP and User"""
remote_ip = ClientIPMiddleware.get_client_ip(request) remote_ip = ClientIPMiddleware.get_client_ip(request)
tenant = get_current_tenant()
new_score = clamp(amount, tenant.reputation_lower_limit, tenant.reputation_upper_limit)
with transaction.atomic(): with transaction.atomic():
reputation, created = Reputation.objects.select_for_update().get_or_create( reputation, created = Reputation.objects.select_for_update().get_or_create(
ip=remote_ip, ip=remote_ip,
identifier=identifier, identifier=identifier,
defaults={ defaults={
"score": clamp( "score": amount,
amount, tenant.reputation_lower_limit, tenant.reputation_upper_limit
),
"ip_geo_data": GEOIP_CONTEXT_PROCESSOR.city_dict(remote_ip) or {}, "ip_geo_data": GEOIP_CONTEXT_PROCESSOR.city_dict(remote_ip) or {},
"ip_asn_data": ASN_CONTEXT_PROCESSOR.asn_dict(remote_ip) or {}, "ip_asn_data": ASN_CONTEXT_PROCESSOR.asn_dict(remote_ip) or {},
"expires": reputation_expiry(), "expires": reputation_expiry(),
@ -42,15 +34,9 @@ def update_score(request: HttpRequest, identifier: str, amount: int):
) )
if not created: if not created:
new_score = clamp( reputation.score = F("score") + amount
reputation.score + amount,
tenant.reputation_lower_limit,
tenant.reputation_upper_limit,
)
reputation.score = new_score
reputation.save() reputation.save()
LOGGER.info("Updated score", amount=amount, for_user=identifier, for_ip=remote_ip)
LOGGER.info("Updated score", amount=new_score, for_user=identifier, for_ip=remote_ip)
@receiver(login_failed) @receiver(login_failed)

View File

@ -6,11 +6,9 @@ from authentik.core.models import User
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.policies.reputation.api import ReputationPolicySerializer from authentik.policies.reputation.api import ReputationPolicySerializer
from authentik.policies.reputation.models import Reputation, ReputationPolicy from authentik.policies.reputation.models import Reputation, ReputationPolicy
from authentik.policies.reputation.signals import update_score
from authentik.policies.types import PolicyRequest from authentik.policies.types import PolicyRequest
from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import authenticate from authentik.stages.password.stage import authenticate
from authentik.tenants.models import DEFAULT_REPUTATION_LOWER_LIMIT, DEFAULT_REPUTATION_UPPER_LIMIT
class TestReputationPolicy(TestCase): class TestReputationPolicy(TestCase):
@ -19,48 +17,36 @@ class TestReputationPolicy(TestCase):
def setUp(self): def setUp(self):
self.request_factory = RequestFactory() self.request_factory = RequestFactory()
self.request = self.request_factory.get("/") self.request = self.request_factory.get("/")
self.ip = "127.0.0.1" self.test_ip = "127.0.0.1"
self.username = "username" self.test_username = "test"
self.password = generate_id()
# We need a user for the one-to-one in userreputation # We need a user for the one-to-one in userreputation
self.user = User.objects.create(username=self.username) self.user = User.objects.create(username=self.test_username)
self.user.set_password(self.password)
self.backends = [BACKEND_INBUILT] self.backends = [BACKEND_INBUILT]
def test_ip_reputation(self): def test_ip_reputation(self):
"""test IP reputation""" """test IP reputation"""
# Trigger negative reputation # Trigger negative reputation
authenticate(self.request, self.backends, username=self.username, password=self.username) authenticate(
self.assertEqual(Reputation.objects.get(ip=self.ip).score, -1) self.request, self.backends, username=self.test_username, password=self.test_username
)
self.assertEqual(Reputation.objects.get(ip=self.test_ip).score, -1)
def test_user_reputation(self): def test_user_reputation(self):
"""test User reputation""" """test User reputation"""
# Trigger negative reputation # Trigger negative reputation
authenticate(self.request, self.backends, username=self.username, password=self.username) authenticate(
self.assertEqual(Reputation.objects.get(identifier=self.username).score, -1) self.request, self.backends, username=self.test_username, password=self.test_username
)
self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, -1)
def test_update_reputation(self): def test_update_reputation(self):
"""test reputation update""" """test reputation update"""
Reputation.objects.create(identifier=self.username, ip=self.ip, score=4) Reputation.objects.create(identifier=self.test_username, ip=self.test_ip, score=43)
# Trigger negative reputation # Trigger negative reputation
authenticate(self.request, self.backends, username=self.username, password=self.username) authenticate(
self.assertEqual(Reputation.objects.get(identifier=self.username).score, 3) self.request, self.backends, username=self.test_username, password=self.test_username
def test_reputation_lower_limit(self):
"""test reputation lower limit"""
Reputation.objects.create(identifier=self.username, ip=self.ip)
update_score(self.request, identifier=self.username, amount=-1000)
self.assertEqual(
Reputation.objects.get(identifier=self.username).score, DEFAULT_REPUTATION_LOWER_LIMIT
)
def test_reputation_upper_limit(self):
"""test reputation upper limit"""
Reputation.objects.create(identifier=self.username, ip=self.ip)
update_score(self.request, identifier=self.username, amount=1000)
self.assertEqual(
Reputation.objects.get(identifier=self.username).score, DEFAULT_REPUTATION_UPPER_LIMIT
) )
self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, 42)
def test_policy(self): def test_policy(self):
"""Test Policy""" """Test Policy"""

View File

@ -126,7 +126,7 @@ class IDToken:
id_token.iat = int(now.timestamp()) id_token.iat = int(now.timestamp())
id_token.auth_time = int(token.auth_time.timestamp()) id_token.auth_time = int(token.auth_time.timestamp())
if token.session: if token.session:
id_token.sid = hash_session_key(token.session.session.session_key) id_token.sid = hash_session_key(token.session.session_key)
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
auth_event = get_login_event(token.session) auth_event = get_login_event(token.session)

View File

@ -1,116 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-27 13:00
from django.db import migrations
import django.db.models.deletion
from django.db import migrations, models
from functools import partial
def migrate_sessions(apps, schema_editor, model):
Model = apps.get_model("authentik_providers_oauth2", model)
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
db_alias = schema_editor.connection.alias
for obj in Model.objects.using(db_alias).all():
if not obj.old_session:
continue
obj.session = (
AuthenticatedSession.objects.using(db_alias)
.filter(session__session_key=obj.old_session.session_key)
.first()
)
if obj.session:
obj.save()
else:
obj.delete()
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"),
("authentik_core", "0046_session_and_more"),
]
operations = [
migrations.RenameField(
model_name="accesstoken",
old_name="session",
new_name="old_session",
),
migrations.RenameField(
model_name="authorizationcode",
old_name="session",
new_name="old_session",
),
migrations.RenameField(
model_name="devicetoken",
old_name="session",
new_name="old_session",
),
migrations.RenameField(
model_name="refreshtoken",
old_name="session",
new_name="old_session",
),
migrations.AddField(
model_name="accesstoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="authorizationcode",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="devicetoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="refreshtoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.RunPython(code=partial(migrate_sessions, model="AccessToken")),
migrations.RunPython(code=partial(migrate_sessions, model="AuthorizationCode")),
migrations.RunPython(code=partial(migrate_sessions, model="DeviceToken")),
migrations.RunPython(code=partial(migrate_sessions, model="RefreshToken")),
migrations.RemoveField(
model_name="accesstoken",
name="old_session",
),
migrations.RemoveField(
model_name="authorizationcode",
name="old_session",
),
migrations.RemoveField(
model_name="devicetoken",
name="old_session",
),
migrations.RemoveField(
model_name="refreshtoken",
name="old_session",
),
]

View File

@ -1,30 +1,18 @@
from django.contrib.auth.signals import user_logged_out from django.contrib.auth.signals import user_logged_out
from django.db.models.signals import post_save, pre_delete from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import User
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
@receiver(user_logged_out) @receiver(user_logged_out)
def user_logged_out_oauth_tokens_removal(sender, request: HttpRequest, user: User, **_): def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User, **_):
"""Revoke tokens upon user logout""" """Revoke access tokens upon user logout"""
if not request.session or not request.session.session_key: if not request.session or not request.session.session_key:
return return
AccessToken.objects.filter( AccessToken.objects.filter(user=user, session__session_key=request.session.session_key).delete()
user=user,
session__session__session_key=request.session.session_key,
).delete()
@receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_):
"""Revoke tokens upon user logout"""
AccessToken.objects.filter(
user=instance.user,
session__session__session_key=instance.session.session_key,
).delete()
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
@ -32,6 +20,6 @@ def user_deactivated(sender, instance: User, **_):
"""Remove user tokens when deactivated""" """Remove user tokens when deactivated"""
if instance.is_active: if instance.is_active:
return return
AccessToken.objects.filter(user=instance).delete() AccessToken.objects.filter(session__user=instance).delete()
RefreshToken.objects.filter(user=instance).delete() RefreshToken.objects.filter(session__user=instance).delete()
DeviceToken.objects.filter(user=instance).delete() DeviceToken.objects.filter(session__user=instance).delete()

View File

@ -7,13 +7,12 @@ from dataclasses import asdict
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from authentik.core.models import Application, AuthenticatedSession, Session from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow 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
from authentik.providers.oauth2.models import ( from authentik.providers.oauth2.models import (
AccessToken, AccessToken,
ClientTypes, ClientTypes,
DeviceToken,
IDToken, IDToken,
OAuth2Provider, OAuth2Provider,
RedirectURI, RedirectURI,
@ -21,7 +20,6 @@ from authentik.providers.oauth2.models import (
RefreshToken, RefreshToken,
) )
from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.root.middleware import ClientIPMiddleware
class TesOAuth2Revoke(OAuthTestCase): class TesOAuth2Revoke(OAuthTestCase):
@ -137,86 +135,3 @@ class TesOAuth2Revoke(OAuthTestCase):
}, },
) )
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
def test_revoke_logout(self):
"""Test revoke on logout"""
self.client.force_login(self.user)
AccessToken.objects.create(
provider=self.provider,
user=self.user,
session=self.client.session["authenticatedsession"],
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
self.client.logout()
self.assertEqual(AccessToken.objects.all().count(), 0)
def test_revoke_session_delete(self):
"""Test revoke on logout"""
session = AuthenticatedSession.objects.create(
session=Session.objects.create(
session_key=generate_id(),
last_ip=ClientIPMiddleware.default_ip,
),
user=self.user,
)
AccessToken.objects.create(
provider=self.provider,
user=self.user,
session=session,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
session.delete()
self.assertEqual(AccessToken.objects.all().count(), 0)
def test_revoke_user_deactivated(self):
"""Test revoke on logout"""
AccessToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
RefreshToken.objects.create(
provider=self.provider,
user=self.user,
token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile",
_id_token=json.dumps(
asdict(
IDToken("foo", "bar"),
)
),
)
DeviceToken.objects.create(
provider=self.provider,
user=self.user,
_scope="openid user profile",
)
self.user.is_active = False
self.user.save()
self.assertEqual(AccessToken.objects.all().count(), 0)
self.assertEqual(RefreshToken.objects.all().count(), 0)
self.assertEqual(DeviceToken.objects.all().count(), 0)

View File

@ -15,7 +15,7 @@ from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Application from authentik.core.models import Application, AuthenticatedSession
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.events.signals import get_login_event from authentik.events.signals import get_login_event
from authentik.flows.challenge import ( from authentik.flows.challenge import (
@ -316,7 +316,9 @@ class OAuthAuthorizationParams:
expires=now + timedelta_from_string(self.provider.access_code_validity), expires=now + timedelta_from_string(self.provider.access_code_validity),
scope=self.scope, scope=self.scope,
nonce=self.nonce, nonce=self.nonce,
session=request.session["authenticatedsession"], session=AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).first(),
) )
if self.code_challenge and self.code_challenge_method: if self.code_challenge and self.code_challenge_method:
@ -613,7 +615,9 @@ class OAuthFulfillmentStage(StageView):
expires=access_token_expiry, expires=access_token_expiry,
provider=self.provider, provider=self.provider,
auth_time=auth_event.created if auth_event else now, auth_time=auth_event.created if auth_event else now,
session=self.request.session["authenticatedsession"], session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key
).first(),
) )
id_token = IDToken.new(self.provider, token, self.request) id_token = IDToken.new(self.provider, token, self.request)

View File

@ -20,4 +20,4 @@ def logout_proxy_revoke_direct(sender: type[User], request: HttpRequest, **_):
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
"""Catch logout by expiring sessions being deleted""" """Catch logout by expiring sessions being deleted"""
proxy_on_logout.delay(instance.session.session_key) proxy_on_logout.delay(instance.session_key)

View File

@ -1,60 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-27 12:59
from django.db import migrations
import django.db.models.deletion
from django.db import migrations, models
def migrate_sessions(apps, schema_editor):
ConnectionToken = apps.get_model("authentik_providers_rac", "ConnectionToken")
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
db_alias = schema_editor.connection.alias
for token in ConnectionToken.objects.using(db_alias).all():
token.session = (
AuthenticatedSession.objects.using(db_alias)
.filter(session_key=token.old_session.session_key)
.first()
)
if token.session:
token.save()
else:
token.delete()
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_rac", "0006_connectiontoken_authentik_p_expires_91f148_idx_and_more"),
("authentik_core", "0046_session_and_more"),
]
operations = [
migrations.RenameField(
model_name="connectiontoken",
old_name="session",
new_name="old_session",
),
migrations.AddField(
model_name="connectiontoken",
name="session",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.RunPython(code=migrate_sessions),
migrations.AlterField(
model_name="connectiontoken",
name="session",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_core.authenticatedsession",
),
),
migrations.RemoveField(
model_name="connectiontoken",
name="old_session",
),
]

View File

@ -8,7 +8,7 @@ from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import User
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
from authentik.providers.rac.consumer_client import ( from authentik.providers.rac.consumer_client import (
RAC_CLIENT_GROUP_SESSION, RAC_CLIENT_GROUP_SESSION,
@ -32,18 +32,6 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
) )
@receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted(sender, instance: AuthenticatedSession, **_):
layer = get_channel_layer()
async_to_sync(layer.group_send)(
RAC_CLIENT_GROUP_SESSION
% {
"session": instance.session.session_key,
},
{"type": "event.disconnect", "reason": "session_logout"},
)
@receiver(pre_delete, sender=ConnectionToken) @receiver(pre_delete, sender=ConnectionToken)
def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_): def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_):
"""Disconnect session when connection token is deleted""" """Disconnect session when connection token is deleted"""

View File

@ -4,13 +4,10 @@
{% block head %} {% block head %}
<script src="{% versioned_script 'dist/rac/index-%v.js' %}" type="module"></script> <script src="{% versioned_script 'dist/rac/index-%v.js' %}" type="module"></script>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> <link rel="icon" href="{{ tenant.branding_favicon_url }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon_url }}">
<link rel="icon" href="{{ tenant.branding_favicon_url }}" />
<link rel="shortcut icon" href="{{ tenant.branding_favicon_url }}" />
{% include "base/header_js.html" %} {% include "base/header_js.html" %}
{% endblock %} {% endblock %}

View File

@ -2,7 +2,7 @@
from django.test import TransactionTestCase from django.test import TransactionTestCase
from authentik.core.models import Application, AuthenticatedSession, Session from authentik.core.models import Application, AuthenticatedSession
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.providers.rac.models import ( from authentik.providers.rac.models import (
@ -36,15 +36,13 @@ class TestModels(TransactionTestCase):
def test_settings_merge(self): def test_settings_merge(self):
"""Test settings merge""" """Test settings merge"""
session = Session.objects.create(
session_key=generate_id(),
last_ip="255.255.255.255",
)
auth_session = AuthenticatedSession.objects.create(session=session, user=self.user)
token = ConnectionToken.objects.create( token = ConnectionToken.objects.create(
provider=self.provider, provider=self.provider,
endpoint=self.endpoint, endpoint=self.endpoint,
session=auth_session, session=AuthenticatedSession.objects.create(
user=self.user,
session_key=generate_id(),
),
) )
path = f"/tmp/connection/{token.token}" # nosec path = f"/tmp/connection/{token.token}" # nosec
self.assertEqual( self.assertEqual(

View File

@ -1,5 +1,7 @@
"""rac urls""" """rac urls"""
from channels.auth import AuthMiddleware
from channels.sessions import CookieMiddleware
from django.urls import path from django.urls import path
from authentik.outposts.channels import TokenOutpostMiddleware from authentik.outposts.channels import TokenOutpostMiddleware
@ -10,7 +12,7 @@ from authentik.providers.rac.api.providers import RACProviderViewSet
from authentik.providers.rac.consumer_client import RACClientConsumer from authentik.providers.rac.consumer_client import RACClientConsumer
from authentik.providers.rac.consumer_outpost import RACOutpostConsumer from authentik.providers.rac.consumer_outpost import RACOutpostConsumer
from authentik.providers.rac.views import RACInterface, RACStartView from authentik.providers.rac.views import RACInterface, RACStartView
from authentik.root.asgi_middleware import AuthMiddlewareStack from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.middleware import ChannelsLoggingMiddleware from authentik.root.middleware import ChannelsLoggingMiddleware
urlpatterns = [ urlpatterns = [
@ -29,7 +31,9 @@ urlpatterns = [
websocket_urlpatterns = [ websocket_urlpatterns = [
path( path(
"ws/rac/<str:token>/", "ws/rac/<str:token>/",
ChannelsLoggingMiddleware(AuthMiddlewareStack(RACClientConsumer.as_asgi())), ChannelsLoggingMiddleware(
CookieMiddleware(SessionMiddleware(AuthMiddleware(RACClientConsumer.as_asgi())))
),
), ),
path( path(
"ws/outpost_rac/<str:channel>/", "ws/outpost_rac/<str:channel>/",

View File

@ -8,7 +8,7 @@ from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from authentik.core.models import Application from authentik.core.models import Application, AuthenticatedSession
from authentik.core.views.interface import InterfaceView from authentik.core.views.interface import InterfaceView
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.challenge import RedirectChallenge from authentik.flows.challenge import RedirectChallenge
@ -113,7 +113,9 @@ class RACFinalStage(RedirectStage):
provider=self.provider, provider=self.provider,
endpoint=self.endpoint, endpoint=self.endpoint,
settings=self.executor.plan.context.get("connection_settings", {}), settings=self.executor.plan.context.get("connection_settings", {}),
session=self.request.session["authenticatedsession"], session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key
).first(),
expires=now() + timedelta_from_string(self.provider.connection_expiry), expires=now() + timedelta_from_string(self.provider.connection_expiry),
expiring=True, expiring=True,
) )

View File

@ -1,41 +0,0 @@
"""RBAC Initial Permissions"""
from rest_framework.serializers import ListSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.rbac.api.rbac import PermissionSerializer
from authentik.rbac.models import InitialPermissions
class InitialPermissionsSerializer(ModelSerializer):
"""InitialPermissions serializer"""
permissions_obj = ListSerializer(
child=PermissionSerializer(),
read_only=True,
source="permissions",
required=False,
)
class Meta:
model = InitialPermissions
fields = [
"pk",
"name",
"mode",
"role",
"permissions",
"permissions_obj",
]
class InitialPermissionsViewSet(UsedByMixin, ModelViewSet):
"""InitialPermissions viewset"""
queryset = InitialPermissions.objects.all()
serializer_class = InitialPermissionsSerializer
search_fields = ["name"]
ordering = ["name"]
filterset_fields = ["name"]

View File

@ -1,39 +0,0 @@
# Generated by Django 5.0.13 on 2025-04-07 13:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("authentik_rbac", "0004_alter_systempermission_options"),
]
operations = [
migrations.CreateModel(
name="InitialPermissions",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("name", models.TextField(max_length=150, unique=True)),
("mode", models.CharField(choices=[("user", "User"), ("role", "Role")])),
("permissions", models.ManyToManyField(blank=True, to="auth.permission")),
(
"role",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_rbac.role"
),
),
],
options={
"verbose_name": "Initial Permissions",
"verbose_name_plural": "Initial Permissions",
},
),
]

View File

@ -3,7 +3,6 @@
from uuid import uuid4 from uuid import uuid4
from django.contrib.auth.management import _get_all_permissions from django.contrib.auth.management import _get_all_permissions
from django.contrib.auth.models import Permission
from django.db import models from django.db import models
from django.db.transaction import atomic from django.db.transaction import atomic
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -76,35 +75,6 @@ class Role(SerializerModel):
] ]
class InitialPermissionsMode(models.TextChoices):
"""Determines which entity the initial permissions are assigned to."""
USER = "user", _("User")
ROLE = "role", _("Role")
class InitialPermissions(SerializerModel):
"""Assigns permissions for newly created objects."""
name = models.TextField(max_length=150, unique=True)
mode = models.CharField(choices=InitialPermissionsMode.choices)
role = models.ForeignKey(Role, on_delete=models.CASCADE)
permissions = models.ManyToManyField(Permission, blank=True)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.rbac.api.initial_permissions import InitialPermissionsSerializer
return InitialPermissionsSerializer
def __str__(self) -> str:
return f"Initial Permissions for Role #{self.role_id}, applying to #{self.mode}"
class Meta:
verbose_name = _("Initial Permissions")
verbose_name_plural = _("Initial Permissions")
class SystemPermission(models.Model): class SystemPermission(models.Model):
"""System-wide permissions that are not related to any direct """System-wide permissions that are not related to any direct
database model""" database model"""

View File

@ -1,13 +1,9 @@
"""RBAC Permissions""" """RBAC Permissions"""
from django.contrib.contenttypes.models import ContentType
from django.db.models import Model from django.db.models import Model
from guardian.shortcuts import assign_perm
from rest_framework.permissions import BasePermission, DjangoObjectPermissions from rest_framework.permissions import BasePermission, DjangoObjectPermissions
from rest_framework.request import Request from rest_framework.request import Request
from authentik.rbac.models import InitialPermissions, InitialPermissionsMode
class ObjectPermissions(DjangoObjectPermissions): class ObjectPermissions(DjangoObjectPermissions):
"""RBAC Permissions""" """RBAC Permissions"""
@ -55,20 +51,3 @@ def HasPermission(*perm: str) -> type[BasePermission]:
return bool(request.user and request.user.has_perms(perm)) return bool(request.user and request.user.has_perms(perm))
return checker return checker
# TODO: add `user: User` type annotation without circular dependencies.
# The author of this function isn't proficient/patient enough to do it.
def assign_initial_permissions(user, instance: Model):
# Performance here should not be an issue, but if needed, there are many optimization routes
initial_permissions_list = InitialPermissions.objects.filter(role__group__in=user.groups.all())
for initial_permissions in initial_permissions_list:
for permission in initial_permissions.permissions.all():
if permission.content_type != ContentType.objects.get_for_model(instance):
continue
assign_to = (
user
if initial_permissions.mode == InitialPermissionsMode.USER
else initial_permissions.role.group
)
assign_perm(permission, assign_to, instance)

View File

@ -1,116 +0,0 @@
"""Test InitialPermissions"""
from django.contrib.auth.models import Permission
from guardian.shortcuts import assign_perm
from rest_framework.reverse import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Group
from authentik.core.tests.utils import create_test_user
from authentik.lib.generators import generate_id
from authentik.rbac.models import InitialPermissions, InitialPermissionsMode, Role
from authentik.stages.dummy.models import DummyStage
class TestInitialPermissions(APITestCase):
"""Test InitialPermissions"""
def setUp(self) -> None:
self.user = create_test_user()
self.same_role_user = create_test_user()
self.different_role_user = create_test_user()
self.role = Role.objects.create(name=generate_id())
self.different_role = Role.objects.create(name=generate_id())
self.group = Group.objects.create(name=generate_id())
self.different_group = Group.objects.create(name=generate_id())
self.group.roles.add(self.role)
self.group.users.add(self.user, self.same_role_user)
self.different_group.roles.add(self.different_role)
self.different_group.users.add(self.different_role_user)
self.ip = InitialPermissions.objects.create(
name=generate_id(), mode=InitialPermissionsMode.USER, role=self.role
)
self.view_role = Permission.objects.filter(codename="view_role").first()
self.ip.permissions.add(self.view_role)
assign_perm("authentik_rbac.add_role", self.user)
self.client.force_login(self.user)
def test_different_role(self):
"""InitialPermissions for different role does nothing"""
self.ip.role = self.different_role
self.ip.save()
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertFalse(self.user.has_perm("authentik_rbac.view_role", role))
def test_different_model(self):
"""InitialPermissions for different model does nothing"""
assign_perm("authentik_stages_dummy.add_dummystage", self.user)
self.client.post(
reverse("authentik_api:stages-dummy-list"), {"name": "test-stage", "throw-error": False}
)
role = Role.objects.filter(name="test-role").first()
self.assertFalse(self.user.has_perm("authentik_rbac.view_role", role))
stage = DummyStage.objects.filter(name="test-stage").first()
self.assertFalse(self.user.has_perm("authentik_stages_dummy.view_dummystage", stage))
def test_mode_user(self):
"""InitialPermissions adds user permission in user mode"""
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
self.assertFalse(self.same_role_user.has_perm("authentik_rbac.view_role", role))
def test_mode_role(self):
"""InitialPermissions adds role permission in role mode"""
self.ip.mode = InitialPermissionsMode.ROLE
self.ip.save()
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
self.assertTrue(self.same_role_user.has_perm("authentik_rbac.view_role", role))
def test_many_permissions(self):
"""InitialPermissions can add multiple permissions"""
change_role = Permission.objects.filter(codename="change_role").first()
self.ip.permissions.add(change_role)
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
self.assertTrue(self.user.has_perm("authentik_rbac.change_role", role))
def test_permissions_separated_by_role(self):
"""When the triggering user is part of two different roles with InitialPermissions in role
mode, it only adds permissions to the relevant role."""
self.ip.mode = InitialPermissionsMode.ROLE
self.ip.save()
different_ip = InitialPermissions.objects.create(
name=generate_id(), mode=InitialPermissionsMode.ROLE, role=self.different_role
)
change_role = Permission.objects.filter(codename="change_role").first()
different_ip.permissions.add(change_role)
self.different_group.users.add(self.user)
self.client.post(reverse("authentik_api:roles-list"), {"name": "test-role"})
role = Role.objects.filter(name="test-role").first()
self.assertTrue(self.user.has_perm("authentik_rbac.view_role", role))
self.assertTrue(self.same_role_user.has_perm("authentik_rbac.view_role", role))
self.assertFalse(self.different_role_user.has_perm("authentik_rbac.view_role", role))
self.assertTrue(self.user.has_perm("authentik_rbac.change_role", role))
self.assertFalse(self.same_role_user.has_perm("authentik_rbac.change_role", role))
self.assertTrue(self.different_role_user.has_perm("authentik_rbac.change_role", role))

View File

@ -1,6 +1,5 @@
"""RBAC API urls""" """RBAC API urls"""
from authentik.rbac.api.initial_permissions import InitialPermissionsViewSet
from authentik.rbac.api.rbac import RBACPermissionViewSet from authentik.rbac.api.rbac import RBACPermissionViewSet
from authentik.rbac.api.rbac_assigned_by_roles import RoleAssignedPermissionViewSet from authentik.rbac.api.rbac_assigned_by_roles import RoleAssignedPermissionViewSet
from authentik.rbac.api.rbac_assigned_by_users import UserAssignedPermissionViewSet from authentik.rbac.api.rbac_assigned_by_users import UserAssignedPermissionViewSet
@ -22,6 +21,5 @@ api_urlpatterns = [
("rbac/permissions/users", UserPermissionViewSet, "permissions-users"), ("rbac/permissions/users", UserPermissionViewSet, "permissions-users"),
("rbac/permissions/roles", RolePermissionViewSet, "permissions-roles"), ("rbac/permissions/roles", RolePermissionViewSet, "permissions-roles"),
("rbac/permissions", RBACPermissionViewSet), ("rbac/permissions", RBACPermissionViewSet),
("rbac/roles", RoleViewSet, "roles"), ("rbac/roles", RoleViewSet),
("rbac/initial_permissions", InitialPermissionsViewSet, "initial-permissions"),
] ]

View File

@ -50,7 +50,7 @@ class TestRecovery(TestCase):
) )
token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user) token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user)
self.client.get(reverse("authentik_recovery:use-token", kwargs={"key": token.key})) self.client.get(reverse("authentik_recovery:use-token", kwargs={"key": token.key}))
self.assertEqual(self.client.session["authenticatedsession"].user.pk, token.user.pk) self.assertEqual(int(self.client.session["_auth_user_id"]), token.user.pk)
def test_recovery_view_invalid(self): def test_recovery_view_invalid(self):
"""Test recovery view with invalid token""" """Test recovery view with invalid token"""

View File

@ -1,12 +1,8 @@
"""ASGI middleware""" """ASGI middleware"""
from channels.auth import UserLazyObject
from channels.db import database_sync_to_async from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
from channels.sessions import CookieMiddleware
from channels.sessions import InstanceSessionWrapper as UpstreamInstanceSessionWrapper from channels.sessions import InstanceSessionWrapper as UpstreamInstanceSessionWrapper
from channels.sessions import SessionMiddleware as UpstreamSessionMiddleware from channels.sessions import SessionMiddleware as UpstreamSessionMiddleware
from django.contrib.auth.models import AnonymousUser
from authentik.root.middleware import SessionMiddleware as HTTPSessionMiddleware from authentik.root.middleware import SessionMiddleware as HTTPSessionMiddleware
@ -37,48 +33,3 @@ class SessionMiddleware(UpstreamSessionMiddleware):
await wrapper.resolve_session() await wrapper.resolve_session()
return await self.inner(wrapper.scope, receive, wrapper.send) return await self.inner(wrapper.scope, receive, wrapper.send)
@database_sync_to_async
def get_user(scope):
"""
Return the user model instance associated with the given scope.
If no user is retrieved, return an instance of `AnonymousUser`.
"""
if "session" not in scope:
raise ValueError(
"Cannot find session in scope. You should wrap your consumer in SessionMiddleware."
)
user = None
if (authenticated_session := scope["session"].get("authenticated_session", None)) is not None:
user = authenticated_session.user
return user or AnonymousUser()
class AuthMiddleware(BaseMiddleware):
def populate_scope(self, scope):
# Make sure we have a session
if "session" not in scope:
raise ValueError(
"AuthMiddleware cannot find session in scope. SessionMiddleware must be above it."
)
# Add it to the scope if it's not there already
if "user" not in scope:
scope["user"] = UserLazyObject()
async def resolve_scope(self, scope):
scope["user"]._wrapped = await get_user(scope)
async def __call__(self, scope, receive, send):
scope = dict(scope)
# Scope injection/mutation per this middleware's needs.
self.populate_scope(scope)
# Grab the finalized/resolved scope
await self.resolve_scope(scope)
return await super().__call__(scope, receive, send)
# Handy shortcut for applying all three layers at once
def AuthMiddlewareStack(inner):
return CookieMiddleware(SessionMiddleware(AuthMiddleware(inner)))

View File

@ -49,7 +49,7 @@ class SessionMiddleware(UpstreamSessionMiddleware):
return False return False
@staticmethod @staticmethod
def decode_session_key(key: str | None) -> str | None: def decode_session_key(key: str) -> str:
"""Decode raw session cookie, and parse JWT""" """Decode raw session cookie, and parse JWT"""
# We need to support the standard django format of just a session key # We need to support the standard django format of just a session key
# for testing setups, where the session is directly set # for testing setups, where the session is directly set
@ -64,11 +64,7 @@ class SessionMiddleware(UpstreamSessionMiddleware):
def process_request(self, request: HttpRequest): def process_request(self, request: HttpRequest):
raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME) raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
session_key = SessionMiddleware.decode_session_key(raw_session) session_key = SessionMiddleware.decode_session_key(raw_session)
request.session = self.SessionStore( request.session = self.SessionStore(session_key)
session_key,
last_ip=ClientIPMiddleware.get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
)
def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse:
""" """

View File

@ -0,0 +1,23 @@
"""
Module for abstract serializer/unserializer base classes.
"""
import pickle # nosec
class PickleSerializer:
"""
Simple wrapper around pickle to be used in signing.dumps()/loads() and
cache backends.
"""
def __init__(self, protocol=None):
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
def dumps(self, obj):
"""Pickle data to be stored in redis"""
return pickle.dumps(obj, self.protocol)
def loads(self, data):
"""Unpickle data to be loaded from redis"""
return pickle.loads(data) # nosec

View File

@ -7,6 +7,7 @@ from pathlib import Path
import orjson import orjson
from celery.schedules import crontab from celery.schedules import crontab
from django.conf import ImproperlyConfigured
from sentry_sdk import set_tag from sentry_sdk import set_tag
from xmlsec import enable_debug_trace from xmlsec import enable_debug_trace
@ -42,6 +43,7 @@ SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None)
APPEND_SLASH = False APPEND_SLASH = False
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
BACKEND_INBUILT, BACKEND_INBUILT,
BACKEND_APP_PASSWORD, BACKEND_APP_PASSWORD,
BACKEND_LDAP, BACKEND_LDAP,
@ -227,7 +229,17 @@ CACHES = {
DJANGO_REDIS_SCAN_ITERSIZE = 1000 DJANGO_REDIS_SCAN_ITERSIZE = 1000
DJANGO_REDIS_IGNORE_EXCEPTIONS = True DJANGO_REDIS_IGNORE_EXCEPTIONS = True
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
SESSION_ENGINE = "authentik.core.sessions" match CONFIG.get("session_storage", "cache"):
case "cache":
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
case "db":
SESSION_ENGINE = "django.contrib.sessions.backends.db"
case _:
raise ImproperlyConfigured(
"Invalid session_storage setting, allowed values are db and cache"
)
SESSION_SERIALIZER = "authentik.root.sessions.pickle.PickleSerializer"
SESSION_CACHE_ALIAS = "default"
# Configured via custom SessionMiddleware # Configured via custom SessionMiddleware
# SESSION_COOKIE_SAMESITE = "None" # SESSION_COOKIE_SAMESITE = "None"
# SESSION_COOKIE_SECURE = True # SESSION_COOKIE_SECURE = True
@ -244,7 +256,7 @@ MIDDLEWARE = [
"django_prometheus.middleware.PrometheusBeforeMiddleware", "django_prometheus.middleware.PrometheusBeforeMiddleware",
"authentik.root.middleware.ClientIPMiddleware", "authentik.root.middleware.ClientIPMiddleware",
"authentik.stages.user_login.middleware.BoundSessionMiddleware", "authentik.stages.user_login.middleware.BoundSessionMiddleware",
"authentik.core.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"authentik.core.middleware.RequestIDMiddleware", "authentik.core.middleware.RequestIDMiddleware",
"authentik.brands.middleware.BrandMiddleware", "authentik.brands.middleware.BrandMiddleware",
"authentik.events.middleware.AuditMiddleware", "authentik.events.middleware.AuditMiddleware",

View File

@ -15,22 +15,11 @@ from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer
from authentik.core.api.sources import ( from authentik.core.api.sources import SourceSerializer
GroupSourceConnectionSerializer,
GroupSourceConnectionViewSet,
SourceSerializer,
UserSourceConnectionSerializer,
UserSourceConnectionViewSet,
)
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.lib.sync.outgoing.api import SyncStatusSerializer from authentik.lib.sync.outgoing.api import SyncStatusSerializer
from authentik.sources.ldap.models import ( from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
GroupLDAPSourceConnection,
LDAPSource,
LDAPSourcePropertyMapping,
UserLDAPSourceConnection,
)
from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES
@ -232,23 +221,3 @@ class LDAPSourcePropertyMappingViewSet(UsedByMixin, ModelViewSet):
filterset_class = LDAPSourcePropertyMappingFilter filterset_class = LDAPSourcePropertyMappingFilter
search_fields = ["name"] search_fields = ["name"]
ordering = ["name"] ordering = ["name"]
class UserLDAPSourceConnectionSerializer(UserSourceConnectionSerializer):
class Meta(UserSourceConnectionSerializer.Meta):
model = UserLDAPSourceConnection
class UserLDAPSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
queryset = UserLDAPSourceConnection.objects.all()
serializer_class = UserLDAPSourceConnectionSerializer
class GroupLDAPSourceConnectionSerializer(GroupSourceConnectionSerializer):
class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupLDAPSourceConnection
class GroupLDAPSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
queryset = GroupLDAPSourceConnection.objects.all()
serializer_class = GroupLDAPSourceConnectionSerializer

View File

@ -1,57 +0,0 @@
# Generated by Django 5.0.14 on 2025-04-11 11:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0047_delete_oldauthenticatedsession"),
("authentik_sources_ldap", "0007_ldapsource_lookup_groups_from_user"),
]
operations = [
migrations.CreateModel(
name="GroupLDAPSourceConnection",
fields=[
(
"groupsourceconnection_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.groupsourceconnection",
),
),
],
options={
"verbose_name": "Group LDAP Source Connection",
"verbose_name_plural": "Group LDAP Source Connections",
},
bases=("authentik_core.groupsourceconnection",),
),
migrations.CreateModel(
name="UserLDAPSourceConnection",
fields=[
(
"usersourceconnection_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.usersourceconnection",
),
),
],
options={
"verbose_name": "User LDAP Source Connection",
"verbose_name_plural": "User LDAP Source Connections",
},
bases=("authentik_core.usersourceconnection",),
),
]

Some files were not shown because too many files have changed in this diff Show More