mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-31 11:11:37 -07:00
Merge branch 'main' into freethreading-explorations
This commit is contained in:
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1 +1,2 @@
|
||||
custom: https://goauthentik.io/pricing/
|
||||
github: goauthentik
|
||||
|
||||
4
.github/actions/setup/action.yml
vendored
4
.github/actions/setup/action.yml
vendored
@@ -37,7 +37,7 @@ runs:
|
||||
sudo rsync -a --delete /tmp/empty/ /usr/local/lib/android/
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v5
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Setup python
|
||||
@@ -64,7 +64,7 @@ runs:
|
||||
rustflags: ""
|
||||
- name: Setup rust dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'rust') }}
|
||||
uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2
|
||||
uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2
|
||||
with:
|
||||
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
|
||||
- name: Setup pnpm
|
||||
|
||||
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
@@ -20,6 +20,13 @@ updates:
|
||||
- dependencies
|
||||
cooldown:
|
||||
default-days: 3
|
||||
groups:
|
||||
codeql:
|
||||
patterns:
|
||||
- "github/codeql-action/*"
|
||||
regclient:
|
||||
patterns:
|
||||
- "regclient/actions//*"
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@@ -24,3 +24,4 @@ Use `closes #N` to auto-close an issue on merge. Use `refs #N` for related issue
|
||||
|
||||
- [ ] The project has been linted, built, and tested (`make all`)
|
||||
- [ ] The documentation has been updated and formatted (`make docs`)
|
||||
- [ ] I have read the [AI usage policy](https://github.com/goauthentik/authentik/blob/main/AI_POLICY.md).
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
with:
|
||||
ref: "${{ inputs.ref }}"
|
||||
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- if: "${{ inputs.should-cache }}"
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
|
||||
168
.github/workflows/_reusable-container-build.yml
vendored
168
.github/workflows/_reusable-container-build.yml
vendored
@@ -42,6 +42,7 @@ jobs:
|
||||
image-build-args: "${{ inputs.image-build-args }}"
|
||||
should-cache: "${{ inputs.should-cache }}"
|
||||
cache-suffix: "${{ inputs.cache-suffix }}"
|
||||
|
||||
build-arm64:
|
||||
uses: ./.github/workflows/_reusable-container-build-single.yml
|
||||
secrets: inherit
|
||||
@@ -54,6 +55,7 @@ jobs:
|
||||
image-build-args: "${{ inputs.image-build-args }}"
|
||||
should-cache: "${{ inputs.should-cache }}"
|
||||
cache-suffix: "${{ inputs.cache-suffix }}"
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
@@ -65,22 +67,172 @@ jobs:
|
||||
with:
|
||||
artifact-ids: "${{ needs.build-amd64.outputs.artifact-id }},${{ needs.build-arm64.outputs.artifact-id }}"
|
||||
merge-multiple: true
|
||||
- name: merge
|
||||
|
||||
- name: Merge and flatten architecture indexes
|
||||
shell: bash
|
||||
run: |
|
||||
regctl image import ocidir://${{ inputs.image-name }}-amd64:build container/${{ inputs.image-name }}-amd64.oci.tar
|
||||
regctl image import ocidir://${{ inputs.image-name }}-arm64:build container/${{ inputs.image-name }}-arm64.oci.tar
|
||||
set -euo pipefail
|
||||
|
||||
regctl index create ocidir://${{ inputs.image-name }}:build
|
||||
image_name="${{ inputs.image-name }}"
|
||||
target="ocidir://${image_name}:build"
|
||||
|
||||
regctl index add ocidir://${{ inputs.image-name }}:build --ref ocidir://${{ inputs.image-name }}-amd64:build --desc-platform linux/amd64
|
||||
regctl index add ocidir://${{ inputs.image-name }}:build --ref ocidir://${{ inputs.image-name }}-arm64:build --desc-platform linux/arm64
|
||||
regctl image import \
|
||||
"ocidir://${image_name}-amd64:build" \
|
||||
"container/${image_name}-amd64.oci.tar"
|
||||
|
||||
regctl image import \
|
||||
"ocidir://${image_name}-arm64:build" \
|
||||
"container/${image_name}-arm64.oci.tar"
|
||||
|
||||
regctl index create "${target}"
|
||||
|
||||
add_platform() {
|
||||
local arch="$1"
|
||||
local source="ocidir://${image_name}-${arch}:build"
|
||||
local source_index
|
||||
local image_digest
|
||||
local attestation_digest
|
||||
|
||||
source_index="$(
|
||||
regctl manifest get "${source}" --format raw-body
|
||||
)"
|
||||
|
||||
# Select the runnable image manifest from the architecture index.
|
||||
image_digest="$(
|
||||
jq -er --arg arch "${arch}" '
|
||||
[
|
||||
.manifests[]
|
||||
| select(
|
||||
.mediaType
|
||||
== "application/vnd.oci.image.manifest.v1+json"
|
||||
and .platform.os == "linux"
|
||||
and .platform.architecture == $arch
|
||||
)
|
||||
| .digest
|
||||
]
|
||||
| if length == 1 then .[0]
|
||||
else error(
|
||||
"expected exactly one linux/" + $arch
|
||||
+ " image manifest"
|
||||
)
|
||||
end
|
||||
' <<<"${source_index}"
|
||||
)"
|
||||
|
||||
# Select the BuildKit attestation associated with that image.
|
||||
attestation_digest="$(
|
||||
jq -er --arg image_digest "${image_digest}" '
|
||||
[
|
||||
.manifests[]
|
||||
| select(
|
||||
.mediaType
|
||||
== "application/vnd.oci.image.manifest.v1+json"
|
||||
and .platform.os == "unknown"
|
||||
and .platform.architecture == "unknown"
|
||||
and .annotations["vnd.docker.reference.type"]
|
||||
== "attestation-manifest"
|
||||
and .annotations["vnd.docker.reference.digest"]
|
||||
== $image_digest
|
||||
)
|
||||
| .digest
|
||||
]
|
||||
| if length == 1 then .[0]
|
||||
else error(
|
||||
"expected exactly one attestation for "
|
||||
+ $image_digest
|
||||
)
|
||||
end
|
||||
' <<<"${source_index}"
|
||||
)"
|
||||
|
||||
# Add the runnable image manifest directly to the final index.
|
||||
regctl index add "${target}" \
|
||||
--ref "${source}@${image_digest}" \
|
||||
--desc-platform "linux/${arch}"
|
||||
|
||||
# Add the associated attestation directly beside the image.
|
||||
regctl index add "${target}" \
|
||||
--ref "${source}@${attestation_digest}" \
|
||||
--desc-platform "unknown/unknown" \
|
||||
--desc-annotation \
|
||||
"vnd.docker.reference.type=attestation-manifest" \
|
||||
--desc-annotation \
|
||||
"vnd.docker.reference.digest=${image_digest}"
|
||||
}
|
||||
|
||||
add_platform amd64
|
||||
add_platform arm64
|
||||
|
||||
final_index="$(
|
||||
regctl manifest get "${target}" --format raw-body
|
||||
)"
|
||||
|
||||
# Ensure the result is a flat index containing two runnable image
|
||||
# manifests and their two attestations, with no nested OCI indexes.
|
||||
jq -e '
|
||||
.mediaType == "application/vnd.oci.image.index.v1+json"
|
||||
and (.manifests | length == 4)
|
||||
and (
|
||||
[.manifests[].mediaType]
|
||||
| all(
|
||||
. == "application/vnd.oci.image.manifest.v1+json"
|
||||
)
|
||||
)
|
||||
and (
|
||||
[
|
||||
.manifests[]
|
||||
| select(.platform.os == "linux")
|
||||
| .platform.architecture
|
||||
]
|
||||
| sort
|
||||
== ["amd64", "arm64"]
|
||||
)
|
||||
and (
|
||||
[
|
||||
.manifests[]
|
||||
| select(
|
||||
.platform.os == "unknown"
|
||||
and .platform.architecture == "unknown"
|
||||
and .annotations["vnd.docker.reference.type"]
|
||||
== "attestation-manifest"
|
||||
)
|
||||
]
|
||||
| length == 2
|
||||
)
|
||||
and (
|
||||
(
|
||||
[
|
||||
.manifests[]
|
||||
| select(.platform.os == "linux")
|
||||
| .digest
|
||||
]
|
||||
| sort
|
||||
)
|
||||
==
|
||||
(
|
||||
[
|
||||
.manifests[]
|
||||
| select(
|
||||
.platform.os == "unknown"
|
||||
and .platform.architecture == "unknown"
|
||||
)
|
||||
| .annotations["vnd.docker.reference.digest"]
|
||||
]
|
||||
| sort
|
||||
)
|
||||
)
|
||||
' <<<"${final_index}"
|
||||
|
||||
mkdir -p build/container/
|
||||
regctl image export ocidir://${{ inputs.image-name }}:build build/container/${{ inputs.image-name }}.oci.tar
|
||||
|
||||
regctl image export \
|
||||
"${target}" \
|
||||
"build/container/${image_name}.oci.tar"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
|
||||
with:
|
||||
name: "container-build-${{ inputs.image-name }}"
|
||||
path: build/
|
||||
if-no-files-found: error
|
||||
retention-days: 2
|
||||
include-hidden-files: true
|
||||
include-hidden-files: true
|
||||
2
.github/workflows/ci-aws-cfn.yml
vendored
2
.github/workflows/ci-aws-cfn.yml
vendored
@@ -50,6 +50,6 @@ jobs:
|
||||
- check-changes-applied
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
4
.github/workflows/ci-docs.yml
vendored
4
.github/workflows/ci-docs.yml
vendored
@@ -113,7 +113,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Login to Container Registry
|
||||
if: "${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}"
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
@@ -143,6 +143,6 @@ jobs:
|
||||
- build-container
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
2
.github/workflows/ci-main.yml
vendored
2
.github/workflows/ci-main.yml
vendored
@@ -479,7 +479,7 @@ jobs:
|
||||
- test-rust
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
publish:
|
||||
|
||||
27
.github/workflows/ci-outpost.yml
vendored
27
.github/workflows/ci-outpost.yml
vendored
@@ -38,7 +38,11 @@ jobs:
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v8
|
||||
with:
|
||||
version: latest
|
||||
# latest (v2.13.0) bundles honnef.co/go/tools v0.8.0-rc.1, whose nilness
|
||||
# analyzer panics on getsentry/sentry-go ("unhandled builtin recover",
|
||||
# https://github.com/dominikh/go-tools/issues/1725). Unpin once a
|
||||
# release with the fix ships.
|
||||
version: v2.12.2
|
||||
args: --timeout 5000s --verbose
|
||||
skip-cache: true
|
||||
test-unittest:
|
||||
@@ -63,7 +67,7 @@ jobs:
|
||||
- test-unittest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
build-binary:
|
||||
@@ -75,7 +79,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
type:
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
- rac
|
||||
@@ -88,24 +91,6 @@ jobs:
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
- name: Pin pnpm store directory
|
||||
run: |
|
||||
echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV"
|
||||
echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV"
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
web/pnpm-lock.yaml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir web install --frozen-lockfile
|
||||
- name: Build web
|
||||
run: pnpm --dir web run build-proxy
|
||||
- name: Build outpost
|
||||
run: |
|
||||
set -x
|
||||
|
||||
2
.github/workflows/ci-web.yml
vendored
2
.github/workflows/ci-web.yml
vendored
@@ -80,7 +80,7 @@ jobs:
|
||||
- lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # release/v1
|
||||
- uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
test:
|
||||
|
||||
6
.github/workflows/qa-codeql.yml
vendored
6
.github/workflows/qa-codeql.yml
vendored
@@ -28,10 +28,10 @@ jobs:
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/autobuild@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
|
||||
2
.github/workflows/release-publish.yml
vendored
2
.github/workflows/release-publish.yml
vendored
@@ -103,7 +103,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
|
||||
2
.github/workflows/release-tag.yml
vendored
2
.github/workflows/release-tag.yml
vendored
@@ -314,7 +314,7 @@ jobs:
|
||||
name: Release ${{ needs.check-inputs.outputs.version }}
|
||||
draft: true
|
||||
prerelease: ${{ inputs.release_reason == 'prerelease' }}
|
||||
generate_release_notes: true
|
||||
generate_release_notes: false
|
||||
body: |
|
||||
See ${{ needs.check-inputs.outputs.changelog-url }}
|
||||
files: |
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -7,8 +7,8 @@ It is a **polyglot monorepo**. Most work lands in one of the subtrees below; whe
|
||||
| Language | Where | What it is | Deeper guide |
|
||||
| -------------- | -------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| **Python** | `authentik/`, `lifecycle/` | The core server — a Django + Django REST Framework app. The source of truth for the IdP. | — |
|
||||
| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, proxy, RAC, RADIUS) and the front reverse-proxy that fronts Django. | — |
|
||||
| **Rust** | `src/`, `packages/ak-*` | Newer server/worker components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — |
|
||||
| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, RAC, RADIUS). | — |
|
||||
| **Rust** | `src/`, `packages/ak-*` | Newer server/worker/proxy outpost components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — |
|
||||
| **TypeScript** | `web/` | The web UI — three Lit + PatternFly apps (Admin, User, Flow). | [`web/AGENTS.md`](web/AGENTS.md) |
|
||||
| **Docs** | `website/` | The documentation, integrations, and API sites (Docusaurus). | [`website/AGENTS.md`](website/AGENTS.md) |
|
||||
|
||||
@@ -19,8 +19,8 @@ The Python core and the web UI talk through a **generated OpenAPI client** — n
|
||||
```
|
||||
authentik/ # Django core — the IdP itself (see "The authentik Django package" below)
|
||||
lifecycle/ # Boot/runtime: migrations, gunicorn config, the `ak` CLI, container + AWS entrypoints
|
||||
cmd/ # Go entrypoints: ldap/ proxy/ rac/ radius/ outposts + server/ (front reverse-proxy)
|
||||
internal/ # Shared Go: outpost implementations, config, web proxy, gounicorn process manager
|
||||
cmd/ # Go entrypoints: ldap/ rac/ radius/ outposts
|
||||
internal/ # Shared Go: outpost implementations, config
|
||||
src/ # Rust server/worker (ak-axum based; gated behind cargo features)
|
||||
packages/ # Shared workspace packages, polyglot:
|
||||
# client-go / client-rust / client-ts — GENERATED API clients (do not hand-edit)
|
||||
@@ -154,7 +154,7 @@ Authoritative contributor docs live under `website/docs/developer-docs/` and are
|
||||
| --------------- | ------------------------------------------------------------------------ |
|
||||
| Core server | Python 3.14, Django 5.2 + Django REST Framework, Channels (ASGI) |
|
||||
| Background work | Dramatiq (Postgres broker) |
|
||||
| Datastore | PostgreSQL (multi-tenant via `django-tenants`) + Redis |
|
||||
| Datastore | PostgreSQL (multi-tenant via `django-tenants`) |
|
||||
| Outposts | Go 1.26 (`goauthentik.io` module) — LDAP, proxy, RAC, RADIUS |
|
||||
| Native services | Rust (2024 edition, `axum`) — server/worker components + shared crates |
|
||||
| Web UI | TypeScript, Lit 3, PatternFly 4 (see `web/`) |
|
||||
|
||||
149
Cargo.lock
generated
149
Cargo.lock
generated
@@ -360,7 +360,6 @@ dependencies = [
|
||||
"authentik-common",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"client-ip",
|
||||
"durstr",
|
||||
"eyre",
|
||||
"futures",
|
||||
@@ -860,15 +859,6 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "client-ip"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39d2056bf065c8b4bce5a8898d40e175211ff4410add2a84d695845d3937c729"
|
||||
dependencies = [
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
@@ -1186,6 +1176,37 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
@@ -1360,10 +1381,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
version = "0.6.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
|
||||
checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"indenter",
|
||||
"once_cell",
|
||||
]
|
||||
@@ -1467,9 +1489,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
|
||||
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -1482,9 +1504,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -1492,15 +1514,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
@@ -1520,38 +1542,38 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -1644,9 +1666,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -1799,9 +1821,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
|
||||
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -2133,6 +2155,41 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -2910,6 +2967,15 @@ version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -3249,9 +3315,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.8"
|
||||
version = "0.14.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
|
||||
checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"rustls-pki-types",
|
||||
@@ -3763,14 +3829,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
version = "3.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
"jiff",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"time",
|
||||
@@ -4793,9 +4860,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.24.0"
|
||||
version = "1.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
|
||||
13
Cargo.toml
13
Cargo.toml
@@ -31,7 +31,6 @@ axum-extra = { version = "= 0.12.6", default-features = false, features = [
|
||||
] }
|
||||
base64 = "= 0.23.1"
|
||||
clap = { version = "= 4.6.6", features = ["derive", "env"] }
|
||||
client-ip = { version = "0.2.1" }
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
color-eyre = "= 0.6.5"
|
||||
colored = "= 3.1.1"
|
||||
@@ -42,10 +41,10 @@ config-rs = { package = "config", version = "= 0.15.22", default-features = fals
|
||||
console-subscriber = "= 0.5.0"
|
||||
dotenvy = "= 0.15.7"
|
||||
durstr = "= 0.5.1"
|
||||
eyre = "= 0.6.12"
|
||||
futures = "= 0.3.33"
|
||||
eyre = "= 0.6.14"
|
||||
futures = "= 0.3.34"
|
||||
glob = "= 0.3.4"
|
||||
http-body-util = "= 0.1.4"
|
||||
http-body-util = "= 0.1.5"
|
||||
hyper = { version = "= 1.11.0", features = ["client", "http1", "http2"] }
|
||||
hyper-rustls = { version = "= 0.27.9", default-features = false, features = [
|
||||
"aws-lc-rs",
|
||||
@@ -77,7 +76,7 @@ pin-project-lite = "= 0.2.17"
|
||||
pyo3 = "= 0.29.0"
|
||||
pyo3-build-config = "= 0.29.0"
|
||||
rand = "= 0.10.2"
|
||||
rcgen = { version = "= 0.14.8", default-features = false, features = [
|
||||
rcgen = { version = "= 0.14.9", default-features = false, features = [
|
||||
"aws_lc_rs",
|
||||
"fips",
|
||||
] }
|
||||
@@ -111,7 +110,7 @@ sentry = { version = "= 0.49.1", default-features = false, features = [
|
||||
serde = { version = "= 1.0.229", features = ["derive"] }
|
||||
serde_json = "= 1.0.151"
|
||||
serde_repr = "= 0.1.21"
|
||||
serde_with = { version = "= 3.21.0", default-features = false, features = [
|
||||
serde_with = { version = "= 3.22.0", default-features = false, features = [
|
||||
"base64",
|
||||
] }
|
||||
sqlx = { version = "= 0.9.0", default-features = false, features = [
|
||||
@@ -152,7 +151,7 @@ tracing-subscriber = { version = "= 0.3.23", features = [
|
||||
"tracing-log",
|
||||
] }
|
||||
url = "= 2.5.8"
|
||||
uuid = { version = "= 1.24.0", features = ["serde", "v4"] }
|
||||
uuid = { version = "= 1.24.1", features = ["serde", "v4"] }
|
||||
|
||||
ak-axum = { package = "authentik-axum", version = "2026.11.0-rc1", path = "./packages/ak-axum" }
|
||||
ak-client = { package = "authentik-client", version = "2026.11.0-rc1", path = "./packages/client-rust" }
|
||||
|
||||
2
Makefile
2
Makefile
@@ -10,7 +10,7 @@ DOCKER_IMAGE ?= "authentik:test"
|
||||
|
||||
UNAME_S := $(shell uname -s)
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
SED_INPLACE = sed -i ''
|
||||
SED_INPLACE = /usr/bin/sed -i ''
|
||||
else
|
||||
SED_INPLACE = sed -i
|
||||
endif
|
||||
|
||||
19
SECURITY.md
19
SECURITY.md
@@ -1,4 +1,4 @@
|
||||
authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce it's existence and release the fixed version.
|
||||
authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce its existence and release the fixed version.
|
||||
|
||||
## Independent audits and pentests
|
||||
|
||||
@@ -6,7 +6,7 @@ We are committed to engaging in regular pentesting and security audits of authen
|
||||
|
||||
## What authentik classifies as a CVE
|
||||
|
||||
CVE (Common Vulnerability and Exposure) is a system designed to aggregate all vulnerabilities. As such, a CVE will be issued when there is a either vulnerability or exposure. Per NIST, A vulnerability is:
|
||||
CVE (Common Vulnerability and Exposure) is a system designed to aggregate all vulnerabilities. As such, a CVE will be issued when there is either a vulnerability or exposure. Per NIST, A vulnerability is:
|
||||
|
||||
“Weakness in an information system, system security procedures, internal controls, or implementation that could be exploited or triggered by a threat source.”
|
||||
|
||||
@@ -20,15 +20,12 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
||||
|
||||
| Version | Supported |
|
||||
| --------- | --------- |
|
||||
| 2026.2.x | ✅ |
|
||||
| 2026.5.x | ✅ |
|
||||
| 2026.8.x | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a potential vulnerability, please report it responsibly through one of the following channels:
|
||||
|
||||
- **Email**: [security@goauthentik.io](mailto:security@goauthentik.io)
|
||||
- **GitHub**: Submit a private security advisory via our [repository’s advisory portal](https://github.com/goauthentik/authentik/security/advisories/new)
|
||||
If you discover a potential vulnerability, please report it responsibly by submitting a private security advisory via our [repository’s advisory portal](https://github.com/goauthentik/authentik/security/advisories/new).
|
||||
|
||||
When submitting a report, please include as much detail as possible, such as:
|
||||
|
||||
@@ -96,7 +93,7 @@ The destinations of outgoing network requests (HTTP, TCP, etc.) made by authenti
|
||||
|
||||
## Disclosure process
|
||||
|
||||
1. Report from Github or Issue is reported via Email as listed above.
|
||||
1. Vulnerability is reported via a GitHub Security Advisory, as listed above.
|
||||
2. The authentik Security team will try to reproduce the issue and ask for more information if required.
|
||||
3. A severity level is assigned.
|
||||
4. A fix is created, and if possible tested by the issue reporter.
|
||||
@@ -107,3 +104,9 @@ The destinations of outgoing network requests (HTTP, TCP, etc.) made by authenti
|
||||
## Getting security notifications
|
||||
|
||||
To get security notifications, subscribe to the mailing list [here](https://groups.google.com/g/authentik-security-announcements) or join the [discord](https://goauthentik.io/discord) server.
|
||||
|
||||
## Contact
|
||||
|
||||
For general inquiries, you can reach the authentik Security team at [security@goauthentik.io](mailto:security@goauthentik.io).
|
||||
|
||||
_Please do not use email for vulnerability reports, instead use our [repository’s advisory portal](https://github.com/goauthentik/authentik/security/advisories/new)._
|
||||
|
||||
@@ -118,7 +118,7 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
|
||||
props[_flag.key]["description"] = _flag.description
|
||||
if _flag.deprecated:
|
||||
props[_flag.key]["deprecated"] = _flag.deprecated
|
||||
if visibility == "public":
|
||||
if visibility == "public" and not _flag.deprecated:
|
||||
required.append(_flag.key)
|
||||
return build_object_type(props, required=required)
|
||||
|
||||
|
||||
@@ -18,13 +18,17 @@ from authentik.core.models import Provider
|
||||
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Provider Serializer"""
|
||||
|
||||
assigned_application_slug = ReadOnlyField(source="application.slug", allow_null=True)
|
||||
assigned_application_name = ReadOnlyField(source="application.name", allow_null=True)
|
||||
assigned_application_slug = ReadOnlyField(
|
||||
source="application.slug", allow_null=True, required=False
|
||||
)
|
||||
assigned_application_name = ReadOnlyField(
|
||||
source="application.name", allow_null=True, required=False
|
||||
)
|
||||
assigned_backchannel_application_slug = ReadOnlyField(
|
||||
source="backchannel_application.slug", allow_null=True
|
||||
source="backchannel_application.slug", allow_null=True, required=False
|
||||
)
|
||||
assigned_backchannel_application_name = ReadOnlyField(
|
||||
source="backchannel_application.name", allow_null=True
|
||||
source="backchannel_application.name", allow_null=True, required=False
|
||||
)
|
||||
|
||||
component = SerializerMethodField()
|
||||
@@ -53,7 +57,12 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"verbose_name_plural",
|
||||
"meta_model_name",
|
||||
]
|
||||
extra_kwargs = {
|
||||
# This serializer is only a general read serializer for listing/reading
|
||||
# providers without their specific type,
|
||||
# setting whether authorization_flow/invalidation_flow are required
|
||||
# is up to the child serializer
|
||||
extra_kwargs = {}
|
||||
extra_write_kwargs = {
|
||||
"authorization_flow": {"required": True, "allow_null": False},
|
||||
"invalidation_flow": {"required": True, "allow_null": False},
|
||||
}
|
||||
|
||||
@@ -84,7 +84,6 @@ from authentik.core.models import (
|
||||
USER_PATH_SERVICE_ACCOUNT,
|
||||
USERNAME_MAX_LENGTH,
|
||||
Group,
|
||||
Session,
|
||||
Token,
|
||||
TokenIntents,
|
||||
User,
|
||||
@@ -556,7 +555,7 @@ class UsersFilter(FilterSet):
|
||||
uuid = UUIDFilter(field_name="uuid")
|
||||
|
||||
path = CharFilter(field_name="path")
|
||||
path_startswith = CharFilter(field_name="path", lookup_expr="startswith")
|
||||
path_startswith = CharFilter(field_name="path", method="filter_path_startswith")
|
||||
|
||||
type = MultipleChoiceFilter(choices=UserTypes.choices, field_name="type")
|
||||
|
||||
@@ -585,6 +584,15 @@ class UsersFilter(FilterSet):
|
||||
return queryset.filter(groups__is_superuser=True).distinct()
|
||||
return queryset.exclude(groups__is_superuser=True).distinct()
|
||||
|
||||
def filter_path_startswith(self, queryset, name, value):
|
||||
"""Filter users by the given path and any of its sub-paths. A plain `startswith`
|
||||
lookup would also match sibling paths sharing the same prefix, so that `foo/bar`
|
||||
would incorrectly match users in `foo/bar2`."""
|
||||
value = value.rstrip("/")
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(Q(path=value) | Q(path__startswith=f"{value}/"))
|
||||
|
||||
def filter_attributes(self, queryset, name, value):
|
||||
"""Filter attributes by query args"""
|
||||
try:
|
||||
@@ -1117,11 +1125,3 @@ class UserViewSet(
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def partial_update(self, request: Request, *args, **kwargs) -> Response:
|
||||
response = super().partial_update(request, *args, **kwargs)
|
||||
instance: User = self.get_object()
|
||||
if not instance.is_active:
|
||||
Session.objects.filter(authenticatedsession__user=instance).delete()
|
||||
LOGGER.debug("Deleted user's sessions", user=instance.username)
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-19 10:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0063_actor"),
|
||||
("authentik_rbac", "0011_initialpermissionspermission"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(
|
||||
fields=["username", "is_active", "type"], name="authentik_c_usernam_2f0e4b_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -402,6 +402,7 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
|
||||
models.Index(fields=["type"]),
|
||||
models.Index(fields=["date_joined"]),
|
||||
models.Index(fields=["last_updated"]),
|
||||
models.Index(fields=["username", "is_active", "type"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
@@ -609,15 +610,14 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
|
||||
|
||||
def locale(self, request: HttpRequest | None = None) -> str:
|
||||
"""Get the locale the user has configured"""
|
||||
if request and hasattr(request, "LANGUAGE_CODE"):
|
||||
return request.LANGUAGE_CODE
|
||||
try:
|
||||
return self.attributes.get("settings", {}).get("locale", "")
|
||||
|
||||
locale = self.attributes.get("settings", {}).get("locale", "")
|
||||
if locale:
|
||||
return locale
|
||||
except Exception as exc: # noqa
|
||||
LOGGER.warning("Failed to get default locale", exc=exc)
|
||||
if request:
|
||||
return request.brand.locale
|
||||
if request and hasattr(request, "LANGUAGE_CODE"):
|
||||
return request.LANGUAGE_CODE
|
||||
return ""
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""authentik core signals"""
|
||||
|
||||
from channels.layers import get_channel_layer
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
@@ -17,9 +19,7 @@ from authentik.core.models import (
|
||||
User,
|
||||
default_token_duration,
|
||||
)
|
||||
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
|
||||
from authentik.lib.models import ExpiringModel
|
||||
from authentik.root.ws.consumer import build_device_group
|
||||
|
||||
password_changed = Signal()
|
||||
"""Arguments: user: User, password: str"""
|
||||
@@ -33,6 +33,47 @@ admin_authenticated_session_deleted = Signal()
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
_CTX_INHIBIT_DEACTIVATION_SESSION_CLEANUP = ContextVar[bool](
|
||||
"authentik_core_inhibit_deactivation_session_cleanup",
|
||||
default=False,
|
||||
)
|
||||
_CTX_INHIBIT_DEACTIVATION_TOKEN_CLEANUP = ContextVar[bool](
|
||||
"authentik_core_inhibit_deactivation_token_cleanup",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def deactivation_inhibit_cleanup(*, sessions: bool = True, tokens: bool = True):
|
||||
"""
|
||||
Prevent the automatic cleanup that runs when a deactivated user is saved,
|
||||
for callers that revoke sessions and/or tokens themselves (e.g. enterprise
|
||||
revocation). `sessions` inhibits session deletion, `tokens` inhibits
|
||||
provider token revocation; both are inhibited by default. Nested uses can
|
||||
add inhibition but not remove the outer context's.
|
||||
"""
|
||||
reset_sessions = _CTX_INHIBIT_DEACTIVATION_SESSION_CLEANUP.set(
|
||||
_CTX_INHIBIT_DEACTIVATION_SESSION_CLEANUP.get() or sessions
|
||||
)
|
||||
reset_tokens = _CTX_INHIBIT_DEACTIVATION_TOKEN_CLEANUP.set(
|
||||
_CTX_INHIBIT_DEACTIVATION_TOKEN_CLEANUP.get() or tokens
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_CTX_INHIBIT_DEACTIVATION_SESSION_CLEANUP.reset(reset_sessions)
|
||||
_CTX_INHIBIT_DEACTIVATION_TOKEN_CLEANUP.reset(reset_tokens)
|
||||
|
||||
|
||||
def deactivation_session_cleanup_inhibited() -> bool:
|
||||
"""Whether deactivation session cleanup is inhibited in the current context"""
|
||||
return _CTX_INHIBIT_DEACTIVATION_SESSION_CLEANUP.get()
|
||||
|
||||
|
||||
def deactivation_token_cleanup_inhibited() -> bool:
|
||||
"""Whether deactivation token cleanup is inhibited in the current context"""
|
||||
return _CTX_INHIBIT_DEACTIVATION_TOKEN_CLEANUP.get()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Application)
|
||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
||||
@@ -53,15 +94,16 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
|
||||
|
||||
AuthenticatedSession.create_from_request(request, user)
|
||||
|
||||
if not RefreshOtherFlowsAfterAuthentication.get():
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def user_deactivated_delete_sessions(sender: type[Model], instance: User, **_):
|
||||
"""Delete all of a user's sessions when they are deactivated"""
|
||||
if instance.is_active:
|
||||
return
|
||||
layer = get_channel_layer()
|
||||
device_cookie = request.COOKIES.get("authentik_device")
|
||||
if device_cookie:
|
||||
layer.group_send_blocking(
|
||||
build_device_group(device_cookie),
|
||||
{"type": "event.session.authenticated"},
|
||||
)
|
||||
if deactivation_session_cleanup_inhibited():
|
||||
return
|
||||
Session.objects.filter(authenticatedsession__user=instance).delete()
|
||||
LOGGER.debug("Deleted deactivated user's sessions", user=instance.username)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=AuthenticatedSession)
|
||||
|
||||
@@ -130,18 +130,51 @@ class SourceFlowManager:
|
||||
self.user_properties = self.mapper.build_object_properties(
|
||||
object_type=User, request=request, user=None, **self.user_info
|
||||
)
|
||||
groups_manager = self.mapper.get_manager(Group, ["group_id", *self.user_info.keys()])
|
||||
self.groups_properties = {
|
||||
group_id: self.mapper.build_object_properties(
|
||||
object_type=Group,
|
||||
manager=groups_manager,
|
||||
request=request,
|
||||
user=None,
|
||||
group_id=group_id,
|
||||
**self.user_info,
|
||||
)
|
||||
for group_id in self.user_properties.setdefault("groups", [])
|
||||
for group_id in self._keyable_group_ids(self.user_properties.setdefault("groups", []))
|
||||
}
|
||||
del self.user_properties["groups"]
|
||||
|
||||
def _keyable_group_ids(self, group_ids: list[Any]) -> list[Any]:
|
||||
"""Drop group identifiers that cannot be used as a `groups_properties` key.
|
||||
|
||||
An unhashable identifier, such as an object in an IdP's `groups` claim,
|
||||
raised `TypeError` out of the constructor and surfaced as HTTP 500,
|
||||
locking every member of that group out of the source. Skipped entries are
|
||||
recorded so a user arriving with fewer groups than the IdP granted stays
|
||||
visible to an operator.
|
||||
"""
|
||||
keyable = []
|
||||
skipped = []
|
||||
for group_id in group_ids:
|
||||
try:
|
||||
hash(group_id)
|
||||
except TypeError:
|
||||
skipped.append(group_id)
|
||||
else:
|
||||
keyable.append(group_id)
|
||||
if skipped:
|
||||
self._logger.warning("Skipping groups with an unusable identifier", groups=skipped)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=(
|
||||
f"Source '{self.source.name}' returned {len(skipped)} group(s) whose "
|
||||
"identifier is not a string; they were not applied to the user."
|
||||
),
|
||||
source=self.source,
|
||||
groups=skipped,
|
||||
).from_http(self.request)
|
||||
return keyable
|
||||
|
||||
def get_action(self, **kwargs) -> tuple[Action, UserSourceConnection | None]: # noqa: PLR0911
|
||||
"""decide which action should be taken"""
|
||||
# When request is authenticated, always link
|
||||
@@ -154,7 +187,7 @@ class SourceFlowManager:
|
||||
if existing := self.user_connection_type.objects.filter(
|
||||
source=self.source, identifier=self.identifier
|
||||
).first():
|
||||
existing = self.update_user_connection(existing)
|
||||
existing = self.update_user_connection(existing, **kwargs)
|
||||
return Action.AUTH, existing
|
||||
return Action.LINK, new_connection
|
||||
|
||||
|
||||
91
authentik/core/tests/test_signals.py
Normal file
91
authentik/core/tests/test_signals.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Test core signals"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.core.models import AuthenticatedSession, Session, User
|
||||
from authentik.core.signals import deactivation_inhibit_cleanup
|
||||
from authentik.core.tests.utils import create_test_session, create_test_user
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestUserDeactivatedSignal(TestCase):
|
||||
"""Test that deactivating a user always deletes their sessions"""
|
||||
|
||||
def test_deactivate_deletes_sessions(self):
|
||||
"""Saving a deactivated user deletes all their sessions"""
|
||||
user = create_test_user()
|
||||
sessions = [create_test_session(user) for _ in range(3)]
|
||||
other_user = create_test_user()
|
||||
other_session = create_test_session(other_user)
|
||||
self.assertEqual(AuthenticatedSession.objects.filter(user=user).count(), 3)
|
||||
|
||||
user.is_active = False
|
||||
user.save()
|
||||
|
||||
for session in sessions:
|
||||
self.assertFalse(
|
||||
Session.objects.filter(session_key=session.session.session_key).exists()
|
||||
)
|
||||
self.assertFalse(AuthenticatedSession.objects.filter(user=user).exists())
|
||||
# Other users' sessions are untouched
|
||||
self.assertTrue(
|
||||
Session.objects.filter(session_key=other_session.session.session_key).exists()
|
||||
)
|
||||
|
||||
def test_create_inactive_user(self):
|
||||
"""A user can be created as inactive and saved again without errors"""
|
||||
user = User.objects.create(username=generate_id(), name=generate_id(), is_active=False)
|
||||
user.name = generate_id()
|
||||
user.save()
|
||||
user.save(update_fields=["name"])
|
||||
self.assertFalse(user.is_active)
|
||||
self.assertFalse(AuthenticatedSession.objects.filter(user=user).exists())
|
||||
|
||||
def test_deactivate_inhibited(self):
|
||||
"""deactivation_inhibit_cleanup leaves the user's sessions alone"""
|
||||
user = create_test_user()
|
||||
session = create_test_session(user)
|
||||
|
||||
user.is_active = False
|
||||
with deactivation_inhibit_cleanup():
|
||||
user.save()
|
||||
|
||||
self.assertFalse(user.is_active)
|
||||
self.assertTrue(Session.objects.filter(session_key=session.session.session_key).exists())
|
||||
# Once the context manager exits, deactivating saves delete sessions again
|
||||
user.save()
|
||||
self.assertFalse(Session.objects.filter(session_key=session.session.session_key).exists())
|
||||
|
||||
def test_deactivate_inhibit_tokens_only(self):
|
||||
"""Inhibiting only token cleanup still deletes sessions"""
|
||||
user = create_test_user()
|
||||
session = create_test_session(user)
|
||||
|
||||
user.is_active = False
|
||||
with deactivation_inhibit_cleanup(sessions=False, tokens=True):
|
||||
user.save()
|
||||
|
||||
self.assertFalse(Session.objects.filter(session_key=session.session.session_key).exists())
|
||||
|
||||
def test_deactivate_via_blueprint(self):
|
||||
"""Deactivating a user via blueprint deletes their sessions"""
|
||||
user = create_test_user()
|
||||
session = create_test_session(user)
|
||||
|
||||
importer = Importer.from_string(f"""version: 1
|
||||
entries:
|
||||
- model: authentik_core.user
|
||||
state: present
|
||||
identifiers:
|
||||
username: {user.username}
|
||||
attrs:
|
||||
name: {user.name}
|
||||
is_active: false
|
||||
""")
|
||||
self.assertTrue(importer.validate()[0])
|
||||
self.assertTrue(importer.apply())
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
self.assertFalse(Session.objects.filter(session_key=session.session.session_key).exists())
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Test Source flow_manager"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
||||
@@ -10,13 +12,18 @@ from authentik.core.sources.flow_manager import Action
|
||||
from authentik.core.sources.matcher import MatchFailureReason
|
||||
from authentik.core.sources.stage import PostSourceStage
|
||||
from authentik.core.tests.utils import RequestFactory, create_test_flow
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
from authentik.policies.expression.models import ExpressionPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.sources.oauth.models import OAuthSource, UserOAuthSourceConnection
|
||||
from authentik.sources.oauth.models import (
|
||||
OAuthSource,
|
||||
OAuthSourcePropertyMapping,
|
||||
UserOAuthSourceConnection,
|
||||
)
|
||||
from authentik.sources.oauth.views.callback import OAuthSourceFlowManager
|
||||
|
||||
|
||||
@@ -82,6 +89,49 @@ class TestSourceFlowManager(TestCase):
|
||||
reverse("authentik_core:if-user") + "#/settings;page-sources",
|
||||
)
|
||||
|
||||
def test_authenticated_auth_forwards_kwargs(self):
|
||||
"""kwargs must reach update_user_connection() for the *existing* connection.
|
||||
|
||||
Sources carry their credential in kwargs — Plex's `plex_token`, for
|
||||
example. The AUTH branch dropped them, so `update_user_connection()`
|
||||
read `kwargs.get(...)` as None and overwrote a valid stored credential
|
||||
with NULL on every subsequent login through the source.
|
||||
|
||||
`get_action()` calls `update_user_connection()` twice on this path: once
|
||||
for a fresh unsaved connection (which always forwarded kwargs) and once
|
||||
for the existing row (which did not). Recording per call and keying on
|
||||
whether the connection is saved is what separates them — an accumulated
|
||||
dict is satisfied by the first call and proves nothing.
|
||||
"""
|
||||
user = User.objects.create(username="foo", email="foo@bar.baz")
|
||||
existing_connection = UserOAuthSourceConnection.objects.create(
|
||||
user=user, source=self.source, identifier=self.identifier
|
||||
)
|
||||
request = self.request_factory.get("/", user=user)
|
||||
|
||||
calls: list[tuple] = []
|
||||
|
||||
class RecordingFlowManager(OAuthSourceFlowManager):
|
||||
def update_user_connection(self, connection, **kwargs):
|
||||
calls.append((connection.pk, kwargs))
|
||||
return connection
|
||||
|
||||
flow_manager = RecordingFlowManager(self.source, request, self.identifier, {"info": {}}, {})
|
||||
action, connection = flow_manager.get_action(some_token="a-credential")
|
||||
|
||||
self.assertEqual(action, Action.AUTH)
|
||||
self.assertEqual(connection.pk, existing_connection.pk)
|
||||
|
||||
for_existing = [kw for pk, kw in calls if pk == existing_connection.pk]
|
||||
self.assertEqual(
|
||||
len(for_existing), 1, "expected exactly one update for the existing connection"
|
||||
)
|
||||
self.assertEqual(
|
||||
for_existing[0].get("some_token"),
|
||||
"a-credential",
|
||||
"get_action() dropped kwargs when updating the existing connection",
|
||||
)
|
||||
|
||||
def test_authenticated_auth(self):
|
||||
"""Test authenticated user linking"""
|
||||
user = User.objects.create(username="foo", email="foo@bar.baz")
|
||||
@@ -112,6 +162,27 @@ class TestSourceFlowManager(TestCase):
|
||||
self.assertIsNone(connection.pk)
|
||||
flow_manager.get_flow()
|
||||
|
||||
def test_unusable_group_identifier_does_not_abort(self):
|
||||
"""Test a group identifier that cannot be used as a key being skipped (#25191)"""
|
||||
request = self.request_factory.get("/", user=AnonymousUser())
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
request,
|
||||
self.identifier,
|
||||
{"info": {"groups": ["usable", {"id": "unusable"}, ["also-unusable"]]}},
|
||||
{},
|
||||
)
|
||||
# Enrolling has to remain possible: raising here surfaced as HTTP 500 and
|
||||
# locked out every member of such a group.
|
||||
self.assertEqual(list(flow_manager.groups_properties.keys()), ["usable"])
|
||||
self.assertEqual(flow_manager.get_action()[0], Action.ENROLL)
|
||||
self.assertEqual(flow_manager.get_flow().status_code, 302)
|
||||
|
||||
event = Event.objects.filter(action=EventAction.CONFIGURATION_ERROR).first()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertIn("2 group(s)", event.context["message"])
|
||||
self.assertEqual(event.context["groups"], [{"id": "unusable"}, ["also-unusable"]])
|
||||
|
||||
def test_unauthenticated_enroll_email(self):
|
||||
"""Test un-authenticated user enrolling (link on email)"""
|
||||
User.objects.create(username="foo", email="foo@bar.baz")
|
||||
@@ -268,3 +339,37 @@ class TestSourceFlowManager(TestCase):
|
||||
self.assertIsInstance(response, AccessDeniedResponse)
|
||||
|
||||
self.assertEqual(response.error_message, "foo")
|
||||
|
||||
def calculate_group_property_mapping_queries(self, group_count: int) -> int:
|
||||
"""Build group properties for `group_count` groups, check them, and return the
|
||||
number of queries it took"""
|
||||
group_ids = [f"group-{index}" for index in range(group_count)]
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source,
|
||||
self.request_factory.get("/", user=AnonymousUser()),
|
||||
self.identifier,
|
||||
{"info": {"groups": group_ids}},
|
||||
{},
|
||||
)
|
||||
self.assertEqual(len(flow_manager.groups_properties), group_count)
|
||||
for group_id in group_ids:
|
||||
# Each group must get its own properties, from both the base properties and
|
||||
# the property mapping
|
||||
self.assertEqual(
|
||||
flow_manager.groups_properties[group_id],
|
||||
{"name": group_id, "attributes": {"group_id": group_id}},
|
||||
)
|
||||
return len(queries.captured_queries)
|
||||
|
||||
def test_group_properties_reuse_mapping_manager(self):
|
||||
"""Test that building group properties doesn't re-fetch and re-compile the
|
||||
source's group property mappings for every single group"""
|
||||
self.source.group_property_mappings.add(
|
||||
OAuthSourcePropertyMapping.objects.create(
|
||||
name=generate_id(),
|
||||
expression="""return {"attributes": {"group_id": group_id}}""",
|
||||
)
|
||||
)
|
||||
# Building N groups must take fewer than N queries
|
||||
self.assertLess(self.calculate_group_property_mapping_queries(100), 100)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.http import HttpRequest
|
||||
from django.test.testcases import TestCase
|
||||
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
@@ -40,6 +41,46 @@ class TestUsers(TestCase):
|
||||
user.ak_groups.all()
|
||||
self.assertEqual(Event.objects.count(), 1)
|
||||
|
||||
def test_locale_user_setting_wins_over_language_code(self):
|
||||
"""Test the user's saved locale takes precedence over request.LANGUAGE_CODE"""
|
||||
user = User.objects.create(
|
||||
username=generate_id(),
|
||||
attributes={"settings": {"locale": "de"}},
|
||||
)
|
||||
request = HttpRequest()
|
||||
request.LANGUAGE_CODE = "fr"
|
||||
self.assertEqual(user.locale(request), "de")
|
||||
|
||||
def test_locale_falls_back_to_language_code(self):
|
||||
"""Test request.LANGUAGE_CODE is used when the user has no saved locale"""
|
||||
user = User.objects.create(username=generate_id())
|
||||
request = HttpRequest()
|
||||
request.LANGUAGE_CODE = "fr"
|
||||
self.assertEqual(user.locale(request), "fr")
|
||||
|
||||
def test_locale_empty_user_setting_falls_back_to_language_code(self):
|
||||
"""Test an empty saved locale does not shadow request.LANGUAGE_CODE"""
|
||||
user = User.objects.create(
|
||||
username=generate_id(),
|
||||
attributes={"settings": {"locale": ""}},
|
||||
)
|
||||
request = HttpRequest()
|
||||
request.LANGUAGE_CODE = "fr"
|
||||
self.assertEqual(user.locale(request), "fr")
|
||||
|
||||
def test_locale_no_request_returns_user_setting(self):
|
||||
"""Test the user's saved locale is returned when there is no request"""
|
||||
user = User.objects.create(
|
||||
username=generate_id(),
|
||||
attributes={"settings": {"locale": "de"}},
|
||||
)
|
||||
self.assertEqual(user.locale(), "de")
|
||||
|
||||
def test_locale_no_request_no_setting_returns_empty(self):
|
||||
"""Test an empty string is returned when there is no request and no saved locale"""
|
||||
user = User.objects.create(username=generate_id())
|
||||
self.assertEqual(user.locale(), "")
|
||||
|
||||
def test_set_password_from_hash_signal_skips_source_sync_receivers(self):
|
||||
"""Test hash password updates do not expose a raw password to sync receivers."""
|
||||
user = User.objects.create(
|
||||
|
||||
@@ -22,6 +22,7 @@ from authentik.core.tests.utils import (
|
||||
create_test_admin_user,
|
||||
create_test_brand,
|
||||
create_test_flow,
|
||||
create_test_session,
|
||||
create_test_user,
|
||||
)
|
||||
from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation
|
||||
@@ -455,6 +456,33 @@ class TestUsersAPI(APITestCase):
|
||||
)
|
||||
self.assertJSONEqual(response.content.decode(), {"paths": expected})
|
||||
|
||||
def test_path_startswith(self):
|
||||
"""Test path_startswith, which must not match sibling paths sharing a prefix"""
|
||||
root = generate_id(20)
|
||||
exact = create_test_user(path=f"{root}/group1")
|
||||
nested = create_test_user(path=f"{root}/group1/sub")
|
||||
sibling = create_test_user(path=f"{root}/group11")
|
||||
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={"path_startswith": f"{root}/group1"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content)
|
||||
pks = [r["pk"] for r in body["results"]]
|
||||
self.assertCountEqual(pks, [exact.pk, nested.pk])
|
||||
self.assertNotIn(sibling.pk, pks)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={"path_startswith": root},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content)
|
||||
pks = [r["pk"] for r in body["results"]]
|
||||
self.assertCountEqual(pks, [exact.pk, nested.pk, sibling.pk])
|
||||
|
||||
def test_path_valid(self):
|
||||
"""Test path"""
|
||||
self.client.force_login(self.admin)
|
||||
@@ -541,6 +569,28 @@ class TestUsersAPI(APITestCase):
|
||||
AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
|
||||
)
|
||||
|
||||
def test_session_delete_put(self):
|
||||
"""Ensure sessions are deleted when a user is deactivated via PUT"""
|
||||
user = create_test_admin_user()
|
||||
session = create_test_session(user)
|
||||
session_id = session.session.session_key
|
||||
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.put(
|
||||
reverse("authentik_api:user-detail", kwargs={"pk": user.pk}),
|
||||
data={
|
||||
"username": user.username,
|
||||
"name": user.name,
|
||||
"is_active": False,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertFalse(Session.objects.filter(session_key=session_id).exists())
|
||||
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()
|
||||
|
||||
@@ -27,10 +27,10 @@ from authentik.core.views.interface import (
|
||||
InterfaceView,
|
||||
RootRedirectView,
|
||||
)
|
||||
from authentik.events.consumer import ClientConsumer
|
||||
from authentik.flows.views.interface import FlowInterfaceView
|
||||
from authentik.root.asgi_middleware import AuthMiddlewareStack
|
||||
from authentik.root.middleware import ChannelsLoggingMiddleware
|
||||
from authentik.root.ws.consumer import MessageConsumer
|
||||
from authentik.tenants.channels import TenantsAwareMiddleware
|
||||
|
||||
urlpatterns = [
|
||||
@@ -116,7 +116,7 @@ websocket_urlpatterns = [
|
||||
path(
|
||||
"ws/client/",
|
||||
ChannelsLoggingMiddleware(
|
||||
TenantsAwareMiddleware(AuthMiddlewareStack(MessageConsumer.as_asgi()))
|
||||
TenantsAwareMiddleware(AuthMiddlewareStack(ClientConsumer.as_asgi()))
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -124,10 +124,10 @@ class AgentConnectorViewSet(
|
||||
token: EnrollmentToken = request.auth
|
||||
data = EnrollSerializer(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
device, _ = Device.objects.get_or_create(
|
||||
device = Device.get_or_create(
|
||||
identifier=data.validated_data["device_serial"],
|
||||
name=data.validated_data["device_name"],
|
||||
defaults={
|
||||
"name": data.validated_data["device_name"],
|
||||
"expiring": False,
|
||||
"access_group": token.device_group,
|
||||
},
|
||||
|
||||
@@ -3,8 +3,8 @@ from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from django.db import models, transaction
|
||||
from django.db.models import OuterRef, Q, Subquery
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from model_utils.managers import InheritanceManager
|
||||
@@ -43,6 +43,15 @@ class Device(InternallyManagedMixin, ExpiringModel, AttributesMixin, PolicyBindi
|
||||
"DeviceAccessGroup", null=True, on_delete=models.SET_DEFAULT, default=None
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def get_or_create(identifier: str, name: str, defaults: dict[str, Any] | None = None) -> Device:
|
||||
defaults = defaults or {}
|
||||
try:
|
||||
return Device.objects.get(Q(identifier=identifier) | Q(name=name))
|
||||
except Device.DoesNotExist:
|
||||
return Device.objects.create(name=name, identifier=identifier, **defaults)
|
||||
|
||||
@property
|
||||
def cache_key_facts(self):
|
||||
return f"goauthentik.io/endpoints/devices/{self.device_uuid}/facts"
|
||||
@@ -187,7 +196,6 @@ class Connector(ScheduledModel, SerializerModel):
|
||||
|
||||
|
||||
class DeviceAccessGroup(AttributesMixin, SerializerModel, PolicyBindingModel):
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.urls import reverse
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.endpoints.connectors.agent.api.agent import (
|
||||
AgentAuthenticationResponse,
|
||||
)
|
||||
from authentik.common.oauth.constants import QS_LOGIN_HINT
|
||||
from authentik.endpoints.connectors.agent.api.agent import AgentAuthenticationResponse
|
||||
from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
DeviceAuthenticationToken,
|
||||
@@ -25,6 +26,7 @@ class AgentConnectorViewSetMixin:
|
||||
@extend_schema(
|
||||
request=OpenApiTypes.NONE,
|
||||
responses=AgentAuthenticationResponse(),
|
||||
parameters=[OpenApiParameter(QS_LOGIN_HINT, type=OpenApiTypes.STR, required=False)],
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
@@ -41,13 +43,12 @@ class AgentConnectorViewSetMixin:
|
||||
device_token=token,
|
||||
connector=token.device.connector.agentconnector,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"url": request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_enterprise_endpoints_connectors_agent:authenticate",
|
||||
kwargs={"token_uuid": auth_token.identifier},
|
||||
)
|
||||
),
|
||||
}
|
||||
final_url = request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_enterprise_endpoints_connectors_agent:authenticate",
|
||||
kwargs={"token_uuid": auth_token.identifier},
|
||||
)
|
||||
)
|
||||
if hint := request.query_params.get(QS_LOGIN_HINT):
|
||||
final_url += "?" + urlencode({QS_LOGIN_HINT: hint})
|
||||
return Response({"url": final_url})
|
||||
|
||||
@@ -127,8 +127,8 @@ class FleetController(BaseController[DBC]):
|
||||
self.logger.warning("Failed to sync conditional access CA", exc=exc)
|
||||
for host in self._paginate_hosts():
|
||||
serial = host["hardware_serial"]
|
||||
device, _ = Device.objects.get_or_create(
|
||||
identifier=serial, defaults={"name": host["hostname"], "expiring": False}
|
||||
device = Device.get_or_create(
|
||||
identifier=serial, name=host["hostname"], defaults={"expiring": False}
|
||||
)
|
||||
connection, _ = DeviceConnection.objects.update_or_create(
|
||||
device=device,
|
||||
|
||||
@@ -59,12 +59,7 @@ class GoogleChromeController(BaseController[GoogleChromeConnector]):
|
||||
# Remove deprecated string representation of deviceSignals
|
||||
response.pop("deviceSignal", None)
|
||||
signals = DeviceSignals(response["deviceSignals"])
|
||||
device, _ = Device.objects.update_or_create(
|
||||
identifier=signals["serialNumber"],
|
||||
defaults={
|
||||
"name": signals["hostname"],
|
||||
},
|
||||
)
|
||||
device = Device.get_or_create(identifier=signals["serialNumber"], name=signals["hostname"])
|
||||
conn, _ = DeviceConnection.objects.update_or_create(
|
||||
device=device,
|
||||
connector=self.connector,
|
||||
|
||||
@@ -38,7 +38,7 @@ from authentik.enterprise.models import (
|
||||
from authentik.tenants.utils import get_unique_identifier
|
||||
|
||||
CACHE_KEY_ENTERPRISE_LICENSE = "goauthentik.io/enterprise/license"
|
||||
CACHE_EXPIRY_ENTERPRISE_LICENSE = 3 * 60 * 60 # 2 Hours
|
||||
CACHE_EXPIRY_ENTERPRISE_LICENSE = 12 * 60 * 60 # 12 Hours
|
||||
|
||||
|
||||
@lru_cache
|
||||
@@ -219,22 +219,25 @@ class LicenseKey:
|
||||
external_user_count=self.get_external_user_count(),
|
||||
status=self.status(),
|
||||
)
|
||||
summary = asdict(self.summary())
|
||||
# Also cache the latest summary for the middleware
|
||||
cache.set(CACHE_KEY_ENTERPRISE_LICENSE, summary, timeout=CACHE_EXPIRY_ENTERPRISE_LICENSE)
|
||||
return usage
|
||||
|
||||
def summary(self) -> LicenseSummary:
|
||||
"""Summary of license status"""
|
||||
status = self.status()
|
||||
latest_valid = datetime.fromtimestamp(self.exp).replace(tzinfo=UTC)
|
||||
return LicenseSummary(
|
||||
summary = LicenseSummary(
|
||||
latest_valid=latest_valid,
|
||||
internal_users=self.internal_users,
|
||||
external_users=self.external_users,
|
||||
status=status,
|
||||
license_flags=self.license_flags,
|
||||
)
|
||||
cache.set(
|
||||
CACHE_KEY_ENTERPRISE_LICENSE,
|
||||
asdict(summary),
|
||||
timeout=CACHE_EXPIRY_ENTERPRISE_LICENSE,
|
||||
)
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
def cached_summary() -> LicenseSummary:
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.utils.translation import gettext as _
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.signals import deactivation_inhibit_cleanup
|
||||
from authentik.enterprise.core.revocation import revoke_user_access
|
||||
from authentik.enterprise.lifecycle.offboarding.models import OffboardingAction
|
||||
from authentik.events.models import Event, EventAction
|
||||
@@ -57,7 +58,8 @@ def offboard_user(
|
||||
|
||||
if action == OffboardingAction.DEACTIVATE:
|
||||
user.is_active = False
|
||||
user.save(update_fields=["is_active"])
|
||||
with deactivation_inhibit_cleanup(sessions=not revoke_sessions, tokens=not revoke_tokens):
|
||||
user.save(update_fields=["is_active"])
|
||||
elif action == OffboardingAction.DELETE:
|
||||
user.delete()
|
||||
else:
|
||||
|
||||
@@ -99,7 +99,7 @@ class WSFederationProviderSerializer(EnterpriseRequiredMixin, SAMLProviderSerial
|
||||
"url_wsfed",
|
||||
"url_issuer",
|
||||
]
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
|
||||
|
||||
|
||||
class WSFederationProviderViewSet(SAMLProviderViewSet):
|
||||
|
||||
@@ -94,8 +94,8 @@ def _binding_default(field_name: str) -> timedelta:
|
||||
class GrantRequestSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
|
||||
created_by = PartialUserSerializer(read_only=True)
|
||||
revoked_by = PartialUserSerializer(read_only=True)
|
||||
agent_owner = PartialUserSerializer(read_only=True)
|
||||
revoked_by = PartialUserSerializer(read_only=True, allow_null=True)
|
||||
agent_owner = PartialUserSerializer(read_only=True, allow_null=True)
|
||||
is_active = BooleanField(read_only=True)
|
||||
|
||||
target_objs = SerializerMethodField()
|
||||
|
||||
@@ -11,6 +11,7 @@ from dramatiq.composition import group
|
||||
from dramatiq.results.errors import ResultTimeout
|
||||
|
||||
from authentik.core.models import User, UserTypes
|
||||
from authentik.core.signals import deactivation_inhibit_cleanup
|
||||
from authentik.enterprise.core.revocation import revoke_user_access
|
||||
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
|
||||
from authentik.events.models import Event, EventAction
|
||||
@@ -89,7 +90,12 @@ class AccountLockdownStageView(StageView):
|
||||
if stage.set_unusable_password:
|
||||
user.set_unusable_password()
|
||||
if stage.deactivate_user:
|
||||
with sync_outgoing_inhibit_dispatch():
|
||||
with (
|
||||
sync_outgoing_inhibit_dispatch(),
|
||||
deactivation_inhibit_cleanup(
|
||||
sessions=not stage.delete_sessions, tokens=not stage.revoke_tokens
|
||||
),
|
||||
):
|
||||
user.save()
|
||||
return
|
||||
user.save()
|
||||
|
||||
39
authentik/events/consumer.py
Normal file
39
authentik/events/consumer.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""websocket Message consumer"""
|
||||
|
||||
from hashlib import sha256
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.exceptions import DenyConnection
|
||||
from channels.generic.websocket import JsonWebsocketConsumer
|
||||
from django.db import connection
|
||||
|
||||
from authentik.core.models import User
|
||||
|
||||
|
||||
def build_user_group(user: User):
|
||||
return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest()
|
||||
|
||||
|
||||
class ClientConsumer(JsonWebsocketConsumer):
|
||||
"""Consumer which sends django.contrib.messages Messages over WS.
|
||||
channel_name is saved into cache with user_id, and when a add_message is called"""
|
||||
|
||||
user: User | None = None
|
||||
|
||||
def connect(self):
|
||||
user = self.scope.get("user")
|
||||
if user is None or not user.is_authenticated:
|
||||
raise DenyConnection()
|
||||
self.user = user
|
||||
self.accept()
|
||||
async_to_sync(self.channel_layer.group_add)(build_user_group(self.user), self.channel_name)
|
||||
|
||||
def disconnect(self, code):
|
||||
if self.user:
|
||||
async_to_sync(self.channel_layer.group_discard)(
|
||||
build_user_group(self.user), self.channel_name
|
||||
)
|
||||
|
||||
def event_notification(self, event: dict):
|
||||
"""Event handler for new notifications"""
|
||||
self.send_json({"message_type": "notification.new", **event})
|
||||
@@ -29,6 +29,7 @@ from authentik.core.middleware import (
|
||||
)
|
||||
from authentik.core.models import Group, PropertyMapping, User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.consumer import build_user_group
|
||||
from authentik.events.context_processors.base import get_context_processors
|
||||
from authentik.events.utils import (
|
||||
cleanse_dict,
|
||||
@@ -50,7 +51,6 @@ from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.outposts.docker_tls import DockerInlineTLS
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
from authentik.root.ws.consumer import build_user_group
|
||||
from authentik.stages.email.models import EmailTemplates
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.tasks.models import TasksModel
|
||||
|
||||
59
authentik/events/tests/test_ws_client.py
Normal file
59
authentik/events/tests/test_ws_client.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.routing import URLRouter
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
EventAction,
|
||||
Notification,
|
||||
NotificationTransport,
|
||||
TransportMode,
|
||||
)
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.root import websocket
|
||||
|
||||
|
||||
class TestClientWS(TransactionTestCase):
|
||||
def setUp(self):
|
||||
self.user = create_test_user()
|
||||
|
||||
async def test_unauthenticated(self):
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertFalse(connected)
|
||||
|
||||
async def test_notification(self):
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
|
||||
)
|
||||
communicator.scope["user"] = self.user
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
transport = await NotificationTransport.objects.acreate(
|
||||
name=generate_id(), mode=TransportMode.LOCAL
|
||||
)
|
||||
event = await sync_to_async(Event.new)(EventAction.LOGIN)
|
||||
event.set_user(self.user)
|
||||
await event.asave()
|
||||
notification = Notification(
|
||||
user=self.user,
|
||||
body="foo",
|
||||
event=event,
|
||||
hyperlink="goauthentik.io",
|
||||
hyperlink_label="a link",
|
||||
)
|
||||
await sync_to_async(transport.send_local)(notification)
|
||||
|
||||
evt = await communicator.receive_json_from(timeout=5)
|
||||
self.assertEqual(evt["message_type"], "notification.new")
|
||||
self.assertEqual(evt["id"], str(notification.pk))
|
||||
self.assertEqual(evt["data"]["pk"], str(notification.pk))
|
||||
self.assertEqual(evt["data"]["body"], "foo")
|
||||
self.assertEqual(evt["data"]["event"]["pk"], str(event.pk))
|
||||
|
||||
await communicator.disconnect()
|
||||
@@ -24,14 +24,6 @@ HIST_FLOWS_PLAN_TIME = Histogram(
|
||||
)
|
||||
|
||||
|
||||
class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others"):
|
||||
|
||||
default = False
|
||||
visibility = "public"
|
||||
description = _("Refresh other tabs after successful authentication.")
|
||||
deprecated = True
|
||||
|
||||
|
||||
class ContinuousLogin(Flag[bool], key="flows_continuous_login"):
|
||||
|
||||
default = False
|
||||
|
||||
@@ -5,6 +5,7 @@ from enum import Enum
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
from uuid import UUID
|
||||
|
||||
from django.contrib.messages import DEFAULT_TAGS
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
from django.http import JsonResponse
|
||||
@@ -42,6 +43,16 @@ class ErrorDetailSerializer(PassiveSerializer):
|
||||
code = CharField()
|
||||
|
||||
|
||||
FLOW_MESSAGE_LEVELS = list(DEFAULT_TAGS.values())
|
||||
|
||||
|
||||
class FlowMessageSerializer(PassiveSerializer):
|
||||
"""Serializer for a django.contrib.messages message"""
|
||||
|
||||
level = ChoiceField(choices=FLOW_MESSAGE_LEVELS, source="level_tag")
|
||||
message = CharField()
|
||||
|
||||
|
||||
class ContextualFlowInfo(PassiveSerializer):
|
||||
"""Contextual flow information for a challenge"""
|
||||
|
||||
@@ -50,6 +61,7 @@ class ContextualFlowInfo(PassiveSerializer):
|
||||
background_themed_urls = ThemedUrlsSerializer(required=False, allow_null=True)
|
||||
cancel_url = CharField()
|
||||
layout = ChoiceField(choices=[(x.value, x.name) for x in FlowLayout])
|
||||
messages = FlowMessageSerializer(many=True, required=False)
|
||||
|
||||
|
||||
class Challenge(PassiveSerializer):
|
||||
@@ -179,7 +191,6 @@ class FrameChallenge(Challenge):
|
||||
|
||||
|
||||
class FrameChallengeResponse(ChallengeResponse):
|
||||
|
||||
component = CharField(default="xak-flow-frame")
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from urllib.parse import urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.messages import get_messages
|
||||
from django.http import HttpRequest
|
||||
from django.http.request import QueryDict
|
||||
from django.http.response import HttpResponse
|
||||
@@ -22,6 +23,7 @@ from authentik.flows.challenge import (
|
||||
Challenge,
|
||||
ChallengeResponse,
|
||||
ContextualFlowInfo,
|
||||
FlowMessageSerializer,
|
||||
HttpChallengeResponse,
|
||||
RedirectChallenge,
|
||||
SessionEndChallenge,
|
||||
@@ -194,6 +196,9 @@ class ChallengeStageView(StageView):
|
||||
if not hasattr(challenge, "initial_data"):
|
||||
challenge.initial_data = {}
|
||||
if "flow_info" not in challenge.initial_data:
|
||||
messages = []
|
||||
if self.request is not None and not isinstance(challenge, RedirectChallenge):
|
||||
messages = get_messages(self.request)
|
||||
# Flow payloads can outlive the previous signed media JWT, so
|
||||
# refreshes must mint fresh URLs instead of reusing cached ones.
|
||||
flow_info = ContextualFlowInfo(
|
||||
@@ -205,7 +210,8 @@ class ChallengeStageView(StageView):
|
||||
),
|
||||
"cancel_url": self.cancel_url,
|
||||
"layout": self.executor.flow.layout,
|
||||
}
|
||||
"messages": FlowMessageSerializer(messages, many=True).data,
|
||||
},
|
||||
)
|
||||
flow_info.is_valid()
|
||||
challenge.initial_data["flow_info"] = flow_info.data
|
||||
|
||||
@@ -822,6 +822,7 @@ class TestFlowExecutor(FlowTestCase):
|
||||
"cancel_url": "/flows/-/cancel/?next=%2Ffoo",
|
||||
"layout": "stacked",
|
||||
"title": flow.title,
|
||||
"messages": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ class TestFlowInspector(APITestCase):
|
||||
"cancel_url": reverse("authentik_flows:cancel"),
|
||||
"title": flow.title,
|
||||
"layout": "stacked",
|
||||
"messages": [],
|
||||
},
|
||||
"flow_designation": "authentication",
|
||||
"passkey_challenge": None,
|
||||
|
||||
116
authentik/flows/tests/test_messages.py
Normal file
116
authentik/flows/tests/test_messages.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for messages attached to challenges"""
|
||||
|
||||
from django.contrib.messages import add_message
|
||||
from django.contrib.messages.constants import SUCCESS, WARNING
|
||||
from django.contrib.messages.storage.session import SessionStorage
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.flows.challenge import Challenge, ChallengeResponse
|
||||
from authentik.flows.models import FlowStageBinding, in_memory_stage
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.stage import ChallengeStageView, StageView
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.stages.dummy.models import DummyStage
|
||||
|
||||
|
||||
class MessageStageView(StageView):
|
||||
"""Stage which queues a message and continues to the next stage"""
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
add_message(request, SUCCESS, "stage message")
|
||||
return self.executor.stage_ok()
|
||||
|
||||
|
||||
class MessageChallengeStageView(ChallengeStageView):
|
||||
"""Stage which queues a message while rendering its challenge"""
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
add_message(self.request, WARNING, "challenge message")
|
||||
return Challenge(data={"component": "ak-stage-dummy"})
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
return self.executor.stage_ok()
|
||||
|
||||
|
||||
class TestFlowMessages(FlowTestCase):
|
||||
"""Test messages attached to challenges"""
|
||||
|
||||
def setUp(self):
|
||||
self.flow = create_test_flow()
|
||||
self.url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||
|
||||
def test_challenge(self):
|
||||
"""Test message queued while the challenge is rendered"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex)
|
||||
plan.append_stage(in_memory_stage(MessageChallengeStageView))
|
||||
self.set_flow_plan(plan)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
raw_response = self.assertStageResponse(response, self.flow)
|
||||
self.assertEqual(
|
||||
raw_response["flow_info"]["messages"],
|
||||
[{"level": "warning", "message": "challenge message"}],
|
||||
)
|
||||
|
||||
def test_challenge_not_repeated(self):
|
||||
"""Test that a message is only ever attached to a single challenge"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex)
|
||||
plan.append_stage(in_memory_stage(MessageChallengeStageView))
|
||||
self.set_flow_plan(plan)
|
||||
|
||||
self.client.get(self.url)
|
||||
# The stage queues a new message on every render, so the second challenge only
|
||||
# contains the message queued for it
|
||||
response = self.client.get(self.url)
|
||||
raw_response = self.assertStageResponse(response, self.flow)
|
||||
self.assertEqual(
|
||||
raw_response["flow_info"]["messages"],
|
||||
[{"level": "warning", "message": "challenge message"}],
|
||||
)
|
||||
|
||||
def test_previous_stage(self):
|
||||
"""Test message queued by a stage which doesn't render a challenge itself, it is
|
||||
attached to the challenge of the next stage"""
|
||||
FlowStageBinding.objects.create(
|
||||
target=self.flow, stage=DummyStage.objects.create(name="dummy"), order=0
|
||||
)
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex)
|
||||
plan.append_stage(in_memory_stage(MessageStageView))
|
||||
plan.append(FlowStageBinding.objects.filter(target=self.flow).first())
|
||||
self.set_flow_plan(plan)
|
||||
|
||||
response = self.client.get(self.url, follow=True)
|
||||
raw_response = self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
component="ak-stage-dummy",
|
||||
)
|
||||
self.assertEqual(
|
||||
raw_response["flow_info"]["messages"],
|
||||
[{"level": "success", "message": "stage message"}],
|
||||
)
|
||||
|
||||
def test_redirect_challenge(self):
|
||||
"""Test message queued by the last stage of a flow. The client navigates away as soon
|
||||
as it gets the redirect challenge the flow finishes with, so the message is left
|
||||
queued for the page we redirect to"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex)
|
||||
plan.append_stage(in_memory_stage(MessageStageView))
|
||||
self.set_flow_plan(plan)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
raw_response = self.assertStageResponse(response, component="xak-flow-redirect")
|
||||
self.assertNotIn("messages", raw_response.get("flow_info", {}))
|
||||
self.assertIn("stage message", self.client.session[SessionStorage.session_key])
|
||||
|
||||
def test_no_messages(self):
|
||||
"""Test that challenges without messages have an empty list"""
|
||||
FlowStageBinding.objects.create(
|
||||
target=self.flow, stage=DummyStage.objects.create(name="dummy"), order=0
|
||||
)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
raw_response = self.assertStageResponse(response)
|
||||
self.assertEqual(raw_response["flow_info"]["messages"], [])
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import re
|
||||
import socket
|
||||
from copy import deepcopy
|
||||
from ipaddress import ip_address, ip_network
|
||||
from smtplib import SMTPException
|
||||
from textwrap import indent
|
||||
@@ -174,8 +175,8 @@ class BaseEvaluator:
|
||||
return fallback value."""
|
||||
attrs = getattr(obj, "attributes", {})
|
||||
value = get_path_from_dict(attrs, attr_key)
|
||||
if value is None and fallback:
|
||||
return getattr(obj, fallback)
|
||||
if value is None and fallback is not None:
|
||||
return getattr(obj, fallback, fallback)
|
||||
return value
|
||||
|
||||
def expr_event_create(self, action: str, **kwargs):
|
||||
@@ -207,7 +208,7 @@ class BaseEvaluator:
|
||||
user = self._context.get("user", get_anonymous_user())
|
||||
req = PolicyRequest(user)
|
||||
if "request" in self._context:
|
||||
req = self._context["request"]
|
||||
req = deepcopy(self._context["request"])
|
||||
req.context.update(kwargs)
|
||||
proc = PolicyThread(PolicyBinding(policy=policy), request=req)
|
||||
return proc.profiling_wrapper()
|
||||
|
||||
@@ -39,6 +39,17 @@ class TestEvaluator(TestCase):
|
||||
"""Test expr_is_group_member"""
|
||||
self.assertFalse(BaseEvaluator.expr_is_group_member(create_test_admin_user(), name="test"))
|
||||
|
||||
def test_expr_obj_attr(self):
|
||||
"""Test expr_obj_attr"""
|
||||
user = create_test_user()
|
||||
user.attributes = {"locale": "en-US"}
|
||||
|
||||
self.assertEqual(BaseEvaluator.expr_obj_attr(user, "locale", "en-GB"), "en-US")
|
||||
self.assertEqual(BaseEvaluator.expr_obj_attr(user, "missing", "username"), user.username)
|
||||
self.assertEqual(BaseEvaluator.expr_obj_attr(user, "missing", "en-GB"), "en-GB")
|
||||
self.assertEqual(BaseEvaluator.expr_obj_attr(user, "missing", ""), "")
|
||||
self.assertIsNone(BaseEvaluator.expr_obj_attr(user, "missing"))
|
||||
|
||||
def test_expr_event_create(self):
|
||||
"""Test expr_event_create"""
|
||||
evaluator = BaseEvaluator(generate_id())
|
||||
|
||||
@@ -22,6 +22,7 @@ from authentik.outposts.models import (
|
||||
DockerServiceConnection,
|
||||
Outpost,
|
||||
OutpostServiceConnectionState,
|
||||
OutpostType,
|
||||
ServiceConnectionInvalid,
|
||||
)
|
||||
|
||||
@@ -195,6 +196,10 @@ class DockerController(BaseController):
|
||||
except NotFound:
|
||||
self.logger.info("(Re-)creating container...")
|
||||
image_name = self.try_pull_image()
|
||||
# Go outposts have a different syntax for this than the rust proxy outpost
|
||||
healthcheck_cmd = [f"/{self.outpost.type}", "healthcheck"]
|
||||
if self.outpost.type == OutpostType.PROXY:
|
||||
healthcheck_cmd = ["/authentik", "healthcheck", self.outpost.type]
|
||||
container_args = {
|
||||
"image": image_name,
|
||||
"name": self.name,
|
||||
@@ -204,7 +209,7 @@ class DockerController(BaseController):
|
||||
"restart_policy": {"Name": "unless-stopped"},
|
||||
"network": self.outpost.config.docker_network,
|
||||
"healthcheck": {
|
||||
"test": ["CMD", f"/{self.outpost.type}", "healthcheck"],
|
||||
"test": ["CMD", *healthcheck_cmd],
|
||||
"interval": 5 * 1_000 * 1_000_000,
|
||||
"retries": 20,
|
||||
"start_period": 3 * 1_000 * 1_000_000,
|
||||
|
||||
@@ -111,6 +111,25 @@ class TestEvaluator(TestCase):
|
||||
res = proc.profiling_wrapper()
|
||||
self.assertEqual(res.messages, ("/", "/", "/"))
|
||||
|
||||
def test_call_policy_kwargs_pollute(self):
|
||||
"""test ak_call_policy"""
|
||||
expr = ExpressionPolicy.objects.create(
|
||||
name=generate_id(),
|
||||
execution_logging=True,
|
||||
expression="return context.get('subkey', False)",
|
||||
)
|
||||
expr2 = ExpressionPolicy.objects.create(
|
||||
name=generate_id(),
|
||||
execution_logging=True,
|
||||
expression=f"""
|
||||
ak_message(ak_call_policy('{expr.name}', subkey=True).passing)
|
||||
ak_message(ak_call_policy('{expr.name}').passing)
|
||||
""",
|
||||
)
|
||||
proc = PolicyProcess(PolicyBinding(policy=expr2), request=self.request, connection=None)
|
||||
res = proc.profiling_wrapper()
|
||||
self.assertEqual(res.messages, (True, False))
|
||||
|
||||
def test_call_policy_test_like(self):
|
||||
"""test ak_call_policy without `obj` set, as if it was when testing policies"""
|
||||
expr = ExpressionPolicy.objects.create(
|
||||
|
||||
@@ -43,7 +43,7 @@ class LDAPProviderSerializer(ProviderSerializer):
|
||||
"bind_mode",
|
||||
"mfa_support",
|
||||
]
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
|
||||
|
||||
|
||||
class LDAPProviderFilter(FilterSet):
|
||||
|
||||
@@ -95,7 +95,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
"jwt_federation_sources",
|
||||
"jwt_federation_providers",
|
||||
]
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
|
||||
|
||||
|
||||
class OAuth2ProviderSetupURLs(PassiveSerializer):
|
||||
|
||||
@@ -8,6 +8,7 @@ from authentik.common.oauth.constants import (
|
||||
PLAN_CONTEXT_OIDC_LOGOUT_IFRAME_SESSIONS,
|
||||
)
|
||||
from authentik.core.models import AuthenticatedSession, ProviderPropertyMapping, User
|
||||
from authentik.core.signals import deactivation_token_cleanup_inhibited
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.providers.iframe_logout import IframeLogoutStageView
|
||||
from authentik.providers.oauth2.models import (
|
||||
@@ -122,6 +123,8 @@ def user_deactivated(sender, instance: User, **_):
|
||||
"""Remove user tokens when deactivated"""
|
||||
if instance.is_active:
|
||||
return
|
||||
if deactivation_token_cleanup_inhibited():
|
||||
return
|
||||
AccessToken.objects.including_expired().filter(user=instance).delete()
|
||||
RefreshToken.objects.including_expired().filter(user=instance).delete()
|
||||
DeviceToken.objects.including_expired().filter(user=instance).delete()
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"""Test OAuth2 Back-Channel Logout implementation"""
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import jwt
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
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
|
||||
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.id_token import IDToken, hash_session_key
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
OAuth2LogoutMethod,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
@@ -191,3 +196,129 @@ class TestBackChannelLogout(OAuthTestCase):
|
||||
send_backchannel_logout_request.send(
|
||||
self.provider.pk, "http://testserver", sub="test-user-uid"
|
||||
).get_result()
|
||||
|
||||
@patch("authentik.providers.oauth2.tasks.get_http_session")
|
||||
def test_frontchannel_provider_no_backchannel(self, mock_get_session):
|
||||
"""Deleting a session does not back-channel a front-channel provider"""
|
||||
self.provider.logout_uri = "http://testserver/logout"
|
||||
self.provider.logout_method = OAuth2LogoutMethod.FRONTCHANNEL
|
||||
self.provider.save()
|
||||
mock_session = Mock()
|
||||
mock_get_session.return_value = mock_session
|
||||
|
||||
session = Session.objects.create(
|
||||
session_key=generate_id(),
|
||||
last_ip="255.255.255.255",
|
||||
last_user_agent="",
|
||||
)
|
||||
auth_session = AuthenticatedSession.objects.create(session=session, user=self.user)
|
||||
AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
session=auth_session,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(asdict(IDToken(iss="http://testserver", sub=str(self.user.uid)))),
|
||||
)
|
||||
|
||||
session.delete()
|
||||
|
||||
mock_session.post.assert_not_called()
|
||||
|
||||
|
||||
class TestBackChannelLogoutUserDeactivation(OAuthTestCase):
|
||||
"""Test that deactivating a user triggers back-channel logout"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.admin = create_test_admin_user()
|
||||
self.user = create_test_admin_user()
|
||||
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,
|
||||
logout_uri="http://testserver/backchannel_logout",
|
||||
logout_method=OAuth2LogoutMethod.BACKCHANNEL,
|
||||
)
|
||||
self.app = Application.objects.create(
|
||||
name=generate_id(), slug=generate_id(), provider=self.provider
|
||||
)
|
||||
self.session_key = generate_id()
|
||||
self.session = Session.objects.create(
|
||||
session_key=self.session_key,
|
||||
last_ip="255.255.255.255",
|
||||
last_user_agent="",
|
||||
)
|
||||
self.auth_session = AuthenticatedSession.objects.create(
|
||||
session=self.session,
|
||||
user=self.user,
|
||||
)
|
||||
self.token = AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
session=self.auth_session,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(asdict(IDToken(iss="http://testserver", sub=str(self.user.uid)))),
|
||||
)
|
||||
|
||||
def _mock_http(self, mock_get_session: Mock) -> Mock:
|
||||
"""Wire up a mocked HTTP session that always succeeds"""
|
||||
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
|
||||
mock_session.post.return_value = mock_response
|
||||
return mock_session
|
||||
|
||||
def _assert_logout_token_sent(self, mock_session: Mock):
|
||||
"""Assert a single logout token was POSTed to the provider's logout URI"""
|
||||
mock_session.post.assert_called_once()
|
||||
args, kwargs = mock_session.post.call_args
|
||||
self.assertEqual(args[0], self.provider.logout_uri)
|
||||
key, alg = self.provider.jwt_key
|
||||
if alg != "HS256":
|
||||
key = self.provider.signing_key.public_key
|
||||
decoded = jwt.decode(
|
||||
kwargs["data"]["logout_token"],
|
||||
key,
|
||||
algorithms=[alg],
|
||||
options={"verify_exp": False, "verify_aud": False},
|
||||
)
|
||||
self.assertEqual(decoded["sub"], str(self.user.uid))
|
||||
self.assertEqual(decoded["sid"], hash_session_key(self.session_key))
|
||||
self.assertIn("http://schemas.openid.net/event/backchannel-logout", decoded["events"])
|
||||
|
||||
@patch("authentik.providers.oauth2.tasks.get_http_session")
|
||||
def test_user_deactivated_model(self, mock_get_session):
|
||||
"""Deactivating a user directly sends back-channel logout"""
|
||||
mock_session = self._mock_http(mock_get_session)
|
||||
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
|
||||
self._assert_logout_token_sent(mock_session)
|
||||
self.assertFalse(Session.objects.filter(session_key=self.session_key).exists())
|
||||
self.assertEqual(AccessToken.objects.including_expired().filter(user=self.user).count(), 0)
|
||||
|
||||
@patch("authentik.providers.oauth2.tasks.get_http_session")
|
||||
def test_user_deactivated_api(self, mock_get_session):
|
||||
"""Deactivating a user through the API sends back-channel logout"""
|
||||
mock_session = self._mock_http(mock_get_session)
|
||||
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.patch(
|
||||
reverse("authentik_api:user-detail", kwargs={"pk": self.user.pk}),
|
||||
data=json.dumps({"is_active": False}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self._assert_logout_token_sent(mock_session)
|
||||
self.assertFalse(Session.objects.filter(session_key=self.session_key).exists())
|
||||
|
||||
@@ -93,6 +93,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
"cancel_url": "/flows/-/cancel/",
|
||||
"layout": "stacked",
|
||||
"title": self.device_flow.title,
|
||||
"messages": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from authentik.core.models import Application, AuthenticatedSession, Session
|
||||
from authentik.core.signals import deactivation_inhibit_cleanup
|
||||
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
|
||||
@@ -233,6 +234,67 @@ class TesOAuth2Revoke(OAuthTestCase):
|
||||
self.assertEqual(RefreshToken.objects.including_expired().all().count(), 0)
|
||||
self.assertEqual(DeviceToken.objects.including_expired().all().count(), 0)
|
||||
|
||||
def test_revoke_user_deactivated_inhibited(self):
|
||||
"""Test tokens are kept when deactivation cleanup is inhibited"""
|
||||
AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
IDToken("foo", "bar"),
|
||||
)
|
||||
),
|
||||
)
|
||||
RefreshToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
IDToken("foo", "bar"),
|
||||
)
|
||||
),
|
||||
)
|
||||
DeviceToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
_scope="openid user profile",
|
||||
)
|
||||
|
||||
self.user.is_active = False
|
||||
with deactivation_inhibit_cleanup():
|
||||
self.user.save()
|
||||
|
||||
self.assertEqual(AccessToken.objects.including_expired().all().count(), 1)
|
||||
self.assertEqual(RefreshToken.objects.including_expired().all().count(), 1)
|
||||
self.assertEqual(DeviceToken.objects.including_expired().all().count(), 1)
|
||||
|
||||
def test_revoke_user_deactivated_inhibit_sessions_only(self):
|
||||
"""Test tokens are still revoked when only session cleanup is inhibited"""
|
||||
AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
token=generate_id(),
|
||||
auth_time=timezone.now(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
IDToken("foo", "bar"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
self.user.is_active = False
|
||||
with deactivation_inhibit_cleanup(sessions=True, tokens=False):
|
||||
self.user.save()
|
||||
|
||||
self.assertEqual(AccessToken.objects.including_expired().all().count(), 0)
|
||||
|
||||
def test_revoke_provider_fed(self):
|
||||
"""Test revoke with federation. self.provider is a confidential
|
||||
client and other_provider is a public client."""
|
||||
|
||||
@@ -99,16 +99,22 @@ class TestTokenExchange(OAuthTestCase):
|
||||
self.user = create_test_user()
|
||||
self.subject_token = self.create_subject_token(self.user)
|
||||
|
||||
def create_subject_token(self, user: User, expires_in: timedelta = timedelta(hours=2)) -> str:
|
||||
def create_subject_token(
|
||||
self,
|
||||
user: User,
|
||||
expires_in: timedelta = timedelta(hours=2),
|
||||
provider: OAuth2Provider | None = None,
|
||||
) -> str:
|
||||
"""Issue an access token from the federated provider, usable as a subject token"""
|
||||
token = self.other_provider.encode(
|
||||
provider = provider or self.other_provider
|
||||
token = provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + expires_in,
|
||||
}
|
||||
)
|
||||
AccessToken.objects.create(
|
||||
provider=self.other_provider,
|
||||
provider=provider,
|
||||
token=token,
|
||||
user=user,
|
||||
auth_time=now(),
|
||||
@@ -307,6 +313,45 @@ class TestTokenExchange(OAuthTestCase):
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["scope"], SCOPE_OPENID)
|
||||
|
||||
def test_audience_subject_token_from_self(self):
|
||||
"""test that a token the requesting provider issued for itself is a valid subject
|
||||
token when `audience` targets another provider -- the target has already opted into
|
||||
the requesting provider, which is what authorizes the exchange"""
|
||||
subject_token = self.create_subject_token(self.user, provider=self.provider)
|
||||
response = self._exchange(
|
||||
subject_token=subject_token, audience=self.target_provider.client_id
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
body = loads(response.content.decode())
|
||||
|
||||
jwt = self._decode_for(self.target_provider, body["access_token"])
|
||||
self.assertEqual(jwt["aud"], self.target_provider.client_id)
|
||||
access_token = AccessToken.objects.get(token=body["access_token"])
|
||||
self.assertEqual(access_token.provider_id, self.target_provider.pk)
|
||||
self.assertEqual(access_token.user_id, self.user.pk)
|
||||
|
||||
def test_subject_token_from_self_without_audience(self):
|
||||
"""test that a self-issued subject token is only accepted when `audience` targets
|
||||
another provider -- naming the requesting provider itself does not widen the trust,
|
||||
since that is the default behavior and no target opted in"""
|
||||
subject_token = self.create_subject_token(self.user, provider=self.provider)
|
||||
for audience in [None, self.provider.client_id]:
|
||||
with self.subTest(audience=audience):
|
||||
extra = {"audience": audience} if audience else {}
|
||||
response = self._exchange(subject_token=subject_token, **extra)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(loads(response.content.decode())["error"], "invalid_grant")
|
||||
|
||||
def test_audience_subject_token_from_self_not_federated(self):
|
||||
"""test that a self-issued subject token does not bypass the target's opt-in"""
|
||||
self.target_provider.jwt_federation_providers.clear()
|
||||
subject_token = self.create_subject_token(self.user, provider=self.provider)
|
||||
response = self._exchange(
|
||||
subject_token=subject_token, audience=self.target_provider.client_id
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(loads(response.content.decode())["error"], "invalid_target")
|
||||
|
||||
def test_resource_rejected(self):
|
||||
"""test that a requested resource is refused rather than silently ignored"""
|
||||
response = self.client.post(
|
||||
@@ -536,17 +581,18 @@ class TestTokenExchange(OAuthTestCase):
|
||||
def _decode(self, access_token: str) -> dict:
|
||||
return self._decode_for(self.provider, access_token)
|
||||
|
||||
def _actor_token_jwt(self, actor: Actor) -> str:
|
||||
def _actor_token_jwt(self, actor: Actor, provider: OAuth2Provider | None = None) -> str:
|
||||
"""Issue an access token for `actor` from the federated provider, usable as a
|
||||
JWT actor_token"""
|
||||
token = self.other_provider.encode(
|
||||
provider = provider or self.other_provider
|
||||
token = provider.encode(
|
||||
{
|
||||
"sub": "actor",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
AccessToken.objects.create(
|
||||
provider=self.other_provider,
|
||||
provider=provider,
|
||||
token=token,
|
||||
user=actor,
|
||||
auth_time=now(),
|
||||
@@ -652,6 +698,59 @@ class TestTokenExchange(OAuthTestCase):
|
||||
access_token = AccessToken.objects.get(token=body["access_token"])
|
||||
self.assertEqual(access_token.actor_id, actor.pk)
|
||||
|
||||
def test_actor_token_jwt_from_self_with_audience(self):
|
||||
"""test that an actor_token issued by the requesting provider itself is accepted
|
||||
when `audience` targets another provider, matching the subject_token trust set"""
|
||||
actor = Actor.for_user(self.user, ActorPolicyInheritance.NONE)
|
||||
actor_token = self._actor_token_jwt(actor, provider=self.provider)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": actor_token,
|
||||
"actor_token_type": TOKEN_TYPE_URI_JWT,
|
||||
"audience": self.target_provider.client_id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
body = loads(response.content.decode())
|
||||
|
||||
jwt = self._decode_for(self.target_provider, body["access_token"])
|
||||
self.assertIn("act", jwt)
|
||||
self.assertEqual(jwt["act"]["sub"], actor.uid)
|
||||
|
||||
access_token = AccessToken.objects.get(token=body["access_token"])
|
||||
self.assertEqual(access_token.provider_id, self.target_provider.pk)
|
||||
self.assertEqual(access_token.actor_id, actor.pk)
|
||||
|
||||
def test_actor_token_jwt_from_self_without_audience(self):
|
||||
"""test that an actor_token issued by the requesting provider itself is rejected
|
||||
without an `audience` targeting another provider"""
|
||||
actor = Actor.for_user(self.user, ActorPolicyInheritance.NONE)
|
||||
actor_token = self._actor_token_jwt(actor, provider=self.provider)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
"scope": SCOPES,
|
||||
"client_id": self.provider.client_id,
|
||||
"client_secret": self.provider.client_secret,
|
||||
"subject_token": self.subject_token,
|
||||
"subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN,
|
||||
"actor_token": actor_token,
|
||||
"actor_token_type": TOKEN_TYPE_URI_JWT,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(loads(response.content.decode())["error"], "invalid_grant")
|
||||
|
||||
def test_actor_token_unowned_builtin_rejected(self):
|
||||
"""test that an actor with no owner cannot be delegated to via a built-in Token
|
||||
actor_token -- only JWTs are supported for ownerless actors"""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
from jwt import PyJWK, PyJWT, PyJWTError, decode
|
||||
@@ -111,9 +112,12 @@ class FederatedTokenRequest(TokenRequest):
|
||||
|
||||
def validate_jwt_from_provider(self, assertion: str) -> FederatedParty | None:
|
||||
token = provider = resolved_user = _key = None
|
||||
federated_token = AccessToken.objects.filter(
|
||||
token=assertion, provider__in=self.provider.jwt_federation_providers.all()
|
||||
).first()
|
||||
providers = Q(provider__in=self.provider.jwt_federation_providers.all())
|
||||
if self.audience_provider:
|
||||
# For token exchange with actor, the given token may likely be a token
|
||||
# of the provider this request is for
|
||||
providers |= Q(provider=self.provider)
|
||||
federated_token = AccessToken.objects.filter(providers, token=assertion).first()
|
||||
if federated_token:
|
||||
_key, _alg = federated_token.provider.jwt_key
|
||||
try:
|
||||
|
||||
@@ -100,7 +100,7 @@ class ProxyProviderSerializer(ProviderSerializer):
|
||||
"refresh_token_validity",
|
||||
"outpost_set",
|
||||
]
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
|
||||
|
||||
|
||||
class ProxyProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@@ -52,7 +52,7 @@ class RadiusProviderSerializer(
|
||||
"mfa_support",
|
||||
"certificate",
|
||||
]
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
|
||||
|
||||
|
||||
class RadiusProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@@ -286,7 +286,7 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"url_slo_post",
|
||||
"url_slo_redirect",
|
||||
]
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
||||
extra_kwargs = ProviderSerializer.Meta.extra_write_kwargs
|
||||
|
||||
|
||||
class SAMLMetadataSerializer(PassiveSerializer):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""SAML Provider signals"""
|
||||
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
@@ -228,37 +228,3 @@ def user_session_deleted_saml_logout(sender, instance: AuthenticatedSession, **_
|
||||
session_index=saml_session.session_index,
|
||||
issuer=saml_session.issuer,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def user_deactivated_saml_logout(sender, instance: User, **kwargs):
|
||||
"""Send SAML backchannel logout requests when user is deactivated"""
|
||||
if instance.is_active:
|
||||
return
|
||||
|
||||
backchannel_saml_sessions = (
|
||||
SAMLSession.objects.filter(
|
||||
user=instance,
|
||||
provider__logout_method=SAMLLogoutMethods.BACKCHANNEL,
|
||||
provider__sls_binding=SAMLBindings.POST,
|
||||
)
|
||||
.exclude(provider__sls_url="")
|
||||
.select_related("provider")
|
||||
)
|
||||
|
||||
for saml_session in backchannel_saml_sessions:
|
||||
LOGGER.info(
|
||||
"Triggering backchannel SAML logout for deactivated user",
|
||||
user=instance,
|
||||
provider=saml_session.provider.name,
|
||||
session_index=saml_session.session_index,
|
||||
)
|
||||
|
||||
send_saml_logout_request.send(
|
||||
provider_pk=saml_session.provider.pk,
|
||||
sls_url=saml_session.provider.sls_url,
|
||||
name_id=saml_session.name_id,
|
||||
name_id_format=saml_session.name_id_format,
|
||||
session_index=saml_session.session_index,
|
||||
issuer=saml_session.issuer,
|
||||
)
|
||||
|
||||
@@ -171,7 +171,15 @@ class GroupMember(BaseGroupMember):
|
||||
|
||||
|
||||
class Bulk(BaseBulk):
|
||||
maxOperations: int = Field()
|
||||
# RFC 7644 Section 5 only defines the bulk limits alongside bulk support, so a
|
||||
# service provider that answers `"bulk": {"supported": false}` and nothing else is
|
||||
# conforming. Requiring the field made that response fail validation, which sent
|
||||
# `get_service_provider_config()` to its fallback and reported `patch` and `filter`
|
||||
# as unsupported regardless of what the provider actually advertised.
|
||||
#
|
||||
# 0 is the value the fallback itself uses, and `_patch_chunked` already reads any
|
||||
# value below 1 as "no limit declared".
|
||||
maxOperations: int = Field(default=0)
|
||||
|
||||
|
||||
class ServiceProviderConfiguration(BaseServiceProviderConfiguration):
|
||||
|
||||
@@ -68,7 +68,7 @@ class SCIMClientTests(TestCase):
|
||||
}
|
||||
],
|
||||
"patch": {"supported": True},
|
||||
"bulk": {"supported": False, "maxOperations": 1, "maxPayloadSize": 1048576},
|
||||
"bulk": {"supported": False},
|
||||
"filter": {"supported": True, "maxResults": 50},
|
||||
"changePassword": {"supported": False},
|
||||
"sort": {"supported": False},
|
||||
@@ -80,6 +80,35 @@ class SCIMClientTests(TestCase):
|
||||
self.assertEqual(mock.request_history[0].method, "GET")
|
||||
self.assertFalse(client._config.is_fallback)
|
||||
|
||||
def test_config_bulk_unsupported_without_max_operations(self):
|
||||
"""A provider that reports bulk as unsupported may omit the bulk limits.
|
||||
|
||||
RFC 7644 Section 5 only defines maxOperations alongside bulk support, so
|
||||
such a response is conforming and must not push the client onto its
|
||||
fallback config -- doing so reports patch and filter as unsupported
|
||||
regardless of what the provider advertised, which silently disables
|
||||
PATCH updates and the filtered lookup that adopts existing objects."""
|
||||
with Mocker() as mock:
|
||||
mock: Mocker
|
||||
mock.get(
|
||||
"https://localhost/ServiceProviderConfig",
|
||||
json={
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
|
||||
"authenticationSchemes": [],
|
||||
"patch": {"supported": True},
|
||||
"bulk": {"supported": False},
|
||||
"filter": {"supported": True, "maxResults": 50},
|
||||
"changePassword": {"supported": False},
|
||||
"sort": {"supported": False},
|
||||
"etag": {"supported": False},
|
||||
},
|
||||
)
|
||||
client = SCIMClient(self.provider)
|
||||
self.assertFalse(client._config.is_fallback)
|
||||
self.assertTrue(client._config.patch.supported)
|
||||
self.assertTrue(client._config.filter.supported)
|
||||
self.assertEqual(client._config.bulk.maxOperations, 0)
|
||||
|
||||
def test_config_invalid(self):
|
||||
"""Test invalid config"""
|
||||
with Mocker() as mock:
|
||||
|
||||
@@ -193,6 +193,7 @@ SPECTACULAR_SETTINGS = {
|
||||
"EventActions": "authentik.events.models.EventAction",
|
||||
"FlowDesignationEnum": "authentik.flows.models.FlowDesignation",
|
||||
"FlowLayoutEnum": "authentik.flows.models.FlowLayout",
|
||||
"FlowMessageLevelEnum": "authentik.flows.challenge.FLOW_MESSAGE_LEVELS",
|
||||
"LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode",
|
||||
"ModelEnum": "authentik.lib.api.Models",
|
||||
"OffboardingActionEnum": (
|
||||
@@ -290,7 +291,7 @@ SESSION_COOKIE_AGE = timedelta_from_string(
|
||||
).total_seconds()
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
|
||||
MESSAGE_STORAGE = "authentik.root.ws.storage.ChannelsStorage"
|
||||
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
|
||||
|
||||
MIDDLEWARE_FIRST = [
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.routing import URLRouter
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from django.http import HttpRequest
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
EventAction,
|
||||
Notification,
|
||||
NotificationTransport,
|
||||
TransportMode,
|
||||
)
|
||||
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.root import websocket
|
||||
from authentik.stages.password import BACKEND_INBUILT
|
||||
from authentik.stages.user_login.stage import COOKIE_NAME_KNOWN_DEVICE
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
|
||||
class TestClientWS(TransactionTestCase):
|
||||
|
||||
def setUp(self):
|
||||
tenant = get_current_tenant()
|
||||
tenant.flags[RefreshOtherFlowsAfterAuthentication().key] = True
|
||||
tenant.save()
|
||||
self.user = create_test_user()
|
||||
|
||||
async def _alogin_cookie(self, user, **kwargs):
|
||||
"""Similar to `client.aforce_login` but allow setting of cookies"""
|
||||
from django.contrib.auth import alogin
|
||||
|
||||
# Create a fake request to store login details.
|
||||
request = HttpRequest()
|
||||
session = await self.client.asession()
|
||||
request.session = session
|
||||
request.COOKIES.update(kwargs)
|
||||
|
||||
await alogin(request, user, BACKEND_INBUILT)
|
||||
# Save the session values.
|
||||
await request.session.asave()
|
||||
self.client._set_login_cookies(request)
|
||||
|
||||
async def test_auth_blank(self):
|
||||
dev_id = generate_id()
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns),
|
||||
"/ws/client/",
|
||||
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
|
||||
|
||||
await communicator.receive_nothing()
|
||||
await communicator.receive_json_from()
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_tab_refresh(self):
|
||||
dev_id = generate_id()
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns),
|
||||
"/ws/client/",
|
||||
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
with patch("authentik.flows.apps.RefreshOtherFlowsAfterAuthentication.get") as flag:
|
||||
flag.return_value = True
|
||||
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
|
||||
|
||||
evt = await communicator.receive_json_from()
|
||||
self.assertEqual(
|
||||
evt, {"message_type": "session.authenticated", "type": "event.session.authenticated"}
|
||||
)
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_notification(self):
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
|
||||
)
|
||||
communicator.scope["user"] = self.user
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
transport = await NotificationTransport.objects.acreate(
|
||||
name=generate_id(), mode=TransportMode.LOCAL
|
||||
)
|
||||
event = await sync_to_async(Event.new)(EventAction.LOGIN)
|
||||
event.set_user(self.user)
|
||||
await event.asave()
|
||||
notification = Notification(
|
||||
user=self.user,
|
||||
body="foo",
|
||||
event=event,
|
||||
hyperlink="goauthentik.io",
|
||||
hyperlink_label="a link",
|
||||
)
|
||||
await sync_to_async(transport.send_local)(notification)
|
||||
|
||||
evt = await communicator.receive_json_from(timeout=5)
|
||||
self.assertEqual(evt["message_type"], "notification.new")
|
||||
self.assertEqual(evt["id"], str(notification.pk))
|
||||
self.assertEqual(evt["data"]["pk"], str(notification.pk))
|
||||
self.assertEqual(evt["data"]["body"], "foo")
|
||||
self.assertEqual(evt["data"]["event"]["pk"], str(event.pk))
|
||||
|
||||
await communicator.disconnect()
|
||||
@@ -1,76 +0,0 @@
|
||||
"""websocket Message consumer"""
|
||||
|
||||
from hashlib import sha256
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.generic.websocket import JsonWebsocketConsumer
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.root.ws.storage import CACHE_PREFIX
|
||||
|
||||
|
||||
def build_session_group(session_key: str):
|
||||
return sha256(
|
||||
f"{connection.schema_name}/group_client_session_{str(session_key)}".encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def build_device_group(device_id: str):
|
||||
return sha256(
|
||||
f"{connection.schema_name}/group_client_device_{str(device_id)}".encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def build_user_group(user: User):
|
||||
return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest()
|
||||
|
||||
|
||||
class MessageConsumer(JsonWebsocketConsumer):
|
||||
"""Consumer which sends django.contrib.messages Messages over WS.
|
||||
channel_name is saved into cache with user_id, and when a add_message is called"""
|
||||
|
||||
session_key: str
|
||||
device_cookie: str | None = None
|
||||
user: User | None = None
|
||||
|
||||
def connect(self):
|
||||
self.accept()
|
||||
self.session_key = self.scope["session"].session_key
|
||||
if self.session_key:
|
||||
cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None)
|
||||
if user := self.scope.get("user"):
|
||||
if user.is_authenticated:
|
||||
async_to_sync(self.channel_layer.group_add)(
|
||||
build_user_group(user), self.channel_name
|
||||
)
|
||||
if device_cookie := self.scope["cookies"].get("authentik_device", None):
|
||||
self.device_cookie = device_cookie
|
||||
async_to_sync(self.channel_layer.group_add)(
|
||||
build_device_group(self.device_cookie), self.channel_name
|
||||
)
|
||||
|
||||
def disconnect(self, code):
|
||||
if self.session_key:
|
||||
cache.delete(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}")
|
||||
if self.device_cookie:
|
||||
async_to_sync(self.channel_layer.group_discard)(
|
||||
build_device_group(self.device_cookie), self.channel_name
|
||||
)
|
||||
if self.user:
|
||||
async_to_sync(self.channel_layer.group_discard)(
|
||||
build_user_group(self.user), self.channel_name
|
||||
)
|
||||
|
||||
def event_message(self, event: dict):
|
||||
"""Event handler which is called by Messages Storage backend"""
|
||||
self.send_json(event)
|
||||
|
||||
def event_session_authenticated(self, event: dict):
|
||||
"""Event handler post user authentication"""
|
||||
self.send_json({"message_type": "session.authenticated", **event})
|
||||
|
||||
def event_notification(self, event: dict):
|
||||
"""Event handler for new notifications"""
|
||||
self.send_json({"message_type": "notification.new", **event})
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Channels Messages storage"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.messages.storage.base import Message
|
||||
from django.contrib.messages.storage.session import SessionStorage
|
||||
from django.core.cache import cache
|
||||
from django.http.request import HttpRequest
|
||||
|
||||
SESSION_KEY = "_messages"
|
||||
CACHE_PREFIX = "goauthentik.io/root/messages_"
|
||||
|
||||
|
||||
class ChannelsStorage(SessionStorage):
|
||||
"""Send contrib.messages over websocket"""
|
||||
|
||||
def __init__(self, request: HttpRequest) -> None:
|
||||
super().__init__(request)
|
||||
self.channel = get_channel_layer()
|
||||
|
||||
def _store(self, messages: list[Message], response, *args, **kwargs):
|
||||
prefix = f"{CACHE_PREFIX}{self.request.session.session_key}_messages_"
|
||||
keys = cache.keys(f"{prefix}*")
|
||||
# if no active connections are open, fallback to storing messages in the
|
||||
# session, so they can always be retrieved
|
||||
if len(keys) < 1:
|
||||
return super()._store(messages, response, *args, **kwargs)
|
||||
for key in keys:
|
||||
uid = key.replace(prefix, "")
|
||||
for message in messages:
|
||||
async_to_sync(self.channel.send)(
|
||||
uid,
|
||||
{
|
||||
"type": "event.message",
|
||||
"message_type": "message",
|
||||
"level": message.level_tag,
|
||||
"tags": message.tags,
|
||||
"message": message.message,
|
||||
},
|
||||
)
|
||||
return []
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Kerberos Source sync tests"""
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import Session, User
|
||||
from authentik.core.tests.utils import create_test_session
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.sources.kerberos.models import KerberosSource, KerberosSourcePropertyMapping
|
||||
from authentik.sources.kerberos.sync import KerberosSync
|
||||
@@ -41,6 +42,21 @@ class TestKerberosSync(KerberosTestCase):
|
||||
User.objects.filter(username=self.realm.nfs_princ.rsplit("@", 1)[0]).exists()
|
||||
)
|
||||
|
||||
def test_sync_deactivate_expired_principal(self):
|
||||
"""Test that a user whose principal expired is deactivated and their sessions deleted"""
|
||||
KerberosSync(self.source, Task()).sync()
|
||||
|
||||
user = User.objects.get(username=self.realm.user_princ.rsplit("@", 1)[0])
|
||||
self.assertTrue(user.is_active)
|
||||
session = create_test_session(user)
|
||||
|
||||
self.realm.run_kadminl(f'modprinc -expire "yesterday" {self.realm.user_princ}')
|
||||
KerberosSync(self.source, Task()).sync()
|
||||
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
self.assertFalse(Session.objects.filter(session_key=session.session.session_key).exists())
|
||||
|
||||
def test_sync_mapping(self):
|
||||
"""Test property mappings"""
|
||||
noop = KerberosSourcePropertyMapping.objects.create(
|
||||
|
||||
@@ -8,8 +8,8 @@ from ldap3.core.exceptions import LDAPInvalidFilterError
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.core.models import Group, Session, User
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_session
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.lib.sync.outgoing.exceptions import StopSync
|
||||
@@ -253,6 +253,32 @@ class LDAPSyncTests(TestCase):
|
||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||
self.assertFalse(User.objects.get(username="user-nsaccountlock").is_active)
|
||||
|
||||
def test_sync_users_freeipa_deactivate_deletes_sessions(self):
|
||||
"""Test that a user deactivated by sync (nsaccountlock) has their sessions deleted"""
|
||||
self.source.object_uniqueness_field = "uid"
|
||||
self.source.user_property_mappings.set(
|
||||
LDAPSourcePropertyMapping.objects.filter(
|
||||
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
|
||||
)
|
||||
)
|
||||
user = User.objects.create(username="user-nsaccountlock", is_active=True)
|
||||
UserLDAPSourceConnection.objects.create(
|
||||
user=user,
|
||||
source=self.source,
|
||||
identifier="user-nsaccountlock",
|
||||
)
|
||||
session = create_test_session(user)
|
||||
connection = MagicMock(return_value=mock_freeipa_connection(LDAP_PASSWORD))
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||
user_sync = UserLDAPSynchronizer(self.source, Task())
|
||||
user_sync.sync_full()
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
self.assertFalse(
|
||||
Session.objects.filter(session_key=session.session.session_key).exists()
|
||||
)
|
||||
|
||||
def test_sync_groups_freeipa_memberOf(self):
|
||||
"""Test group sync when membership is derived from memberOf user attribute"""
|
||||
self.source.object_uniqueness_field = "uid"
|
||||
|
||||
@@ -108,3 +108,20 @@ class TestPropertyMappings(TestCase):
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_group_property_mappings_with_object_groups(self):
|
||||
"""An object-shaped `groups` entry is skipped instead of aborting the flow.
|
||||
|
||||
Added in #25195 asserting the `TypeError` that #25191 is about; the
|
||||
identifier is still unusable as a key, so the entry is dropped rather
|
||||
than mapped.
|
||||
"""
|
||||
info = deepcopy(INFO)
|
||||
info["groups"] = [
|
||||
{"id": "group-1", "name": "Admins"},
|
||||
]
|
||||
|
||||
request = self.request_factory.get("/", user=AnonymousUser())
|
||||
|
||||
flow_manager = OAuthSourceFlowManager(self.source, request, IDENTIFIER, {"info": info}, {})
|
||||
self.assertEqual(flow_manager.groups_properties, {})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test SCIM Group"""
|
||||
|
||||
from json import dumps
|
||||
from json import dumps, loads
|
||||
from uuid import uuid4
|
||||
|
||||
from django.urls import reverse
|
||||
@@ -60,6 +60,96 @@ class TestSCIMGroups(APITestCase):
|
||||
self.assertEqual(response.status_code, second=200)
|
||||
SCIMGroupSchema.model_validate_json(response.content, strict=True)
|
||||
|
||||
def test_group_list_filter_members(self):
|
||||
"""Test group list filtered by ID and member"""
|
||||
user = create_test_user()
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group.users.add(user)
|
||||
other_group = Group.objects.create(name=generate_id())
|
||||
other_group.users.add(user)
|
||||
for _group in [group, other_group]:
|
||||
SCIMSourceGroup.objects.create(
|
||||
source=self.source, group=_group, external_id=str(uuid4())
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data={"filter": f'id eq "{group.pk}" and members eq "{user.uuid}"'},
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
body = loads(response.content)
|
||||
self.assertEqual(body["totalResults"], 1)
|
||||
self.assertEqual(body["Resources"][0]["id"], str(group.pk))
|
||||
|
||||
def test_group_list_filter_members_no_match(self):
|
||||
"""Test group list filtered by a member that isn't part of the group"""
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group.users.add(create_test_user())
|
||||
SCIMSourceGroup.objects.create(source=self.source, group=group, external_id=str(uuid4()))
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data={"filter": f'id eq "{group.pk}" and members eq "{uuid4()}"'},
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertEqual(loads(response.content)["totalResults"], 0)
|
||||
|
||||
def test_group_list_filter_unknown_attribute(self):
|
||||
"""Test group list filtered by an attribute that can't be mapped"""
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data={"filter": f'urn:foo:bar eq "{generate_id()}"'},
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content,
|
||||
{
|
||||
"detail": "Unsupported filter attribute.",
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"scimType": "invalidFilter",
|
||||
"status": 400,
|
||||
},
|
||||
)
|
||||
|
||||
def test_group_list_filter_invalid(self):
|
||||
"""Test group list filtered by an unparsable filter"""
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-groups",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data={"filter": "displayName eq"},
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content,
|
||||
{
|
||||
"detail": "Invalid filter.",
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"scimType": "invalidFilter",
|
||||
"status": 400,
|
||||
},
|
||||
)
|
||||
|
||||
def test_group_create(self):
|
||||
"""Test group create"""
|
||||
ext_id = generate_id()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Test SCIM User"""
|
||||
|
||||
from json import dumps
|
||||
from json import dumps, loads
|
||||
from uuid import uuid4
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.core.models import Session
|
||||
from authentik.core.tests.utils import create_test_session, create_test_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.scim.clients.schema import User as SCIMUserSchema
|
||||
@@ -55,6 +56,50 @@ class TestSCIMUsers(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
SCIMUserSchema.model_validate_json(response.content, strict=True)
|
||||
|
||||
def test_user_list_filter(self):
|
||||
"""Test user list with a filter"""
|
||||
user = create_test_user()
|
||||
SCIMSourceUser.objects.create(source=self.source, user=user, external_id=str(uuid4()))
|
||||
other_user = create_test_user()
|
||||
SCIMSourceUser.objects.create(source=self.source, user=other_user, external_id=str(uuid4()))
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-users",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data={"filter": f'id eq "{user.uuid}" and userName eq "{user.username}"'},
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
body = loads(response.content)
|
||||
self.assertEqual(body["totalResults"], 1)
|
||||
self.assertEqual(body["Resources"][0]["id"], str(user.uuid))
|
||||
|
||||
def test_user_list_filter_invalid(self):
|
||||
"""Test user list with an unparsable filter"""
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-users",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data={"filter": "userName eq"},
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content,
|
||||
{
|
||||
"detail": "Invalid filter.",
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"scimType": "invalidFilter",
|
||||
"status": 400,
|
||||
},
|
||||
)
|
||||
|
||||
def test_user_create(self):
|
||||
"""Test user create"""
|
||||
user = create_test_user()
|
||||
@@ -214,6 +259,40 @@ class TestSCIMUsers(APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_user_update_deactivate(self):
|
||||
"""Test user deactivated via update deletes their sessions"""
|
||||
user = create_test_user()
|
||||
session = create_test_session(user)
|
||||
existing = SCIMSourceUser.objects.create(source=self.source, user=user, external_id=uuid4())
|
||||
response = self.client.put(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-users",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
"user_id": str(user.uuid),
|
||||
},
|
||||
),
|
||||
data=dumps(
|
||||
{
|
||||
"id": str(existing.pk),
|
||||
"userName": user.username,
|
||||
"active": False,
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"value": user.email,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
user.refresh_from_db()
|
||||
self.assertFalse(user.is_active)
|
||||
self.assertFalse(Session.objects.filter(session_key=session.session.session_key).exists())
|
||||
|
||||
def test_user_update_patch(self):
|
||||
"""Test user update (patch)"""
|
||||
user = create_test_user()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from django.core.exceptions import FieldError
|
||||
from django.core.paginator import Page, Paginator
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.http import HttpRequest
|
||||
@@ -12,6 +13,7 @@ from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from scim2_filter_parser.parser import SCIMParserError
|
||||
from scim2_filter_parser.transpilers.django_q_object import get_query
|
||||
from structlog import BoundLogger
|
||||
from structlog.stdlib import get_logger
|
||||
@@ -21,7 +23,7 @@ from authentik.core.sources.mapper import SourceMapper
|
||||
from authentik.lib.sync.mapper import PropertyMappingManager
|
||||
from authentik.sources.scim.models import SCIMSource
|
||||
from authentik.sources.scim.views.v2.auth import SCIMTokenAuth
|
||||
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
|
||||
from authentik.sources.scim.views.v2.exceptions import SCIMInvalidFilterError, SCIMNotFoundError
|
||||
|
||||
SCIM_CONTENT_TYPE = "application/scim+json"
|
||||
|
||||
@@ -62,27 +64,47 @@ class SCIMView(APIView):
|
||||
data.pop(key.strip(), None)
|
||||
return data
|
||||
|
||||
def filter_parse(self, request: Request):
|
||||
"""Parse the path of a Patch Operation"""
|
||||
def filter_parse(self, request: Request) -> Q:
|
||||
"""Parse the `filter` query parameter into a Q object"""
|
||||
path = request.query_params.get("filter")
|
||||
if not path:
|
||||
return Q()
|
||||
attr_map = {}
|
||||
if self.model == User:
|
||||
attr_map = {
|
||||
("id", None, None): "user__uuid",
|
||||
("userName", None, None): "user__username",
|
||||
("active", None, None): "user__is_active",
|
||||
("name", "familyName", None): "attributes__familyName",
|
||||
}
|
||||
elif self.model == Group:
|
||||
attr_map = {
|
||||
("id", None, None): "group__group_uuid",
|
||||
("displayName", None, None): "group__name",
|
||||
("members", None, None): "group__users",
|
||||
# `members` is a many-to-many relation, so it has to be filtered on a
|
||||
# concrete field of the related user instead of on the relation itself
|
||||
("members", None, None): "group__users__uuid",
|
||||
}
|
||||
return get_query(
|
||||
path,
|
||||
attr_map,
|
||||
)
|
||||
try:
|
||||
query = get_query(path, attr_map)
|
||||
except (SCIMParserError, ValueError) as exc:
|
||||
self.logger.debug("Failed to parse filter", filter=path, exc=exc)
|
||||
raise SCIMInvalidFilterError("Invalid filter.") from exc
|
||||
if query is None:
|
||||
# None is returned when none of the attributes in the filter can be mapped
|
||||
# to a field, in which case we can't return any meaningful result
|
||||
self.logger.debug("Failed to map filter to any field", filter=path)
|
||||
raise SCIMInvalidFilterError("Unsupported filter attribute.")
|
||||
return query
|
||||
|
||||
def filter_query(self, request: Request, query: QuerySet) -> QuerySet:
|
||||
"""Apply the `filter` query parameter to `query`"""
|
||||
parsed = self.filter_parse(request)
|
||||
try:
|
||||
return query.filter(parsed)
|
||||
except FieldError as exc:
|
||||
self.logger.debug("Failed to apply filter", filter=parsed, exc=exc)
|
||||
raise SCIMInvalidFilterError("Unsupported filter.") from exc
|
||||
|
||||
def paginate_query(self, query: QuerySet) -> Page:
|
||||
per_page = int(self.request.tenant.pagination_default_page_size)
|
||||
|
||||
@@ -56,3 +56,16 @@ class SCIMNotFoundError(SCIMValidationError):
|
||||
status=self.status_code,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SCIMInvalidFilterError(SCIMValidationError):
|
||||
status_code = 400
|
||||
|
||||
def __init__(self, detail: str):
|
||||
super().__init__(
|
||||
SCIMError(
|
||||
detail=detail,
|
||||
scimType=SCIMErrorTypes.invalid_filter,
|
||||
status=self.status_code,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -72,8 +72,8 @@ class GroupsView(SCIMObjectView):
|
||||
if not connection:
|
||||
raise SCIMNotFoundError("Group not found.")
|
||||
return Response(self.group_to_scim(connection))
|
||||
connections = (
|
||||
base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request))
|
||||
connections = self.filter_query(
|
||||
request, base_query.filter(source=self.source).order_by("pk")
|
||||
)
|
||||
page = self.paginate_query(connections)
|
||||
return Response(
|
||||
|
||||
@@ -74,10 +74,10 @@ class UsersView(SCIMObjectView):
|
||||
if not connection:
|
||||
raise SCIMNotFoundError("User not found.")
|
||||
return Response(self.user_to_scim(connection))
|
||||
connections = (
|
||||
SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk")
|
||||
connections = self.filter_query(
|
||||
request,
|
||||
SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk"),
|
||||
)
|
||||
connections = connections.filter(self.filter_parse(request))
|
||||
page = self.paginate_query(connections)
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -11,7 +11,10 @@ from rest_framework.exceptions import ValidationError
|
||||
|
||||
from authentik.core.tests.utils import create_test_flow, create_test_user
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||
from authentik.flows.planner import PLAN_CONTEXT_REDIRECT, FlowPlan
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.sources.telegram.models import UserTelegramSourceConnection
|
||||
from authentik.sources.telegram.stage import TelegramChallengeResponse
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
@@ -185,6 +188,53 @@ class TestTelegramViews(MockTelegramResponseMixin, FlowTestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_challenge_view_preserves_next_redirect(self):
|
||||
"""Regression test for #18450: completing Telegram auth must preserve the OIDC
|
||||
redirect_uri so the user returns to the application instead of /if/user/.
|
||||
|
||||
Root cause: the pre_authentication_flow executor overwrites SESSION_KEY_GET with
|
||||
an empty dict (its redirect URL has no query string), losing the original next=
|
||||
parameter. The fix reads PLAN_CONTEXT_REDIRECT from the executor plan context —
|
||||
where TelegramStartView saved it before the overwrite — and restores SESSION_KEY_GET.
|
||||
"""
|
||||
initial_redirect = f"http://{generate_id()}"
|
||||
|
||||
self._make_initial_request()
|
||||
|
||||
# Simulate SESSION_KEY_GET as set by the flow executor when the user arrives
|
||||
# from an OIDC authorization request (e.g. next=/application/o/authorize/?...).
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_GET] = {NEXT_ARG_NAME: initial_redirect}
|
||||
session.save()
|
||||
|
||||
# TelegramStartView reads SESSION_KEY_GET and saves it as PLAN_CONTEXT_REDIRECT
|
||||
# in the pre_authentication_flow plan, then redirects to that flow.
|
||||
self._make_start_request()
|
||||
|
||||
# GET the pre_auth_flow executor. This runs FlowExecutorView.dispatch() which
|
||||
# unconditionally overwrites SESSION_KEY_GET with an empty dict (the redirect URL
|
||||
# from TelegramStartView carries no query string). PLAN_CONTEXT_REDIRECT in the
|
||||
# plan is still intact at this point.
|
||||
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.pre_auth_flow.slug})
|
||||
self.client.get(url)
|
||||
|
||||
form_data = self._make_valid_response()
|
||||
form_data["component"] = "ak-source-telegram"
|
||||
response = self.client.post(url, form_data)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageRedirects(
|
||||
response,
|
||||
reverse(
|
||||
"authentik_core:if-flow", kwargs={"flow_slug": self.source.enrollment_flow.slug}
|
||||
),
|
||||
)
|
||||
|
||||
# The enrollment plan must carry PLAN_CONTEXT_REDIRECT so the user eventually
|
||||
# reaches the application's callback URL rather than the default /if/user/.
|
||||
plan: FlowPlan = self.client.session.get(SESSION_KEY_PLAN)
|
||||
self.assertIsNotNone(plan)
|
||||
self.assertEqual(plan.context.get(PLAN_CONTEXT_REDIRECT), initial_redirect)
|
||||
|
||||
def test_connect_user(self):
|
||||
user = create_test_user("testuser")
|
||||
user2 = create_test_user("testuser2")
|
||||
|
||||
@@ -86,6 +86,12 @@ class TelegramLoginView(ChallengeStageView):
|
||||
raw_info.pop("hash")
|
||||
raw_info.pop("auth_date")
|
||||
source = self.source
|
||||
# The pre_authentication_flow executor overwrites SESSION_KEY_GET with an empty
|
||||
# dict (its redirect URL carries no query string), losing the original next= param.
|
||||
# Restore it from PLAN_CONTEXT_REDIRECT, which TelegramStartView saved before that
|
||||
# overwrite. Mirrors the same fix in the SAML source's ACSView.
|
||||
if plan_redirect := self.executor.plan.context.get(PLAN_CONTEXT_REDIRECT):
|
||||
self.request.session[SESSION_KEY_GET] = {NEXT_ARG_NAME: plan_redirect}
|
||||
sfm = TelegramSourceFlowManager(
|
||||
source=source,
|
||||
request=self.request,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""AuthenticatorDuoStage API Views"""
|
||||
|
||||
from ssl import SSLCertVerificationError, SSLError
|
||||
from typing import Any
|
||||
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
@@ -208,10 +210,33 @@ class AuthenticatorDuoStageViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
created += 1
|
||||
return {"error": "", "count": created}
|
||||
# `duo_client` surfaces transport failures as the underlying socket/TLS
|
||||
# error, which is an OSError and not a RuntimeError. Catching only
|
||||
# RuntimeError let those escape the view entirely, so an unreachable or
|
||||
# untrusted Duo endpoint produced no actionable error at all.
|
||||
# SSLCertVerificationError < SSLError < OSError, so order matters here.
|
||||
except SSLCertVerificationError as exc:
|
||||
LOGGER.warning("failed to verify duo api certificate", exc=exc)
|
||||
return {
|
||||
"error": _("Failed to connect to Duo: TLS certificate verification failed."),
|
||||
"count": created,
|
||||
}
|
||||
except SSLError as exc:
|
||||
LOGGER.warning("tls error connecting to duo", exc=exc)
|
||||
return {
|
||||
"error": _("Failed to connect to Duo: TLS error."),
|
||||
"count": created,
|
||||
}
|
||||
except OSError as exc:
|
||||
LOGGER.warning("failed to connect to duo", exc=exc)
|
||||
return {
|
||||
"error": _("Failed to connect to Duo."),
|
||||
"count": created,
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
LOGGER.warning("failed to get users from duo", exc=exc)
|
||||
return {
|
||||
"error": "An internal error occurred while importing devices.",
|
||||
"error": _("An internal error occurred while importing devices."),
|
||||
"count": created,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test duo stage"""
|
||||
|
||||
from ssl import SSLCertVerificationError, SSLError
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -187,6 +188,120 @@ class AuthenticatorDuoStageTests(FlowTestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_api_import_automatic_tls_failure(self):
|
||||
"""test `import_devices_automatic` when the Duo API certificate doesn't verify
|
||||
|
||||
Regression test for #22896: `SSLCertVerificationError` is an `OSError`,
|
||||
not a `RuntimeError`, so it escaped the handler entirely instead of
|
||||
producing the documented 400 with a descriptive error.
|
||||
"""
|
||||
self.client.force_login(self.user)
|
||||
stage = AuthenticatorDuoStage.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_id(),
|
||||
api_hostname=generate_id(),
|
||||
admin_integration_key=generate_id(),
|
||||
admin_secret_key=generate_id(),
|
||||
)
|
||||
ssl_error = SSLCertVerificationError(
|
||||
1,
|
||||
"[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: "
|
||||
"unable to get local issuer certificate (_ssl.c:1081)",
|
||||
)
|
||||
with patch(
|
||||
"duo_client.admin.Admin.get_users_iterator",
|
||||
MagicMock(side_effect=ssl_error),
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:authenticatorduostage-import-devices-automatic",
|
||||
kwargs={
|
||||
"pk": str(stage.pk),
|
||||
},
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content,
|
||||
{
|
||||
"error": "Failed to connect to Duo: TLS certificate verification failed.",
|
||||
"count": 0,
|
||||
},
|
||||
)
|
||||
|
||||
def test_api_import_automatic_tls_error(self):
|
||||
"""test `import_devices_automatic` on a non-certificate TLS failure
|
||||
|
||||
A generic `SSLError` must be reported as a TLS error rather than
|
||||
claiming certificate verification specifically failed.
|
||||
"""
|
||||
self.client.force_login(self.user)
|
||||
stage = AuthenticatorDuoStage.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_id(),
|
||||
api_hostname=generate_id(),
|
||||
admin_integration_key=generate_id(),
|
||||
admin_secret_key=generate_id(),
|
||||
)
|
||||
with patch(
|
||||
"duo_client.admin.Admin.get_users_iterator",
|
||||
MagicMock(side_effect=SSLError("handshake failure")),
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:authenticatorduostage-import-devices-automatic",
|
||||
kwargs={
|
||||
"pk": str(stage.pk),
|
||||
},
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content,
|
||||
{
|
||||
"error": "Failed to connect to Duo: TLS error.",
|
||||
"count": 0,
|
||||
},
|
||||
)
|
||||
|
||||
def test_api_import_automatic_connection_failure(self):
|
||||
"""test `import_devices_automatic` when the Duo API is unreachable
|
||||
|
||||
A non-TLS `OSError` must also surface as a descriptive 400 rather than
|
||||
escaping the handler.
|
||||
"""
|
||||
self.client.force_login(self.user)
|
||||
stage = AuthenticatorDuoStage.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_id(),
|
||||
api_hostname=generate_id(),
|
||||
admin_integration_key=generate_id(),
|
||||
admin_secret_key=generate_id(),
|
||||
)
|
||||
with patch(
|
||||
"duo_client.admin.Admin.get_users_iterator",
|
||||
MagicMock(side_effect=OSError("connection refused")),
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:authenticatorduostage-import-devices-automatic",
|
||||
kwargs={
|
||||
"pk": str(stage.pk),
|
||||
},
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content,
|
||||
{
|
||||
"error": "Failed to connect to Duo.",
|
||||
"count": 0,
|
||||
},
|
||||
)
|
||||
|
||||
def test_api_import_automatic(self):
|
||||
"""test `import_devices_automatic`"""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,9 +1,8 @@
|
||||
"""Send a test-email with global settings"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.management.base import no_translations
|
||||
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.tasks import send_mail
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
@@ -16,34 +15,27 @@ class Command(TenantCommand):
|
||||
@no_translations
|
||||
def handle_per_tenant(self, *args, **options):
|
||||
"""Send a test-email with global settings"""
|
||||
delete_stage = False
|
||||
stage = None
|
||||
if options["stage"]:
|
||||
stages = EmailStage.objects.filter(name=options["stage"])
|
||||
if not stages.exists():
|
||||
self.stderr.write(f"Stage '{options['stage']}' does not exist")
|
||||
return
|
||||
stage = stages.first()
|
||||
else:
|
||||
stage = EmailStage.objects.create(
|
||||
name=f"temp-global-stage-{uuid4()}", use_global_settings=True
|
||||
)
|
||||
delete_stage = True
|
||||
message = TemplateEmailMessage(
|
||||
subject="authentik Test-Email",
|
||||
to=[("", options["to"])],
|
||||
template_name="email/setup.html",
|
||||
template_context={},
|
||||
)
|
||||
try:
|
||||
if not stage.use_global_settings:
|
||||
message.from_email = stage.from_address
|
||||
# Use the class path instead of the class itself for serialization
|
||||
stage_class_path, stage_pk = None, None
|
||||
if stage:
|
||||
stage_class_path = class_to_path(stage.__class__)
|
||||
stage_pk = str(stage.pk)
|
||||
send_mail.send(message.__dict__, stage_class_path, stage_pk).get_result(block=True)
|
||||
|
||||
send_mail.send(message.__dict__, stage.pk).get_result(block=True)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Test email sent to {options['to']}"))
|
||||
finally:
|
||||
if delete_stage:
|
||||
stage.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Test email sent to {options['to']}"))
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("to", type=str)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test email management commands"""
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core import mail
|
||||
from django.core.mail.backends.locmem import EmailBackend
|
||||
@@ -49,18 +49,34 @@ class TestEmailManagementCommands(TestCase):
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_test_email_command_with_custom_from(self):
|
||||
"""Test test_email command respects custom from address"""
|
||||
def test_test_email_command_uses_stage_settings(self):
|
||||
"""Test test_email command uses the stage's settings, not the global ones"""
|
||||
EmailStage.objects.create(
|
||||
name="test-stage",
|
||||
use_global_settings=False,
|
||||
from_address="custom@authentik.local",
|
||||
host="localhost",
|
||||
port=25,
|
||||
host="stage.authentik.local",
|
||||
port=587,
|
||||
username="stage-user",
|
||||
password="stage-password", # nosec
|
||||
use_tls=True,
|
||||
)
|
||||
|
||||
with patch("authentik.stages.email.models.EmailStage.backend_class", EmailBackend):
|
||||
backend_class = MagicMock(wraps=EmailBackend)
|
||||
with patch("authentik.stages.email.models.EmailStage.backend_class", backend_class):
|
||||
call_command("test_email", "test@example.com", stage="test-stage")
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].from_email, "custom@authentik.local")
|
||||
self.assertEqual(mail.outbox[0].to, ["test@example.com"])
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].from_email, "custom@authentik.local")
|
||||
self.assertEqual(
|
||||
backend_class.call_args.kwargs,
|
||||
{
|
||||
"host": "stage.authentik.local",
|
||||
"port": 587,
|
||||
"username": "stage-user",
|
||||
"password": "stage-password",
|
||||
"use_tls": True,
|
||||
"use_ssl": False,
|
||||
"timeout": 10,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Identification stage logic"""
|
||||
|
||||
from dataclasses import asdict
|
||||
from functools import cache
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.hashers import make_password
|
||||
@@ -69,6 +70,22 @@ def get_login_serializers():
|
||||
return mapping
|
||||
|
||||
|
||||
@cache
|
||||
def login_capable_source_subclasses() -> list[type[Source]]:
|
||||
"""Concrete Source subclasses that can render a UI login button.
|
||||
|
||||
``Source.ui_login_button`` returns None, so a source only reaches the
|
||||
challenge below if its subclass overrides it. Abstract subclasses are skipped
|
||||
because they have no table to join against.
|
||||
"""
|
||||
return [
|
||||
source_type
|
||||
for source_type in all_subclasses(Source)
|
||||
if not source_type._meta.abstract
|
||||
and source_type.ui_login_button is not Source.ui_login_button
|
||||
]
|
||||
|
||||
|
||||
@extend_schema_field(
|
||||
PolymorphicProxySerializer(
|
||||
component_name="LoginChallengeTypes",
|
||||
@@ -386,7 +403,9 @@ class IdentificationStageView(ChallengeStageView):
|
||||
# Check all enabled source, add them if they have a UI Login button.
|
||||
ui_sources = []
|
||||
sources: list[Source] = (
|
||||
current_stage.sources.filter(enabled=True).order_by("name").select_subclasses()
|
||||
current_stage.sources.filter(enabled=True)
|
||||
.order_by("name")
|
||||
.select_subclasses(*login_capable_source_subclasses())
|
||||
)
|
||||
for source in sources:
|
||||
ui_login_button = source.ui_login_button(self.request)
|
||||
|
||||
@@ -19,7 +19,6 @@ from authentik.tenants.utils import normalize_base_url
|
||||
|
||||
|
||||
class FlagJSONField(JSONDictField):
|
||||
|
||||
def to_internal_value(self, data: str):
|
||||
flags = super().to_internal_value(data)
|
||||
for flag in Flag.available(visibility="system", exclude_system=False):
|
||||
@@ -61,6 +60,7 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
|
||||
|
||||
def map_serializer_field(self, auto_schema, direction):
|
||||
props = {}
|
||||
required = []
|
||||
for flag in Flag.available():
|
||||
_flag = flag()
|
||||
props[_flag.key] = build_basic_type(get_args(_flag.__orig_bases__[0])[0])
|
||||
@@ -68,7 +68,9 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
|
||||
props[_flag.key]["description"] = _flag.description
|
||||
if _flag.deprecated:
|
||||
props[_flag.key]["deprecated"] = _flag.deprecated
|
||||
return build_object_type(props, required=props.keys())
|
||||
if not _flag.deprecated:
|
||||
required.append(_flag.key)
|
||||
return build_object_type(props, required=required)
|
||||
|
||||
|
||||
class SettingsSerializer(ModelSerializer):
|
||||
|
||||
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
|
||||
model_name="tenant",
|
||||
name="default_token_duration",
|
||||
field=models.TextField(
|
||||
default=CONFIG.get("default_token_duration", "minutes=30"),
|
||||
default=CONFIG.get("default_token_duration", "days=1"),
|
||||
help_text="Default token duration",
|
||||
validators=[authentik.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
|
||||
@@ -33,7 +33,7 @@ entries:
|
||||
!Find [authentik_flows.flow, [slug, default-request]]
|
||||
identifiers:
|
||||
domain: authentik-default
|
||||
default: true
|
||||
default: !Env [AUTHENTIK_DEFAULT_BRAND_ENABLED, True]
|
||||
state: created
|
||||
conditions:
|
||||
# Only create default brand if no other default brand exists
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/constants"
|
||||
"goauthentik.io/internal/outpost/ak/entrypoint"
|
||||
"goauthentik.io/internal/outpost/ak/healthcheck"
|
||||
"goauthentik.io/internal/outpost/proxyv2"
|
||||
)
|
||||
|
||||
const helpMessage = `authentik proxy
|
||||
|
||||
Required environment variables:
|
||||
- AUTHENTIK_HOST: URL to connect to (format "http://authentik.company")
|
||||
- AUTHENTIK_TOKEN: Token to authenticate with
|
||||
- AUTHENTIK_INSECURE: Skip SSL Certificate verification
|
||||
|
||||
Optionally, you can set these:
|
||||
- AUTHENTIK_HOST_BROWSER: URL to use in the browser, when it differs from AUTHENTIK_HOST`
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Long: helpMessage,
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: common.PreRun,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := entrypoint.OutpostMain("authentik.outpost.proxy", proxyv2.NewProxyServer)
|
||||
if err != nil {
|
||||
fmt.Println(helpMessage)
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
rootCmd.AddCommand(healthcheck.Command)
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
func main() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/constants"
|
||||
"goauthentik.io/internal/debug"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/proxyv2"
|
||||
sentryutils "goauthentik.io/internal/utils/sentry"
|
||||
webutils "goauthentik.io/internal/utils/web"
|
||||
"goauthentik.io/internal/web"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "authentik",
|
||||
Short: "Start authentik instance",
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: common.PreRun,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
debug.EnableDebugServer("authentik.core")
|
||||
l := log.WithField("logger", "authentik.root")
|
||||
|
||||
if config.Get().ErrorReporting.Enabled {
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: config.Get().ErrorReporting.SentryDSN,
|
||||
AttachStacktrace: true,
|
||||
EnableTracing: true,
|
||||
TracesSampler: sentryutils.SamplerFunc(config.Get().ErrorReporting.SampleRate),
|
||||
Release: fmt.Sprintf("authentik@%s", constants.VERSION()),
|
||||
Environment: config.Get().ErrorReporting.Environment,
|
||||
HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgent(), http.DefaultTransport),
|
||||
IgnoreErrors: []string{
|
||||
http.ErrAbortHandler.Error(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("failed to init sentry")
|
||||
}
|
||||
}
|
||||
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
|
||||
u := url.URL{
|
||||
Scheme: "unix",
|
||||
Host: fmt.Sprintf("%s/%s", os.TempDir(), web.SocketName),
|
||||
Path: config.Get().Web.Path,
|
||||
}
|
||||
|
||||
ws := web.NewWebServer()
|
||||
ws.Core().AddHealthyCallback(func() {
|
||||
if config.Get().Outposts.DisableEmbeddedOutpost {
|
||||
return
|
||||
}
|
||||
go attemptProxyStart(ws, u)
|
||||
})
|
||||
ws.Start()
|
||||
<-ex
|
||||
l.Info("shutting down webserver")
|
||||
go ws.Shutdown()
|
||||
},
|
||||
}
|
||||
|
||||
func attemptProxyStart(ws *web.WebServer, u url.URL) {
|
||||
maxTries := 100
|
||||
attempt := 0
|
||||
l := log.WithField("logger", "authentik.server")
|
||||
for {
|
||||
l.Debug("attempting to init outpost")
|
||||
ac := ak.NewAPIController(u, config.Get().SecretKey)
|
||||
if ac == nil {
|
||||
attempt += 1
|
||||
time.Sleep(1 * time.Second)
|
||||
if attempt > maxTries {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
ac.AddRefreshHandler(func() {
|
||||
ws.BrandTLS.Check()
|
||||
})
|
||||
|
||||
srv := proxyv2.NewProxyServer(ac)
|
||||
ws.ProxyServer = srv.(*proxyv2.ProxyServer)
|
||||
ac.Server = srv
|
||||
l.Debug("attempting to start outpost")
|
||||
err := ac.StartBackgroundTasks()
|
||||
if err != nil {
|
||||
l.WithError(err).Warning("outpost failed to start")
|
||||
attempt += 1
|
||||
time.Sleep(15 * time.Second)
|
||||
if attempt > maxTries {
|
||||
break
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
select {}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
go.mod
69
go.mod
@@ -6,36 +6,27 @@ require (
|
||||
beryju.io/ldap v0.2.2
|
||||
beryju.io/radius-eap v0.1.1
|
||||
github.com/avast/retry-go/v4 v4.7.0
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/getsentry/sentry-go v0.48.0
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
||||
github.com/go-ldap/ldap/v3 v3.4.14
|
||||
github.com/go-openapi/runtime v0.33.0
|
||||
github.com/go-openapi/runtime v0.33.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/handlers v1.5.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/securecookie v1.1.2
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/grafana/pyroscope-go v1.4.2
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||
github.com/pires/go-proxyproto v0.15.0
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/sethvargo/go-envconfig v1.4.3
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/sirupsen/logrus v1.10.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.22.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gorm.io/driver/postgres v1.6.2
|
||||
gorm.io/gorm v1.31.2
|
||||
layeh.com/radius v0.0.0-20231213012653-1006025d24f8
|
||||
)
|
||||
|
||||
@@ -43,57 +34,45 @@ require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 // indirect
|
||||
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/analysis v0.25.5 // indirect
|
||||
github.com/go-openapi/analysis v0.26.0 // indirect
|
||||
github.com/go-openapi/errors v0.22.8 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/loads v0.25.0 // indirect
|
||||
github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect
|
||||
github.com/go-openapi/loads v0.25.1 // indirect
|
||||
github.com/go-openapi/runtime/server-middleware v0.33.1 // indirect
|
||||
github.com/go-openapi/spec v0.22.9 // indirect
|
||||
github.com/go-openapi/strfmt v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/loading v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/pools v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.27.3 // indirect
|
||||
github.com/go-openapi/validate v0.26.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/pools v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
|
||||
github.com/go-openapi/validate v0.26.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
go.opentelemetry.io/otel v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
162
go.sum
162
go.sum
@@ -12,29 +12,14 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
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.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY=
|
||||
github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 h1:zga7zaRE8HCbWjcXMDlfvmQtH0/kMVLo7cQ48dy6kWg=
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1/go.mod h1:PumS+5d59wmAGsZo6IfRpVNaJUq+6xjC4Utt/k8GO6Q=
|
||||
github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 h1:O6yi4xa9b2DMosGsXzlMe2E9qXgXCVkRLCoRX+5amxI=
|
||||
github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27/go.mod h1:AYvN8omj7nKLmbcXS2dyABYU6JB1Lz1bHmkkq1kf4I4=
|
||||
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9yue4+QkG/HQ/W67wvtQmWJ4SDo9aK/GIno=
|
||||
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
|
||||
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -42,50 +27,50 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI=
|
||||
github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc=
|
||||
github.com/go-openapi/analysis v0.26.0 h1:1xECln1iMMmQnTjgcknC1vi1hA4KISt6IHpSwnqcuwI=
|
||||
github.com/go-openapi/analysis v0.26.0/go.mod h1:40gERFi/2dyXA1FaqRRLxkv1IlC6X+GPDNd1xrYAjZE=
|
||||
github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I=
|
||||
github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w=
|
||||
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
||||
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
|
||||
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
|
||||
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
|
||||
github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI=
|
||||
github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8=
|
||||
github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU=
|
||||
github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw=
|
||||
github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog=
|
||||
github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs=
|
||||
github.com/go-openapi/loads v0.25.1 h1:toKQdIDLxlqfKLLGUUmUsiTd5/X0Chzvde9EGYQP/Ac=
|
||||
github.com/go-openapi/loads v0.25.1/go.mod h1:33Hen4tsKXHL45TyYojvfD5fZUFN4O1y4r/XhsRW2zc=
|
||||
github.com/go-openapi/runtime v0.33.1 h1:jCvhI+wAdsn29byy+RgcPcg+j39YT6E304QOE/WqIVk=
|
||||
github.com/go-openapi/runtime v0.33.1/go.mod h1:Dl5SMVRnJz+d8bX6Y1zxy0QKpqe/ysvVeUEh1nCpEZ4=
|
||||
github.com/go-openapi/runtime/server-middleware v0.33.1 h1:IAeKbwWnBnpsYTpuPVS8t73ZrPpKvRZnK2iJ2KJGUV0=
|
||||
github.com/go-openapi/runtime/server-middleware v0.33.1/go.mod h1:2Gej5fDxqeJxY+w38vxXYW0BgFASfgBsJ5rXwN1Fseg=
|
||||
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=
|
||||
github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0=
|
||||
github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM=
|
||||
github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc=
|
||||
github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU=
|
||||
github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ=
|
||||
github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE=
|
||||
github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
|
||||
github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc=
|
||||
github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
|
||||
github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0=
|
||||
github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE=
|
||||
github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg=
|
||||
github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
|
||||
github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk=
|
||||
github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
|
||||
github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI=
|
||||
github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
|
||||
github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4=
|
||||
github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs=
|
||||
github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
|
||||
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
|
||||
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us=
|
||||
github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo=
|
||||
github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
|
||||
github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
|
||||
github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU=
|
||||
github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
|
||||
github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
|
||||
github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
|
||||
github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM=
|
||||
github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
|
||||
github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
|
||||
github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
|
||||
github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
|
||||
github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
|
||||
github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
|
||||
github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4=
|
||||
github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q=
|
||||
github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-openapi/validate v0.26.3 h1:OkfZgLvLDnGP2hrRGD+42WBiPWWkoHomTJ+IVI+KaDc=
|
||||
github.com/go-openapi/validate v0.26.3/go.mod h1:7DOOa4raU6NRe7A8VQSKbm3VcuUIioREYHFt+er9Sk8=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
@@ -97,14 +82,10 @@ github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
@@ -116,14 +97,6 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
@@ -138,10 +111,6 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
@@ -151,16 +120,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 h1:D9EvfGQvlkKaDr2CRKN++7HbSXbefUNDrPq60T+g24s=
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484/go.mod h1:O1EljZ+oHprtxDDPHiMWVo/5dBT6PlvWX5PSwj80aBA=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
|
||||
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
@@ -169,8 +136,6 @@ github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
@@ -185,47 +150,43 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
|
||||
github.com/sethvargo/go-envconfig v1.4.3 h1:9RJrW9aiy3SJVRJ1svntpZvBw3ghj941u/BseS/TokY=
|
||||
github.com/sethvargo/go-envconfig v1.4.3/go.mod h1:ebe6rgj7KzrRZPzDXU4W6WZWDEirQwvcgmS0bmC3Sjg=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q=
|
||||
github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
|
||||
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab h1:628ME69lBm9C6JY2wXhAph/yjN3jezx1z7BIDLUwxjo=
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -233,10 +194,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -264,8 +223,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -278,14 +237,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
|
||||
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
layeh.com/radius v0.0.0-20231213012653-1006025d24f8 h1:orYXpi6BJZdvgytfHH4ybOe4wHnLbbS71Cmd8mWdZjs=
|
||||
layeh.com/radius v0.0.0-20231213012653-1006025d24f8/go.mod h1:QRf+8aRqXc019kHkpcs/CTgyWXFzf+bxlsyuo2nAl1o=
|
||||
|
||||
@@ -191,44 +191,6 @@ func (c *Config) parseScheme(rawVal string) string {
|
||||
return rawVal
|
||||
}
|
||||
|
||||
// RefreshPostgreSQLConfig re-reads PostgreSQL configuration from file:// and env:// URIs
|
||||
// This enables hot-reloading when credentials are rotated by updating the referenced files.
|
||||
// Note: Plain environment variables (without file:// or env:// prefixes) are read from the
|
||||
// process environment and will not change unless the process is restarted or os.Setenv is called.
|
||||
func (c *Config) RefreshPostgreSQLConfig() PostgreSQLConfig {
|
||||
// Start with current config as base
|
||||
refreshed := c.PostgreSQL
|
||||
|
||||
// Manually read from environment variables with proper prefix
|
||||
// We can't use env.Process directly on PostgreSQLConfig because it loses the AUTHENTIK_POSTGRESQL__ prefix
|
||||
// Map of environment variable suffix to config field pointer
|
||||
envVars := map[string]*string{
|
||||
"HOST": &refreshed.Host,
|
||||
"PORT": &refreshed.Port,
|
||||
"USER": &refreshed.User,
|
||||
"PASSWORD": &refreshed.Password,
|
||||
"NAME": &refreshed.Name,
|
||||
"SSLMODE": &refreshed.SSLMode,
|
||||
"SSLROOTCERT": &refreshed.SSLRootCert,
|
||||
"SSLCERT": &refreshed.SSLCert,
|
||||
"SSLKEY": &refreshed.SSLKey,
|
||||
"DEFAULT_SCHEMA": &refreshed.DefaultSchema,
|
||||
"CONN_OPTIONS": &refreshed.ConnOptions,
|
||||
}
|
||||
|
||||
// Read each environment variable if it exists
|
||||
for suffix, field := range envVars {
|
||||
if val, ok := os.LookupEnv("AUTHENTIK_POSTGRESQL__" + suffix); ok {
|
||||
*field = val
|
||||
}
|
||||
}
|
||||
|
||||
// Process file:// and env:// URI schemes
|
||||
c.walkScheme(&refreshed)
|
||||
|
||||
return refreshed
|
||||
}
|
||||
|
||||
func (c *Config) configureLogger() {
|
||||
switch strings.ToLower(c.LogLevel) {
|
||||
case "trace":
|
||||
|
||||
@@ -10,22 +10,22 @@ import (
|
||||
)
|
||||
|
||||
func TestConfigEnv(t *testing.T) {
|
||||
assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", "bar"))
|
||||
assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", "bar"))
|
||||
cfg = nil
|
||||
if err := Get().fromEnv(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
assert.Equal(t, "bar", Get().SecretKey)
|
||||
assert.Equal(t, "bar", Get().LogLevel)
|
||||
}
|
||||
|
||||
func TestConfigEnv_Scheme(t *testing.T) {
|
||||
assert.NoError(t, os.Setenv("foo", "bar"))
|
||||
assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", "env://foo"))
|
||||
assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", "env://foo"))
|
||||
cfg = nil
|
||||
if err := Get().fromEnv(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
assert.Equal(t, "bar", Get().SecretKey)
|
||||
assert.Equal(t, "bar", Get().LogLevel)
|
||||
}
|
||||
|
||||
func TestConfigEnv_File(t *testing.T) {
|
||||
@@ -41,10 +41,10 @@ func TestConfigEnv_File(t *testing.T) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", fmt.Sprintf("file://%s", file.Name())))
|
||||
assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", fmt.Sprintf("file://%s", file.Name())))
|
||||
cfg = nil
|
||||
if err := Get().fromEnv(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
assert.Equal(t, "bar", Get().SecretKey)
|
||||
assert.Equal(t, "bar", Get().LogLevel)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user