mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 07:02:51 +02:00
Compare commits
120 Commits
webdriver-
...
web/bundle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9213f8b06f | ||
|
|
7ed3fed5c3 | ||
|
|
8eecc537fa | ||
|
|
c91c97178f | ||
|
|
9ced8b948a | ||
|
|
5a9cec6c61 | ||
|
|
9caca181cb | ||
|
|
43343adfd8 | ||
|
|
f38c472eec | ||
|
|
9913345670 | ||
|
|
c705eaabca | ||
|
|
87eadea92b | ||
|
|
35c478d045 | ||
|
|
c0e0fcdcb1 | ||
|
|
3d97120cc2 | ||
|
|
29047ecf7a | ||
|
|
6cd4916d82 | ||
|
|
5a24a6eef4 | ||
|
|
f0b9cbd517 | ||
|
|
137223f7a0 | ||
|
|
6b9810ebe4 | ||
|
|
b873bd7c80 | ||
|
|
a34cd14ad7 | ||
|
|
2cd7e9f5ad | ||
|
|
8a5f4c3f63 | ||
|
|
c0feb98635 | ||
|
|
745b7621ab | ||
|
|
2de0649898 | ||
|
|
a80b18cabe | ||
|
|
f054af82dd | ||
|
|
6c176e0ba5 | ||
|
|
2cdc741b0b | ||
|
|
331732ce94 | ||
|
|
7e66f0ad94 | ||
|
|
fa5e8f993e | ||
|
|
b9a2722ef7 | ||
|
|
c3101fdf64 | ||
|
|
97a2e02dcb | ||
|
|
336aa7f5e9 | ||
|
|
bfe26a8b23 | ||
|
|
2392ccb945 | ||
|
|
7e46a7defc | ||
|
|
2d5247f1bc | ||
|
|
2c2d7c38d2 | ||
|
|
d3963172cc | ||
|
|
08abe34b81 | ||
|
|
47263aef3f | ||
|
|
162934679d | ||
|
|
1903c35ee0 | ||
|
|
b97635f710 | ||
|
|
fd1f65eefc | ||
|
|
1e8eff4cb2 | ||
|
|
cfe113b36a | ||
|
|
90274b357a | ||
|
|
9848e4fbe0 | ||
|
|
a9deefe481 | ||
|
|
d29896cfe1 | ||
|
|
30670bb547 | ||
|
|
0f64471115 | ||
|
|
249b22963a | ||
|
|
b3a513273b | ||
|
|
7ca013d527 | ||
|
|
2e65e307fe | ||
|
|
0c07bad6f6 | ||
|
|
eb1c56dbeb | ||
|
|
766a294e55 | ||
|
|
db84a29ad7 | ||
|
|
58e65e4612 | ||
|
|
95b2d15476 | ||
|
|
2bdc5ef8b1 | ||
|
|
83cae926f7 | ||
|
|
213cf44928 | ||
|
|
3c97b081b0 | ||
|
|
ba725365ec | ||
|
|
e5e9708ec2 | ||
|
|
6a604e42ca | ||
|
|
ab1f87cfd6 | ||
|
|
de9b795c97 | ||
|
|
0377e3593e | ||
|
|
951c24dab5 | ||
|
|
707eca883e | ||
|
|
8bc64ea478 | ||
|
|
8b1240ff0b | ||
|
|
56ff8b1f97 | ||
|
|
cf26aace7b | ||
|
|
46021e904a | ||
|
|
a47196776d | ||
|
|
58ce20c840 | ||
|
|
29b0177235 | ||
|
|
f15ddfcccd | ||
|
|
7e4bdac093 | ||
|
|
5f16ea4718 | ||
|
|
d50a266d74 | ||
|
|
41ebfa24da | ||
|
|
8b7cc18988 | ||
|
|
e780b7d519 | ||
|
|
25894592ae | ||
|
|
5021d08c69 | ||
|
|
cb74b47674 | ||
|
|
aafd81ca09 | ||
|
|
a4f8e15f91 | ||
|
|
0c20169739 | ||
|
|
24ca89c439 | ||
|
|
0352d31af0 | ||
|
|
5bdbf06351 | ||
|
|
b3f1e7b1a2 | ||
|
|
2dfda8833d | ||
|
|
9094b30860 | ||
|
|
7e52e932fc | ||
|
|
b5fc28a3fd | ||
|
|
df49dd4ec8 | ||
|
|
3f4c58a05b | ||
|
|
46c9bfb0aa | ||
|
|
6d325d566c | ||
|
|
5689336f61 | ||
|
|
e04ca70cb2 | ||
|
|
6a5342f621 | ||
|
|
e250c8f514 | ||
|
|
a4e7aa0adc | ||
|
|
ac79acd2bc |
@@ -4,7 +4,7 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
GITHUB_OUTPUT=/dev/stdout \
|
||||
GITHUB_REF=ref \
|
||||
GITHUB_SHA=sha \
|
||||
IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
|
||||
IMAGE_NAME=ghcr.io/goauthentik/server,authentik/server \
|
||||
GITHUB_REPOSITORY=goauthentik/authentik \
|
||||
python $SCRIPT_DIR/push_vars.py
|
||||
|
||||
@@ -12,7 +12,7 @@ GITHUB_OUTPUT=/dev/stdout \
|
||||
GITHUB_OUTPUT=/dev/stdout \
|
||||
GITHUB_REF=ref \
|
||||
GITHUB_SHA=sha \
|
||||
IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
|
||||
IMAGE_NAME=ghcr.io/goauthentik/server,authentik/server \
|
||||
GITHUB_REPOSITORY=goauthentik/authentik \
|
||||
DOCKER_USERNAME=foo \
|
||||
python $SCRIPT_DIR/push_vars.py
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
# Re-usable workflow for a single-architecture build
|
||||
name: Single-arch Container build
|
||||
name: Reusable - Single-arch Container build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
# Re-usable workflow for a multi-architecture build
|
||||
name: Multi-arch container build
|
||||
name: Reusable - Multi-arch container build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
5
.github/workflows/api-py-publish.yml
vendored
5
.github/workflows/api-py-publish.yml
vendored
@@ -1,10 +1,13 @@
|
||||
name: authentik-api-py-publish
|
||||
---
|
||||
name: API - Publish Python client
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "schema.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
|
||||
5
.github/workflows/api-ts-publish.yml
vendored
5
.github/workflows/api-ts-publish.yml
vendored
@@ -1,10 +1,13 @@
|
||||
name: authentik-api-ts-publish
|
||||
---
|
||||
name: API - Publish Typescript client
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "schema.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
|
||||
5
.github/workflows/ci-api-docs.yml
vendored
5
.github/workflows/ci-api-docs.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: authentik-ci-api-docs
|
||||
---
|
||||
name: CI - API Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -66,7 +67,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
|
||||
3
.github/workflows/ci-aws-cfn.yml
vendored
3
.github/workflows/ci-aws-cfn.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: authentik-ci-aws-cfn
|
||||
---
|
||||
name: CI - AWS cfn
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: authentik-publish-source-docs
|
||||
---
|
||||
name: CI - Source code docs
|
||||
|
||||
on:
|
||||
push:
|
||||
3
.github/workflows/ci-docs.yml
vendored
3
.github/workflows/ci-docs.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: authentik-ci-docs
|
||||
---
|
||||
name: CI - Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
2
.github/workflows/ci-main-daily.yml
vendored
2
.github/workflows/ci-main-daily.yml
vendored
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: authentik-ci-main-daily
|
||||
name: CI - Main daily
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
20
.github/workflows/ci-main.yml
vendored
20
.github/workflows/ci-main.yml
vendored
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: authentik-ci-main
|
||||
name: CI - Main
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -17,6 +17,12 @@ env:
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||
|
||||
permissions:
|
||||
# Needed for checkout
|
||||
contents: read
|
||||
# Needed for codecov OIDC token
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
strategy:
|
||||
@@ -136,13 +142,13 @@ jobs:
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
use_oidc: true
|
||||
- if: ${{ !cancelled() }}
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
flags: unit
|
||||
file: unittest.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
use_oidc: true
|
||||
test-integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
@@ -160,13 +166,13 @@ jobs:
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
flags: integration
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
use_oidc: true
|
||||
- if: ${{ !cancelled() }}
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
flags: integration
|
||||
file: unittest.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
use_oidc: true
|
||||
test-e2e:
|
||||
name: test-e2e (${{ matrix.job.name }})
|
||||
runs-on: ubuntu-latest
|
||||
@@ -219,13 +225,13 @@ jobs:
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
flags: e2e
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
use_oidc: true
|
||||
- if: ${{ !cancelled() }}
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
flags: e2e
|
||||
file: unittest.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
use_oidc: true
|
||||
ci-core-mark:
|
||||
if: always()
|
||||
needs:
|
||||
|
||||
2
.github/workflows/ci-outpost.yml
vendored
2
.github/workflows/ci-outpost.yml
vendored
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: authentik-ci-outpost
|
||||
name: CI - Outpost
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
3
.github/workflows/ci-web.yml
vendored
3
.github/workflows/ci-web.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: authentik-ci-web
|
||||
---
|
||||
name: CI - Web
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: authentik-compress-images
|
||||
name: Gen - Compress images
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -1,4 +1,6 @@
|
||||
name: authentik-gen-update-webauthn-mds
|
||||
---
|
||||
name: Gen - Webauthn MDS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
# See https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||
name: Cleanup cache after PR is closed
|
||||
name: GH - Cleanup actions cache after PR is closed
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
@@ -1,4 +1,5 @@
|
||||
name: ghcr-retention
|
||||
---
|
||||
name: GH - GHCR retention
|
||||
|
||||
on:
|
||||
# schedule:
|
||||
5
.github/workflows/packages-npm-publish.yml
vendored
5
.github/workflows/packages-npm-publish.yml
vendored
@@ -1,4 +1,6 @@
|
||||
name: authentik-packages-npm-publish
|
||||
---
|
||||
name: Packages - Publish NPM packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -9,6 +11,7 @@ on:
|
||||
- packages/tsconfig/**
|
||||
- packages/esbuild-plugin-live-reload/**
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: "CodeQL"
|
||||
---
|
||||
name: QA - CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -1,4 +1,6 @@
|
||||
name: authentik-semgrep
|
||||
---
|
||||
name: QA - Semgrep
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
pull_request: {}
|
||||
@@ -7,10 +9,11 @@ on:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- .github/workflows/semgrep.yml
|
||||
- .github/workflows/qa-semgrep.yml
|
||||
schedule:
|
||||
# random HH:MM to avoid a load spike on GitHub Actions at 00:00
|
||||
- cron: '12 15 * * *'
|
||||
|
||||
jobs:
|
||||
semgrep:
|
||||
name: semgrep/ci
|
||||
3
.github/workflows/release-next-branch.yml
vendored
3
.github/workflows/release-next-branch.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: authentik-on-release-next-branch
|
||||
---
|
||||
name: Release - Update next branch
|
||||
|
||||
on:
|
||||
schedule:
|
||||
|
||||
16
.github/workflows/release-publish.yml
vendored
16
.github/workflows/release-publish.yml
vendored
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: authentik-on-release
|
||||
name: Release - On publish
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
with:
|
||||
image_name: ghcr.io/goauthentik/server,beryju/authentik
|
||||
image_name: ghcr.io/goauthentik/server,authentik/server
|
||||
release: true
|
||||
registry_dockerhub: true
|
||||
registry_ghcr: true
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/docs
|
||||
- name: Login to GitHub Container Registry
|
||||
@@ -92,9 +92,9 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/${{ matrix.type }},beryju/authentik-${{ matrix.type }}
|
||||
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
|
||||
- name: make empty clients
|
||||
run: |
|
||||
mkdir -p ./gen-ts-api
|
||||
@@ -102,8 +102,8 @@ jobs:
|
||||
- name: Docker Login Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/server
|
||||
- name: Get static files from docker image
|
||||
|
||||
2
.github/workflows/release-tag.yml
vendored
2
.github/workflows/release-tag.yml
vendored
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: authentik-on-tag
|
||||
name: Release - On tag
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
3
.github/workflows/repo-mirror-cleanup.yml
vendored
3
.github/workflows/repo-mirror-cleanup.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: "authentik-repo-mirror-cleanup"
|
||||
---
|
||||
name: Repo - Cleanup internal mirror
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
3
.github/workflows/repo-mirror.yml
vendored
3
.github/workflows/repo-mirror.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: "authentik-repo-mirror"
|
||||
---
|
||||
name: Repo - Mirror to internal
|
||||
|
||||
on: [push, delete]
|
||||
|
||||
|
||||
3
.github/workflows/repo-stale.yml
vendored
3
.github/workflows/repo-stale.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: "authentik-repo-stale"
|
||||
---
|
||||
name: Repo - Mark and close stale issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
|
||||
3
.github/workflows/translation-advice.yml
vendored
3
.github/workflows/translation-advice.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: authentik-translation-advice
|
||||
---
|
||||
name: Translation - Post advice
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
name: authentik-translate-extract-compile
|
||||
name: Translation - Extract and compile
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *" # every day at midnight
|
||||
|
||||
3
.github/workflows/translation-rename.yml
vendored
3
.github/workflows/translation-rename.yml
vendored
@@ -1,6 +1,7 @@
|
||||
---
|
||||
# Rename transifex pull requests to have a correct naming
|
||||
# Also enables auto squash-merge
|
||||
name: authentik-translation-transifex-rename
|
||||
name: Translation - Auto-rename Transifex PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -76,7 +76,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.8.3 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.8.6 AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base
|
||||
|
||||
@@ -134,11 +134,16 @@ ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
|
||||
LABEL org.opencontainers.image.url=https://goauthentik.io
|
||||
LABEL org.opencontainers.image.description="goauthentik.io Main server image, see https://goauthentik.io for more info."
|
||||
LABEL org.opencontainers.image.source=https://github.com/goauthentik/authentik
|
||||
LABEL org.opencontainers.image.version=${VERSION}
|
||||
LABEL org.opencontainers.image.revision=${GIT_BUILD_HASH}
|
||||
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
|
||||
org.opencontainers.image.description="goauthentik.io Main server image, see https://goauthentik.io for more info." \
|
||||
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
|
||||
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \
|
||||
org.opencontainers.image.revision=${GIT_BUILD_HASH} \
|
||||
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
|
||||
org.opencontainers.image.title="authentik server image" \
|
||||
org.opencontainers.image.url="https://goauthentik.io" \
|
||||
org.opencontainers.image.vendor="Authentik Security Inc." \
|
||||
org.opencontainers.image.version=${VERSION}
|
||||
|
||||
WORKDIR /
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
[](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml)
|
||||
[](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml)
|
||||
[](https://codecov.io/gh/goauthentik/authentik)
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://www.transifex.com/authentik/authentik/)
|
||||
|
||||
## What is authentik?
|
||||
|
||||
@@ -8,8 +8,6 @@ API Browser - {{ brand.branding_title }}
|
||||
|
||||
{% block head %}
|
||||
<script src="{% versioned_script 'dist/standalone/api-browser/index-%v.js' %}" type="module"></script>
|
||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
@@ -49,11 +49,28 @@ class GroupMemberSerializer(ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class GroupChildSerializer(ModelSerializer):
|
||||
"""Stripped down group serializer to show relevant children for groups"""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"is_superuser",
|
||||
"attributes",
|
||||
"group_uuid",
|
||||
]
|
||||
|
||||
|
||||
class GroupSerializer(ModelSerializer):
|
||||
"""Group Serializer"""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
users_obj = SerializerMethodField(allow_null=True)
|
||||
children_obj = SerializerMethodField(allow_null=True)
|
||||
roles_obj = ListSerializer(
|
||||
child=RoleSerializer(),
|
||||
read_only=True,
|
||||
@@ -61,7 +78,6 @@ class GroupSerializer(ModelSerializer):
|
||||
required=False,
|
||||
)
|
||||
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
|
||||
|
||||
num_pk = IntegerField(read_only=True)
|
||||
|
||||
@property
|
||||
@@ -71,12 +87,25 @@ class GroupSerializer(ModelSerializer):
|
||||
return True
|
||||
return str(request.query_params.get("include_users", "true")).lower() == "true"
|
||||
|
||||
@property
|
||||
def _should_include_children(self) -> bool:
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return True
|
||||
return str(request.query_params.get("include_children", "false")).lower() == "true"
|
||||
|
||||
@extend_schema_field(GroupMemberSerializer(many=True))
|
||||
def get_users_obj(self, instance: Group) -> list[GroupMemberSerializer] | None:
|
||||
if not self._should_include_users:
|
||||
return None
|
||||
return GroupMemberSerializer(instance.users, many=True).data
|
||||
|
||||
@extend_schema_field(GroupChildSerializer(many=True))
|
||||
def get_children_obj(self, instance: Group) -> list[GroupChildSerializer] | None:
|
||||
if not self._should_include_children:
|
||||
return None
|
||||
return GroupChildSerializer(instance.children, many=True).data
|
||||
|
||||
def validate_parent(self, parent: Group | None):
|
||||
"""Validate group parent (if set), ensuring the parent isn't itself"""
|
||||
if not self.instance or not parent:
|
||||
@@ -126,11 +155,17 @@ class GroupSerializer(ModelSerializer):
|
||||
"attributes",
|
||||
"roles",
|
||||
"roles_obj",
|
||||
"children",
|
||||
"children_obj",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"users": {
|
||||
"default": list,
|
||||
},
|
||||
"children": {
|
||||
"required": False,
|
||||
"default": list,
|
||||
},
|
||||
# TODO: This field isn't unique on the database which is hard to backport
|
||||
# hence we just validate the uniqueness here
|
||||
"name": {"validators": [UniqueValidator(Group.objects.all())]},
|
||||
@@ -203,11 +238,15 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
Prefetch("users", queryset=User.objects.all().only("id"))
|
||||
)
|
||||
|
||||
if self.serializer_class(context={"request": self.request})._should_include_children:
|
||||
base_qs = base_qs.prefetch_related("children")
|
||||
|
||||
return base_qs
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
@@ -216,6 +255,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
]
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
|
||||
@@ -5,7 +5,7 @@ from json import loads
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import AnonymousUser, Permission
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django.urls import reverse_lazy
|
||||
@@ -16,6 +16,7 @@ from django.utils.translation import gettext as _
|
||||
from django_filters.filters import (
|
||||
BooleanFilter,
|
||||
CharFilter,
|
||||
IsoDateTimeFilter,
|
||||
ModelMultipleChoiceFilter,
|
||||
MultipleChoiceFilter,
|
||||
UUIDFilter,
|
||||
@@ -153,7 +154,8 @@ class UserSerializer(ModelSerializer):
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
self.fields["password"] = CharField(required=False, allow_null=True)
|
||||
self.fields["permissions"] = ListField(
|
||||
required=False, child=ChoiceField(choices=get_permission_choices())
|
||||
required=False,
|
||||
child=ChoiceField(choices=get_permission_choices()),
|
||||
)
|
||||
|
||||
def create(self, validated_data: dict) -> User:
|
||||
@@ -241,6 +243,7 @@ class UserSerializer(ModelSerializer):
|
||||
"type",
|
||||
"uuid",
|
||||
"password_change_date",
|
||||
"last_updated",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"name": {"allow_blank": True},
|
||||
@@ -267,7 +270,10 @@ class UserSelfSerializer(ModelSerializer):
|
||||
ListSerializer(
|
||||
child=inline_serializer(
|
||||
"UserSelfGroups",
|
||||
{"name": CharField(read_only=True), "pk": CharField(read_only=True)},
|
||||
{
|
||||
"name": CharField(read_only=True),
|
||||
"pk": CharField(read_only=True),
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -315,7 +321,8 @@ class UserSelfSerializer(ModelSerializer):
|
||||
|
||||
class SessionUserSerializer(PassiveSerializer):
|
||||
"""Response for the /user/me endpoint, returns the currently active user (as `user` property)
|
||||
and, if this user is being impersonated, the original user in the `original` property."""
|
||||
and, if this user is being impersonated, the original user in the `original` property.
|
||||
"""
|
||||
|
||||
user = UserSelfSerializer()
|
||||
original = UserSelfSerializer(required=False)
|
||||
@@ -331,6 +338,14 @@ class UsersFilter(FilterSet):
|
||||
method="filter_attributes",
|
||||
)
|
||||
|
||||
date_joined__lt = IsoDateTimeFilter(field_name="date_joined", lookup_expr="lt")
|
||||
date_joined = IsoDateTimeFilter(field_name="date_joined")
|
||||
date_joined__gt = IsoDateTimeFilter(field_name="date_joined", lookup_expr="gt")
|
||||
|
||||
last_updated__lt = IsoDateTimeFilter(field_name="last_updated", lookup_expr="lt")
|
||||
last_updated = IsoDateTimeFilter(field_name="last_updated")
|
||||
last_updated__gt = IsoDateTimeFilter(field_name="last_updated", lookup_expr="gt")
|
||||
|
||||
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser")
|
||||
uuid = UUIDFilter(field_name="uuid")
|
||||
|
||||
@@ -376,6 +391,8 @@ class UsersFilter(FilterSet):
|
||||
fields = [
|
||||
"username",
|
||||
"email",
|
||||
"date_joined",
|
||||
"last_updated",
|
||||
"name",
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
@@ -390,15 +407,18 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
"""User Viewset"""
|
||||
|
||||
queryset = User.objects.none()
|
||||
ordering = ["username"]
|
||||
ordering = ["username", "date_joined", "last_updated"]
|
||||
serializer_class = UserSerializer
|
||||
filterset_class = UsersFilter
|
||||
search_fields = ["username", "name", "is_active", "email", "uuid", "attributes"]
|
||||
search_fields = ["email", "name", "uuid", "username"]
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import BoolField, StrField
|
||||
|
||||
from authentik.enterprise.search.fields import ChoiceSearchField, JSONSearchField
|
||||
from authentik.enterprise.search.fields import (
|
||||
ChoiceSearchField,
|
||||
JSONSearchField,
|
||||
)
|
||||
|
||||
return [
|
||||
StrField(User, "username"),
|
||||
@@ -435,6 +455,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
user: User = self.get_object()
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
self.request._request.user = AnonymousUser()
|
||||
try:
|
||||
plan = planner.plan(
|
||||
self.request._request,
|
||||
@@ -492,7 +513,12 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
},
|
||||
)
|
||||
@action(detail=False, methods=["POST"], pagination_class=None, filter_backends=[])
|
||||
@action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
)
|
||||
def service_account(self, request: Request) -> Response:
|
||||
"""Create a new user account that is marked as a service account"""
|
||||
username = request.data.get("name")
|
||||
@@ -536,7 +562,13 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
||||
|
||||
@extend_schema(responses={200: SessionUserSerializer(many=False)})
|
||||
@action(url_path="me", url_name="me", detail=False, pagination_class=None, filter_backends=[])
|
||||
@action(
|
||||
url_path="me",
|
||||
url_name="me",
|
||||
detail=False,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
)
|
||||
def user_me(self, request: Request) -> Response:
|
||||
"""Get information about current user"""
|
||||
context = {"request": request}
|
||||
@@ -662,14 +694,18 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
if not request.user.has_perm(
|
||||
"authentik_core.impersonate", user_to_be
|
||||
) and not request.user.has_perm("authentik_core.impersonate"):
|
||||
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
||||
LOGGER.debug(
|
||||
"User attempted to impersonate without permissions",
|
||||
user=request.user,
|
||||
)
|
||||
return Response(status=401)
|
||||
if user_to_be.pk == self.request.user.pk:
|
||||
LOGGER.debug("User attempted to impersonate themselves", user=request.user)
|
||||
return Response(status=401)
|
||||
if not reason and request.tenant.impersonation_require_reason:
|
||||
LOGGER.debug(
|
||||
"User attempted to impersonate without providing a reason", user=request.user
|
||||
"User attempted to impersonate without providing a reason",
|
||||
user=request.user,
|
||||
)
|
||||
return Response(status=401)
|
||||
|
||||
@@ -708,7 +744,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
@extend_schema(
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
"UserPathSerializer", {"paths": ListField(child=CharField(), read_only=True)}
|
||||
"UserPathSerializer",
|
||||
{"paths": ListField(child=CharField(), read_only=True)},
|
||||
)
|
||||
},
|
||||
parameters=[
|
||||
|
||||
27
authentik/core/migrations/0050_user_last_updated_and_more.py
Normal file
27
authentik/core/migrations/0050_user_last_updated_and_more.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.1.11 on 2025-07-15 15:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("authentik_core", "0049_alter_token_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="last_updated",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["last_updated"], name="authentik_c_last_up_ed7486_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["date_joined"], name="authentik_c_date_jo_58c256_idx"),
|
||||
),
|
||||
]
|
||||
@@ -274,6 +274,8 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
|
||||
ak_groups = models.ManyToManyField("Group", related_name="users")
|
||||
password_change_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
class Meta:
|
||||
@@ -293,6 +295,8 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
|
||||
models.Index(fields=["uuid"]),
|
||||
models.Index(fields=["path"]),
|
||||
models.Index(fields=["type"]),
|
||||
models.Index(fields=["date_joined"]),
|
||||
models.Index(fields=["last_updated"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
|
||||
{% block head_before %}
|
||||
{% endblock %}
|
||||
|
||||
{% include "base/theme.html" %}
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||
|
||||
<style>{{ brand_css }}</style>
|
||||
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
|
||||
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
|
||||
|
||||
11
authentik/core/templates/base/theme.html
Normal file
11
authentik/core/templates/base/theme.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% if ui_theme == "dark" %}
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#18191a">
|
||||
{% elif ui_theme == "light" %}
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
{% else %}
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
|
||||
{% endif %}
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
{% block head %}
|
||||
<script src="{% versioned_script 'dist/admin/AdminInterface-%v.js' %}" type="module"></script>
|
||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
{% include "base/header_js.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
{% block head %}
|
||||
<script src="{% versioned_script 'dist/user/UserInterface-%v.js' %}" type="module"></script>
|
||||
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#1c1e21" media="(prefers-color-scheme: dark)">
|
||||
{% include "base/header_js.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from authentik.core.tests.utils import (
|
||||
create_test_flow,
|
||||
create_test_user,
|
||||
)
|
||||
from authentik.flows.models import FlowDesignation
|
||||
from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.stages.email.models import EmailStage
|
||||
|
||||
@@ -103,8 +103,11 @@ class TestUsersAPI(APITestCase):
|
||||
self.assertTrue(self.admin.check_password(new_pw))
|
||||
|
||||
def test_recovery(self):
|
||||
"""Test user recovery link (no recovery flow set)"""
|
||||
flow = create_test_flow(FlowDesignation.RECOVERY)
|
||||
"""Test user recovery link"""
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.RECOVERY,
|
||||
authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
|
||||
)
|
||||
brand: Brand = create_test_brand()
|
||||
brand.flow_recovery = flow
|
||||
brand.save()
|
||||
@@ -387,3 +390,72 @@ class TestUsersAPI(APITestCase):
|
||||
self.assertFalse(
|
||||
AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
|
||||
)
|
||||
|
||||
def test_sort_by_last_updated(self):
|
||||
"""Test API sorting by last_updated"""
|
||||
User.objects.all().delete()
|
||||
admin = create_test_admin_user()
|
||||
self.client.force_login(admin)
|
||||
|
||||
user = create_test_user()
|
||||
admin.first_name = "Sample change"
|
||||
admin.last_name = "To trigger an update"
|
||||
admin.save()
|
||||
|
||||
# Ascending
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={
|
||||
"ordering": "last_updated",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["pk"], user.pk)
|
||||
|
||||
# Descending
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={
|
||||
"ordering": "-last_updated",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["pk"], admin.pk)
|
||||
|
||||
def test_sort_by_date_joined(self):
|
||||
"""Test API sorting by date_joined"""
|
||||
User.objects.all().delete()
|
||||
admin = create_test_admin_user()
|
||||
self.client.force_login(admin)
|
||||
|
||||
user = create_test_user()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={
|
||||
"ordering": "date_joined",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["pk"], admin.pk)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={
|
||||
"ordering": "-date_joined",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["pk"], user.pk)
|
||||
|
||||
@@ -46,8 +46,10 @@ class InterfaceView(TemplateView):
|
||||
"""Base interface view"""
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
brand = CurrentBrandSerializer(self.request.brand)
|
||||
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data)
|
||||
kwargs["brand_json"] = dumps(CurrentBrandSerializer(self.request.brand).data)
|
||||
kwargs["ui_theme"] = brand.data["ui_theme"]
|
||||
kwargs["brand_json"] = dumps(brand.data)
|
||||
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
|
||||
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
|
||||
kwargs["build"] = get_build_hash()
|
||||
|
||||
@@ -55,6 +55,7 @@ class TestEnterpriseAudit(APITestCase):
|
||||
self.assertIsNotNone(event)
|
||||
self.assertIsNotNone(event.context["diff"])
|
||||
diff = event.context["diff"]
|
||||
diff.pop("last_updated")
|
||||
self.assertEqual(
|
||||
diff,
|
||||
{
|
||||
@@ -116,6 +117,7 @@ class TestEnterpriseAudit(APITestCase):
|
||||
self.assertIsNotNone(event)
|
||||
self.assertIsNotNone(event.context["diff"])
|
||||
diff = event.context["diff"]
|
||||
diff.pop("last_updated")
|
||||
self.assertEqual(
|
||||
diff,
|
||||
{
|
||||
|
||||
@@ -301,6 +301,7 @@ class SessionEndStage(ChallengeStageView):
|
||||
"flow_slug": self.request.brand.flow_invalidation.slug,
|
||||
},
|
||||
)
|
||||
|
||||
return SessionEndChallenge(data=data)
|
||||
|
||||
# This can never be reached since this challenge is created on demand and only the
|
||||
|
||||
@@ -42,7 +42,7 @@ class TestBindingsAPI(APITestCase):
|
||||
)
|
||||
|
||||
def test_invalid_too_little(self):
|
||||
"""Test invvalid binding (too little)"""
|
||||
"""Test invalid binding (too little)"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:policybinding-list"),
|
||||
data={"target": self.pbm.pk, "order": 0},
|
||||
|
||||
@@ -70,6 +70,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
"signing_key",
|
||||
"encryption_key",
|
||||
"redirect_uris",
|
||||
"backchannel_logout_uri",
|
||||
"sub_mode",
|
||||
"property_mappings",
|
||||
"issuer_mode",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""OAuth/OpenID Constants"""
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
|
||||
GRANT_TYPE_IMPLICIT = "implicit"
|
||||
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
|
||||
@@ -51,3 +54,23 @@ AMR_MFA = "mfa"
|
||||
AMR_OTP = "otp"
|
||||
AMR_WEBAUTHN = "user"
|
||||
AMR_SMART_CARD = "sc"
|
||||
|
||||
|
||||
class SubModes(models.TextChoices):
|
||||
"""Mode after which 'sub' attribute is generated, for compatibility reasons"""
|
||||
|
||||
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
|
||||
USER_ID = "user_id", _("Based on user ID")
|
||||
USER_UUID = "user_uuid", _("Based on user UUID")
|
||||
USER_USERNAME = "user_username", _("Based on the username")
|
||||
USER_EMAIL = (
|
||||
"user_email",
|
||||
_("Based on the User's Email. This is recommended over the UPN method."),
|
||||
)
|
||||
USER_UPN = (
|
||||
"user_upn",
|
||||
_(
|
||||
"Based on the User's UPN, only works if user has a 'upn' attribute set. "
|
||||
"Use this method only if you have different UPN and Mail domains."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,10 +4,8 @@ from dataclasses import asdict, dataclass, field
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.core.models import default_token_duration
|
||||
from authentik.events.signals import get_login_event
|
||||
@@ -18,6 +16,7 @@ from authentik.providers.oauth2.constants import (
|
||||
AMR_PASSWORD,
|
||||
AMR_SMART_CARD,
|
||||
AMR_WEBAUTHN,
|
||||
SubModes,
|
||||
)
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
@@ -30,26 +29,6 @@ def hash_session_key(session_key: str) -> str:
|
||||
return sha256(session_key.encode("ascii")).hexdigest()
|
||||
|
||||
|
||||
class SubModes(models.TextChoices):
|
||||
"""Mode after which 'sub' attribute is generated, for compatibility reasons"""
|
||||
|
||||
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
|
||||
USER_ID = "user_id", _("Based on user ID")
|
||||
USER_UUID = "user_uuid", _("Based on user UUID")
|
||||
USER_USERNAME = "user_username", _("Based on the username")
|
||||
USER_EMAIL = (
|
||||
"user_email",
|
||||
_("Based on the User's Email. This is recommended over the UPN method."),
|
||||
)
|
||||
USER_UPN = (
|
||||
"user_upn",
|
||||
_(
|
||||
"Based on the User's UPN, only works if user has a 'upn' attribute set. "
|
||||
"Use this method only if you have different UPN and Mail domains."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IDToken:
|
||||
"""The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.11 on 2025-07-04 03:23
|
||||
|
||||
import authentik.lib.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0028_migrate_session"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="backchannel_logout_uri",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
|
||||
verbose_name="Back-Channel Logout URI",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="_redirect_uris",
|
||||
field=models.JSONField(default=list, verbose_name="Redirect URIs"),
|
||||
),
|
||||
]
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from functools import cached_property
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
@@ -42,11 +42,14 @@ from authentik.core.models import (
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.models import DomainlessURLValidator, SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.providers.oauth2.id_token import IDToken, SubModes
|
||||
from authentik.providers.oauth2.constants import SubModes
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@@ -193,9 +196,14 @@ class OAuth2Provider(WebfingerProvider, Provider):
|
||||
default=generate_client_secret,
|
||||
)
|
||||
_redirect_uris = models.JSONField(
|
||||
default=dict,
|
||||
default=list,
|
||||
verbose_name=_("Redirect URIs"),
|
||||
)
|
||||
backchannel_logout_uri = models.TextField(
|
||||
validators=[DomainlessURLValidator(schemes=("http", "https"))],
|
||||
verbose_name=_("Back-Channel Logout URI"),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
include_claims_in_id_token = models.BooleanField(
|
||||
default=True,
|
||||
@@ -480,13 +488,15 @@ class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
return f"Access Token for {self.provider_id} for user {self.user_id}"
|
||||
|
||||
@property
|
||||
def id_token(self) -> IDToken:
|
||||
def id_token(self) -> "IDToken":
|
||||
"""Load ID Token from json"""
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
|
||||
raw_token = json.loads(self._id_token)
|
||||
return from_dict(IDToken, raw_token)
|
||||
|
||||
@id_token.setter
|
||||
def id_token(self, value: IDToken):
|
||||
def id_token(self, value: "IDToken"):
|
||||
self.token = value.to_access_token(self.provider)
|
||||
self._id_token = json.dumps(asdict(value))
|
||||
|
||||
@@ -531,13 +541,15 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
return f"Refresh Token for {self.provider_id} for user {self.user_id}"
|
||||
|
||||
@property
|
||||
def id_token(self) -> IDToken:
|
||||
def id_token(self) -> "IDToken":
|
||||
"""Load ID Token from json"""
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
|
||||
raw_token = json.loads(self._id_token)
|
||||
return from_dict(IDToken, raw_token)
|
||||
|
||||
@id_token.setter
|
||||
def id_token(self, value: IDToken):
|
||||
def id_token(self, value: "IDToken"):
|
||||
self._id_token = json.dumps(asdict(value))
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
|
||||
from authentik.providers.oauth2.tasks import backchannel_logout_notification_dispatch
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_):
|
||||
def user_session_deleted_oauth_backchannel_logout_and_tokens_removal(
|
||||
sender, instance: AuthenticatedSession, **_
|
||||
):
|
||||
"""Revoke tokens upon user logout"""
|
||||
AccessToken.objects.filter(
|
||||
LOGGER.debug("Sending back-channel logout notifications signal!", session=instance)
|
||||
|
||||
access_tokens = AccessToken.objects.filter(
|
||||
user=instance.user,
|
||||
session__session__session_key=instance.session.session_key,
|
||||
).delete()
|
||||
)
|
||||
|
||||
backchannel_logout_notification_dispatch.send(
|
||||
revocations=[
|
||||
(token.provider_id, token.id_token.iss, token.session.user.uid)
|
||||
for token in access_tokens
|
||||
],
|
||||
)
|
||||
|
||||
access_tokens.delete()
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
|
||||
68
authentik/providers/oauth2/tasks.py
Normal file
68
authentik/providers/oauth2/tasks.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""OAuth2 Provider Tasks"""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_dramatiq_postgres.middleware import CurrentTask
|
||||
from dramatiq.actor import actor
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.providers.oauth2.utils import create_logout_token
|
||||
from authentik.tasks.models import Task
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@actor(description=_("Send a back-channel logout request to the registered client"))
|
||||
def send_backchannel_logout_request(provider_pk: int, iss: str, sub: str = None) -> bool:
|
||||
"""Send a back-channel logout request to the registered client
|
||||
|
||||
Args:
|
||||
provider_pk: The OAuth2 provider's primary key
|
||||
iss: The issuer URL for the logout token
|
||||
sub: The subject identifier to include in the logout token
|
||||
|
||||
Returns:
|
||||
bool: True if the request was sent successfully, False otherwise
|
||||
"""
|
||||
self: Task = CurrentTask.get_task()
|
||||
LOGGER.debug("Sending back-channel logout request", provider_pk=provider_pk, sub=sub)
|
||||
|
||||
provider = OAuth2Provider.objects.filter(pk=provider_pk).first()
|
||||
if provider is None:
|
||||
return
|
||||
|
||||
# Generate the logout token
|
||||
logout_token = create_logout_token(iss, provider, None, sub)
|
||||
|
||||
# Get the back-channel logout URI from the provider's dedicated backchannel_logout_uri field
|
||||
# Back-channel logout requires explicit configuration - no fallback to redirect URIs
|
||||
|
||||
backchannel_logout_uri = provider.backchannel_logout_uri
|
||||
if not backchannel_logout_uri:
|
||||
self.info("No back-channel logout URI found for provider")
|
||||
return
|
||||
|
||||
# Send the back-channel logout request
|
||||
response = get_http_session().post(
|
||||
backchannel_logout_uri,
|
||||
data={"logout_token": logout_token},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
allow_redirects=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
self.info("Back-channel logout successful", sub=sub)
|
||||
return True
|
||||
|
||||
|
||||
@actor(description=_("Handle backchannel logout notifications dispatched via signal"))
|
||||
def backchannel_logout_notification_dispatch(revocations: list, **kwargs):
|
||||
"""Handle backchannel logout notifications dispatched via signal"""
|
||||
for revocation in revocations:
|
||||
provider_pk, iss, sub = revocation
|
||||
provider = OAuth2Provider.objects.filter(pk=provider_pk).first()
|
||||
send_backchannel_logout_request.send_with_options(
|
||||
args=(provider_pk, iss, sub),
|
||||
rel_obj=provider,
|
||||
)
|
||||
@@ -81,4 +81,46 @@ class TestAPI(APITestCase):
|
||||
},
|
||||
)
|
||||
self.assertJSONEqual(response.content, {"redirect_uris": ["Invalid Regex Pattern: **"]})
|
||||
|
||||
def test_backchannel_logout_uri_validation(self):
|
||||
"""Test backchannel_logout_uri API validation"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:oauth2provider-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"authorization_flow": create_test_flow().pk,
|
||||
"invalidation_flow": create_test_flow().pk,
|
||||
"redirect_uris": [
|
||||
{"matching_mode": "strict", "url": "http://goauthentik.io"},
|
||||
],
|
||||
"backchannel_logout_uri": "invalid-url",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_backchannel_logout_uri_create_and_retrieve(self):
|
||||
"""Test creating and retrieving backchannel logout URI"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:oauth2provider-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"authorization_flow": create_test_flow().pk,
|
||||
"invalidation_flow": create_test_flow().pk,
|
||||
"redirect_uris": [
|
||||
{"matching_mode": "strict", "url": "http://goauthentik.io"},
|
||||
],
|
||||
"backchannel_logout_uri": "http://goauthentik.io/logout",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
provider_data = response.json()
|
||||
self.assertEqual(provider_data["backchannel_logout_uri"], "http://goauthentik.io/logout")
|
||||
|
||||
# Test retrieving the provider
|
||||
provider_pk = provider_data["pk"]
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:oauth2provider-detail", kwargs={"pk": provider_pk})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
retrieved_data = response.json()
|
||||
self.assertEqual(retrieved_data["backchannel_logout_uri"], "http://goauthentik.io/logout")
|
||||
|
||||
223
authentik/providers/oauth2/tests/test_backchannel_logout.py
Normal file
223
authentik/providers/oauth2/tests/test_backchannel_logout.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Test OAuth2 Back-Channel Logout implementation"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import jwt
|
||||
from django.test import RequestFactory
|
||||
from django.utils import timezone
|
||||
from dramatiq.results.errors import ResultFailure
|
||||
from requests import Response
|
||||
from requests.exceptions import HTTPError, Timeout
|
||||
|
||||
from authentik.core.models import Application, AuthenticatedSession, Session
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.id_token import hash_session_key
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.oauth2.tasks import send_backchannel_logout_request
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.utils import create_logout_token
|
||||
|
||||
|
||||
class TestBackChannelLogout(OAuthTestCase):
|
||||
"""Test Back-Channel Logout functionality"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.factory = RequestFactory()
|
||||
self.user = create_test_admin_user()
|
||||
self.app = Application.objects.create(name=generate_id(), slug="test-app")
|
||||
self.provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver/callback"),
|
||||
],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
self.app.provider = self.provider
|
||||
self.app.save()
|
||||
|
||||
def _create_session(self, session_key=None):
|
||||
"""Create a session with the given key or a generated one"""
|
||||
session_key = session_key or f"session-{generate_id()}"
|
||||
session = Session.objects.create(
|
||||
session_key=session_key,
|
||||
expires=timezone.now() + timezone.timedelta(hours=1),
|
||||
last_ip="255.255.255.255",
|
||||
)
|
||||
auth_session = AuthenticatedSession.objects.create(
|
||||
session=session,
|
||||
user=self.user,
|
||||
)
|
||||
return auth_session
|
||||
|
||||
def _create_token(
|
||||
self, provider, user, session=None, token_type="access", token_id=None
|
||||
): # nosec
|
||||
"""Create a token of the specified type"""
|
||||
token_id = token_id or f"{token_type}-token-{generate_id()}"
|
||||
kwargs = {
|
||||
"provider": provider,
|
||||
"user": user,
|
||||
"session": session,
|
||||
"token": token_id,
|
||||
"_id_token": "{}",
|
||||
"auth_time": timezone.now(),
|
||||
}
|
||||
|
||||
if token_type == "access": # nosec
|
||||
return AccessToken.objects.create(**kwargs)
|
||||
else: # refresh
|
||||
return RefreshToken.objects.create(**kwargs)
|
||||
|
||||
def _create_provider(self, name=None):
|
||||
"""Create an OAuth2 provider"""
|
||||
name = name or f"provider-{generate_id()}"
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=name,
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, f"http://{name}/callback"),
|
||||
],
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
return provider
|
||||
|
||||
def _create_logout_token(
|
||||
self,
|
||||
provider: OAuth2Provider | None = None,
|
||||
session_id: str | None = None,
|
||||
sub: str | None = None,
|
||||
):
|
||||
"""Create a logout token with the given parameters"""
|
||||
provider = provider or self.provider
|
||||
|
||||
# Create a token with the same issuer that the view will expect
|
||||
# Use the same request object that will be used in the test
|
||||
request = self.factory.post("/backchannel_logout")
|
||||
|
||||
return create_logout_token(
|
||||
iss=provider.get_issuer(request),
|
||||
provider=provider,
|
||||
session_key=session_id,
|
||||
sub=sub,
|
||||
)
|
||||
|
||||
def _decode_token(self, token, provider=None):
|
||||
"""Helper to decode and validate a JWT token"""
|
||||
provider = provider or self.provider
|
||||
key, alg = provider.jwt_key
|
||||
if alg != "HS256":
|
||||
key = provider.signing_key.public_key
|
||||
return jwt.decode(
|
||||
token, key, algorithms=[alg], options={"verify_exp": False, "verify_aud": False}
|
||||
)
|
||||
|
||||
def test_create_logout_token_variants(self):
|
||||
"""Test creating logout tokens with different combinations of parameters"""
|
||||
# Test case 1: With session_id only
|
||||
session_id = "test-session-123"
|
||||
token1 = self._create_logout_token(session_id=session_id)
|
||||
decoded1 = self._decode_token(token1)
|
||||
|
||||
self.assertIn("iss", decoded1)
|
||||
self.assertEqual(decoded1["aud"], self.provider.client_id)
|
||||
self.assertIn("iat", decoded1)
|
||||
self.assertIn("jti", decoded1)
|
||||
self.assertEqual(decoded1["sid"], hash_session_key(session_id))
|
||||
self.assertIn("events", decoded1)
|
||||
self.assertIn("http://schemas.openid.net/event/backchannel-logout", decoded1["events"])
|
||||
self.assertNotIn("sub", decoded1)
|
||||
|
||||
# Test case 2: With sub only
|
||||
sub = "user-123"
|
||||
token2 = self._create_logout_token(sub=sub)
|
||||
decoded2 = self._decode_token(token2)
|
||||
|
||||
self.assertEqual(decoded2["sub"], sub)
|
||||
self.assertIn("events", decoded2)
|
||||
self.assertIn("http://schemas.openid.net/event/backchannel-logout", decoded2["events"])
|
||||
self.assertNotIn("sid", decoded2)
|
||||
|
||||
# Test case 3: With both session_id and sub
|
||||
token3 = self._create_logout_token(session_id=session_id, sub=sub)
|
||||
decoded3 = self._decode_token(token3)
|
||||
|
||||
self.assertEqual(decoded3["sid"], hash_session_key(session_id))
|
||||
self.assertEqual(decoded3["sub"], sub)
|
||||
self.assertIn("events", decoded3)
|
||||
|
||||
@patch("authentik.providers.oauth2.tasks.get_http_session")
|
||||
def test_send_backchannel_logout_request_scenarios(self, mock_get_session):
|
||||
"""Test various scenarios for backchannel logout request task"""
|
||||
# Setup provider with backchannel logout URI
|
||||
self.provider.backchannel_logout_uri = "http://testserver/backchannel_logout"
|
||||
self.provider.save()
|
||||
|
||||
# Setup mock session and response
|
||||
mock_session = Mock()
|
||||
mock_get_session.return_value = mock_session
|
||||
mock_response = Mock(spec=Response)
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status.return_value = None # No exception for successful request
|
||||
mock_session.post.return_value = mock_response
|
||||
|
||||
result = send_backchannel_logout_request.send(
|
||||
self.provider.pk, "http://testserver", sub="test-user-uid"
|
||||
)
|
||||
self.assertTrue(result)
|
||||
mock_session.post.assert_called_once()
|
||||
call_args = mock_session.post.call_args
|
||||
self.assertIn("logout_token", call_args[1]["data"])
|
||||
self.assertEqual(
|
||||
call_args[1]["headers"]["Content-Type"], "application/x-www-form-urlencoded"
|
||||
)
|
||||
|
||||
# Scenario 2: Failed request (400 response) - should raise exception
|
||||
mock_session.post.reset_mock()
|
||||
error_response = Mock(spec=Response)
|
||||
error_response.status_code = 400
|
||||
error_response.raise_for_status.side_effect = HTTPError("HTTP 400")
|
||||
mock_session.post.return_value = error_response
|
||||
with self.assertRaises(ResultFailure):
|
||||
send_backchannel_logout_request.send(
|
||||
self.provider.pk, "http://testserver", sub="test-user-uid"
|
||||
).get_result()
|
||||
|
||||
# Scenario 3: No URI configured
|
||||
mock_session.post.reset_mock()
|
||||
self.provider.backchannel_logout_uri = ""
|
||||
self.provider.save()
|
||||
result = send_backchannel_logout_request.send(
|
||||
self.provider.pk, "http://testserver", sub="test-user-uid"
|
||||
).get_result()
|
||||
self.assertIsNone(result)
|
||||
mock_session.post.assert_not_called()
|
||||
|
||||
# Scenario 4: No sub provided - should fail
|
||||
result = send_backchannel_logout_request.send(
|
||||
self.provider.pk, "http://testserver"
|
||||
).get_result()
|
||||
self.assertIsNone(result)
|
||||
|
||||
# Scenario 5: Non-existent provider
|
||||
result = send_backchannel_logout_request.send(
|
||||
99999, "http://testserver", sub="test-user-uid"
|
||||
).get_result()
|
||||
self.assertIsNone(result)
|
||||
|
||||
# Scenario 6: Request timeout
|
||||
mock_session.post.side_effect = Timeout("Request timed out")
|
||||
self.provider.backchannel_logout_uri = "http://testserver/backchannel_logout"
|
||||
self.provider.save()
|
||||
with self.assertRaises(ResultFailure):
|
||||
send_backchannel_logout_request.send(
|
||||
self.provider.pk, "http://testserver", sub="test-user-uid"
|
||||
).get_result()
|
||||
@@ -11,9 +11,9 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
IDToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
|
||||
@@ -10,11 +10,11 @@ from django.utils import timezone
|
||||
from authentik.core.models import Application, AuthenticatedSession, Session
|
||||
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.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
ClientTypes,
|
||||
DeviceToken,
|
||||
IDToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
|
||||
@@ -11,9 +11,9 @@ from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
IDToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""OAuth2/OpenID Utils"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from base64 import b64decode
|
||||
from binascii import Error
|
||||
from time import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -14,6 +16,7 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.providers.oauth2.errors import BearerTokenError
|
||||
from authentik.providers.oauth2.id_token import hash_session_key
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -211,3 +214,36 @@ class HttpResponseRedirectScheme(HttpResponseRedirect):
|
||||
) -> None:
|
||||
self.allowed_schemes = allowed_schemes or ["http", "https", "ftp"]
|
||||
super().__init__(redirect_to, *args, **kwargs)
|
||||
|
||||
|
||||
def create_logout_token(
|
||||
iss: str,
|
||||
provider: OAuth2Provider,
|
||||
session_key: str | None = None,
|
||||
sub: str | None = None,
|
||||
) -> str:
|
||||
"""Create a logout token for Back-Channel Logout
|
||||
|
||||
As per https://openid.net/specs/openid-connect-backchannel-1_0.html
|
||||
"""
|
||||
|
||||
LOGGER.debug("Creating logout token", provider=provider, session_key=session_key, sub=sub)
|
||||
|
||||
# Create the logout token payload
|
||||
payload = {
|
||||
"iss": str(iss),
|
||||
"aud": provider.client_id,
|
||||
"iat": int(time()),
|
||||
"jti": str(uuid.uuid4()),
|
||||
"events": {
|
||||
"http://schemas.openid.net/event/backchannel-logout": {},
|
||||
},
|
||||
}
|
||||
|
||||
# Add either sub or sid (or both)
|
||||
if sub:
|
||||
payload["sub"] = sub
|
||||
if session_key:
|
||||
payload["sid"] = hash_session_key(session_key)
|
||||
# Encode the token
|
||||
return provider.encode(payload)
|
||||
|
||||
@@ -9,7 +9,8 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.providers.oauth2.errors import TokenIntrospectionError
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -72,6 +72,8 @@ class ProviderInfoView(View):
|
||||
"device_authorization_endpoint": self.request.build_absolute_uri(
|
||||
reverse("authentik_providers_oauth2:device")
|
||||
),
|
||||
"backchannel_logout_supported": True,
|
||||
"backchannel_logout_session_supported": True,
|
||||
"response_types_supported": [
|
||||
ResponseTypes.CODE,
|
||||
ResponseTypes.ID_TOKEN,
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
{% block head %}
|
||||
<script src="{% versioned_script 'dist/rac/index-%v.js' %}" type="module"></script>
|
||||
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<link rel="icon" href="{{ tenant.branding_favicon_url }}">
|
||||
<link rel="shortcut icon" href="{{ tenant.branding_favicon_url }}">
|
||||
{% include "base/header_js.html" %}
|
||||
|
||||
@@ -190,6 +190,7 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"sign_response",
|
||||
"sp_binding",
|
||||
"default_relay_state",
|
||||
"default_name_id_policy",
|
||||
"url_download_metadata",
|
||||
"url_sso_post",
|
||||
"url_sso_redirect",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-18 09:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_saml", "0018_alter_samlprovider_acs_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="default_name_id_policy",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "Email"),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "Persistent"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName", "X509"),
|
||||
(
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
|
||||
"Windows",
|
||||
),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:transient", "Transient"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", "Unspecified"),
|
||||
],
|
||||
default="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -12,6 +12,7 @@ from authentik.core.models import PropertyMapping, Provider
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DSA_SHA1,
|
||||
ECDSA_SHA1,
|
||||
@@ -179,6 +180,9 @@ class SAMLProvider(Provider):
|
||||
default_relay_state = models.TextField(
|
||||
default="", blank=True, help_text=_("Default relay_state value for IDP-initiated logins")
|
||||
)
|
||||
default_name_id_policy = models.TextField(
|
||||
choices=SAMLNameIDPolicy.choices, default=SAMLNameIDPolicy.UNSPECIFIED
|
||||
)
|
||||
|
||||
sign_assertion = models.BooleanField(default=True)
|
||||
sign_response = models.BooleanField(default=False)
|
||||
|
||||
@@ -205,6 +205,13 @@ class AssertionProcessor:
|
||||
def get_name_id(self) -> Element:
|
||||
"""Get NameID Element"""
|
||||
name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID")
|
||||
# For requests that don't specify a NameIDPolicy, check if we
|
||||
# can fall back to the provider default
|
||||
if (
|
||||
self.auth_n_request.name_id_policy == SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
and self.provider.default_name_id_policy != SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
):
|
||||
self.auth_n_request.name_id_policy = self.provider.default_name_id_policy
|
||||
name_id.attrib["Format"] = self.auth_n_request.name_id_policy
|
||||
# persistent is used as a fallback, so always generate it
|
||||
persistent = self.http_request.user.uid
|
||||
|
||||
@@ -13,6 +13,7 @@ from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DSA_SHA1,
|
||||
NS_MAP,
|
||||
@@ -175,7 +176,9 @@ class AuthNRequestParser:
|
||||
|
||||
def idp_initiated(self) -> AuthNRequest:
|
||||
"""Create IdP Initiated AuthNRequest"""
|
||||
relay_state = None
|
||||
request = AuthNRequest(relay_state=None)
|
||||
if self.provider.default_relay_state != "":
|
||||
relay_state = self.provider.default_relay_state
|
||||
return AuthNRequest(relay_state=relay_state)
|
||||
request.relay_state = self.provider.default_relay_state
|
||||
if self.provider.default_name_id_policy != SAMLNameIDPolicy.UNSPECIFIED:
|
||||
request.name_id_policy = self.provider.default_name_id_policy
|
||||
return request
|
||||
|
||||
@@ -13,6 +13,7 @@ from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import PEM_FOOTER, PEM_HEADER
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_METADATA,
|
||||
@@ -46,6 +47,7 @@ class ServiceProviderMetadata:
|
||||
|
||||
auth_n_request_signed: bool
|
||||
assertion_signed: bool
|
||||
name_id_policy: SAMLNameIDPolicy
|
||||
|
||||
signing_keypair: CertificateKeyPair | None = None
|
||||
|
||||
@@ -60,6 +62,7 @@ class ServiceProviderMetadata:
|
||||
provider.issuer = self.entity_id
|
||||
provider.sp_binding = self.acs_binding
|
||||
provider.acs_url = self.acs_location
|
||||
provider.default_name_id_policy = self.name_id_policy
|
||||
if self.signing_keypair and self.auth_n_request_signed:
|
||||
self.signing_keypair.name = f"Provider {name} - SAML Signing Certificate"
|
||||
self.signing_keypair.save()
|
||||
@@ -148,6 +151,11 @@ class ServiceProviderMetadataParser:
|
||||
if signing_keypair:
|
||||
self.check_signature(root, signing_keypair)
|
||||
|
||||
name_id_format = descriptor.findall(f"{{{NS_SAML_METADATA}}}NameIDFormat")
|
||||
name_id_policy = SAMLNameIDPolicy.UNSPECIFIED
|
||||
if len(name_id_format) > 0:
|
||||
name_id_policy = SAMLNameIDPolicy(name_id_format[0].text)
|
||||
|
||||
return ServiceProviderMetadata(
|
||||
entity_id=entity_id,
|
||||
acs_binding=acs_binding,
|
||||
@@ -155,4 +163,5 @@ class ServiceProviderMetadataParser:
|
||||
auth_n_request_signed=auth_n_request_signed,
|
||||
assertion_signed=assertion_signed,
|
||||
signing_keypair=signing_keypair,
|
||||
name_id_policy=name_id_policy,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
cacheDuration="PT604800S"
|
||||
entityID="http://localhost:8080/saml/metadata">
|
||||
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="http://localhost:8080/saml/acs"
|
||||
index="1" />
|
||||
|
||||
@@ -14,6 +14,7 @@ from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||
from authentik.providers.saml.processors.metadata_parser import ServiceProviderMetadataParser
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import ECDSA_SHA256, NS_MAP, NS_SAML_METADATA
|
||||
|
||||
|
||||
@@ -86,6 +87,7 @@ class TestServiceProviderMetadataParser(TestCase):
|
||||
self.assertEqual(provider.acs_url, "http://localhost:8080/saml/acs")
|
||||
self.assertEqual(provider.issuer, "http://localhost:8080/saml/metadata")
|
||||
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
|
||||
self.assertEqual(provider.default_name_id_policy, SAMLNameIDPolicy.EMAIL)
|
||||
self.assertEqual(
|
||||
len(provider.property_mappings.all()),
|
||||
len(SAMLPropertyMapping.objects.exclude(managed__isnull=True)),
|
||||
|
||||
@@ -75,7 +75,9 @@ TENANT_APPS = [
|
||||
"pgtrigger",
|
||||
"authentik.admin",
|
||||
"authentik.api",
|
||||
"authentik.core",
|
||||
"authentik.crypto",
|
||||
"authentik.enterprise",
|
||||
"authentik.events",
|
||||
"authentik.flows",
|
||||
"authentik.outposts",
|
||||
@@ -171,6 +173,7 @@ SPECTACULAR_SETTINGS = {
|
||||
"PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes",
|
||||
"ProxyMode": "authentik.providers.proxy.models.ProxyMode",
|
||||
"TaskAggregatedStatusEnum": "authentik.tasks.models.TaskStatus",
|
||||
"SAMLNameIDPolicyEnum": "authentik.sources.saml.models.SAMLNameIDPolicy",
|
||||
"UserTypeEnum": "authentik.core.models.UserTypes",
|
||||
"UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification",
|
||||
},
|
||||
@@ -245,10 +248,12 @@ SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
|
||||
MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage"
|
||||
|
||||
MIDDLEWARE_FIRST = [
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
]
|
||||
MIDDLEWARE = [
|
||||
"django_tenants.middleware.default.DefaultTenantMiddleware",
|
||||
"authentik.root.middleware.LoggingMiddleware",
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
"authentik.root.middleware.ClientIPMiddleware",
|
||||
"authentik.stages.user_login.middleware.BoundSessionMiddleware",
|
||||
"authentik.core.middleware.AuthenticationMiddleware",
|
||||
@@ -261,6 +266,8 @@ MIDDLEWARE = [
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"authentik.core.middleware.ImpersonateMiddleware",
|
||||
]
|
||||
MIDDLEWARE_LAST = [
|
||||
"django_prometheus.middleware.PrometheusAfterMiddleware",
|
||||
]
|
||||
|
||||
@@ -496,7 +503,9 @@ _DISALLOWED_ITEMS = [
|
||||
"SHARED_APPS",
|
||||
"TENANT_APPS",
|
||||
"INSTALLED_APPS",
|
||||
"MIDDLEWARE_FIRST",
|
||||
"MIDDLEWARE",
|
||||
"MIDDLEWARE_LAST",
|
||||
"AUTHENTICATION_BACKENDS",
|
||||
"SPECTACULAR_SETTINGS",
|
||||
"REST_FRAMEWORK",
|
||||
@@ -514,16 +523,35 @@ SILENCED_SYSTEM_CHECKS = [
|
||||
]
|
||||
|
||||
|
||||
def _update_settings(app_path: str):
|
||||
def subtract_list(a: list, b: list) -> list:
|
||||
return [item for item in a if item not in b]
|
||||
|
||||
|
||||
def _filter_and_update(apps: list[str]) -> None:
|
||||
for _app in set(apps):
|
||||
if not _app.startswith("authentik"):
|
||||
continue
|
||||
_update_settings(f"{_app}.settings")
|
||||
|
||||
|
||||
def _update_settings(app_path: str) -> None:
|
||||
try:
|
||||
settings_module = importlib.import_module(app_path)
|
||||
CONFIG.log("debug", "Loaded app settings", path=app_path)
|
||||
SHARED_APPS.extend(getattr(settings_module, "SHARED_APPS", []))
|
||||
TENANT_APPS.extend(getattr(settings_module, "TENANT_APPS", []))
|
||||
|
||||
new_shared_apps = subtract_list(getattr(settings_module, "SHARED_APPS", []), SHARED_APPS)
|
||||
new_tenant_apps = subtract_list(getattr(settings_module, "TENANT_APPS", []), TENANT_APPS)
|
||||
SHARED_APPS.extend(new_shared_apps)
|
||||
TENANT_APPS.extend(new_tenant_apps)
|
||||
_filter_and_update(new_shared_apps + new_tenant_apps)
|
||||
|
||||
MIDDLEWARE_FIRST.extend(getattr(settings_module, "MIDDLEWARE_FIRST", []))
|
||||
MIDDLEWARE.extend(getattr(settings_module, "MIDDLEWARE", []))
|
||||
|
||||
AUTHENTICATION_BACKENDS.extend(getattr(settings_module, "AUTHENTICATION_BACKENDS", []))
|
||||
SPECTACULAR_SETTINGS.update(getattr(settings_module, "SPECTACULAR_SETTINGS", {}))
|
||||
REST_FRAMEWORK.update(getattr(settings_module, "REST_FRAMEWORK", {}))
|
||||
|
||||
for _attr in dir(settings_module):
|
||||
if not _attr.startswith("__") and _attr not in _DISALLOWED_ITEMS:
|
||||
globals()[_attr] = getattr(settings_module, _attr)
|
||||
@@ -538,26 +566,13 @@ if DEBUG:
|
||||
SHARED_APPS.insert(SHARED_APPS.index("django.contrib.staticfiles"), "daphne")
|
||||
enable_debug_trace(True)
|
||||
|
||||
TENANT_APPS.append("authentik.core")
|
||||
|
||||
CONFIG.log("info", "Booting authentik", version=__version__)
|
||||
|
||||
# Attempt to load enterprise app, if available
|
||||
try:
|
||||
importlib.import_module("authentik.enterprise.apps")
|
||||
CONFIG.log("info", "Enabled authentik enterprise")
|
||||
TENANT_APPS.append("authentik.enterprise")
|
||||
_update_settings("authentik.enterprise.settings")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
# Load subapps's settings
|
||||
for _app in set(SHARED_APPS + TENANT_APPS):
|
||||
if not _app.startswith("authentik"):
|
||||
continue
|
||||
_update_settings(f"{_app}.settings")
|
||||
_filter_and_update(SHARED_APPS + TENANT_APPS)
|
||||
_update_settings("data.user_settings")
|
||||
|
||||
MIDDLEWARE = list(OrderedDict.fromkeys(MIDDLEWARE_FIRST + MIDDLEWARE + MIDDLEWARE_LAST))
|
||||
SHARED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS))
|
||||
INSTALLED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-18 09:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_saml", "0019_migrate_usersamlsourceconnection_identifier"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="samlsource",
|
||||
name="name_id_policy",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "Email"),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "Persistent"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName", "X509"),
|
||||
(
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
|
||||
"Windows",
|
||||
),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:transient", "Transient"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", "Unspecified"),
|
||||
],
|
||||
default="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
|
||||
help_text="NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -39,6 +39,7 @@ from authentik.sources.saml.processors.constants import (
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_PERSISTENT,
|
||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
SAML_NAME_ID_FORMAT_WINDOWS,
|
||||
SAML_NAME_ID_FORMAT_X509,
|
||||
SHA1,
|
||||
@@ -73,6 +74,7 @@ class SAMLNameIDPolicy(models.TextChoices):
|
||||
X509 = SAML_NAME_ID_FORMAT_X509
|
||||
WINDOWS = SAML_NAME_ID_FORMAT_WINDOWS
|
||||
TRANSIENT = SAML_NAME_ID_FORMAT_TRANSIENT
|
||||
UNSPECIFIED = SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
|
||||
|
||||
class SAMLSource(Source):
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -44,6 +44,8 @@ class EmailStageSerializer(StageSerializer):
|
||||
"subject",
|
||||
"template",
|
||||
"activate_user_on_success",
|
||||
"recovery_max_attempts",
|
||||
"recovery_cache_timeout",
|
||||
]
|
||||
extra_kwargs = {"password": {"write_only": True}}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.11 on 2025-07-23 11:26
|
||||
|
||||
import authentik.lib.utils.time
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_stages_email", "0005_alter_emailstage_token_expiry"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="emailstage",
|
||||
name="recovery_cache_timeout",
|
||||
field=models.TextField(
|
||||
default="minutes=5",
|
||||
help_text="The time window used to count recent account recovery attempts. If the number of attempts exceed recovery_max_attempts within this period, further attempts will be rate-limited. (Format: hours=1;minutes=2;seconds=3).",
|
||||
validators=[authentik.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailstage",
|
||||
name="recovery_max_attempts",
|
||||
field=models.PositiveIntegerField(default=5),
|
||||
),
|
||||
]
|
||||
@@ -16,6 +16,8 @@ from authentik.flows.models import Stage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
EMAIL_RECOVERY_MAX_ATTEMPTS = 5
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@@ -70,6 +72,17 @@ class EmailStage(Stage):
|
||||
use_ssl = models.BooleanField(default=False)
|
||||
timeout = models.IntegerField(default=10)
|
||||
from_address = models.EmailField(default="system@authentik.local")
|
||||
recovery_max_attempts = models.PositiveIntegerField(default=EMAIL_RECOVERY_MAX_ATTEMPTS)
|
||||
recovery_cache_timeout = models.TextField(
|
||||
default="minutes=5",
|
||||
validators=[timedelta_string_validator],
|
||||
help_text=_(
|
||||
"The time window used to count recent account recovery attempts. "
|
||||
"If the number of attempts exceed recovery_max_attempts within "
|
||||
"this period, further attempts will be rate-limited. "
|
||||
"(Format: hours=1;minutes=2;seconds=3)."
|
||||
),
|
||||
)
|
||||
|
||||
activate_user_on_success = models.BooleanField(
|
||||
default=False, help_text=_("Activate users upon completion of stage.")
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""authentik multi-stage authentication engine"""
|
||||
|
||||
from datetime import timedelta
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from hashlib import sha256
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
@@ -27,6 +30,8 @@ from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.tasks import send_mails
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
EMAIL_RECOVERY_CACHE_KEY = "goauthentik.io/stages/email/stage/"
|
||||
|
||||
PLAN_CONTEXT_EMAIL_SENT = "email_sent"
|
||||
PLAN_CONTEXT_EMAIL_OVERRIDE = "email"
|
||||
|
||||
@@ -170,10 +175,66 @@ class EmailStageView(ChallengeStageView):
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
return super().challenge_invalid(response)
|
||||
|
||||
def _get_cache_key(self) -> str:
|
||||
"""Return the cache key used for rate limiting email recovery attempts."""
|
||||
user = self.get_pending_user()
|
||||
user_email_hashed = sha256(user.email.lower().encode("utf-8")).hexdigest()
|
||||
return EMAIL_RECOVERY_CACHE_KEY + user_email_hashed
|
||||
|
||||
def _is_rate_limited(self) -> int | None:
|
||||
"""Check whether the email recovery attempt should be rate limited.
|
||||
|
||||
If the request should be rate limited, update the cache and return the
|
||||
remaining time in minutes before the user is allowed to try again.
|
||||
Otherwise, return None."""
|
||||
cache_key = self._get_cache_key()
|
||||
attempts = cache.get(cache_key, [])
|
||||
|
||||
stage = self.executor.current_stage
|
||||
stage.refresh_from_db()
|
||||
max_attempts = stage.recovery_max_attempts
|
||||
cache_timeout_delta = timedelta_from_string(stage.recovery_cache_timeout)
|
||||
|
||||
_now = now()
|
||||
start_window = _now - cache_timeout_delta
|
||||
|
||||
# Convert unix timestamps to datetime objects for comparison
|
||||
recent_attempts_in_window = [
|
||||
datetime.fromtimestamp(attempt, UTC)
|
||||
for attempt in attempts
|
||||
if datetime.fromtimestamp(attempt, UTC) > start_window
|
||||
]
|
||||
|
||||
if len(recent_attempts_in_window) >= max_attempts:
|
||||
retry_after = (min(recent_attempts_in_window) + cache_timeout_delta) - _now
|
||||
minutes_left = max(1, math.ceil(retry_after.total_seconds() / 60))
|
||||
return minutes_left
|
||||
|
||||
recent_attempts_in_window.append(_now)
|
||||
|
||||
# Convert datetime objects back to unix timestamps to update cache
|
||||
recent_attempts_in_window = [attempt.timestamp() for attempt in recent_attempts_in_window]
|
||||
|
||||
cache.set(
|
||||
cache_key,
|
||||
recent_attempts_in_window,
|
||||
int(cache_timeout_delta.total_seconds()),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def challenge_invalid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
if minutes_left := self._is_rate_limited():
|
||||
error = _(
|
||||
"Too many account verification attempts. Please try again after {minutes} minutes."
|
||||
).format(minutes=minutes_left)
|
||||
messages.error(self.request, error)
|
||||
return super().challenge_invalid(response)
|
||||
|
||||
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
|
||||
messages.error(self.request, _("No pending user."))
|
||||
return super().challenge_invalid(response)
|
||||
|
||||
self.send_email()
|
||||
messages.success(self.request, _("Email Successfully sent."))
|
||||
# We can't call stage_ok yet, as we're still waiting
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""email tests"""
|
||||
|
||||
from hashlib import sha256
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core import mail
|
||||
from django.core.mail.backends.locmem import EmailBackend
|
||||
from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
|
||||
@@ -9,6 +11,7 @@ from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.flows.markers import StageMarker
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
|
||||
@@ -17,6 +20,7 @@ from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN, FlowExecutorView
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import get_request
|
||||
from authentik.stages.consent.stage import SESSION_KEY_CONSENT_TOKEN
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE, EmailStageView
|
||||
@@ -291,3 +295,173 @@ class TestEmailStage(FlowTestCase):
|
||||
stage_view.get_full_url(**{QS_KEY_TOKEN: token}),
|
||||
f"http://testserver/if/flow/{self.flow.slug}/?foo=bar&flow_token={token}",
|
||||
)
|
||||
|
||||
def test_get_cache_key(self):
|
||||
"""Test to ensure that the correct cache key is returned."""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
request = self.factory.post(url)
|
||||
request.user = self.user
|
||||
request.session = session
|
||||
|
||||
executor = FlowExecutorView(request=request, flow=self.flow)
|
||||
executor.plan = plan
|
||||
|
||||
stage_view = EmailStageView(executor, request=request)
|
||||
|
||||
cache_key = stage_view._get_cache_key()
|
||||
|
||||
expected_hash = sha256(self.user.email.lower().encode("utf-8")).hexdigest()
|
||||
expected_cache_key = "goauthentik.io/stages/email/stage/" + expected_hash
|
||||
|
||||
self.assertEqual(cache_key, expected_cache_key)
|
||||
|
||||
def test_is_rate_limited_returns_none(self):
|
||||
"""Test to ensure None is returned if the request shouldn't be rate limited."""
|
||||
self.stage.recovery_max_attempts = 2
|
||||
self.stage.recovery_cache_timeout = "minutes=10"
|
||||
self.stage.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
request = self.factory.post(url)
|
||||
request.user = self.user
|
||||
request.session = session
|
||||
|
||||
executor = FlowExecutorView(request=request, flow=self.flow)
|
||||
executor.current_stage = self.stage
|
||||
executor.plan = plan
|
||||
|
||||
stage_view = EmailStageView(executor, request=request)
|
||||
|
||||
result = stage_view._is_rate_limited()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_is_rate_limited_returns_remaining_time(self):
|
||||
"""Test to ensure the remaining time is returned if the request
|
||||
should be rate limited."""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
request = self.factory.post(url)
|
||||
request.user = self.user
|
||||
request.session = session
|
||||
|
||||
executor = FlowExecutorView(request=request, flow=self.flow)
|
||||
executor.current_stage = self.stage
|
||||
executor.plan = plan
|
||||
|
||||
stage_view = EmailStageView(executor, request=request)
|
||||
|
||||
test_cases = [
|
||||
# 2 attempts within 2 minutes
|
||||
(2, "seconds=120", 2),
|
||||
# 4 attempts within 5 minutes
|
||||
(4, "minutes=5", 5),
|
||||
# 6 attempts within 5 minutes. Although 299 seconds is less than
|
||||
# 5 minutes, the user is intentionally shown "5 minutes". This is
|
||||
# because an initial rate limiting message like "Try again after 4 minutes"
|
||||
# can be confusing.
|
||||
(6, "seconds=299", 5),
|
||||
]
|
||||
for test_case in test_cases:
|
||||
max_attempts, cache_timeout, minutes_remaining = test_case
|
||||
with self.subTest(
|
||||
f"Test recovery with {max_attempts} max attempts and "
|
||||
f"{cache_timeout} cache timeout seconds"
|
||||
):
|
||||
self.stage.recovery_max_attempts = max_attempts
|
||||
self.stage.recovery_cache_timeout = cache_timeout
|
||||
self.stage.save()
|
||||
|
||||
# Simulate multiple requests
|
||||
for _ in range(max_attempts):
|
||||
stage_view._is_rate_limited()
|
||||
|
||||
# The following request should be rate-limited
|
||||
result = stage_view._is_rate_limited()
|
||||
|
||||
self.assertEqual(result, minutes_remaining)
|
||||
|
||||
def _challenge_invalid_helper(self):
|
||||
"""Helper to test the challenge_invalid() method."""
|
||||
self.stage.recovery_max_attempts = 1
|
||||
self.stage.recovery_cache_timeout = "seconds=300"
|
||||
self.stage.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
session.save()
|
||||
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
request = get_request(url, user=self.user)
|
||||
request.session = session
|
||||
|
||||
request.brand = Brand.objects.create(domain="foo-domain.com", default=True)
|
||||
|
||||
executor = FlowExecutorView(request=request, flow=self.flow)
|
||||
executor.current_stage = self.stage
|
||||
executor.plan = plan
|
||||
|
||||
stage_view = EmailStageView(executor, request=request)
|
||||
challenge_response = stage_view.get_response_instance(data={})
|
||||
challenge_response.is_valid()
|
||||
|
||||
return challenge_response, stage_view, request
|
||||
|
||||
def test_challenge_invalid_not_rate_limited(self):
|
||||
"""Tests that the request is not rate limited and email is sent."""
|
||||
challenge_response, stage_view, request = self._challenge_invalid_helper()
|
||||
|
||||
with patch.object(stage_view, "send_email") as mock_send_email:
|
||||
result = stage_view.challenge_invalid(challenge_response)
|
||||
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
mock_send_email.assert_called_once()
|
||||
|
||||
message_list = list(messages.get_messages(request))
|
||||
self.assertEqual(len(message_list), 1)
|
||||
self.assertEqual(
|
||||
"Email Successfully sent.",
|
||||
message_list[-1].message,
|
||||
)
|
||||
|
||||
def test_challenge_invalid_returns_error_if_rate_limited(self):
|
||||
"""Tests that an error is returned if the request is rate limited. Ensure
|
||||
that an email is not sent."""
|
||||
challenge_response, stage_view, request = self._challenge_invalid_helper()
|
||||
|
||||
# Initial request that shouldn't be rate limited
|
||||
stage_view.challenge_invalid(challenge_response)
|
||||
|
||||
with patch.object(stage_view, "send_email") as mock_send_email:
|
||||
# This next request should be rate limited
|
||||
result = stage_view.challenge_invalid(challenge_response)
|
||||
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
message_list = list(messages.get_messages(request))
|
||||
self.assertEqual(len(message_list), 2)
|
||||
self.assertEqual(
|
||||
"Too many account verification attempts. Please try again after 5 minutes.",
|
||||
message_list[-1].message,
|
||||
)
|
||||
|
||||
@@ -61,6 +61,8 @@ entries:
|
||||
subject: authentik
|
||||
template: email/password_reset.html
|
||||
activate_user_on_success: true
|
||||
recovery_max_attempts: 5
|
||||
recovery_cache_timeout: minutes=5
|
||||
- identifiers:
|
||||
name: default-recovery-user-write
|
||||
id: default-recovery-user-write
|
||||
|
||||
@@ -4689,6 +4689,14 @@
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": "Roles"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": "Children"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@@ -8465,6 +8473,10 @@
|
||||
},
|
||||
"title": "Redirect uris"
|
||||
},
|
||||
"backchannel_logout_uri": {
|
||||
"type": "string",
|
||||
"title": "Back-Channel Logout URI"
|
||||
},
|
||||
"sub_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -9287,6 +9299,18 @@
|
||||
"type": "string",
|
||||
"title": "Default relay state",
|
||||
"description": "Default relay_state value for IDP-initiated logins"
|
||||
},
|
||||
"default_name_id_policy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName",
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
|
||||
],
|
||||
"title": "Default name id policy"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@@ -11714,7 +11738,8 @@
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName",
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
|
||||
],
|
||||
"title": "Name id policy",
|
||||
"description": "NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent."
|
||||
@@ -14311,6 +14336,18 @@
|
||||
"type": "boolean",
|
||||
"title": "Activate user on success",
|
||||
"description": "Activate users upon completion of stage."
|
||||
},
|
||||
"recovery_max_attempts": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 2147483647,
|
||||
"title": "Recovery max attempts"
|
||||
},
|
||||
"recovery_cache_timeout": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Recovery cache timeout",
|
||||
"description": "The time window used to count recent account recovery attempts. If the number of attempts exceed recovery_max_attempts within this period, further attempts will be rate-limited. (Format: hours=1;minutes=2;seconds=3)."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
||||
19
go.mod
19
go.mod
@@ -5,12 +5,12 @@ go 1.24.0
|
||||
require (
|
||||
beryju.io/ldap v0.1.0
|
||||
github.com/avast/retry-go/v4 v4.6.1
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/getsentry/sentry-go v0.34.1
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/getsentry/sentry-go v0.35.0
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
||||
github.com/go-ldap/ldap/v3 v3.4.11
|
||||
github.com/go-openapi/runtime v0.28.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/handlers v1.5.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
@@ -22,14 +22,14 @@ require (
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||
github.com/pires/go-proxyproto v0.8.1
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/redis/go-redis/v9 v9.11.0
|
||||
github.com/sethvargo/go-envconfig v1.3.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2025064.2
|
||||
goauthentik.io/api/v3 v3.2025064.7
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sync v0.16.0
|
||||
@@ -69,18 +69,17 @@ require (
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
go.mongodb.org/mongo-driver v1.14.0 // indirect
|
||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
36
go.sum
36
go.sum
@@ -16,8 +16,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -26,8 +26,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/getsentry/sentry-go v0.34.1 h1:HSjc1C/OsnZttohEPrrqKH42Iud0HuLCXpv8cU1pWcw=
|
||||
github.com/getsentry/sentry-go v0.34.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
|
||||
github.com/getsentry/sentry-go v0.35.0 h1:+FJNlnjJsZMG3g0/rmmP7GiKjQoUF5EXfEtBwtPtkzY=
|
||||
github.com/getsentry/sentry-go v0.35.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
@@ -67,8 +67,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
|
||||
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
@@ -140,14 +140,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
|
||||
github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
@@ -185,8 +185,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
|
||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
goauthentik.io/api/v3 v3.2025064.2 h1:WFXe12hfsRe29EkLCxWCvrdK6peAkCA6ftdEh04hKLg=
|
||||
goauthentik.io/api/v3 v3.2025064.2/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
|
||||
goauthentik.io/api/v3 v3.2025064.7 h1:nh7Uh9K/XsHEz6hmPvZCK+aQC9uqwOto5hTCvtdvXPc=
|
||||
goauthentik.io/api/v3 v3.2025064.7/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
@@ -211,8 +211,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -17,6 +17,7 @@ type LDAPGroup struct {
|
||||
Uid string
|
||||
GidNumber string
|
||||
Member []string
|
||||
MemberOf []string
|
||||
IsSuperuser bool
|
||||
IsVirtualGroup bool
|
||||
Attributes map[string]interface{}
|
||||
@@ -38,6 +39,7 @@ func (lg *LDAPGroup) Entry() *ldap.Entry {
|
||||
"ak-superuser": {strconv.FormatBool(lg.IsSuperuser)},
|
||||
"objectClass": objectClass,
|
||||
"member": lg.Member,
|
||||
"memberOf": lg.MemberOf,
|
||||
"cn": {lg.CN},
|
||||
"uid": {lg.Uid},
|
||||
"sAMAccountName": {lg.CN},
|
||||
@@ -52,7 +54,8 @@ func FromAPIGroup(g api.Group, si server.LDAPServerInstance) *LDAPGroup {
|
||||
CN: g.Name,
|
||||
Uid: string(g.Pk),
|
||||
GidNumber: si.GetGroupGidNumber(g),
|
||||
Member: si.UsersForGroup(g),
|
||||
Member: si.MembersForGroup(g),
|
||||
MemberOf: si.MemberOfForGroup(g),
|
||||
IsVirtualGroup: false,
|
||||
IsSuperuser: *g.IsSuperuser,
|
||||
Attributes: g.Attributes,
|
||||
|
||||
@@ -155,7 +155,7 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult,
|
||||
if needGroups {
|
||||
errs.Go(func() error {
|
||||
gapisp := sentry.StartSpan(errCtx, "authentik.providers.ldap.search.api_group")
|
||||
searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()).IncludeUsers(true), parsedFilter, false)
|
||||
searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()).IncludeUsers(true).IncludeChildren(true), parsedFilter, false)
|
||||
if skip {
|
||||
req.Log().Trace("Skip backend request")
|
||||
return nil
|
||||
|
||||
@@ -165,7 +165,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult,
|
||||
for _, u := range g.UsersObj {
|
||||
if flag.UserPk == u.Pk {
|
||||
// TODO: Is there a better way to clone this object?
|
||||
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, g.ParentName, []api.GroupMember{u}, []api.Role{})
|
||||
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, g.ParentName, []api.GroupMember{u}, []api.Role{}, []api.GroupChild{})
|
||||
fg.SetUsers([]int32{flag.UserPk})
|
||||
if g.Parent.IsSet() {
|
||||
if p := g.Parent.Get(); p != nil {
|
||||
|
||||
@@ -32,7 +32,8 @@ type LDAPServerInstance interface {
|
||||
GetUserGidNumber(api.User) string
|
||||
GetGroupGidNumber(api.Group) string
|
||||
|
||||
UsersForGroup(api.Group) []string
|
||||
MembersForGroup(api.Group) []string
|
||||
MemberOfForGroup(api.Group) []string
|
||||
|
||||
GetFlags(dn string) *flags.UserFlags
|
||||
SetFlags(dn string, flags *flags.UserFlags)
|
||||
|
||||
@@ -15,12 +15,27 @@ func (pi *ProviderInstance) GroupsForUser(user api.User) []string {
|
||||
return groups
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) UsersForGroup(group api.Group) []string {
|
||||
func (pi *ProviderInstance) MembersForGroup(group api.Group) []string {
|
||||
users := make([]string, len(group.UsersObj))
|
||||
for i, user := range group.UsersObj {
|
||||
users[i] = pi.GetUserDN(user.Username)
|
||||
}
|
||||
return users
|
||||
children := make([]string, len(group.ChildrenObj))
|
||||
for i, child := range group.ChildrenObj {
|
||||
children[i] = pi.GetGroupDN(child.Name)
|
||||
}
|
||||
return append(users, children...)
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) MemberOfForGroup(group api.Group) []string {
|
||||
if group.ParentName.IsSet() {
|
||||
parent := group.ParentName.Get()
|
||||
if parent != nil {
|
||||
return []string{pi.GetGroupDN(*group.ParentName.Get())}
|
||||
}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) GetUserDN(user string) string {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/outpost/proxyv2/codecs"
|
||||
"goauthentik.io/internal/outpost/proxyv2/constants"
|
||||
"goauthentik.io/internal/outpost/proxyv2/filesystemstore"
|
||||
"goauthentik.io/internal/outpost/proxyv2/redisstore"
|
||||
"goauthentik.io/internal/utils"
|
||||
)
|
||||
@@ -90,7 +91,10 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL)
|
||||
return rs, nil
|
||||
}
|
||||
dir := os.TempDir()
|
||||
cs := sessions.NewFilesystemStore(dir)
|
||||
cs, err := filesystemstore.GetPersistentStore(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cs.Codecs = codecs.CodecsFromPairs(maxAge, []byte(*p.CookieSecret))
|
||||
// https://github.com/markbates/goth/commit/7276be0fdf719ddff753f3574ef0f967e4a5a5f7
|
||||
// set the maxLength of the cookies stored on the disk to a larger number to prevent issues with:
|
||||
@@ -123,7 +127,7 @@ func (a *Application) getAllCodecs() []securecookie.Codec {
|
||||
}
|
||||
|
||||
func (a *Application) Logout(ctx context.Context, filter func(c Claims) bool) error {
|
||||
if _, ok := a.sessions.(*sessions.FilesystemStore); ok {
|
||||
if _, ok := a.sessions.(*filesystemstore.Store); ok {
|
||||
files, err := os.ReadDir(os.TempDir())
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
226
internal/outpost/proxyv2/filesystemstore/filesystemstore.go
Normal file
226
internal/outpost/proxyv2/filesystemstore/filesystemstore.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package filesystemstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionCleanupInterval = 5 * time.Minute
|
||||
SessionCleanupLockFileName = "session-cleanup.lock"
|
||||
SessionFilePrefix = "session_"
|
||||
SessionTestFile = SessionFilePrefix + "write_test"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSessionCleanupAlreadyRunning = errors.New("session cleanup is already running by another instance")
|
||||
ErrSessionStoreNoPermission = errors.New("path is not writable")
|
||||
ErrSessionStorePathNotExist = errors.New("path does not exist")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
*sessions.FilesystemStore
|
||||
storePath string
|
||||
log *log.Entry
|
||||
}
|
||||
|
||||
// NewStore checks if the specified store path exists, is writable and creates a new filesystem session store.
|
||||
func NewStore(storePath string, keyPairs ...[]byte) (*Store, error) {
|
||||
if storePath == "" {
|
||||
storePath = os.TempDir()
|
||||
}
|
||||
|
||||
// check if path exists
|
||||
_, err := os.ReadDir(storePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrSessionStorePathNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// check if path is writable
|
||||
testPath := path.Join(storePath, SessionTestFile)
|
||||
testFile, err := os.OpenFile(testPath, os.O_CREATE, 0600)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
return nil, ErrSessionStoreNoPermission
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err = testFile.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.Remove(testPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Store{
|
||||
FilesystemStore: sessions.NewFilesystemStore(storePath, keyPairs...),
|
||||
storePath: storePath,
|
||||
log: log.WithField("logger", "authentik.outpost.proxyv2.filesystemstore"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SessionCleanup acquires a file lock to ensure only one instance runs at a time,
|
||||
// then checks and deletes expired session files from the filesystem session store.
|
||||
// It supports context-based cancellation to allow graceful shutdowns or timeouts.
|
||||
func (s *Store) SessionCleanup(ctx context.Context) error {
|
||||
s.log.Info("Starting session cleanup")
|
||||
lockPath := path.Join(s.storePath, SessionCleanupLockFileName)
|
||||
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := lockFile.Close(); closeErr != nil {
|
||||
s.log.WithError(closeErr).Warn("failed to close lock file")
|
||||
}
|
||||
}()
|
||||
|
||||
err = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err != nil {
|
||||
if errno, ok := err.(syscall.Errno); ok && errno == syscall.EWOULDBLOCK {
|
||||
return ErrSessionCleanupAlreadyRunning
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if flockErr := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); flockErr != nil {
|
||||
s.log.WithError(flockErr).Warn("failed to unlock file")
|
||||
}
|
||||
|
||||
if removeErr := os.Remove(lockPath); removeErr != nil {
|
||||
s.log.WithError(removeErr).Warn("failed to remove lock file")
|
||||
}
|
||||
}()
|
||||
|
||||
return s.sessionCleanup(ctx)
|
||||
}
|
||||
|
||||
// sessionCleanup checks the modification time of all session files and removes them
|
||||
// when they reach the configured maximum age in the session store.
|
||||
// Since the FilesystemStore from Gorilla does not have a session cleanup function,
|
||||
// it is only necessary for the filesystem session store.
|
||||
func (s *Store) sessionCleanup(ctx context.Context) error {
|
||||
files, err := os.ReadDir(s.storePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, file := range files {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.log.Warn("session cleanup interrupted during file processing")
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(file.Name(), SessionFilePrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := path.Join(s.storePath, file.Name())
|
||||
stat, err := os.Lstat(fullPath)
|
||||
if err != nil {
|
||||
s.log.WithError(err).WithField("path", fullPath).Warning("failed to read stats from file")
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
modTime := stat.ModTime()
|
||||
if time.Since(modTime) <= time.Duration(s.Options.MaxAge)*time.Second {
|
||||
s.log.WithField("max-age", s.Options.MaxAge).WithField("modified", modTime.String()).Debug("session still valid")
|
||||
continue
|
||||
}
|
||||
|
||||
s.log.WithField("path", fullPath).WithField("modified", modTime.String()).Info("cleanup expired session")
|
||||
if err = os.Remove(fullPath); err != nil {
|
||||
s.log.WithError(err).WithField("path", fullPath).Warn("failed to delete session")
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
var (
|
||||
cancelCleanup context.CancelFunc
|
||||
doneCleanup chan struct{}
|
||||
globalStore *Store
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
// GetPersistentStore creates a new filesystem store if it is the first time the function has been called,
|
||||
// or if the path string has changed. It then stores this in the globalStore variable.
|
||||
// If the function is called multiple times, the store from the variable is returned to ensure that only one instance is running.
|
||||
func GetPersistentStore(path string) (*Store, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if globalStore == nil || globalStore.storePath != path {
|
||||
if cancelCleanup != nil {
|
||||
cancelCleanup()
|
||||
if doneCleanup != nil {
|
||||
<-doneCleanup
|
||||
}
|
||||
}
|
||||
store, err := NewStore(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
globalStore = store
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelCleanup = cancel
|
||||
doneCleanup = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(doneCleanup)
|
||||
globalStore.log.Info("Scheduling session cleanup job")
|
||||
ticker := time.NewTicker(SessionCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
err := globalStore.SessionCleanup(ctx)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, ErrSessionCleanupAlreadyRunning) {
|
||||
globalStore.log.WithError(err).Warn("Session cleanup is locked by another job")
|
||||
continue
|
||||
}
|
||||
globalStore.log.WithError(err).Warn("Session cleanup returned error")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
return globalStore, nil
|
||||
}
|
||||
|
||||
// StopPersistentStore stops the cleanup background job and clears the globalStore variable.
|
||||
func StopPersistentStore() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if cancelCleanup != nil {
|
||||
cancelCleanup()
|
||||
if doneCleanup != nil {
|
||||
<-doneCleanup
|
||||
}
|
||||
}
|
||||
cancelCleanup = nil
|
||||
doneCleanup = nil
|
||||
globalStore = nil
|
||||
}
|
||||
146
internal/outpost/proxyv2/filesystemstore/filesystemstore_test.go
Normal file
146
internal/outpost/proxyv2/filesystemstore/filesystemstore_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package filesystemstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createTempSessionFile(t *testing.T, dir string, modTime time.Time) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "session_test")
|
||||
err := os.WriteFile(path, []byte("session data"), 0600)
|
||||
require.NoError(t, err)
|
||||
err = os.Chtimes(path, modTime, modTime)
|
||||
require.NoError(t, err)
|
||||
return path
|
||||
}
|
||||
|
||||
func TestNewStore_PathNotExist(t *testing.T) {
|
||||
_, err := NewStore("/invalid_path")
|
||||
assert.ErrorIs(t, err, ErrSessionStorePathNotExist)
|
||||
}
|
||||
|
||||
func TestNewStore_PathNotWritable(t *testing.T) {
|
||||
storePath := path.Join(os.TempDir(), "test")
|
||||
err := os.Mkdir(storePath, 0400)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = NewStore(storePath)
|
||||
assert.ErrorIs(t, err, ErrSessionStoreNoPermission)
|
||||
|
||||
_ = os.RemoveAll(storePath)
|
||||
}
|
||||
|
||||
func TestNewStore(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
store, err := NewStore(tmpDir)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, store)
|
||||
}
|
||||
|
||||
func TestSessionCleanup_RemovesExpired(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
store, err := NewStore(tmpDir)
|
||||
require.NoError(t, err)
|
||||
store.Options.MaxAge = 1 // 1 second
|
||||
|
||||
// Create an expired session file
|
||||
oldTime := time.Now().Add(-10 * time.Second)
|
||||
createTempSessionFile(t, tmpDir, oldTime)
|
||||
|
||||
ctx := context.Background()
|
||||
err = store.SessionCleanup(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// File should be deleted
|
||||
files, _ := os.ReadDir(tmpDir)
|
||||
assert.Empty(t, files)
|
||||
}
|
||||
|
||||
func TestSessionCleanup_PreservesValid(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
store, err := NewStore(tmpDir)
|
||||
require.NoError(t, err)
|
||||
store.Options.MaxAge = 3600 // 1 hour
|
||||
|
||||
// Create a valid (non-expired) session file
|
||||
modTime := time.Now().Add(-10 * time.Second)
|
||||
createTempSessionFile(t, tmpDir, modTime)
|
||||
|
||||
ctx := context.Background()
|
||||
err = store.SessionCleanup(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// File should still exist
|
||||
files, _ := os.ReadDir(tmpDir)
|
||||
assert.Len(t, files, 1)
|
||||
}
|
||||
|
||||
func TestSessionCleanup_ContextCancel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
store, err := NewStore(tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
err = store.SessionCleanup(ctx)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestSessionCleanup_AlreadyRunning(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
store, err := NewStore(tmpDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Manually acquire the lock before calling SessionCleanup
|
||||
lockPath := path.Join(tmpDir, SessionCleanupLockFileName)
|
||||
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600)
|
||||
require.NoError(t, err, "failed to create lock file")
|
||||
|
||||
err = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
require.NoError(t, err, "failed to acquire lock for test")
|
||||
|
||||
// Run SessionCleanup while lock is held
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = store.SessionCleanup(ctx)
|
||||
assert.ErrorIs(t, err, ErrSessionCleanupAlreadyRunning)
|
||||
|
||||
// Unlock and clean up
|
||||
_ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
|
||||
_ = lockFile.Close()
|
||||
_ = os.Remove(lockPath)
|
||||
}
|
||||
|
||||
func TestPersistentStore_ReusesStore(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
store1, err := GetPersistentStore(tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, store1)
|
||||
|
||||
store2, err := GetPersistentStore(tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, store1, store2)
|
||||
|
||||
StopPersistentStore()
|
||||
}
|
||||
|
||||
func TestStopPersistentStore(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
_, err := GetPersistentStore(tmpDir)
|
||||
require.NoError(t, err)
|
||||
StopPersistentStore()
|
||||
|
||||
// call again should not panic
|
||||
StopPersistentStore()
|
||||
}
|
||||
@@ -37,11 +37,16 @@ ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
|
||||
LABEL org.opencontainers.image.url=https://goauthentik.io
|
||||
LABEL org.opencontainers.image.description="goauthentik.io LDAP outpost, see https://goauthentik.io for more info."
|
||||
LABEL org.opencontainers.image.source=https://github.com/goauthentik/authentik
|
||||
LABEL org.opencontainers.image.version=${VERSION}
|
||||
LABEL org.opencontainers.image.revision=${GIT_BUILD_HASH}
|
||||
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
|
||||
org.opencontainers.image.description="goauthentik.io LDAP outpost, see https://goauthentik.io for more info." \
|
||||
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
|
||||
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \
|
||||
org.opencontainers.image.revision=${GIT_BUILD_HASH} \
|
||||
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
|
||||
org.opencontainers.image.title="authentik LDAP outpost image" \
|
||||
org.opencontainers.image.url="https://goauthentik.io" \
|
||||
org.opencontainers.image.vendor="Authentik Security Inc." \
|
||||
org.opencontainers.image.version=${VERSION}
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
|
||||
8
lifecycle/aws/package-lock.json
generated
8
lifecycle/aws/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1022.0",
|
||||
"aws-cdk": "^2.1024.0",
|
||||
"cross-env": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -24,9 +24,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1022.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1022.0.tgz",
|
||||
"integrity": "sha512-GHCu+tDtYMqCiElCl7Fad2/Bt2GmtXEV3dynudoAsV9PlL5ETeLmEN7jflDQxhmr7KhKpQeZJo/PM0DoWCvoHw==",
|
||||
"version": "2.1024.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1024.0.tgz",
|
||||
"integrity": "sha512-hY0iVT2gPX/QOQXL7RSP2sqIRI/4BYU27vSmbhZxLEj//c3pkMkd9QpIHj7gOhyWC2gf6n5JuYPw27Dgw8FEdA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1022.0",
|
||||
"aws-cdk": "^2.1024.0",
|
||||
"cross-env": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-28 16:09+0000\n"
|
||||
"POT-Creation-Date: 2025-08-07 00:12+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -1483,27 +1483,27 @@ msgstr ""
|
||||
msgid "Invalid Regex Pattern: {url}"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid "Based on the Hashed User ID"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid "Based on user ID"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid "Based on user UUID"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid "Based on the username"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid "Based on the User's Email. This is recommended over the UPN method."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid ""
|
||||
"Based on the User's UPN, only works if user has a 'upn' attribute set. Use "
|
||||
"this method only if you have different UPN and Mail domains."
|
||||
@@ -1617,6 +1617,10 @@ msgstr ""
|
||||
msgid "Redirect URIs"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "Back-Channel Logout URI"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "Include claims in id_token"
|
||||
msgstr ""
|
||||
@@ -1732,6 +1736,14 @@ msgstr ""
|
||||
msgid "Device Tokens"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/tasks.py
|
||||
msgid "Send a back-channel logout request to the registered client"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/tasks.py
|
||||
msgid "Handle backchannel logout notifications dispatched via signal"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/providers/oauth2/views/authorize.py
|
||||
#: authentik/providers/saml/views/flows.py
|
||||
#, python-brace-format
|
||||
@@ -3238,6 +3250,13 @@ msgstr ""
|
||||
msgid "Account Confirmation"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/models.py
|
||||
msgid ""
|
||||
"The time window used to count recent account recovery attempts. If the "
|
||||
"number of attempts exceed recovery_max_attempts within this period, further "
|
||||
"attempts will be rate-limited. (Format: hours=1;minutes=2;seconds=3)."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/models.py
|
||||
msgid "Activate users upon completion of stage."
|
||||
msgstr ""
|
||||
@@ -3262,6 +3281,13 @@ msgstr ""
|
||||
msgid "Email sent."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/stage.py
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Too many account verification attempts. Please try again after {minutes} "
|
||||
"minutes."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/stages/email/stage.py
|
||||
msgid "Email Successfully sent."
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
@@ -19,7 +19,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-04 00:12+0000\n"
|
||||
"POT-Creation-Date: 2025-08-07 00:12+0000\n"
|
||||
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
||||
"Last-Translator: Marc Schmitt, 2025\n"
|
||||
"Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n"
|
||||
@@ -33,6 +33,10 @@ msgstr ""
|
||||
msgid "Version history"
|
||||
msgstr "Historique des versions"
|
||||
|
||||
#: authentik/admin/tasks.py
|
||||
msgid "Update latest version info."
|
||||
msgstr "Mettre à jour les dernières informations de version."
|
||||
|
||||
#: authentik/admin/tasks.py
|
||||
#, python-brace-format
|
||||
msgid "New version {version} available!"
|
||||
@@ -88,10 +92,25 @@ msgstr "Instances du plan"
|
||||
msgid "authentik Export - {date}"
|
||||
msgstr "Export authentik - {date}"
|
||||
|
||||
#: authentik/blueprints/v1/tasks.py authentik/crypto/tasks.py
|
||||
#, python-brace-format
|
||||
msgid "Successfully imported {count} files."
|
||||
msgstr "{count} fichiers importés avec succès."
|
||||
#: authentik/blueprints/v1/tasks.py
|
||||
msgid "Find blueprints as `blueprints_find` does, but return a safe dict."
|
||||
msgstr ""
|
||||
"Cherche les plans comme le fait `blueprints_find`, mais renvoie un safe "
|
||||
"dict."
|
||||
|
||||
#: authentik/blueprints/v1/tasks.py
|
||||
msgid "Find blueprints and check if they need to be created in the database."
|
||||
msgstr ""
|
||||
"Cherche les plans et vérifie s'ils doivent être créés dans la base de "
|
||||
"données."
|
||||
|
||||
#: authentik/blueprints/v1/tasks.py
|
||||
msgid "Apply single blueprint."
|
||||
msgstr "Applique un seul plan."
|
||||
|
||||
#: authentik/blueprints/v1/tasks.py
|
||||
msgid "Remove blueprints which couldn't be fetched."
|
||||
msgstr "Supprime les plans qui n'ont pas pu être récupérés."
|
||||
|
||||
#: authentik/brands/models.py
|
||||
msgid ""
|
||||
@@ -129,10 +148,6 @@ msgstr "Marques"
|
||||
msgid "User does not have access to application."
|
||||
msgstr "L'utilisateur n'a pas accès à l'application."
|
||||
|
||||
#: authentik/core/api/devices.py
|
||||
msgid "Extra description not available"
|
||||
msgstr "Description supplémentaire indisponible"
|
||||
|
||||
#: authentik/core/api/groups.py
|
||||
msgid "Cannot set group as parent of itself."
|
||||
msgstr "Impossible de définir le groupe en tant que parent de lui-même."
|
||||
@@ -379,6 +394,10 @@ msgstr "Jetons"
|
||||
msgid "View token's key"
|
||||
msgstr "Voir la clé du jeton"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Set a token's key"
|
||||
msgstr "Définir la clé d'un jeton"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Property Mapping"
|
||||
msgstr "Mappage de propriété"
|
||||
@@ -434,6 +453,14 @@ msgstr "{source} liée avec succès !"
|
||||
msgid "Source is not configured for enrollment."
|
||||
msgstr "La source n'est pas configurée pour l'inscription."
|
||||
|
||||
#: authentik/core/tasks.py
|
||||
msgid "Remove expired objects."
|
||||
msgstr "Supprime les objets expirés"
|
||||
|
||||
#: authentik/core/tasks.py
|
||||
msgid "Remove temporary users created by SAML Sources."
|
||||
msgstr "Supprime les utilisateurs temporaires créés par les sources SAML."
|
||||
|
||||
#: authentik/core/templates/if/error.html
|
||||
msgid "Go home"
|
||||
msgstr "Retourner à l'accueil"
|
||||
@@ -486,6 +513,12 @@ msgstr "Paire de clé/certificat"
|
||||
msgid "Certificate-Key Pairs"
|
||||
msgstr "Paires de clé/certificat"
|
||||
|
||||
#: authentik/crypto/tasks.py
|
||||
msgid "Discover, import and update certificates from the filesystem."
|
||||
msgstr ""
|
||||
"Découvre, importe et met à jour les certificats depuis le système de "
|
||||
"fichiers."
|
||||
|
||||
#: authentik/enterprise/api.py
|
||||
msgid "Enterprise is required to create/update this object."
|
||||
msgstr "Entreprise est requis pour créer/mettre à jour cet objet."
|
||||
@@ -538,6 +571,18 @@ msgstr "Politiques d'unicité des mots de passe"
|
||||
msgid "User Password History"
|
||||
msgstr "Historique des mots de passe utilisateur"
|
||||
|
||||
#: authentik/enterprise/policies/unique_password/tasks.py
|
||||
msgid ""
|
||||
"Check if any UniquePasswordPolicy exists, and if not, purge the password "
|
||||
"history table."
|
||||
msgstr ""
|
||||
"Vérifie si une politique de mot de passe unique existe et, si ce n'est pas "
|
||||
"le cas, purge la table de l'historique des mots de passe."
|
||||
|
||||
#: authentik/enterprise/policies/unique_password/tasks.py
|
||||
msgid "Remove user password history that are too old."
|
||||
msgstr "Supprime l'historique des mots de passe utilisateur trop anciens."
|
||||
|
||||
#: authentik/enterprise/policy.py
|
||||
msgid "Enterprise required to access this feature."
|
||||
msgstr "Entreprise est requis pour accéder à cette fonctionnalité."
|
||||
@@ -586,6 +631,42 @@ msgstr "Mappage de propriété Google Workspace"
|
||||
msgid "Google Workspace Provider Mappings"
|
||||
msgstr "Mappages de propriété Google Workspace"
|
||||
|
||||
#: authentik/enterprise/providers/google_workspace/tasks.py
|
||||
msgid "Sync Google Workspace provider objects."
|
||||
msgstr "Synchronise les objets du fournisseur Google Workspace."
|
||||
|
||||
#: authentik/enterprise/providers/google_workspace/tasks.py
|
||||
msgid "Full sync for Google Workspace provider."
|
||||
msgstr "Synchronisation complète pour le fournisseur Google Workspace."
|
||||
|
||||
#: authentik/enterprise/providers/google_workspace/tasks.py
|
||||
msgid "Sync a direct object (user, group) for Google Workspace provider."
|
||||
msgstr ""
|
||||
"Synchronise un objet direct (utilisateur, groupe) pour le fournisseur Google"
|
||||
" Workspace."
|
||||
|
||||
#: authentik/enterprise/providers/google_workspace/tasks.py
|
||||
msgid ""
|
||||
"Dispatch syncs for a direct object (user, group) for Google Workspace "
|
||||
"providers."
|
||||
msgstr ""
|
||||
"Déclenche des synchronisations pour un objet direct (utilisateur, groupe) "
|
||||
"pour les fournisseurs Google Workspace."
|
||||
|
||||
#: authentik/enterprise/providers/google_workspace/tasks.py
|
||||
msgid "Sync a related object (memberships) for Google Workspace provider."
|
||||
msgstr ""
|
||||
"Synchronise un objet lié (appartenances) pour le fournisseur Google "
|
||||
"Workspace."
|
||||
|
||||
#: authentik/enterprise/providers/google_workspace/tasks.py
|
||||
msgid ""
|
||||
"Dispatch syncs for a related object (memberships) for Google Workspace "
|
||||
"providers."
|
||||
msgstr ""
|
||||
"Déclenche des synchronisations pour un objet lié (appartenances) pour les "
|
||||
"fournisseurs Google Workspace."
|
||||
|
||||
#: authentik/enterprise/providers/microsoft_entra/models.py
|
||||
msgid "Microsoft Entra Provider User"
|
||||
msgstr "Utilisateur du fournisseur Microsoft Entra"
|
||||
@@ -614,6 +695,42 @@ msgstr "Mappage de propriété Microsoft Entra"
|
||||
msgid "Microsoft Entra Provider Mappings"
|
||||
msgstr "Mappages de propriété Microsoft Entra"
|
||||
|
||||
#: authentik/enterprise/providers/microsoft_entra/tasks.py
|
||||
msgid "Sync Microsoft Entra provider objects."
|
||||
msgstr "Synchronise les objets du fournisseur Microsoft Entra."
|
||||
|
||||
#: authentik/enterprise/providers/microsoft_entra/tasks.py
|
||||
msgid "Full sync for Microsoft Entra provider."
|
||||
msgstr "Synchronisation complète pour le fournisseur Microsoft Entra."
|
||||
|
||||
#: authentik/enterprise/providers/microsoft_entra/tasks.py
|
||||
msgid "Sync a direct object (user, group) for Microsoft Entra provider."
|
||||
msgstr ""
|
||||
"Synchronise un objet direct (utilisateur, groupe) pour le fournisseur "
|
||||
"Microsoft Entra."
|
||||
|
||||
#: authentik/enterprise/providers/microsoft_entra/tasks.py
|
||||
msgid ""
|
||||
"Dispatch syncs for a direct object (user, group) for Microsoft Entra "
|
||||
"providers."
|
||||
msgstr ""
|
||||
"Déclenche les synchronisations pour un objet direct (utilisateur, groupe) "
|
||||
"pour les fournisseurs Microsoft Entra."
|
||||
|
||||
#: authentik/enterprise/providers/microsoft_entra/tasks.py
|
||||
msgid "Sync a related object (memberships) for Microsoft Entra provider."
|
||||
msgstr ""
|
||||
"Synchronise un objet lié (appartenances) pour le fournisseur Microsoft "
|
||||
"Entra."
|
||||
|
||||
#: authentik/enterprise/providers/microsoft_entra/tasks.py
|
||||
msgid ""
|
||||
"Dispatch syncs for a related object (memberships) for Microsoft Entra "
|
||||
"providers."
|
||||
msgstr ""
|
||||
"Déclenche des synchronisations pour un objet lié (appartenances) pour les "
|
||||
"fournisseurs Microsoft Entra."
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "Signing Key"
|
||||
@@ -652,8 +769,12 @@ msgid "SSF Stream Events"
|
||||
msgstr "Évènements du flux SSF"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/tasks.py
|
||||
msgid "Failed to send request"
|
||||
msgstr "Échec de l'envoi de la requête"
|
||||
msgid "Dispatch SSF events."
|
||||
msgstr "Distribue les événements SSF."
|
||||
|
||||
#: authentik/enterprise/providers/ssf/tasks.py
|
||||
msgid "Send an SSF event."
|
||||
msgstr "Envoye un événement SSF."
|
||||
|
||||
#: authentik/enterprise/stages/authenticator_endpoint_gdtc/models.py
|
||||
msgid "Endpoint Authenticator Google Device Trust Connector Stage"
|
||||
@@ -725,10 +846,9 @@ msgstr "Étape Source"
|
||||
msgid "Source Stages"
|
||||
msgstr "Étapes Source"
|
||||
|
||||
#: authentik/events/api/tasks.py
|
||||
#, python-brace-format
|
||||
msgid "Successfully started task {name}."
|
||||
msgstr "La tâche {name} a été démarrée avec succès."
|
||||
#: authentik/enterprise/tasks.py
|
||||
msgid "Update enterprise license status."
|
||||
msgstr "Mettre à jour le statut de licence entreprise."
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid "Event"
|
||||
@@ -840,6 +960,15 @@ msgstr ""
|
||||
"Définir à quel groupe d'utilisateur cette notification doit être envoyée et "
|
||||
"affichée. Si laissé vide, les notifications ne seront pas envoyées."
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid ""
|
||||
"When enabled, notification will be sent to user the user that triggered the "
|
||||
"event.When destination_group is configured, notification is sent to both."
|
||||
msgstr ""
|
||||
"Lorsque cette option est activée, une notification est envoyée à "
|
||||
"l'utilisateur qui a déclenché l'événement. Si destination_group est "
|
||||
"configuré, la notification est envoyée aux deux."
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid "Notification Rule"
|
||||
msgstr "Règle de Notification"
|
||||
@@ -856,10 +985,6 @@ msgstr "Mappage de Webhook"
|
||||
msgid "Webhook Mappings"
|
||||
msgstr "Mappages de Webhook"
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid "Run task"
|
||||
msgstr "Lancer la tâche"
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid "System Task"
|
||||
msgstr "Tâches du système"
|
||||
@@ -868,9 +993,31 @@ msgstr "Tâches du système"
|
||||
msgid "System Tasks"
|
||||
msgstr "Tâches du système"
|
||||
|
||||
#: authentik/events/system_tasks.py
|
||||
msgid "Task has not been run yet."
|
||||
msgstr "Tâche pas encore exécutée."
|
||||
#: authentik/events/tasks.py
|
||||
msgid "Dispatch new event notifications."
|
||||
msgstr "Envoye les notifications d'un nouvel événement."
|
||||
|
||||
#: authentik/events/tasks.py
|
||||
msgid ""
|
||||
"Check if policies attached to NotificationRule match event and dispatch "
|
||||
"notification tasks."
|
||||
msgstr ""
|
||||
"Vérifier si les politiques attachées à une règle de notifications "
|
||||
"correspondent à l'événement et déclenche les tâches de notification."
|
||||
|
||||
#: authentik/events/tasks.py
|
||||
msgid "Send notification."
|
||||
msgstr "Envoye une notification."
|
||||
|
||||
#: authentik/events/tasks.py
|
||||
msgid "Cleanup events for GDPR compliance."
|
||||
msgstr "Nettoye les événements pour la conformité au RGPD."
|
||||
|
||||
#: authentik/events/tasks.py
|
||||
msgid "Cleanup seen notifications and notifications whose event expired."
|
||||
msgstr ""
|
||||
"Nettoye les notifications vues et les notifications dont l'événement a "
|
||||
"expiré."
|
||||
|
||||
#: authentik/flows/api/flows.py
|
||||
#, python-brace-format
|
||||
@@ -1051,32 +1198,6 @@ msgstr ""
|
||||
"Si activé, le fournisseur ne changera ou ne créera pas d'objets auprès du "
|
||||
"système distant."
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
msgid "Starting full provider sync"
|
||||
msgstr "Démarrage d'une synchronisation complète du fournisseur"
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
msgid "Syncing users"
|
||||
msgstr "Synchronisation des utilisateurs"
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
msgid "Syncing groups"
|
||||
msgstr "Synchronisation des groupes"
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
#, python-brace-format
|
||||
msgid "Syncing page {page} of {object_type}"
|
||||
msgstr "Synchronisation de la page {page} de {object_type}"
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
msgid "Dropping mutating request due to dry run"
|
||||
msgstr "Abandon de la requête de mutation en raison d'une simulation"
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
#, python-brace-format
|
||||
msgid "Stopping sync due to error: {error}"
|
||||
msgstr "Arrêt de la synchronisation due à l'erreur : {error}"
|
||||
|
||||
#: authentik/lib/utils/time.py
|
||||
#, python-format
|
||||
msgid "%(value)s is not in the correct format of 'hours=3;minutes=1'."
|
||||
@@ -1183,6 +1304,32 @@ msgstr "Avant-poste"
|
||||
msgid "Outposts"
|
||||
msgstr "Avant-postes"
|
||||
|
||||
#: authentik/outposts/tasks.py
|
||||
msgid "Update cached state of service connection."
|
||||
msgstr "Met à jour l'état mis en cache de la connexion de service."
|
||||
|
||||
#: authentik/outposts/tasks.py
|
||||
msgid "Create/update/monitor/delete the deployment of an Outpost."
|
||||
msgstr "Crée/met à jour/surveille/supprime le déploiement d'un avant-poste."
|
||||
|
||||
#: authentik/outposts/tasks.py
|
||||
msgid "Ensure that all Outposts have valid Service Accounts and Tokens."
|
||||
msgstr ""
|
||||
"S'assure que tous les avant-postes ont des comptes de service et des jetons "
|
||||
"valides."
|
||||
|
||||
#: authentik/outposts/tasks.py
|
||||
msgid "Send update to outpost"
|
||||
msgstr "Envoye une mise à jour à un avant-poste"
|
||||
|
||||
#: authentik/outposts/tasks.py
|
||||
msgid "Checks the local environment and create Service connections."
|
||||
msgstr "Vérifie l'environnement local et crée les connexions de service."
|
||||
|
||||
#: authentik/outposts/tasks.py
|
||||
msgid "Terminate session on all outposts."
|
||||
msgstr "Met fin à la session sur tous les avant-postes."
|
||||
|
||||
#: authentik/policies/denied.py
|
||||
msgid "Access denied"
|
||||
msgstr "Accès refusé"
|
||||
@@ -1517,29 +1664,29 @@ msgstr "Rechercher dans l'annuaire LDAP complet"
|
||||
msgid "Invalid Regex Pattern: {url}"
|
||||
msgstr "Pattern de regex invalide : {url}"
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid "Based on the Hashed User ID"
|
||||
msgstr "Basé sur le hash de l'ID utilisateur"
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid "Based on user ID"
|
||||
msgstr "Basé sur l'ID de l'utilisateur"
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid "Based on user UUID"
|
||||
msgstr "Basé sur le UUID de l'utilisateur"
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid "Based on the username"
|
||||
msgstr "Basé sur le nom d'utilisateur"
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid "Based on the User's Email. This is recommended over the UPN method."
|
||||
msgstr ""
|
||||
"Basé sur le courriel utilisateur. Ceci est recommandé par rapport à la "
|
||||
"méthode UPN."
|
||||
|
||||
#: authentik/providers/oauth2/id_token.py
|
||||
#: authentik/providers/oauth2/constants.py
|
||||
msgid ""
|
||||
"Based on the User's UPN, only works if user has a 'upn' attribute set. Use "
|
||||
"this method only if you have different UPN and Mail domains."
|
||||
@@ -1662,6 +1809,10 @@ msgstr "Secret du client"
|
||||
msgid "Redirect URIs"
|
||||
msgstr "URIs de redirection"
|
||||
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "Back-Channel Logout URI"
|
||||
msgstr "URI de déconnexion Back-Channel"
|
||||
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "Include claims in id_token"
|
||||
msgstr "Include les demandes utilisateurs dans id_token"
|
||||
@@ -1790,6 +1941,15 @@ msgstr "Jeton d'équipement"
|
||||
msgid "Device Tokens"
|
||||
msgstr "Jetons d'équipement"
|
||||
|
||||
#: authentik/providers/oauth2/tasks.py
|
||||
msgid "Send a back-channel logout request to the registered client"
|
||||
msgstr "Envoyer une requête de déconnexion Back-Channel au client enregistré"
|
||||
|
||||
#: authentik/providers/oauth2/tasks.py
|
||||
msgid "Handle backchannel logout notifications dispatched via signal"
|
||||
msgstr ""
|
||||
"Gérer les notifications de déconnexion Back-Channel envoyées via un signal"
|
||||
|
||||
#: authentik/providers/oauth2/views/authorize.py
|
||||
#: authentik/providers/saml/views/flows.py
|
||||
#, python-brace-format
|
||||
@@ -1901,6 +2061,10 @@ msgstr "Fournisseur Proxy"
|
||||
msgid "Proxy Providers"
|
||||
msgstr "Fournisseur de Proxy"
|
||||
|
||||
#: authentik/providers/proxy/tasks.py
|
||||
msgid "Terminate session on Proxy outpost."
|
||||
msgstr "Met fin à la session sur l'avant-poste Proxy."
|
||||
|
||||
#: authentik/providers/rac/models.py authentik/stages/user_login/models.py
|
||||
msgid ""
|
||||
"Determines how long a session lasts. Default of 0 means that the sessions "
|
||||
@@ -2245,6 +2409,35 @@ msgstr "Mappage fournisseur SCIM"
|
||||
msgid "SCIM Provider Mappings"
|
||||
msgstr "Mappages fournisseur SCIM"
|
||||
|
||||
#: authentik/providers/scim/tasks.py
|
||||
msgid "Sync SCIM provider objects."
|
||||
msgstr "Synchronise les objets du fournisseur SCIM."
|
||||
|
||||
#: authentik/providers/scim/tasks.py
|
||||
msgid "Full sync for SCIM provider."
|
||||
msgstr "Synchronisation complète pour le fournisseur SCIM."
|
||||
|
||||
#: authentik/providers/scim/tasks.py
|
||||
msgid "Sync a direct object (user, group) for SCIM provider."
|
||||
msgstr ""
|
||||
"Synchronise un objet direct (utilisateur, groupe) pour le fournisseur SCIM."
|
||||
|
||||
#: authentik/providers/scim/tasks.py
|
||||
msgid "Dispatch syncs for a direct object (user, group) for SCIM providers."
|
||||
msgstr ""
|
||||
"Déclenche les synchronisations pour un objet direct (utilisateur, groupe) "
|
||||
"pour les fournisseurs SCIM."
|
||||
|
||||
#: authentik/providers/scim/tasks.py
|
||||
msgid "Sync a related object (memberships) for SCIM provider."
|
||||
msgstr "Synchronise un objet lié (appartenances) pour le fournisseur SCIM."
|
||||
|
||||
#: authentik/providers/scim/tasks.py
|
||||
msgid "Dispatch syncs for a related object (memberships) for SCIM providers."
|
||||
msgstr ""
|
||||
"Déclenche des synchronisations pour un objet lié (appartenances) pour les "
|
||||
"fournisseurs SCIM."
|
||||
|
||||
#: authentik/rbac/models.py
|
||||
msgid "Role"
|
||||
msgstr "Rôle"
|
||||
@@ -2399,6 +2592,14 @@ msgstr "Connexion du groupe à la source Kerberos"
|
||||
msgid "Group Kerberos Source Connections"
|
||||
msgstr "Connexions du groupe à la source Kerberos"
|
||||
|
||||
#: authentik/sources/kerberos/tasks.py
|
||||
msgid "Check connectivity for Kerberos sources."
|
||||
msgstr "Vérifie la connectivité des sources Kerberos."
|
||||
|
||||
#: authentik/sources/kerberos/tasks.py
|
||||
msgid "Sync Kerberos source."
|
||||
msgstr "Synchronise la source Kerberos."
|
||||
|
||||
#: authentik/sources/kerberos/views.py
|
||||
msgid "SPNEGO authentication required"
|
||||
msgstr "Authentification SPNEGO requise"
|
||||
@@ -2566,6 +2767,18 @@ msgstr "Connexions du groupe à la source LDAP"
|
||||
msgid "Password does not match Active Directory Complexity."
|
||||
msgstr "Le mot de passe ne correspond pas à la complexité d'Active Directory."
|
||||
|
||||
#: authentik/sources/ldap/tasks.py
|
||||
msgid "Check connectivity for LDAP source."
|
||||
msgstr "Vérifie la connectivité des sources LDAP."
|
||||
|
||||
#: authentik/sources/ldap/tasks.py
|
||||
msgid "Sync LDAP source."
|
||||
msgstr "Synchronise la source LDAP."
|
||||
|
||||
#: authentik/sources/ldap/tasks.py
|
||||
msgid "Sync page for LDAP source."
|
||||
msgstr "Synchronise une page pour la source LDAP."
|
||||
|
||||
#: authentik/sources/oauth/clients/oauth2.py
|
||||
msgid "No token received."
|
||||
msgstr "Pas de jeton reçu."
|
||||
@@ -2715,6 +2928,14 @@ msgstr "Source d'OAuth Azure AD"
|
||||
msgid "Azure AD OAuth Sources"
|
||||
msgstr "Source d'OAuth Azure AD"
|
||||
|
||||
#: authentik/sources/oauth/models.py
|
||||
msgid "Entra ID OAuth Source"
|
||||
msgstr "Source d'OAuth Entra ID"
|
||||
|
||||
#: authentik/sources/oauth/models.py
|
||||
msgid "Entra ID OAuth Sources"
|
||||
msgstr "Sources d'OAuth Entra ID"
|
||||
|
||||
#: authentik/sources/oauth/models.py
|
||||
msgid "OpenID OAuth Source"
|
||||
msgstr "Source d'OAuth OpenID"
|
||||
@@ -2771,6 +2992,14 @@ msgstr "Connexion du groupe à la source OAuth"
|
||||
msgid "Group OAuth Source Connections"
|
||||
msgstr "Connexions du groupe à la source OAuth"
|
||||
|
||||
#: authentik/sources/oauth/tasks.py
|
||||
msgid ""
|
||||
"Update OAuth sources' config from well_known, and JWKS info from the "
|
||||
"configured URL."
|
||||
msgstr ""
|
||||
"Met à jour la configuration des sources OAuth à partir de well_known, et les"
|
||||
" informations JWKS à partir de l'URL configurée."
|
||||
|
||||
#: authentik/sources/oauth/views/callback.py
|
||||
#, python-brace-format
|
||||
msgid "Authentication failed: {reason}"
|
||||
@@ -2829,6 +3058,10 @@ msgstr "Connexion du groupe à la source Plex"
|
||||
msgid "Group Plex Source Connections"
|
||||
msgstr "Connexions du groupe à la source OAuth"
|
||||
|
||||
#: authentik/sources/plex/tasks.py
|
||||
msgid "Check the validity of a Plex source."
|
||||
msgstr "Vérifie la validité d'une source Plex."
|
||||
|
||||
#: authentik/sources/saml/models.py
|
||||
msgid "Redirect Binding"
|
||||
msgstr "Liaison de Redirection"
|
||||
@@ -3273,6 +3506,13 @@ msgstr "Type d'appareil WebAuthn"
|
||||
msgid "WebAuthn Device types"
|
||||
msgstr "Types d'appareil WebAuthn"
|
||||
|
||||
#: authentik/stages/authenticator_webauthn/tasks.py
|
||||
msgid ""
|
||||
"Background task to import FIDO Alliance MDS blob and AAGUIDs into database."
|
||||
msgstr ""
|
||||
"Tâche de fond pour importer le blob MDS de la FIDO Alliance et les AAGUID "
|
||||
"dans la base de données."
|
||||
|
||||
#: authentik/stages/captcha/models.py
|
||||
msgid "Public key, acquired your captcha Provider."
|
||||
msgstr "Clé publique, acquise auprès de votre fournisseur captcha."
|
||||
@@ -3371,6 +3611,17 @@ msgstr "Réinitialiser le Mot de Passe"
|
||||
msgid "Account Confirmation"
|
||||
msgstr "Confirmation du Compte"
|
||||
|
||||
#: authentik/stages/email/models.py
|
||||
msgid ""
|
||||
"The time window used to count recent account recovery attempts. If the "
|
||||
"number of attempts exceed recovery_max_attempts within this period, further "
|
||||
"attempts will be rate-limited. (Format: hours=1;minutes=2;seconds=3)."
|
||||
msgstr ""
|
||||
"La fenêtre de temps utilisée pour compter les tentatives récentes de "
|
||||
"récupération de compte. Si le nombre de tentatives dépasse "
|
||||
"recovery_max_attempts au cours de cette période, les tentatives "
|
||||
"supplémentaires seront limitées. (Format : hours=1;minutes=2;seconds=3)."
|
||||
|
||||
#: authentik/stages/email/models.py
|
||||
msgid "Activate users upon completion of stage."
|
||||
msgstr "Activer les utilisateurs à la complétion de l'étape."
|
||||
@@ -3395,10 +3646,23 @@ msgstr "Pas d'utilisateurs en attente."
|
||||
msgid "Email sent."
|
||||
msgstr "Email envoyé."
|
||||
|
||||
#: authentik/stages/email/stage.py
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Too many account verification attempts. Please try again after {minutes} "
|
||||
"minutes."
|
||||
msgstr ""
|
||||
"Trop de tentatives de vérification de compte. Veuillez réessayer après "
|
||||
"{minutes} minutes."
|
||||
|
||||
#: authentik/stages/email/stage.py
|
||||
msgid "Email Successfully sent."
|
||||
msgstr "Couriel envoyé avec succès."
|
||||
|
||||
#: authentik/stages/email/tasks.py
|
||||
msgid "Send email."
|
||||
msgstr "Envoye un courriel."
|
||||
|
||||
#: authentik/stages/email/templates/email/account_confirmation.html
|
||||
#: authentik/stages/email/templates/email/account_confirmation.txt
|
||||
msgid "Welcome!"
|
||||
@@ -3867,6 +4131,16 @@ msgstr ""
|
||||
"souvenir de moi ne sera pas proposée. (Format: "
|
||||
"hours=-1;minutes=-2;seconds=-3)"
|
||||
|
||||
#: authentik/stages/user_login/models.py
|
||||
msgid ""
|
||||
"When set to a non-zero value, authentik will save a cookie with a longer "
|
||||
"expiry,to remember the device the user is logging in from. (Format: "
|
||||
"hours=-1;minutes=-2;seconds=-3)"
|
||||
msgstr ""
|
||||
"Si cette valeur est différente de zéro, authentik enregistrera un cookie "
|
||||
"avec une expiration plus longue, afin de se souvenir de l'appareil à partir "
|
||||
"duquel l'utilisateur se connecte. (Format : hours=-1;minutes=-2;seconds=-3)"
|
||||
|
||||
#: authentik/stages/user_login/models.py
|
||||
msgid "User Login Stage"
|
||||
msgstr "Étape de connexion utlisateur"
|
||||
@@ -3918,6 +4192,38 @@ msgid "Failed to update user. Please try again later."
|
||||
msgstr ""
|
||||
"Échec de mise à jour de l'utilisateur. Merci de réessayer ultérieurement,"
|
||||
|
||||
#: authentik/tasks/models.py
|
||||
msgid "Tenant this task belongs to"
|
||||
msgstr "Tenant auquel cette tâche appartient"
|
||||
|
||||
#: authentik/tasks/models.py
|
||||
msgid "Retry failed task"
|
||||
msgstr "Relancer la tâche échouée"
|
||||
|
||||
#: authentik/tasks/models.py
|
||||
msgid "Worker status"
|
||||
msgstr "État du worker"
|
||||
|
||||
#: authentik/tasks/models.py
|
||||
msgid "Worker statuses"
|
||||
msgstr "États du worker"
|
||||
|
||||
#: authentik/tasks/schedules/models.py
|
||||
msgid "Unique schedule identifier"
|
||||
msgstr "Identifiant unique des planifications"
|
||||
|
||||
#: authentik/tasks/schedules/models.py
|
||||
msgid "User schedule identifier"
|
||||
msgstr "Identifiant utilisateur des planifications"
|
||||
|
||||
#: authentik/tasks/schedules/models.py
|
||||
msgid "Manually trigger a schedule"
|
||||
msgstr "Déclencher manuellement une planification"
|
||||
|
||||
#: authentik/tasks/tasks.py
|
||||
msgid "Remove old worker statuses."
|
||||
msgstr "Supprime les anciens statuts des workers."
|
||||
|
||||
#: authentik/tenants/models.py
|
||||
msgid ""
|
||||
"Schema name must start with t_, only contain lowercase letters and numbers "
|
||||
@@ -4010,3 +4316,76 @@ msgstr "Domaine"
|
||||
#: authentik/tenants/models.py
|
||||
msgid "Domains"
|
||||
msgstr "Domaines"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Queue name"
|
||||
msgstr "Nom de la file"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Dramatiq actor name"
|
||||
msgstr "Nom de l'acteur Dramatiq"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Message body"
|
||||
msgstr "Corps du message"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Task status"
|
||||
msgstr "État de la tâche"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Task last modified time"
|
||||
msgstr "Heure de dernière modification de la tâche"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Task result"
|
||||
msgstr "Résultat de la tâche"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Result expiry time"
|
||||
msgstr "Délai d'expiration du résultat"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Task"
|
||||
msgstr "Tâche"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Tasks"
|
||||
msgstr "Tâches"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
#, python-format
|
||||
msgid "%(value)s is not a valid crontab"
|
||||
msgstr "%(value)s n'est pas un crontab valide"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Dramatiq actor to call"
|
||||
msgstr "Acteur Dramatiq à invoquer"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Args to send to the actor"
|
||||
msgstr "Args à passer à l'acteur"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Kwargs to send to the actor"
|
||||
msgstr "Kwargs à passer à l'acteur"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Options to send to the actor"
|
||||
msgstr "Options à passer à l'acteur"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "When to schedule tasks"
|
||||
msgstr "Quand planifier les tâches"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Pause this schedule"
|
||||
msgstr "Mettre cette planification en pause"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Schedule"
|
||||
msgstr "Planification"
|
||||
|
||||
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
|
||||
msgid "Schedules"
|
||||
msgstr "Planifications"
|
||||
|
||||
@@ -186,19 +186,19 @@ class MetricsMiddleware(Middleware):
|
||||
"The total number of dead-lettered tasks.",
|
||||
self.labels,
|
||||
)
|
||||
self.inprogress_messages = Gauge(
|
||||
f"{self.prefix}_tasks_inprogress",
|
||||
self.in_progress_messages = Gauge(
|
||||
f"{self.prefix}_tasks_in_progress",
|
||||
"The number of tasks in progress.",
|
||||
self.labels,
|
||||
multiprocess_mode="livesum",
|
||||
)
|
||||
self.inprogress_delayed_messages = Gauge(
|
||||
f"{self.prefix}_tasks_delayed_inprogress",
|
||||
self.in_progress_delayed_messages = Gauge(
|
||||
f"{self.prefix}_tasks_delayed_in_progress",
|
||||
"The number of delayed tasks in memory.",
|
||||
self.labels,
|
||||
)
|
||||
self.messages_durations = Histogram(
|
||||
f"{self.prefix}_tasks_duration_miliseconds",
|
||||
f"{self.prefix}_tasks_duration_milliseconds",
|
||||
"The time spent processing tasks.",
|
||||
self.labels,
|
||||
buckets=(
|
||||
@@ -244,15 +244,15 @@ class MetricsMiddleware(Middleware):
|
||||
|
||||
def before_delay_message(self, broker: Broker, message: Message):
|
||||
self.delayed_messages.add(message.message_id)
|
||||
self.inprogress_delayed_messages.labels(*self._make_labels(message)).inc()
|
||||
self.in_progress_delayed_messages.labels(*self._make_labels(message)).inc()
|
||||
|
||||
def before_process_message(self, broker: Broker, message: Message):
|
||||
labels = self._make_labels(message)
|
||||
if message.message_id in self.delayed_messages:
|
||||
self.delayed_messages.remove(message.message_id)
|
||||
self.inprogress_delayed_messages.labels(*labels).dec()
|
||||
self.in_progress_delayed_messages.labels(*labels).dec()
|
||||
|
||||
self.inprogress_messages.labels(*labels).inc()
|
||||
self.in_progress_messages.labels(*labels).inc()
|
||||
self.message_start_times[message.message_id] = current_millis()
|
||||
|
||||
def after_process_message(
|
||||
@@ -269,7 +269,7 @@ class MetricsMiddleware(Middleware):
|
||||
message_duration = current_millis() - message_start_time
|
||||
self.messages_durations.labels(*labels).observe(message_duration)
|
||||
|
||||
self.inprogress_messages.labels(*labels).dec()
|
||||
self.in_progress_messages.labels(*labels).dec()
|
||||
self.total_messages.labels(*labels).inc()
|
||||
if exception is not None:
|
||||
self.total_errored_messages.labels(*labels).inc()
|
||||
|
||||
35
packages/docusaurus-config/package-lock.json
generated
35
packages/docusaurus-config/package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"prism-react-renderer": "^2.4.1"
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/theme-common": "^3.8.1",
|
||||
@@ -34,8 +35,7 @@
|
||||
"@docusaurus/theme-common": "^3.8.1",
|
||||
"@docusaurus/theme-search-algolia": "^3.8.1",
|
||||
"@docusaurus/types": "^3.8.0",
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
"react": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@docusaurus/theme-search-algolia": {
|
||||
@@ -43,9 +43,6 @@
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4656,9 +4653,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
|
||||
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
||||
"version": "19.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
|
||||
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -15821,25 +15818,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
"react": "^19.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
@@ -17958,9 +17955,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -661,16 +661,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@gerrit0/mini-shiki": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.7.0.tgz",
|
||||
"integrity": "sha512-7iY9wg4FWXmeoFJpUL2u+tsmh0d0jcEJHAIzVxl3TG4KL493JNnisdLAILZ77zcD+z3J0keEXZ+lFzUgzQzPDg==",
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.9.2.tgz",
|
||||
"integrity": "sha512-Tvsj+AOO4Z8xLRJK900WkyfxHsZQu+Zm1//oT1w443PO6RiYMoq/4NGOhaNuZoUMYsjKIAPVQ6eOFMddj6yphQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/engine-oniguruma": "^3.7.0",
|
||||
"@shikijs/langs": "^3.7.0",
|
||||
"@shikijs/themes": "^3.7.0",
|
||||
"@shikijs/types": "^3.7.0",
|
||||
"@shikijs/engine-oniguruma": "^3.9.2",
|
||||
"@shikijs/langs": "^3.9.2",
|
||||
"@shikijs/themes": "^3.9.2",
|
||||
"@shikijs/types": "^3.9.2",
|
||||
"@shikijs/vscode-textmate": "^10.0.2"
|
||||
}
|
||||
},
|
||||
@@ -845,40 +845,40 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/engine-oniguruma": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.7.0.tgz",
|
||||
"integrity": "sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw==",
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.9.2.tgz",
|
||||
"integrity": "sha512-Vn/w5oyQ6TUgTVDIC/BrpXwIlfK6V6kGWDVVz2eRkF2v13YoENUvaNwxMsQU/t6oCuZKzqp9vqtEtEzKl9VegA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "3.7.0",
|
||||
"@shikijs/types": "3.9.2",
|
||||
"@shikijs/vscode-textmate": "^10.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/langs": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.7.0.tgz",
|
||||
"integrity": "sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ==",
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.9.2.tgz",
|
||||
"integrity": "sha512-X1Q6wRRQXY7HqAuX3I8WjMscjeGjqXCg/Sve7J2GWFORXkSrXud23UECqTBIdCSNKJioFtmUGJQNKtlMMZMn0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "3.7.0"
|
||||
"@shikijs/types": "3.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/themes": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.7.0.tgz",
|
||||
"integrity": "sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ==",
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.9.2.tgz",
|
||||
"integrity": "sha512-6z5lBPBMRfLyyEsgf6uJDHPa6NAGVzFJqH4EAZ+03+7sedYir2yJBRu2uPZOKmj43GyhVHWHvyduLDAwJQfDjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@shikijs/types": "3.7.0"
|
||||
"@shikijs/types": "3.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@shikijs/types": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.7.0.tgz",
|
||||
"integrity": "sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg==",
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.9.2.tgz",
|
||||
"integrity": "sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -904,13 +904,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
|
||||
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
|
||||
"version": "24.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz",
|
||||
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
@@ -2668,9 +2668,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz",
|
||||
"integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==",
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
|
||||
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2704,13 +2704,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typedoc": {
|
||||
"version": "0.28.8",
|
||||
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.8.tgz",
|
||||
"integrity": "sha512-16GfLopc8icHfdvqZDqdGBoS2AieIRP2rpf9mU+MgN+gGLyEQvAO0QgOa6NJ5QNmQi0LFrDY9in4F2fUNKgJKA==",
|
||||
"version": "0.28.9",
|
||||
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.9.tgz",
|
||||
"integrity": "sha512-aw45vwtwOl3QkUAmWCnLV9QW1xY+FSX2zzlit4MAfE99wX+Jij4ycnpbAWgBXsRrxmfs9LaYktg/eX5Bpthd3g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@gerrit0/mini-shiki": "^3.7.0",
|
||||
"@gerrit0/mini-shiki": "^3.9.0",
|
||||
"lunr": "^2.3.9",
|
||||
"markdown-it": "^14.1.0",
|
||||
"minimatch": "^9.0.5",
|
||||
@@ -2724,13 +2724,13 @@
|
||||
"pnpm": ">= 10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x"
|
||||
"typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x"
|
||||
}
|
||||
},
|
||||
"node_modules/typedoc-plugin-markdown": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.7.1.tgz",
|
||||
"integrity": "sha512-HN/fHLm2S6MD4HX8txfB4eWvVBzX/mEYy5U5s1KTAdh3E5uX5/lilswqTzZlPTT6fNZInAboAdFGpbAuBKnE4A==",
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.8.0.tgz",
|
||||
"integrity": "sha512-BQqXnT9PETe6WEFf8bcsvvGEGQHbwTo/BFyY+RUIsSB05Y0Wn56iF+fK1PY2OKJJIhV4kp4dp7osaP9Bm5a0Zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2762,9 +2762,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
144
packages/eslint-config/package-lock.json
generated
144
packages/eslint-config/package-lock.json
generated
@@ -501,17 +501,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz",
|
||||
"integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz",
|
||||
"integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.38.0",
|
||||
"@typescript-eslint/type-utils": "8.38.0",
|
||||
"@typescript-eslint/utils": "8.38.0",
|
||||
"@typescript-eslint/visitor-keys": "8.38.0",
|
||||
"@typescript-eslint/scope-manager": "8.39.0",
|
||||
"@typescript-eslint/type-utils": "8.39.0",
|
||||
"@typescript-eslint/utils": "8.39.0",
|
||||
"@typescript-eslint/visitor-keys": "8.39.0",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
@@ -525,9 +525,9 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.39.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
@@ -541,16 +541,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz",
|
||||
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.0.tgz",
|
||||
"integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.38.0",
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/typescript-estree": "8.38.0",
|
||||
"@typescript-eslint/visitor-keys": "8.38.0",
|
||||
"@typescript-eslint/scope-manager": "8.39.0",
|
||||
"@typescript-eslint/types": "8.39.0",
|
||||
"@typescript-eslint/typescript-estree": "8.39.0",
|
||||
"@typescript-eslint/visitor-keys": "8.39.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -562,18 +562,18 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz",
|
||||
"integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz",
|
||||
"integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.38.0",
|
||||
"@typescript-eslint/types": "^8.38.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.39.0",
|
||||
"@typescript-eslint/types": "^8.39.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -584,18 +584,18 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz",
|
||||
"integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz",
|
||||
"integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/visitor-keys": "8.38.0"
|
||||
"@typescript-eslint/types": "8.39.0",
|
||||
"@typescript-eslint/visitor-keys": "8.39.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -606,9 +606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz",
|
||||
"integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz",
|
||||
"integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -619,19 +619,19 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz",
|
||||
"integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz",
|
||||
"integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/typescript-estree": "8.38.0",
|
||||
"@typescript-eslint/utils": "8.38.0",
|
||||
"@typescript-eslint/types": "8.39.0",
|
||||
"@typescript-eslint/typescript-estree": "8.39.0",
|
||||
"@typescript-eslint/utils": "8.39.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
@@ -644,13 +644,13 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz",
|
||||
"integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz",
|
||||
"integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -662,16 +662,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz",
|
||||
"integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz",
|
||||
"integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.38.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.38.0",
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/visitor-keys": "8.38.0",
|
||||
"@typescript-eslint/project-service": "8.39.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.39.0",
|
||||
"@typescript-eslint/types": "8.39.0",
|
||||
"@typescript-eslint/visitor-keys": "8.39.0",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -687,7 +687,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
@@ -730,16 +730,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz",
|
||||
"integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz",
|
||||
"integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.38.0",
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/typescript-estree": "8.38.0"
|
||||
"@typescript-eslint/scope-manager": "8.39.0",
|
||||
"@typescript-eslint/types": "8.39.0",
|
||||
"@typescript-eslint/typescript-estree": "8.39.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -750,17 +750,17 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz",
|
||||
"integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz",
|
||||
"integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.38.0",
|
||||
"@typescript-eslint/types": "8.39.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4694,9 +4694,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -4708,16 +4708,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.38.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz",
|
||||
"integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==",
|
||||
"version": "8.39.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.39.0.tgz",
|
||||
"integrity": "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.38.0",
|
||||
"@typescript-eslint/parser": "8.38.0",
|
||||
"@typescript-eslint/typescript-estree": "8.38.0",
|
||||
"@typescript-eslint/utils": "8.38.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.39.0",
|
||||
"@typescript-eslint/parser": "8.39.0",
|
||||
"@typescript-eslint/typescript-estree": "8.39.0",
|
||||
"@typescript-eslint/utils": "8.39.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -4728,7 +4728,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user