Compare commits

..

39 Commits

Author SHA1 Message Date
ad7ad1fa78 release: 2024.8.2 2024-09-16 14:13:04 +02:00
c70e609e50 website/docs: prepare release notes for 2024.8.2 (#11394)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>

# Conflicts:
#	website/docs/releases/2024/v2024.8.md
2024-09-16 14:12:28 +02:00
5f08485fff web: revert lockfile lint, re-add integrity (#11380)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	web/package-lock.json
2024-09-14 23:16:56 +02:00
3a2ed11821 providers/proxy: fix URL path getting lost when partial URL is given to rd= (cherry-pick #11354) (#11355)
providers/proxy: fix URL path getting lost when partial URL is given to rd= (#11354)

* providers/proxy: fix URL path getting lost when partial URL is given to rd=



* better fallback + tests



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-12 18:58:47 +02:00
ee04f39e28 enterprise: fix API mixin license validity check (cherry-pick #11331) (#11342)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
fix API mixin license validity check (#11331)
2024-09-11 13:22:01 +00:00
2c6aa72f3c sources/ldap: fix missing search attribute (cherry-pick #11125) (#11340)
sources/ldap: fix missing search attribute (#11125)

* unrelated



* sources/ldap: fix ldap sync not requesting uniqueness attribute



* check object_uniqueness_field for none



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-11 14:03:12 +02:00
bd0afef790 enterprise: show specific error if Install ID is invalid in license (cherry-pick #11317) (#11319)
enterprise: show specific error if Install ID is invalid in license (#11317)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-10 19:38:45 +02:00
fc11cc0a1a core: fix permission check for scoped impersonation (cherry-pick #11315) (#11316)
core: fix permission check for scoped impersonation (#11315)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-10 14:19:30 +02:00
fb78303e8f web/admin: fix notification property mapping forms (cherry-pick #11298) (#11300)
web/admin: fix notification property mapping forms (#11298)

* fix incorrect base class



* fix doclink url

closes #11276



* fix sidebar order in website



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-09 19:27:29 +02:00
2ea04440db events: optimise marking events as seen (cherry-pick #11297) (#11299)
events: optimise marking events as seen (#11297)

* events: optimise marking events as seen



* add tests



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-09 19:26:43 +02:00
96e1636be3 core: ensure all providers have correct priority (cherry-pick #11280) (#11281)
core: ensure all providers have correct priority (#11280)

follow up to #11267 which broke SAML lookup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-08 16:09:09 +02:00
c546451a73 root: fix ensure `outpost_connection_discovery runs on worker startup (cherry-pick #11260) (#11270)
root: fix ensure `outpost_connection_discovery runs on worker startup (#11260)

* root: fix ensure outpost_connection_discovery runs on worker startup

Make outpost_connection_discovery a startup task for default_tenant to ensure it's ran during worker startup. Without this waiting for the 8 hour schedule to fire is required.

fixes: https://github.com/goauthentik/authentik/issues/10933



* format



---------

Signed-off-by: Anthony Rabbito <arabbito@coreweave.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Anthony Rabbito <hello@anthonyrabbito.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2024-09-07 21:54:30 +02:00
61778053b4 core: ensure proxy provider is correctly looked up (cherry-pick #11267) (#11269)
core: ensure proxy provider is correctly looked up (#11267)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-07 21:53:30 +02:00
f5580d311d release: 2024.8.1 2024-09-07 16:14:54 +02:00
99d292bce0 web/users: show - if device was registered before we started saving the time (cherry-pick #11256) (#11257)
web/users: show - if device was registered before we started saving the time (#11256)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-06 21:13:03 +02:00
b2801641bc internal: fix go paginator not setting page correctly (cherry-pick #11253) (#11255)
internal: fix go paginator not setting page correctly (#11253)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-06 18:46:18 +02:00
bfaa1046b2 core: fix missing argument name escaping for property mapping (cherry-pick #11231) (#11252)
core: fix missing argument name escaping for property mapping (#11231)

* escape property mapping args



* improve display of error



* fix error handling, missing dry_run argument



* use different sanitisation



* update docs



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-06 16:47:27 +02:00
95c30400cc providers/ldap: rework search_group migration to work with read replicas (cherry-pick #11228) (#11229)
providers/ldap: rework search_group migration to work with read replicas (#11228)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-05 15:57:01 +02:00
e77480ee1d web/admin: improve error handling (cherry-pick #11212) (#11219)
web/admin: improve error handling (#11212)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-05 13:48:28 +02:00
905800e535 providers/ldap: fix incorrect permission check for search access (cherry-pick #11217) (#11218)
providers/ldap: fix incorrect permission check for search access (#11217)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-05 01:30:48 +02:00
fadeaef4c6 web/admin: fix missing Sync object button SCIM Provider (cherry-pick #11211) (#11213)
web/admin: fix missing Sync object button SCIM Provider (#11211)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-04 21:34:34 +02:00
437efda649 website/docs: add note about terraform provider (cherry-pick #11206) (#11208)
website/docs: add note about terraform provider (#11206)

* website/docs: add note about terraform provider



* Update website/docs/releases/2024/v2024.8.md



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Jens L. <jens@goauthentik.io>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2024-09-04 19:50:00 +02:00
dd75d5f54b web/admin: fix misc dual select on different forms (#11203)
* fix prompt stage

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix identification stage

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix OAuth JWKS sources

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix oauth provider default scopes

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix outpost form

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix webauthn

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix transport form

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts
#	web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts
2024-09-04 13:46:45 +02:00
392a2e582e core: bump cryptography from 43.0.0 to 43.0.1 (cherry-pick #11185) (#11202)
core: bump cryptography from 43.0.0 to 43.0.1 (#11185)

Bumps [cryptography](https://github.com/pyca/cryptography) from 43.0.0 to 43.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/43.0.0...43.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-04 12:27:54 +02:00
a1da183721 root: backport s3 storage changes (cherry-pick #11181) (#11183)
root: backport s3 storage changes (#11181)

re-add _strip_signing_parameters
removed in https://github.com/jschneier/django-storages/pull/1402
could probably be re-factored to use the same approach that PR uses

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-03 22:08:55 +02:00
feea2df0b1 core: fix change_user_type always requiring usernames (cherry-pick #11177) (#11178)
core: fix change_user_type always requiring usernames (#11177)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-03 19:09:53 +02:00
b47acd8c76 web/admin: fix error in Outpost creation form (cherry-pick #11173) (#11175)
web/admin: fix error in Outpost creation form (#11173)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-03 18:26:37 +02:00
6fd87d9ced providers/ldap: fix migration assuming search group is set (cherry-pick #11170) (#11172)
providers/ldap: fix migration assuming search group is set (#11170)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-09-03 16:27:06 +02:00
acbb065808 website/docs: update release notes (#11151)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	website/docs/releases/2024/v2024.8.md
2024-09-03 14:05:18 +02:00
2fb097061d release: 2024.8.0 2024-09-02 14:14:03 +02:00
8962d17e03 web: fix dual-select with dynamic selection (cherry-pick #11133) (#11134)
web: fix dual-select with dynamic selection (#11133)

* web: fix dual-select with dynamic selection

For dynamic selection, the property name is `.selector` to message that it's a function the
API layer uses to select the elements.

A few bits of lint picked.

* web: added comment to clarify what the fallback selector does

Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
2024-08-30 19:07:36 +02:00
8326e1490c ci: fix failing release attestation (cherry-pick #11107) (#11120)
ci: fix failing release attestation (#11107)

* ci: fix failing release attestation



* fix



---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-08-29 13:29:47 +02:00
091e4d3e4c enterprise: fix incorrect comparison for latest validity date (cherry-pick #11109) (#11110)
enterprise: fix incorrect comparison for latest validity date (#11109)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-08-29 01:58:56 +02:00
6ee77edcbb website/docs: 2024.8 release notes: reword group sync disable and fix typo (cherry-pick #11103) (#11108)
website/docs: 2024.8 release notes: reword group sync disable and fix… (#11103)

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-08-29 01:34:33 +02:00
763e2288bf release: 2024.8.0-rc2 2024-08-28 20:22:52 +02:00
9cdb177ca7 website/docs: a couple of minor rewrite things (#11099)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	website/docs/releases/2024/v2024.8.md
2024-08-28 20:22:21 +02:00
6070508058 providers/oauth2: audit_ignore last_login change for generated service account (cherry-pick #11085) (#11086)
providers/oauth2: audit_ignore last_login change for generated service account (#11085)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2024-08-27 14:32:17 +02:00
ec13a5d84d release: 2024.8.0-rc1 2024-08-26 16:34:53 +02:00
057de82b01 schemas: fix XML Schema loading...for some reason?
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2024-08-26 16:34:47 +02:00
876 changed files with 30070 additions and 35250 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2024.10.0
current_version = 2024.8.2
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?

View File

@ -14,7 +14,7 @@ runs:
run: |
pipx install poetry || true
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext
- name: Setup python and restore poetry
uses: actions/setup-python@v5
with:

View File

@ -23,6 +23,7 @@ updates:
- package-ecosystem: npm
directories:
- "/web"
- "/tests/wdio"
- "/web/sfe"
schedule:
interval: daily
@ -43,11 +44,9 @@ updates:
- "babel-*"
eslint:
patterns:
- "@eslint/*"
- "@typescript-eslint/*"
- "eslint-*"
- "eslint"
- "typescript-eslint"
- "eslint-*"
storybook:
patterns:
- "@storybook/*"
@ -55,12 +54,10 @@ updates:
esbuild:
patterns:
- "@esbuild/*"
- "esbuild*"
rollup:
patterns:
- "@rollup/*"
- "rollup-*"
- "rollup*"
swc:
patterns:
- "@swc/*"

View File

@ -1,7 +1,7 @@
<!--
👋 Hi there! Welcome.
Please check the Contributing guidelines: https://docs.goauthentik.io/docs/developer-docs/#how-can-i-contribute
Please check the Contributing guidelines: https://goauthentik.io/developer-docs/#how-can-i-contribute
-->
## Details

View File

@ -40,7 +40,7 @@ jobs:
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- uses: peter-evans/create-pull-request@v7
- uses: peter-evans/create-pull-request@v6
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -120,12 +120,6 @@ jobs:
with:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
flags: unit
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
test-integration:
runs-on: ubuntu-latest
timeout-minutes: 30
@ -144,12 +138,6 @@ jobs:
with:
flags: integration
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
flags: integration
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
test-e2e:
name: test-e2e (${{ matrix.job.name }})
runs-on: ubuntu-latest
@ -180,7 +168,7 @@ jobs:
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
docker compose -f tests/e2e/docker-compose.yml up -d
- id: cache-web
uses: actions/cache@v4
with:
@ -202,12 +190,6 @@ jobs:
with:
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
flags: e2e
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
ci-core-mark:
needs:
- lint

View File

@ -24,11 +24,17 @@ jobs:
- prettier-check
project:
- web
- tests/wdio
include:
- command: tsc
project: web
- command: lit-analyse
project: web
exclude:
- command: lint:lockfile
project: tests/wdio
- command: tsc
project: tests/wdio
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@ -39,12 +45,21 @@ jobs:
- working-directory: ${{ matrix.project }}/
run: |
npm ci
${{ matrix.extra_setup }}
- name: Generate API
run: make gen-client-ts
- name: Lint
working-directory: ${{ matrix.project }}/
run: npm run ${{ matrix.command }}
ci-web-mark:
needs:
- lint
runs-on: ubuntu-latest
steps:
- run: echo mark
build:
needs:
- ci-web-mark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -60,13 +75,6 @@ jobs:
- name: build
working-directory: web/
run: npm run build
ci-web-mark:
needs:
- build
- lint
runs-on: ubuntu-latest
steps:
- run: echo mark
test:
needs:
- ci-web-mark

View File

@ -24,7 +24,7 @@ jobs:
- name: Setup authentik env
uses: ./.github/actions/setup
- run: poetry run ak update_webauthn_mds
- uses: peter-evans/create-pull-request@v7
- uses: peter-evans/create-pull-request@v6
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}

View File

@ -42,7 +42,7 @@ jobs:
with:
githubToken: ${{ steps.generate_token.outputs.token }}
compressOnly: ${{ github.event_name != 'pull_request' }}
- uses: peter-evans/create-pull-request@v7
- uses: peter-evans/create-pull-request@v6
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
id: cpr
with:

View File

@ -32,7 +32,7 @@ jobs:
poetry run ak compilemessages
make web-check-compile
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v6
with:
token: ${{ steps.generate_token.outputs.token }}
branch: extract-compile-backend-translation

View File

@ -6,7 +6,6 @@
"authn",
"entra",
"goauthentik",
"jwe",
"jwks",
"kubernetes",
"oidc",

View File

@ -94,7 +94,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Python dependencies
FROM ghcr.io/goauthentik/fips-python:3.12.7-slim-bookworm-fips-full AS python-deps
FROM ghcr.io/goauthentik/fips-python:3.12.5-slim-bookworm-fips-full AS python-deps
ARG TARGETARCH
ARG TARGETVARIANT
@ -110,7 +110,7 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloa
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
apt-get update && \
# Required for installing pip packages
apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev libkrb5-dev
apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev
RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
--mount=type=bind,target=./poetry.lock,src=./poetry.lock \
@ -124,7 +124,7 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
pip install --force-reinstall /wheels/*"
# Stage 6: Run
FROM ghcr.io/goauthentik/fips-python:3.12.7-slim-bookworm-fips-full AS final-image
FROM ghcr.io/goauthentik/fips-python:3.12.5-slim-bookworm-fips-full AS final-image
ARG VERSION
ARG GIT_BUILD_HASH
@ -141,7 +141,7 @@ WORKDIR /
# We cannot cache this layer otherwise we'll end up with a bigger image
RUN apt-get update && \
# Required for runtime
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 && \
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates && \
# Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends runit && \
apt-get clean && \
@ -161,7 +161,6 @@ COPY ./tests /tests
COPY ./manage.py /
COPY ./blueprints /blueprints
COPY ./lifecycle/ /lifecycle
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
COPY --from=go-builder /go/authentik /bin/authentik
COPY --from=python-deps /ak-root/venv /ak-root/venv
COPY --from=web-builder /work/web/dist/ /web/dist/

View File

@ -19,13 +19,14 @@ pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null)
CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
-I .github/codespell-words.txt \
-S 'web/src/locales/**' \
-S 'website/docs/developer-docs/api/reference/**' \
-S 'website/developer-docs/api/reference/**' \
authentik \
internal \
cmd \
web/src \
website/src \
website/blog \
website/developer-docs \
website/docs \
website/integrations \
website/src

View File

@ -34,7 +34,7 @@ For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/h
## Development
See [Developer Documentation](https://docs.goauthentik.io/docs/developer-docs/?utm_source=github)
See [Developer Documentation](https://goauthentik.io/developer-docs/?utm_source=github)
## Security

View File

@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
| Version | Supported |
| -------- | --------- |
| 2024.4.x | ✅ |
| 2024.6.x | ✅ |
| 2024.8.x | ✅ |
## Reporting a Vulnerability

View File

@ -2,7 +2,7 @@
from os import environ
__version__ = "2024.10.0"
__version__ = "2024.8.2"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -1,33 +0,0 @@
from rest_framework.permissions import IsAdminUser
from rest_framework.viewsets import ReadOnlyModelViewSet
from authentik.admin.models import VersionHistory
from authentik.core.api.utils import ModelSerializer
class VersionHistorySerializer(ModelSerializer):
"""VersionHistory Serializer"""
class Meta:
model = VersionHistory
fields = [
"id",
"timestamp",
"version",
"build",
]
class VersionHistoryViewSet(ReadOnlyModelViewSet):
"""VersionHistory Viewset"""
queryset = VersionHistory.objects.all()
serializer_class = VersionHistorySerializer
permission_classes = [IsAdminUser]
filterset_fields = [
"version",
"build",
]
search_fields = ["version", "build"]
ordering = ["-timestamp"]
pagination_class = None

View File

@ -1,22 +0,0 @@
"""authentik admin models"""
from django.db import models
from django.utils.translation import gettext_lazy as _
class VersionHistory(models.Model):
id = models.BigAutoField(primary_key=True)
timestamp = models.DateTimeField()
version = models.TextField()
build = models.TextField()
class Meta:
managed = False
db_table = "authentik_version_history"
ordering = ("-timestamp",)
verbose_name = _("Version history")
verbose_name_plural = _("Version history")
default_permissions = []
def __str__(self):
return f"{self.version}.{self.build} ({self.timestamp})"

View File

@ -1,8 +1,10 @@
"""authentik admin tasks"""
import re
from django.core.cache import cache
from django.core.validators import URLValidator
from django.db import DatabaseError, InternalError, ProgrammingError
from django.utils.translation import gettext_lazy as _
from packaging.version import parse
from requests import RequestException
from structlog.stdlib import get_logger
@ -19,6 +21,8 @@ LOGGER = get_logger()
VERSION_NULL = "0.0.0"
VERSION_CACHE_KEY = "authentik_latest_version"
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
# Chop of the first ^ because we want to search the entire string
URL_FINDER = URLValidator.regex.pattern[1:]
LOCAL_VERSION = parse(__version__)
@ -74,16 +78,10 @@ def update_latest_version(self: SystemTask):
context__new_version=upstream_version,
).exists():
return
Event.new(
EventAction.UPDATE_AVAILABLE,
message=_(
"New version {version} available!".format(
version=upstream_version,
)
),
new_version=upstream_version,
changelog=data.get("stable", {}).get("changelog_url"),
).save()
event_dict = {"new_version": upstream_version}
if match := re.search(URL_FINDER, data.get("stable", {}).get("changelog", "")):
event_dict["message"] = f"Changelog: {match.group()}"
Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save()
except (RequestException, IndexError) as exc:
cache.set(VERSION_CACHE_KEY, VERSION_NULL, VERSION_CACHE_TIMEOUT)
self.set_error(exc)

View File

@ -17,7 +17,6 @@ RESPONSE_VALID = {
"stable": {
"version": "99999999.9999999",
"changelog": "See https://goauthentik.io/test",
"changelog_url": "https://goauthentik.io/test",
"reason": "bugfix",
},
}
@ -36,7 +35,7 @@ class TestAdminTasks(TestCase):
Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE,
context__new_version="99999999.9999999",
context__message="New version 99999999.9999999 available!",
context__message="Changelog: https://goauthentik.io/test",
).exists()
)
# test that a consecutive check doesn't create a duplicate event
@ -46,7 +45,7 @@ class TestAdminTasks(TestCase):
Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE,
context__new_version="99999999.9999999",
context__message="New version 99999999.9999999 available!",
context__message="Changelog: https://goauthentik.io/test",
)
),
1,

View File

@ -6,7 +6,6 @@ from authentik.admin.api.meta import AppsViewSet, ModelViewSet
from authentik.admin.api.metrics import AdministrationMetricsViewSet
from authentik.admin.api.system import SystemView
from authentik.admin.api.version import VersionView
from authentik.admin.api.version_history import VersionHistoryViewSet
from authentik.admin.api.workers import WorkerView
api_urlpatterns = [
@ -18,7 +17,6 @@ api_urlpatterns = [
name="admin_metrics",
),
path("admin/version/", VersionView.as_view(), name="admin_version"),
("admin/version/history", VersionHistoryViewSet, "version_history"),
path("admin/workers/", WorkerView.as_view(), name="admin_workers"),
path("admin/system/", SystemView.as_view(), name="admin_system"),
]

View File

@ -51,11 +51,9 @@ class BlueprintInstanceSerializer(ModelSerializer):
context = self.instance.context if self.instance else {}
valid, logs = Importer.from_string(content, context).validate()
if not valid:
text_logs = "\n".join([x["event"] for x in logs])
raise ValidationError(
[
_("Failed to validate blueprint"),
*[f"- {x.event}" for x in logs],
]
_("Failed to validate blueprint: {logs}".format_map({"logs": text_logs}))
)
return content

View File

@ -29,7 +29,9 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
if version != 1:
return
blueprint_file.seek(0)
instance = BlueprintInstance.objects.using(db_alias).filter(path=path).first()
instance: BlueprintInstance = (
BlueprintInstance.objects.using(db_alias).filter(path=path).first()
)
rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir")))
meta = None
if metadata:

View File

@ -78,5 +78,5 @@ class TestBlueprintsV1API(APITestCase):
self.assertEqual(res.status_code, 400)
self.assertJSONEqual(
res.content.decode(),
{"content": ["Failed to validate blueprint", "- Invalid blueprint version"]},
{"content": ["Failed to validate blueprint: Invalid blueprint version"]},
)

View File

@ -51,10 +51,6 @@ from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.rac.models import ConnectionToken
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
EndpointDevice,
EndpointDeviceConnection,
)
from authentik.events.logs import LogEvent, capture_logs
from authentik.events.models import SystemTask
from authentik.events.utils import cleanse_dict
@ -73,7 +69,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.tenants.models import Tenant
# Context set when the serializer is created in a blueprint context
# Update website/docs/customize/blueprints/v1/models.md when used
# Update website/developer-docs/blueprints/v1/models.md when used
SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry"
@ -123,8 +119,6 @@ def excluded_models() -> list[type[Model]]:
GoogleWorkspaceProviderGroup,
MicrosoftEntraProviderUser,
MicrosoftEntraProviderGroup,
EndpointDevice,
EndpointDeviceConnection,
)
@ -435,7 +429,7 @@ class Importer:
orig_import = deepcopy(self._import)
if self._import.version != 1:
self.logger.warning("Invalid blueprint version")
return False, [LogEvent("Invalid blueprint version", log_level="warning", logger=None)]
return False, [{"event": "Invalid blueprint version"}]
with (
transaction_rollback(),
capture_logs() as logs,

View File

@ -6,45 +6,34 @@ from rest_framework.fields import (
BooleanField,
CharField,
DateTimeField,
IntegerField,
SerializerMethodField,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
from authentik.rbac.decorators import permission_required
from authentik.stages.authenticator import device_classes, devices_for_user
from authentik.stages.authenticator.models import Device
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
class DeviceSerializer(MetaNameSerializer):
"""Serializer for Duo authenticator devices"""
pk = CharField()
pk = IntegerField()
name = CharField()
type = SerializerMethodField()
confirmed = BooleanField()
created = DateTimeField(read_only=True)
last_updated = DateTimeField(read_only=True)
last_used = DateTimeField(read_only=True, allow_null=True)
extra_description = SerializerMethodField()
def get_type(self, instance: Device) -> str:
"""Get type of device"""
return instance._meta.label
def get_extra_description(self, instance: Device) -> str:
"""Get extra description"""
if isinstance(instance, WebAuthnDevice):
return instance.device_type.description
if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel")
return ""
class DeviceViewSet(ViewSet):
"""Viewset for authenticator devices"""
@ -63,7 +52,7 @@ class AdminDeviceViewSet(ViewSet):
"""Viewset for authenticator devices"""
serializer_class = DeviceSerializer
permission_classes = []
permission_classes = [IsAdminUser]
def get_devices(self, **kwargs):
"""Get all devices in all child classes"""
@ -81,10 +70,6 @@ class AdminDeviceViewSet(ViewSet):
],
responses={200: DeviceSerializer(many=True)},
)
@permission_required(
None,
[f"{model._meta.app_label}.view_{model._meta.model_name}" for model in device_classes()],
)
def list(self, request: Request) -> Response:
"""Get all devices for current user"""
kwargs = {}

View File

@ -38,7 +38,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"name",
"authentication_flow",
"authorization_flow",
"invalidation_flow",
"property_mappings",
"component",
"assigned_application_slug",
@ -51,7 +50,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
]
extra_kwargs = {
"authorization_flow": {"required": True, "allow_null": False},
"invalidation_flow": {"required": True, "allow_null": False},
}

View File

@ -679,10 +679,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
LOGGER.debug("User attempted to impersonate", user=request.user)
return Response(status=401)
user_to_be = self.get_object()
# Check both object-level perms and global perms
if not request.user.has_perm(
"authentik_core.impersonate", user_to_be
) and not request.user.has_perm("authentik_core.impersonate"):
if not request.user.has_perm("impersonate", user_to_be):
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
return Response(status=401)
if user_to_be.pk == self.request.user.pk:

View File

@ -4,7 +4,6 @@ import code
import platform
import sys
import traceback
from pprint import pprint
from django.apps import apps
from django.core.management.base import BaseCommand
@ -35,9 +34,7 @@ class Command(BaseCommand):
def get_namespace(self):
"""Prepare namespace with all models"""
namespace = {
"pprint": pprint,
}
namespace = {}
# Gather Django models and constants from each app
for app in apps.get_app_configs():

View File

@ -1,55 +0,0 @@
# Generated by Django 5.0.9 on 2024-10-02 11:35
import django.db.models.deletion
from django.db import migrations, models
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_invalidation_flow_default(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.flows.models import FlowDesignation, FlowAuthenticationRequirement
db_alias = schema_editor.connection.alias
Flow = apps.get_model("authentik_flows", "Flow")
Provider = apps.get_model("authentik_core", "Provider")
# So this flow is managed via a blueprint, bue we're in a migration so we don't want to rely on that
# since the blueprint is just an empty flow we can just create it here
# and let it be managed by the blueprint later
flow, _ = Flow.objects.using(db_alias).update_or_create(
slug="default-provider-invalidation-flow",
defaults={
"name": "Logged out of application",
"title": "You've logged out of %(app)s.",
"authentication": FlowAuthenticationRequirement.NONE,
"designation": FlowDesignation.INVALIDATION,
},
)
Provider.objects.using(db_alias).filter(invalidation_flow=None).update(invalidation_flow=flow)
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
("authentik_flows", "0027_auto_20231028_1424"),
]
operations = [
migrations.AddField(
model_name="provider",
name="invalidation_flow",
field=models.ForeignKey(
default=None,
help_text="Flow used ending the session from a provider.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="provider_invalidation",
to="authentik_flows.flow",
),
),
migrations.RunPython(migrate_invalidation_flow_default),
]

View File

@ -330,13 +330,11 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
"""superuser == staff user"""
return self.is_superuser # type: ignore
def set_password(self, raw_password, signal=True, sender=None):
def set_password(self, raw_password, signal=True):
if self.pk and signal:
from authentik.core.signals import password_changed
if not sender:
sender = self
password_changed.send(sender=sender, user=self, password=raw_password)
password_changed.send(sender=self, user=self, password=raw_password)
self.password_change_date = now()
return super().set_password(raw_password)
@ -393,23 +391,14 @@ class Provider(SerializerModel):
),
related_name="provider_authentication",
)
authorization_flow = models.ForeignKey(
"authentik_flows.Flow",
# Set to cascade even though null is allowed, since most providers
# still require an authorization flow set
on_delete=models.CASCADE,
null=True,
help_text=_("Flow used when authorizing this provider."),
related_name="provider_authorization",
)
invalidation_flow = models.ForeignKey(
"authentik_flows.Flow",
on_delete=models.SET_DEFAULT,
default=None,
null=True,
help_text=_("Flow used ending the session from a provider."),
related_name="provider_invalidation",
)
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)

View File

@ -1,9 +1,11 @@
"""Source decision helper"""
from enum import Enum
from typing import Any
from django.contrib import messages
from django.db import IntegrityError, transaction
from django.db.models.query_utils import Q
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
@ -14,11 +16,12 @@ from authentik.core.models import (
Group,
GroupSourceConnection,
Source,
SourceGroupMatchingModes,
SourceUserMatchingModes,
User,
UserSourceConnection,
)
from authentik.core.sources.mapper import SourceMapper
from authentik.core.sources.matcher import Action, SourceMatcher
from authentik.core.sources.stage import (
PLAN_CONTEXT_SOURCES_CONNECTION,
PostSourceStage,
@ -51,6 +54,16 @@ SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token"
PLAN_CONTEXT_SOURCE_GROUPS = "source_groups"
class Action(Enum):
"""Actions that can be decided based on the request
and source settings"""
LINK = "link"
AUTH = "auth"
ENROLL = "enroll"
DENY = "deny"
class MessageStage(StageView):
"""Show a pre-configured message after the flow is done"""
@ -73,7 +86,6 @@ class SourceFlowManager:
source: Source
mapper: SourceMapper
matcher: SourceMatcher
request: HttpRequest
identifier: str
@ -96,9 +108,6 @@ class SourceFlowManager:
) -> None:
self.source = source
self.mapper = SourceMapper(self.source)
self.matcher = SourceMatcher(
self.source, self.user_connection_type, self.group_connection_type
)
self.request = request
self.identifier = identifier
self.user_info = user_info
@ -122,19 +131,66 @@ class SourceFlowManager:
def get_action(self, **kwargs) -> tuple[Action, UserSourceConnection | None]: # noqa: PLR0911
"""decide which action should be taken"""
new_connection = self.user_connection_type(source=self.source, identifier=self.identifier)
# When request is authenticated, always link
if self.request.user.is_authenticated:
new_connection = self.user_connection_type(
source=self.source, identifier=self.identifier
)
new_connection.user = self.request.user
new_connection = self.update_user_connection(new_connection, **kwargs)
return Action.LINK, new_connection
action, connection = self.matcher.get_user_action(self.identifier, self.user_properties)
if connection:
connection = self.update_user_connection(connection, **kwargs)
return action, connection
existing_connections = self.user_connection_type.objects.filter(
source=self.source, identifier=self.identifier
)
if existing_connections.exists():
connection = existing_connections.first()
return Action.AUTH, self.update_user_connection(connection, **kwargs)
# No connection exists, but we match on identifier, so enroll
if self.source.user_matching_mode == SourceUserMatchingModes.IDENTIFIER:
# We don't save the connection here cause it doesn't have a user assigned yet
return Action.ENROLL, self.update_user_connection(new_connection, **kwargs)
# Check for existing users with matching attributes
query = Q()
# Either query existing user based on email or username
if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_LINK,
SourceUserMatchingModes.EMAIL_DENY,
]:
if not self.user_properties.get("email", None):
self._logger.warning("Refusing to use none email")
return Action.DENY, None
query = Q(email__exact=self.user_properties.get("email", None))
if self.source.user_matching_mode in [
SourceUserMatchingModes.USERNAME_LINK,
SourceUserMatchingModes.USERNAME_DENY,
]:
if not self.user_properties.get("username", None):
self._logger.warning("Refusing to use none username")
return Action.DENY, None
query = Q(username__exact=self.user_properties.get("username", None))
self._logger.debug("trying to link with existing user", query=query)
matching_users = User.objects.filter(query)
# No matching users, always enroll
if not matching_users.exists():
self._logger.debug("no matching users found, enrolling")
return Action.ENROLL, self.update_user_connection(new_connection, **kwargs)
user = matching_users.first()
if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_LINK,
SourceUserMatchingModes.USERNAME_LINK,
]:
new_connection.user = user
new_connection = self.update_user_connection(new_connection, **kwargs)
return Action.LINK, new_connection
if self.source.user_matching_mode in [
SourceUserMatchingModes.EMAIL_DENY,
SourceUserMatchingModes.USERNAME_DENY,
]:
self._logger.info("denying source because user exists", user=user)
return Action.DENY, None
# Should never get here as default enroll case is returned above.
return Action.DENY, None # pragma: no cover
def update_user_connection(
self, connection: UserSourceConnection, **kwargs
@ -272,6 +328,7 @@ class SourceFlowManager:
connection: UserSourceConnection,
) -> HttpResponse:
"""Login user and redirect."""
flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
return self._prepare_flow(
self.source.authentication_flow,
connection,
@ -285,11 +342,7 @@ class SourceFlowManager:
),
)
],
**{
PLAN_CONTEXT_PENDING_USER: connection.user,
PLAN_CONTEXT_PROMPT: delete_none_values(self.user_properties),
PLAN_CONTEXT_USER_PATH: self.source.get_user_path(),
},
**flow_kwargs,
)
def handle_existing_link(
@ -355,16 +408,74 @@ class SourceFlowManager:
class GroupUpdateStage(StageView):
"""Dynamically injected stage which updates the user after enrollment/authentication."""
def get_action(
self, group_id: str, group_properties: dict[str, Any | dict[str, Any]]
) -> tuple[Action, GroupSourceConnection | None]:
"""decide which action should be taken"""
new_connection = self.group_connection_type(source=self.source, identifier=group_id)
existing_connections = self.group_connection_type.objects.filter(
source=self.source, identifier=group_id
)
if existing_connections.exists():
return Action.LINK, existing_connections.first()
# No connection exists, but we match on identifier, so enroll
if self.source.group_matching_mode == SourceGroupMatchingModes.IDENTIFIER:
# We don't save the connection here cause it doesn't have a user assigned yet
return Action.ENROLL, new_connection
# Check for existing groups with matching attributes
query = Q()
if self.source.group_matching_mode in [
SourceGroupMatchingModes.NAME_LINK,
SourceGroupMatchingModes.NAME_DENY,
]:
if not group_properties.get("name", None):
LOGGER.warning(
"Refusing to use none group name", source=self.source, group_id=group_id
)
return Action.DENY, None
query = Q(name__exact=group_properties.get("name"))
LOGGER.debug(
"trying to link with existing group", source=self.source, query=query, group_id=group_id
)
matching_groups = Group.objects.filter(query)
# No matching groups, always enroll
if not matching_groups.exists():
LOGGER.debug(
"no matching groups found, enrolling", source=self.source, group_id=group_id
)
return Action.ENROLL, new_connection
group = matching_groups.first()
if self.source.group_matching_mode in [
SourceGroupMatchingModes.NAME_LINK,
]:
new_connection.group = group
return Action.LINK, new_connection
if self.source.group_matching_mode in [
SourceGroupMatchingModes.NAME_DENY,
]:
LOGGER.info(
"denying source because group exists",
source=self.source,
group=group,
group_id=group_id,
)
return Action.DENY, None
# Should never get here as default enroll case is returned above.
return Action.DENY, None # pragma: no cover
def handle_group(
self, group_id: str, group_properties: dict[str, Any | dict[str, Any]]
) -> Group | None:
action, connection = self.matcher.get_group_action(group_id, group_properties)
action, connection = self.get_action(group_id, group_properties)
if action == Action.ENROLL:
group = Group.objects.create(**group_properties)
connection.group = group
connection.save()
return group
elif action in (Action.LINK, Action.AUTH):
elif action == Action.LINK:
group = connection.group
group.update_attributes(group_properties)
connection.save()
@ -378,7 +489,6 @@ class GroupUpdateStage(StageView):
self.group_connection_type: GroupSourceConnection = (
self.executor.current_stage.group_connection_type
)
self.matcher = SourceMatcher(self.source, None, self.group_connection_type)
raw_groups: dict[str, dict[str, Any | dict[str, Any]]] = self.executor.plan.context[
PLAN_CONTEXT_SOURCE_GROUPS

View File

@ -1,152 +0,0 @@
"""Source user and group matching"""
from dataclasses import dataclass
from enum import Enum
from typing import Any
from django.db.models import Q
from structlog import get_logger
from authentik.core.models import (
Group,
GroupSourceConnection,
Source,
SourceGroupMatchingModes,
SourceUserMatchingModes,
User,
UserSourceConnection,
)
class Action(Enum):
"""Actions that can be decided based on the request and source settings"""
LINK = "link"
AUTH = "auth"
ENROLL = "enroll"
DENY = "deny"
@dataclass
class MatchableProperty:
property: str
link_mode: SourceUserMatchingModes | SourceGroupMatchingModes
deny_mode: SourceUserMatchingModes | SourceGroupMatchingModes
class SourceMatcher:
def __init__(
self,
source: Source,
user_connection_type: type[UserSourceConnection],
group_connection_type: type[GroupSourceConnection],
):
self.source = source
self.user_connection_type = user_connection_type
self.group_connection_type = group_connection_type
self._logger = get_logger().bind(source=self.source)
def get_action(
self,
object_type: type[User | Group],
matchable_properties: list[MatchableProperty],
identifier: str,
properties: dict[str, Any | dict[str, Any]],
) -> tuple[Action, UserSourceConnection | GroupSourceConnection | None]:
connection_type = None
matching_mode = None
identifier_matching_mode = None
if object_type == User:
connection_type = self.user_connection_type
matching_mode = self.source.user_matching_mode
identifier_matching_mode = SourceUserMatchingModes.IDENTIFIER
if object_type == Group:
connection_type = self.group_connection_type
matching_mode = self.source.group_matching_mode
identifier_matching_mode = SourceGroupMatchingModes.IDENTIFIER
if not connection_type or not matching_mode or not identifier_matching_mode:
return Action.DENY, None
new_connection = connection_type(source=self.source, identifier=identifier)
existing_connections = connection_type.objects.filter(
source=self.source, identifier=identifier
)
if existing_connections.exists():
return Action.AUTH, existing_connections.first()
# No connection exists, but we match on identifier, so enroll
if matching_mode == identifier_matching_mode:
# We don't save the connection here cause it doesn't have a user/group assigned yet
return Action.ENROLL, new_connection
# Check for existing users with matching attributes
query = Q()
for matchable_property in matchable_properties:
property = matchable_property.property
if matching_mode in [matchable_property.link_mode, matchable_property.deny_mode]:
if not properties.get(property, None):
self._logger.warning(
"Refusing to use none property", identifier=identifier, property=property
)
return Action.DENY, None
query_args = {
f"{property}__exact": properties[property],
}
query = Q(**query_args)
self._logger.debug(
"Trying to link with existing object", query=query, identifier=identifier
)
matching_objects = object_type.objects.filter(query)
# Not matching objects, always enroll
if not matching_objects.exists():
self._logger.debug("No matching objects found, enrolling")
return Action.ENROLL, new_connection
obj = matching_objects.first()
if matching_mode in [mp.link_mode for mp in matchable_properties]:
attr = None
if object_type == User:
attr = "user"
if object_type == Group:
attr = "group"
setattr(new_connection, attr, obj)
return Action.LINK, new_connection
if matching_mode in [mp.deny_mode for mp in matchable_properties]:
self._logger.info("Denying source because object exists", obj=obj)
return Action.DENY, None
# Should never get here as default enroll case is returned above.
return Action.DENY, None # pragma: no cover
def get_user_action(
self, identifier: str, properties: dict[str, Any | dict[str, Any]]
) -> tuple[Action, UserSourceConnection | None]:
return self.get_action(
User,
[
MatchableProperty(
"username",
SourceUserMatchingModes.USERNAME_LINK,
SourceUserMatchingModes.USERNAME_DENY,
),
MatchableProperty(
"email", SourceUserMatchingModes.EMAIL_LINK, SourceUserMatchingModes.EMAIL_DENY
),
],
identifier,
properties,
)
def get_group_action(
self, identifier: str, properties: dict[str, Any | dict[str, Any]]
) -> tuple[Action, GroupSourceConnection | None]:
return self.get_action(
Group,
[
MatchableProperty(
"name", SourceGroupMatchingModes.NAME_LINK, SourceGroupMatchingModes.NAME_DENY
),
],
identifier,
properties,
)

View File

@ -0,0 +1,43 @@
{% extends 'login/base_full.html' %}
{% load static %}
{% load i18n %}
{% block title %}
{% trans 'End session' %} - {{ brand.branding_title }}
{% endblock %}
{% block card_title %}
{% blocktrans with application=application.name %}
You've logged out of {{ application }}.
{% endblocktrans %}
{% endblock %}
{% block card %}
<form method="POST" class="pf-c-form">
<p>
{% blocktrans with application=application.name branding_title=brand.branding_title %}
You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account.
{% endblocktrans %}
</p>
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary">
{% trans 'Go back to overview' %}
</a>
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">
{% blocktrans with branding_title=brand.branding_title %}
Log out of {{ branding_title }}
{% endblocktrans %}
</a>
{% if application.get_launch_url %}
<a href="{{ application.get_launch_url }}" class="pf-c-button pf-m-secondary">
{% blocktrans with application=application.name %}
Log back into {{ application }}
{% endblocktrans %}
</a>
{% endif %}
</form>
{% endblock %}

View File

@ -134,7 +134,6 @@ class TestApplicationsAPI(APITestCase):
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
"component": "ak-provider-oauth2-form",
"meta_model_name": "authentik_providers_oauth2.oauth2provider",
@ -187,7 +186,6 @@ class TestApplicationsAPI(APITestCase):
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
"component": "ak-provider-oauth2-form",
"meta_model_name": "authentik_providers_oauth2.oauth2provider",

View File

@ -1,59 +0,0 @@
"""Test Devices API"""
from json import loads
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user, create_test_user
class TestDevicesAPI(APITestCase):
"""Test applications API"""
def setUp(self) -> None:
self.admin = create_test_admin_user()
self.user1 = create_test_user()
self.device1 = self.user1.staticdevice_set.create()
self.user2 = create_test_user()
self.device2 = self.user2.staticdevice_set.create()
def test_user_api(self):
"""Test user API"""
self.client.force_login(self.user1)
response = self.client.get(
reverse(
"authentik_api:device-list",
)
)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(len(body), 1)
self.assertEqual(body[0]["pk"], str(self.device1.pk))
def test_user_api_as_admin(self):
"""Test user API"""
self.client.force_login(self.admin)
response = self.client.get(
reverse(
"authentik_api:device-list",
)
)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(len(body), 0)
def test_admin_api(self):
"""Test admin API"""
self.client.force_login(self.admin)
response = self.client.get(
reverse(
"authentik_api:admin-device-list",
)
)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(len(body), 2)
self.assertEqual(
{body[0]["pk"], body[1]["pk"]}, {str(self.device1.pk), str(self.device2.pk)}
)

View File

@ -44,26 +44,6 @@ class TestImpersonation(APITestCase):
self.assertEqual(response_body["user"]["username"], self.user.username)
self.assertNotIn("original", response_body)
def test_impersonate_global(self):
"""Test impersonation with global permissions"""
new_user = create_test_user()
assign_perm("authentik_core.impersonate", new_user)
assign_perm("authentik_core.view_user", new_user)
self.client.force_login(new_user)
response = self.client.post(
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
)
)
self.assertEqual(response.status_code, 201)
response = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode())
self.assertEqual(response_body["user"]["username"], self.other_user.username)
self.assertEqual(response_body["original"]["username"], new_user.username)
def test_impersonate_scoped(self):
"""Test impersonation with scoped permissions"""
new_user = create_test_user()

View File

@ -19,6 +19,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
"""Test transactional Application + provider creation"""
self.client.force_login(self.user)
uid = generate_id()
authorization_flow = create_test_flow()
response = self.client.put(
reverse("authentik_api:core-transactional-application"),
data={
@ -29,8 +30,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
"provider_model": "authentik_providers_oauth2.oauth2provider",
"provider": {
"name": uid,
"authorization_flow": str(create_test_flow().pk),
"invalidation_flow": str(create_test_flow().pk),
"authorization_flow": str(authorization_flow.pk),
},
},
)
@ -56,16 +56,10 @@ class TestTransactionalApplicationsAPI(APITestCase):
"provider": {
"name": uid,
"authorization_flow": "",
"invalidation_flow": "",
},
},
)
self.assertJSONEqual(
response.content.decode(),
{
"provider": {
"authorization_flow": ["This field may not be null."],
"invalidation_flow": ["This field may not be null."],
}
},
{"provider": {"authorization_flow": ["This field may not be null."]}},
)

View File

@ -5,6 +5,7 @@ from channels.sessions import CookieMiddleware
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie
from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
@ -23,6 +24,7 @@ from authentik.core.views.interface import (
InterfaceView,
RootRedirectView,
)
from authentik.core.views.session import EndSessionView
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer
@ -43,21 +45,26 @@ urlpatterns = [
# Interfaces
path(
"if/admin/",
BrandDefaultRedirectView.as_view(template_name="if/admin.html"),
ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/admin.html")),
name="if-admin",
),
path(
"if/user/",
BrandDefaultRedirectView.as_view(template_name="if/user.html"),
ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/user.html")),
name="if-user",
),
path(
"if/flow/<slug:flow_slug>/",
# FIXME: move this url to the flows app...also will cause all
# of the reverse calls to be adjusted
FlowInterfaceView.as_view(),
ensure_csrf_cookie(FlowInterfaceView.as_view()),
name="if-flow",
),
path(
"if/session-end/<slug:application_slug>/",
ensure_csrf_cookie(EndSessionView.as_view()),
name="if-session-end",
),
# Fallback for WS
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
path(

View File

@ -0,0 +1,23 @@
"""authentik Session Views"""
from typing import Any
from django.shortcuts import get_object_or_404
from django.views.generic.base import TemplateView
from authentik.core.models import Application
from authentik.policies.views import PolicyAccessView
class EndSessionView(TemplateView, PolicyAccessView):
"""Allow the client to end the Session"""
template_name = "if/end_session.html"
def resolve_provider_application(self):
self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["application"] = self.application
return context

View File

@ -68,7 +68,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"connection_expiry": "hours=8",
"delete_token_on_disconnect": False,
@ -121,7 +120,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
@ -151,7 +149,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,

View File

@ -3,6 +3,7 @@
from channels.auth import AuthMiddleware
from channels.sessions import CookieMiddleware
from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie
from authentik.enterprise.providers.rac.api.connection_tokens import ConnectionTokenViewSet
from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet
@ -18,12 +19,12 @@ from authentik.root.middleware import ChannelsLoggingMiddleware
urlpatterns = [
path(
"application/rac/<slug:app>/<uuid:endpoint>/",
RACStartView.as_view(),
ensure_csrf_cookie(RACStartView.as_view()),
name="start",
),
path(
"if/rac/<str:token>/",
RACInterface.as_view(),
ensure_csrf_cookie(RACInterface.as_view()),
name="if-rac",
),
]

View File

@ -17,7 +17,6 @@ TENANT_APPS = [
"authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.rac",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.source",
]

View File

@ -1,82 +0,0 @@
"""AuthenticatorEndpointGDTCStage API Views"""
from django_filters.rest_framework.backends import DjangoFilterBackend
from rest_framework import mixins
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import IsAdminUser
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.authorization import OwnerFilter, OwnerPermissions
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
AuthenticatorEndpointGDTCStage,
EndpointDevice,
)
from authentik.flows.api.stages import StageSerializer
LOGGER = get_logger()
class AuthenticatorEndpointGDTCStageSerializer(EnterpriseRequiredMixin, StageSerializer):
"""AuthenticatorEndpointGDTCStage Serializer"""
class Meta:
model = AuthenticatorEndpointGDTCStage
fields = StageSerializer.Meta.fields + [
"configure_flow",
"friendly_name",
"credentials",
]
class AuthenticatorEndpointGDTCStageViewSet(UsedByMixin, ModelViewSet):
"""AuthenticatorEndpointGDTCStage Viewset"""
queryset = AuthenticatorEndpointGDTCStage.objects.all()
serializer_class = AuthenticatorEndpointGDTCStageSerializer
filterset_fields = [
"name",
"configure_flow",
]
search_fields = ["name"]
ordering = ["name"]
class EndpointDeviceSerializer(ModelSerializer):
"""Serializer for Endpoint authenticator devices"""
class Meta:
model = EndpointDevice
fields = ["pk", "name"]
depth = 2
class EndpointDeviceViewSet(
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
UsedByMixin,
GenericViewSet,
):
"""Viewset for Endpoint authenticator devices"""
queryset = EndpointDevice.objects.all()
serializer_class = EndpointDeviceSerializer
search_fields = ["name"]
filterset_fields = ["name"]
ordering = ["name"]
permission_classes = [OwnerPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
class EndpointAdminDeviceViewSet(ModelViewSet):
"""Viewset for Endpoint authenticator devices (for admins)"""
permission_classes = [IsAdminUser]
queryset = EndpointDevice.objects.all()
serializer_class = EndpointDeviceSerializer
search_fields = ["name"]
filterset_fields = ["name"]
ordering = ["name"]

View File

@ -1,13 +0,0 @@
"""authentik Endpoint app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikStageAuthenticatorEndpointConfig(EnterpriseConfig):
"""authentik endpoint config"""
name = "authentik.enterprise.stages.authenticator_endpoint_gdtc"
label = "authentik_stages_authenticator_endpoint_gdtc"
verbose_name = "authentik Enterprise.Stages.Authenticator.Endpoint GDTC"
default = True
mountpoint = "endpoint/gdtc/"

View File

@ -1,115 +0,0 @@
# Generated by Django 5.0.9 on 2024-10-22 11:40
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_flows", "0027_auto_20231028_1424"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="AuthenticatorEndpointGDTCStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_flows.stage",
),
),
("friendly_name", models.TextField(null=True)),
("credentials", models.JSONField()),
(
"configure_flow",
models.ForeignKey(
blank=True,
help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_flows.flow",
),
),
],
options={
"verbose_name": "Endpoint Authenticator Google Device Trust Connector Stage",
"verbose_name_plural": "Endpoint Authenticator Google Device Trust Connector Stages",
},
bases=("authentik_flows.stage", models.Model),
),
migrations.CreateModel(
name="EndpointDevice",
fields=[
("created", models.DateTimeField(auto_now_add=True)),
("last_updated", models.DateTimeField(auto_now=True)),
(
"name",
models.CharField(
help_text="The human-readable name of this device.", max_length=64
),
),
(
"confirmed",
models.BooleanField(default=True, help_text="Is this device ready for use?"),
),
("last_used", models.DateTimeField(null=True)),
("uuid", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
(
"host_identifier",
models.TextField(
help_text="A unique identifier for the endpoint device, usually the device serial number",
unique=True,
),
),
("data", models.JSONField()),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Endpoint Device",
"verbose_name_plural": "Endpoint Devices",
},
),
migrations.CreateModel(
name="EndpointDeviceConnection",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("attributes", models.JSONField()),
(
"device",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_stages_authenticator_endpoint_gdtc.endpointdevice",
),
),
(
"stage",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage",
),
),
],
),
]

View File

@ -1,101 +0,0 @@
"""Endpoint stage"""
from uuid import uuid4
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
from google.oauth2.service_account import Credentials
from rest_framework.serializers import BaseSerializer, Serializer
from authentik.core.types import UserSettingSerializer
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
from authentik.flows.stage import StageView
from authentik.lib.models import SerializerModel
from authentik.stages.authenticator.models import Device
class AuthenticatorEndpointGDTCStage(ConfigurableStage, FriendlyNamedStage, Stage):
"""Setup Google Chrome Device-trust connection"""
credentials = models.JSONField()
def google_credentials(self):
return {
"credentials": Credentials.from_service_account_info(
self.credentials, scopes=["https://www.googleapis.com/auth/verifiedaccess"]
),
}
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.stages.authenticator_endpoint_gdtc.api import (
AuthenticatorEndpointGDTCStageSerializer,
)
return AuthenticatorEndpointGDTCStageSerializer
@property
def view(self) -> type[StageView]:
from authentik.enterprise.stages.authenticator_endpoint_gdtc.stage import (
AuthenticatorEndpointStageView,
)
return AuthenticatorEndpointStageView
@property
def component(self) -> str:
return "ak-stage-authenticator-endpoint-gdtc-form"
def ui_user_settings(self) -> UserSettingSerializer | None:
return UserSettingSerializer(
data={
"title": self.friendly_name or str(self._meta.verbose_name),
"component": "ak-user-settings-authenticator-endpoint",
}
)
def __str__(self) -> str:
return f"Endpoint Authenticator Google Device Trust Connector Stage {self.name}"
class Meta:
verbose_name = _("Endpoint Authenticator Google Device Trust Connector Stage")
verbose_name_plural = _("Endpoint Authenticator Google Device Trust Connector Stages")
class EndpointDevice(SerializerModel, Device):
"""Endpoint Device for a single user"""
uuid = models.UUIDField(primary_key=True, default=uuid4)
host_identifier = models.TextField(
unique=True,
help_text="A unique identifier for the endpoint device, usually the device serial number",
)
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
data = models.JSONField()
@property
def serializer(self) -> Serializer:
from authentik.enterprise.stages.authenticator_endpoint_gdtc.api import (
EndpointDeviceSerializer,
)
return EndpointDeviceSerializer
def __str__(self):
return str(self.name) or str(self.user_id)
class Meta:
verbose_name = _("Endpoint Device")
verbose_name_plural = _("Endpoint Devices")
class EndpointDeviceConnection(models.Model):
device = models.ForeignKey(EndpointDevice, on_delete=models.CASCADE)
stage = models.ForeignKey(AuthenticatorEndpointGDTCStage, on_delete=models.CASCADE)
attributes = models.JSONField()
def __str__(self) -> str:
return f"Endpoint device connection {self.device_id} to {self.stage_id}"

View File

@ -1,32 +0,0 @@
from django.http import HttpResponse
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
FrameChallenge,
FrameChallengeResponse,
)
from authentik.flows.stage import ChallengeStageView
class AuthenticatorEndpointStageView(ChallengeStageView):
"""Endpoint stage"""
response_class = FrameChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge:
return FrameChallenge(
data={
"component": "xak-flow-frame",
"url": self.request.build_absolute_uri(
reverse("authentik_stages_authenticator_endpoint_gdtc:chrome")
),
"loading_overlay": True,
"loading_text": _("Verifying your browser..."),
}
)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return self.executor.stage_ok()

View File

@ -1,9 +0,0 @@
<html>
<script>
window.parent.postMessage({
message: "submit",
source: "goauthentik.io",
context: "flow-executor"
});
</script>
</html>

View File

@ -1,26 +0,0 @@
"""API URLs"""
from django.urls import path
from authentik.enterprise.stages.authenticator_endpoint_gdtc.api import (
AuthenticatorEndpointGDTCStageViewSet,
EndpointAdminDeviceViewSet,
EndpointDeviceViewSet,
)
from authentik.enterprise.stages.authenticator_endpoint_gdtc.views.dtc import (
GoogleChromeDeviceTrustConnector,
)
urlpatterns = [
path("chrome/", GoogleChromeDeviceTrustConnector.as_view(), name="chrome"),
]
api_urlpatterns = [
("authenticators/endpoint", EndpointDeviceViewSet),
(
"authenticators/admin/endpoint",
EndpointAdminDeviceViewSet,
"admin-endpointdevice",
),
("stages/authenticator/endpoint_gdtc", AuthenticatorEndpointGDTCStageViewSet),
]

View File

@ -1,84 +0,0 @@
from json import dumps, loads
from typing import Any
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.template.response import TemplateResponse
from django.urls import reverse
from django.views import View
from googleapiclient.discovery import build
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
AuthenticatorEndpointGDTCStage,
EndpointDevice,
EndpointDeviceConnection,
)
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
# Header we get from chrome that initiates verified access
HEADER_DEVICE_TRUST = "X-Device-Trust"
# Header we send to the client with the challenge
HEADER_ACCESS_CHALLENGE = "X-Verified-Access-Challenge"
# Header we get back from the client that we verify with google
HEADER_ACCESS_CHALLENGE_RESPONSE = "X-Verified-Access-Challenge-Response"
# Header value for x-device-trust that initiates the flow
DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess"
class GoogleChromeDeviceTrustConnector(View):
"""Google Chrome Device-trust connector based endpoint authenticator"""
def get_flow_plan(self) -> FlowPlan:
flow_plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
return flow_plan
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
super().setup(request, *args, **kwargs)
stage: AuthenticatorEndpointGDTCStage = self.get_flow_plan().bindings[0].stage
self.google_client = build(
"verifiedaccess",
"v2",
cache_discovery=False,
**stage.google_credentials(),
)
def get(self, request: HttpRequest) -> HttpResponse:
x_device_trust = request.headers.get(HEADER_DEVICE_TRUST)
x_access_challenge_response = request.headers.get(HEADER_ACCESS_CHALLENGE_RESPONSE)
if x_device_trust == "VerifiedAccess" and x_access_challenge_response is None:
challenge = self.google_client.challenge().generate().execute()
res = HttpResponseRedirect(
self.request.build_absolute_uri(
reverse("authentik_stages_authenticator_endpoint_gdtc:chrome")
)
)
res[HEADER_ACCESS_CHALLENGE] = dumps(challenge)
return res
if x_access_challenge_response:
response = (
self.google_client.challenge()
.verify(body=loads(x_access_challenge_response))
.execute()
)
# Remove deprecated string representation of deviceSignals
response.pop("deviceSignal", None)
flow_plan: FlowPlan = self.get_flow_plan()
device, _ = EndpointDevice.objects.update_or_create(
host_identifier=response["deviceSignals"]["serialNumber"],
user=flow_plan.context.get(PLAN_CONTEXT_PENDING_USER),
defaults={"name": response["deviceSignals"]["hostname"], "data": response},
)
EndpointDeviceConnection.objects.update_or_create(
device=device,
stage=flow_plan.bindings[0].stage,
defaults={
"attributes": response,
},
)
flow_plan.context.setdefault(PLAN_CONTEXT_METHOD, "trusted_endpoint")
flow_plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {})
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS].setdefault("endpoints", [])
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS]["endpoints"].append(response)
request.session[SESSION_KEY_PLAN] = flow_plan
return TemplateResponse(request, "stages/authenticator_endpoint/google_chrome_dtc.html")

View File

@ -50,7 +50,7 @@ class ASNContextProcessor(MMDBContextProcessor):
"""Wrapper for Reader.asn"""
with start_span(
op="authentik.events.asn.asn",
name=ip_address,
description=ip_address,
):
if not self.configured():
return None

View File

@ -51,7 +51,7 @@ class GeoIPContextProcessor(MMDBContextProcessor):
"""Wrapper for Reader.city"""
with start_span(
op="authentik.events.geo.city",
name=ip_address,
description=ip_address,
):
if not self.configured():
return None

View File

@ -49,7 +49,6 @@ from authentik.policies.models import PolicyBindingModel
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant
from authentik.tenants.utils import get_current_tenant
LOGGER = get_logger()
DISCORD_FIELD_LIMIT = 25
@ -59,11 +58,7 @@ NOTIFICATION_SUMMARY_LENGTH = 75
def default_event_duration():
"""Default duration an Event is saved.
This is used as a fallback when no brand is available"""
try:
tenant = get_current_tenant()
return now() + timedelta_from_string(tenant.event_retention)
except Tenant.DoesNotExist:
return now() + timedelta(days=365)
return now() + timedelta(days=365)
def default_brand():
@ -250,6 +245,12 @@ class Event(SerializerModel, ExpiringModel):
if QS_QUERY in self.context["http_request"]["args"]:
wrapped = self.context["http_request"]["args"][QS_QUERY]
self.context["http_request"]["args"] = cleanse_dict(QueryDict(wrapped))
if hasattr(request, "tenant"):
tenant: Tenant = request.tenant
# Because self.created only gets set on save, we can't use it's value here
# hence we set self.created to now and then use it
self.created = now()
self.expires = self.created + timedelta_from_string(tenant.event_retention)
if hasattr(request, "brand"):
brand: Brand = request.brand
self.brand = sanitize_dict(model_to_dict(brand))

View File

@ -1,22 +1,19 @@
"""authentik events signal listener"""
from importlib import import_module
from typing import Any
from django.conf import settings
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from rest_framework.request import Request
from authentik.core.models import AuthenticatedSession, User
from authentik.core.models import User
from authentik.core.signals import login_failed, password_changed
from authentik.events.apps import SYSTEM_TASK_STATUS
from authentik.events.models import Event, EventAction, SystemTask
from authentik.events.tasks import event_notification_handler, gdpr_cleanup
from authentik.flows.models import Stage
from authentik.flows.planner import PLAN_CONTEXT_OUTPOST, PLAN_CONTEXT_SOURCE, FlowPlan
from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.root.monitoring import monitoring_set
from authentik.stages.invitation.models import Invitation
@ -26,7 +23,6 @@ from authentik.stages.user_write.signals import user_write
from authentik.tenants.utils import get_current_tenant
SESSION_LOGIN_EVENT = "login_event"
_session_engine = import_module(settings.SESSION_ENGINE)
@receiver(user_logged_in)
@ -42,25 +38,13 @@ def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
# Save the login method used
kwargs[PLAN_CONTEXT_METHOD] = flow_plan.context[PLAN_CONTEXT_METHOD]
kwargs[PLAN_CONTEXT_METHOD_ARGS] = flow_plan.context.get(PLAN_CONTEXT_METHOD_ARGS, {})
if PLAN_CONTEXT_OUTPOST in flow_plan.context:
# Save outpost context
kwargs[PLAN_CONTEXT_OUTPOST] = flow_plan.context[PLAN_CONTEXT_OUTPOST]
event = Event.new(EventAction.LOGIN, **kwargs).from_http(request, user=user)
request.session[SESSION_LOGIN_EVENT] = event
request.session.save()
def get_login_event(request_or_session: HttpRequest | AuthenticatedSession | None) -> Event | None:
def get_login_event(request: HttpRequest) -> Event | None:
"""Wrapper to get login event that can be mocked in tests"""
session = None
if not request_or_session:
return None
if isinstance(request_or_session, HttpRequest | Request):
session = request_or_session.session
if isinstance(request_or_session, AuthenticatedSession):
SessionStore = _session_engine.SessionStore
session = SessionStore(request_or_session.session_key)
return session.get(SESSION_LOGIN_EVENT, None)
return request.session.get(SESSION_LOGIN_EVENT, None)
@receiver(user_logged_out)

View File

@ -6,7 +6,6 @@ from django.db.models import Model
from django.test import TestCase
from authentik.core.models import default_token_key
from authentik.events.models import default_event_duration
from authentik.lib.utils.reflection import get_apps
@ -21,7 +20,7 @@ def model_tester_factory(test_model: type[Model]) -> Callable:
allowed = 0
# Token-like objects need to lookup the current tenant to get the default token length
for field in test_model._meta.fields:
if field.default in [default_token_key, default_event_duration]:
if field.default == default_token_key:
allowed += 1
with self.assertNumQueries(allowed):
str(test_model())

View File

@ -8,7 +8,7 @@ from uuid import UUID
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.http import JsonResponse
from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField
from rest_framework.fields import CharField, ChoiceField, DictField
from rest_framework.request import Request
from authentik.core.api.utils import PassiveSerializer
@ -110,21 +110,8 @@ class FlowErrorChallenge(Challenge):
class AccessDeniedChallenge(WithUserInfoChallenge):
"""Challenge when a flow's active stage calls `stage_invalid()`."""
component = CharField(default="ak-stage-access-denied")
error_message = CharField(required=False)
class SessionEndChallenge(WithUserInfoChallenge):
"""Challenge for ending a session"""
component = CharField(default="ak-stage-session-end")
application_name = CharField(required=False)
application_launch_url = CharField(required=False)
invalidation_flow_url = CharField(required=False)
brand_name = CharField(required=True)
component = CharField(default="ak-stage-access-denied")
class PermissionDict(TypedDict):
@ -160,20 +147,6 @@ class AutoSubmitChallengeResponse(ChallengeResponse):
component = CharField(default="ak-stage-autosubmit")
class FrameChallenge(Challenge):
"""Challenge type to render a frame"""
component = CharField(default="xak-flow-frame")
url = CharField()
loading_overlay = BooleanField(default=False)
loading_text = CharField()
class FrameChallengeResponse(ChallengeResponse):
component = CharField(default="xak-flow-frame")
class DataclassEncoder(DjangoJSONEncoder):
"""Convert any dataclass to json"""

View File

@ -6,18 +6,20 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from guardian.conf import settings as guardian_settings
from guardian.shortcuts import get_anonymous_user
Flow = apps.get_model("authentik_flows", "Flow")
User = apps.get_model("authentik_core", "User")
db_alias = schema_editor.connection.alias
users = (
User.objects.using(db_alias)
.exclude(username="akadmin")
.exclude(username=guardian_settings.ANONYMOUS_USER_NAME)
)
users = User.objects.using(db_alias).exclude(username="akadmin")
try:
users = users.exclude(pk=get_anonymous_user().pk)
except Exception: # nosec
pass
if users.exists():
Flow.objects.using(db_alias).filter(slug="initial-setup").update(
authentication="require_superuser"

View File

@ -107,9 +107,7 @@ class Stage(SerializerModel):
def in_memory_stage(view: type["StageView"], **kwargs) -> Stage:
"""Creates an in-memory stage instance, based on a `view` as view.
Any key-word arguments are set as attributes on the stage object,
accessible via `self.executor.current_stage`."""
"""Creates an in-memory stage instance, based on a `view` as view."""
stage = Stage()
# Because we can't pickle a locally generated function,
# we set the view as a separate property and reference a generic function

View File

@ -23,7 +23,6 @@ from authentik.flows.models import (
in_memory_stage,
)
from authentik.lib.config import CONFIG
from authentik.outposts.models import Outpost
from authentik.policies.engine import PolicyEngine
from authentik.root.middleware import ClientIPMiddleware
@ -33,7 +32,6 @@ PLAN_CONTEXT_SSO = "is_sso"
PLAN_CONTEXT_REDIRECT = "redirect"
PLAN_CONTEXT_APPLICATION = "application"
PLAN_CONTEXT_SOURCE = "source"
PLAN_CONTEXT_OUTPOST = "outpost"
# Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan
# was restored.
PLAN_CONTEXT_IS_RESTORED = "is_restored"
@ -145,28 +143,15 @@ class FlowPlanner:
and not request.user.is_superuser
):
raise FlowNonApplicableException()
outpost_user = ClientIPMiddleware.get_outpost_user(request)
if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST:
outpost_user = ClientIPMiddleware.get_outpost_user(request)
if not outpost_user:
raise FlowNonApplicableException()
if outpost_user:
outpost = Outpost.objects.filter(
# TODO: Since Outpost and user are not directly connected, we have to look up a user
# like this. This should ideally by in authentik/outposts/models.py
pk=outpost_user.username.replace("ak-outpost-", "")
).first()
if outpost:
return {
PLAN_CONTEXT_OUTPOST: {
"instance": outpost,
}
}
return {}
def plan(self, request: HttpRequest, default_context: dict[str, Any] | None = None) -> FlowPlan:
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
and return ordered list"""
with start_span(op="authentik.flow.planner.plan", name=self.flow.slug) as span:
with start_span(op="authentik.flow.planner.plan", description=self.flow.slug) as span:
span: Span
span.set_data("flow", self.flow)
span.set_data("request", request)
@ -174,12 +159,11 @@ class FlowPlanner:
self._logger.debug(
"f(plan): starting planning process",
)
context = default_context or {}
# Bit of a workaround here, if there is a pending user set in the default context
# we use that user for our cache key
# to make sure they don't get the generic response
if context and PLAN_CONTEXT_PENDING_USER in context:
user = context[PLAN_CONTEXT_PENDING_USER]
if default_context and PLAN_CONTEXT_PENDING_USER in default_context:
user = default_context[PLAN_CONTEXT_PENDING_USER]
else:
user = request.user
# We only need to check the flow authentication if it's planned without a user
@ -187,13 +171,14 @@ class FlowPlanner:
# or if a flow is restarted due to `invalid_response_action` being set to
# `restart_with_context`, which can only happen if the user was already authorized
# to use the flow
context.update(self._check_authentication(request))
self._check_authentication(request)
# First off, check the flow's direct policy bindings
# to make sure the user even has access to the flow
engine = PolicyEngine(self.flow, user, request)
engine.use_cache = self.use_cache
span.set_data("context", cleanse_dict(context))
engine.request.context.update(context)
if default_context:
span.set_data("default_context", cleanse_dict(default_context))
engine.request.context.update(default_context)
engine.build()
result = engine.result
if not result.passing:
@ -210,12 +195,12 @@ class FlowPlanner:
key=cached_plan_key,
)
# Reset the context as this isn't factored into caching
cached_plan.context = context
cached_plan.context = default_context or {}
return cached_plan
self._logger.debug(
"f(plan): building plan",
)
plan = self._build_plan(user, request, context)
plan = self._build_plan(user, request, default_context)
if self.use_cache:
cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT)
if not plan.bindings and not self.allow_empty_flows:
@ -233,7 +218,7 @@ class FlowPlanner:
with (
start_span(
op="authentik.flow.planner.build_plan",
name=self.flow.slug,
description=self.flow.slug,
) as span,
HIST_FLOWS_PLAN_TIME.labels(flow_slug=self.flow.slug).time(),
):

View File

@ -13,7 +13,7 @@ from rest_framework.request import Request
from sentry_sdk import start_span
from structlog.stdlib import BoundLogger, get_logger
from authentik.core.models import Application, User
from authentik.core.models import User
from authentik.flows.challenge import (
AccessDeniedChallenge,
Challenge,
@ -21,7 +21,6 @@ from authentik.flows.challenge import (
ContextualFlowInfo,
HttpChallengeResponse,
RedirectChallenge,
SessionEndChallenge,
WithUserInfoChallenge,
)
from authentik.flows.exceptions import StageInvalidException
@ -126,7 +125,7 @@ class ChallengeStageView(StageView):
with (
start_span(
op="authentik.flow.stage.challenge_invalid",
name=self.__class__.__name__,
description=self.__class__.__name__,
),
HIST_FLOWS_STAGE_TIME.labels(
stage_type=self.__class__.__name__, method="challenge_invalid"
@ -136,7 +135,7 @@ class ChallengeStageView(StageView):
with (
start_span(
op="authentik.flow.stage.challenge_valid",
name=self.__class__.__name__,
description=self.__class__.__name__,
),
HIST_FLOWS_STAGE_TIME.labels(
stage_type=self.__class__.__name__, method="challenge_valid"
@ -162,7 +161,7 @@ class ChallengeStageView(StageView):
with (
start_span(
op="authentik.flow.stage.get_challenge",
name=self.__class__.__name__,
description=self.__class__.__name__,
),
HIST_FLOWS_STAGE_TIME.labels(
stage_type=self.__class__.__name__, method="get_challenge"
@ -175,7 +174,7 @@ class ChallengeStageView(StageView):
return self.executor.stage_invalid()
with start_span(
op="authentik.flow.stage._get_challenge",
name=self.__class__.__name__,
description=self.__class__.__name__,
):
if not hasattr(challenge, "initial_data"):
challenge.initial_data = {}
@ -231,7 +230,7 @@ class ChallengeStageView(StageView):
return HttpChallengeResponse(challenge_response)
class AccessDeniedStage(ChallengeStageView):
class AccessDeniedChallengeView(ChallengeStageView):
"""Used internally by FlowExecutor's stage_invalid()"""
error_message: str | None
@ -269,31 +268,3 @@ class RedirectStage(ChallengeStageView):
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return HttpChallengeResponse(self.get_challenge())
class SessionEndStage(ChallengeStageView):
"""Stage inserted when a flow is used as invalidation flow. By default shows actions
that the user is likely to take after signing out of a provider."""
def get_challenge(self, *args, **kwargs) -> Challenge:
application: Application | None = self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION)
data = {
"component": "ak-stage-session-end",
"brand_name": self.request.brand.branding_title,
}
if application:
data["application_name"] = application.name
data["application_launch_url"] = application.get_launch_url(self.get_pending_user())
if self.request.brand.flow_invalidation:
data["invalidation_flow_url"] = reverse(
"authentik_core:if-flow",
kwargs={
"flow_slug": self.request.brand.flow_invalidation.slug,
},
)
return SessionEndChallenge(data=data)
# This can never be reached since this challenge is created on demand and only the
# .get() method is called
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: # pragma: no cover
return self.executor.cancel()

View File

@ -46,7 +46,6 @@ class TestFlowInspector(APITestCase):
res.content,
{
"allow_show_password": False,
"captcha_stage": None,
"component": "ak-stage-identification",
"flow_info": {
"background": flow.background_url,

View File

@ -54,7 +54,7 @@ from authentik.flows.planner import (
FlowPlan,
FlowPlanner,
)
from authentik.flows.stage import AccessDeniedStage, StageView
from authentik.flows.stage import AccessDeniedChallengeView, StageView
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses, class_to_path
@ -153,7 +153,7 @@ class FlowExecutorView(APIView):
return plan
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
with start_span(op="authentik.flow.executor.dispatch", name=self.flow.slug) as span:
with start_span(op="authentik.flow.executor.dispatch", description=self.flow.slug) as span:
span.set_data("authentik Flow", self.flow.slug)
get_params = QueryDict(request.GET.get(QS_QUERY, ""))
if QS_KEY_TOKEN in get_params:
@ -273,7 +273,7 @@ class FlowExecutorView(APIView):
with (
start_span(
op="authentik.flow.executor.stage",
name=class_path,
description=class_path,
) as span,
HIST_FLOW_EXECUTION_STAGE_TIME.labels(
method=request.method.upper(),
@ -324,7 +324,7 @@ class FlowExecutorView(APIView):
with (
start_span(
op="authentik.flow.executor.stage",
name=class_path,
description=class_path,
) as span,
HIST_FLOW_EXECUTION_STAGE_TIME.labels(
method=request.method.upper(),
@ -441,7 +441,7 @@ class FlowExecutorView(APIView):
)
return self.restart_flow(keep_context)
self.cancel()
challenge_view = AccessDeniedStage(self, error_message)
challenge_view = AccessDeniedChallengeView(self, error_message)
challenge_view.request = self.request
return to_stage_response(self.request, challenge_view.get(self.request))

View File

@ -1,4 +1,4 @@
# update website/docs/install-config/configuration/configuration.mdx
# update website/docs/installation/configuration.mdx
# This is the default configuration file
postgresql:
host: localhost
@ -105,10 +105,6 @@ ldap:
tls:
ciphers: null
sources:
kerberos:
task_timeout_hours: 2
reputation:
expiry: 86400

View File

@ -30,11 +30,6 @@ class TestHTTP(TestCase):
request = self.factory.get("/", HTTP_X_FORWARDED_FOR="127.0.0.2")
self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.2")
def test_forward_for_invalid(self):
"""Test invalid forward for"""
request = self.factory.get("/", HTTP_X_FORWARDED_FOR="foobar")
self.assertEqual(ClientIPMiddleware.get_client_ip(request), ClientIPMiddleware.default_ip)
def test_fake_outpost(self):
"""Test faked IP which is overridden by an outpost"""
token = Token.objects.create(
@ -58,17 +53,6 @@ class TestHTTP(TestCase):
},
)
self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1")
# Invalid, not a real IP
self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
self.user.save()
request = self.factory.get(
"/",
**{
ClientIPMiddleware.outpost_remote_ip_header: "foobar",
ClientIPMiddleware.outpost_token_header: token.key,
},
)
self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1")
# Valid
self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
self.user.save()

View File

@ -21,14 +21,7 @@ class DebugSession(Session):
def send(self, req: PreparedRequest, *args, **kwargs):
request_id = str(uuid4())
LOGGER.debug(
"HTTP request sent",
uid=request_id,
url=req.url,
method=req.method,
headers=req.headers,
body=req.body,
)
LOGGER.debug("HTTP request sent", uid=request_id, path=req.path_url, headers=req.headers)
resp = super().send(req, *args, **kwargs)
LOGGER.debug(
"HTTP response received",

View File

@ -53,7 +53,7 @@ class ServiceConnectionInvalid(SentryIgnoredException):
class OutpostConfig:
"""Configuration an outpost uses to configure it self"""
# update website/docs/add-secure-apps/outposts/_config.md
# update website/docs/outposts/_config.md
authentik_host: str = ""
authentik_host_insecure: bool = False

View File

@ -113,7 +113,7 @@ class PolicyEngine:
with (
start_span(
op="authentik.policy.engine.build",
name=self.__pbm,
description=self.__pbm,
) as span,
HIST_POLICIES_ENGINE_TOTAL_TIME.labels(
obj_type=class_to_path(self.__pbm.__class__),

View File

@ -108,7 +108,7 @@ class EventMatcherPolicy(Policy):
result=result,
)
matches.append(result)
passing = all(x.passing for x in matches)
passing = any(x.passing for x in matches)
messages = chain(*[x.messages for x in matches])
result = PolicyResult(passing, *messages)
result.source_results = matches

View File

@ -77,24 +77,11 @@ class TestEventMatcherPolicy(TestCase):
request = PolicyRequest(get_anonymous_user())
request.context["event"] = event
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
client_ip="1.2.3.5", app="foo"
client_ip="1.2.3.5", app="bar"
)
response = policy.passes(request)
self.assertFalse(response.passing)
def test_multiple(self):
"""Test multiple"""
event = Event.new(EventAction.LOGIN)
event.app = "foo"
event.client_ip = "1.2.3.4"
request = PolicyRequest(get_anonymous_user())
request.context["event"] = event
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(
client_ip="1.2.3.4", app="foo"
)
response = policy.passes(request)
self.assertTrue(response.passing)
def test_invalid(self):
"""Test passing event"""
request = PolicyRequest(get_anonymous_user())

View File

@ -87,7 +87,6 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
application_slug = SerializerMethodField()
bind_flow_slug = CharField(source="authorization_flow.slug")
unbind_flow_slug = SerializerMethodField()
def get_application_slug(self, instance: LDAPProvider) -> str:
"""Prioritise backchannel slug over direct application slug"""
@ -95,16 +94,6 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
return instance.backchannel_application.slug
return instance.application.slug
def get_unbind_flow_slug(self, instance: LDAPProvider) -> str | None:
"""Get slug for unbind flow, defaulting to brand's default flow."""
flow = instance.invalidation_flow
if not flow and "request" in self.context:
request = self.context.get("request")
flow = request.brand.flow_invalidation
if not flow:
return None
return flow.slug
class Meta:
model = LDAPProvider
fields = [
@ -112,7 +101,6 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
"name",
"base_dn",
"bind_flow_slug",
"unbind_flow_slug",
"application_slug",
"certificate",
"tls_server_name",

View File

@ -39,7 +39,6 @@ class OAuth2ProviderSerializer(ProviderSerializer):
"refresh_token_validity",
"include_claims_in_id_token",
"signing_key",
"encryption_key",
"redirect_uris",
"sub_mode",
"property_mappings",

View File

@ -1,7 +1,6 @@
"""id_token utils"""
from dataclasses import asdict, dataclass, field
from hashlib import sha256
from typing import TYPE_CHECKING, Any
from django.db import models
@ -24,13 +23,8 @@ if TYPE_CHECKING:
from authentik.providers.oauth2.models import BaseGrantModel, OAuth2Provider
def hash_session_key(session_key: str) -> str:
"""Hash the session key for inclusion in JWTs as `sid`"""
return sha256(session_key.encode("ascii")).hexdigest()
class SubModes(models.TextChoices):
"""Mode after which 'sub' attribute is generated, for compatibility reasons"""
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
USER_ID = "user_id", _("Based on user ID")
@ -57,8 +51,7 @@ class IDToken:
and potentially other requested Claims. The ID Token is represented as a
JSON Web Token (JWT) [JWT].
https://openid.net/specs/openid-connect-core-1_0.html#IDToken
https://www.iana.org/assignments/jwt/jwt.xhtml"""
https://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
# Issuer, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1
iss: str | None = None
@ -86,8 +79,6 @@ class IDToken:
nonce: str | None = None
# Access Token hash value, http://openid.net/specs/openid-connect-core-1_0.html
at_hash: str | None = None
# Session ID, https://openid.net/specs/openid-connect-frontchannel-1_0.html#ClaimsContents
sid: str | None = None
claims: dict[str, Any] = field(default_factory=dict)
@ -125,11 +116,9 @@ class IDToken:
now = timezone.now()
id_token.iat = int(now.timestamp())
id_token.auth_time = int(token.auth_time.timestamp())
if token.session:
id_token.sid = hash_session_key(token.session.session_key)
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
auth_event = get_login_event(token.session)
auth_event = get_login_event(request)
if auth_event:
# Also check which method was used for authentication
method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")

View File

@ -3,7 +3,6 @@
import django.db.models.deletion
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import authentik.lib.utils.time
@ -15,7 +14,7 @@ scope_uid_map = {
}
def set_managed_flag(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
def set_managed_flag(apps: Apps, schema_editor):
ScopeMapping = apps.get_model("authentik_providers_oauth2", "ScopeMapping")
db_alias = schema_editor.connection.alias
for mapping in ScopeMapping.objects.using(db_alias).filter(name__startswith="Autogenerated "):

View File

@ -1,23 +0,0 @@
# Generated by Django 5.0.9 on 2024-09-26 16:25
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0018_alter_accesstoken_expires_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name="accesstoken",
index=models.Index(fields=["token"], name="authentik_p_token_4bc870_idx"),
),
migrations.AddIndex(
model_name="refreshtoken",
index=models.Index(fields=["token"], name="authentik_p_token_1a841f_idx"),
),
]

View File

@ -1,31 +0,0 @@
# Generated by Django 5.0.9 on 2024-09-27 14:50
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0019_accesstoken_authentik_p_token_4bc870_idx_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveIndex(
model_name="accesstoken",
name="authentik_p_token_4bc870_idx",
),
migrations.RemoveIndex(
model_name="refreshtoken",
name="authentik_p_token_1a841f_idx",
),
migrations.AddIndex(
model_name="accesstoken",
index=models.Index(fields=["token", "provider"], name="authentik_p_token_f99422_idx"),
),
migrations.AddIndex(
model_name="refreshtoken",
index=models.Index(fields=["token", "provider"], name="authentik_p_token_a1d921_idx"),
),
]

View File

@ -1,42 +0,0 @@
# Generated by Django 5.0.9 on 2024-10-16 14:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_crypto", "0004_alter_certificatekeypair_name"),
(
"authentik_providers_oauth2",
"0020_remove_accesstoken_authentik_p_token_4bc870_idx_and_more",
),
]
operations = [
migrations.AddField(
model_name="oauth2provider",
name="encryption_key",
field=models.ForeignKey(
help_text="Key used to encrypt the tokens. When set, tokens will be encrypted and returned as JWEs.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="oauth2provider_encryption_key_set",
to="authentik_crypto.certificatekeypair",
verbose_name="Encryption Key",
),
),
migrations.AlterField(
model_name="oauth2provider",
name="signing_key",
field=models.ForeignKey(
help_text="Key used to sign the tokens.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="oauth2provider_signing_key_set",
to="authentik_crypto.certificatekeypair",
verbose_name="Signing Key",
),
),
]

View File

@ -1,113 +0,0 @@
# Generated by Django 5.0.9 on 2024-10-23 13:38
from hashlib import sha256
import django.db.models.deletion
from django.db import migrations, models
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from authentik.lib.migrations import progress_bar
def migrate_session(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
AuthenticatedSession = apps.get_model("authentik_core", "authenticatedsession")
AuthorizationCode = apps.get_model("authentik_providers_oauth2", "authorizationcode")
AccessToken = apps.get_model("authentik_providers_oauth2", "accesstoken")
RefreshToken = apps.get_model("authentik_providers_oauth2", "refreshtoken")
db_alias = schema_editor.connection.alias
print(f"\nFetching session keys, this might take a couple of minutes...")
session_ids = {}
for session in progress_bar(AuthenticatedSession.objects.using(db_alias).all()):
session_ids[sha256(session.session_key.encode("ascii")).hexdigest()] = session.session_key
for model in [AuthorizationCode, AccessToken, RefreshToken]:
print(
f"\nAdding session to {model._meta.verbose_name}, this might take a couple of minutes..."
)
for code in progress_bar(model.objects.using(db_alias).all()):
if code.session_id_old not in session_ids:
continue
code.session = (
AuthenticatedSession.objects.using(db_alias)
.filter(session_key=session_ids[code.session_id_old])
.first()
)
code.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0040_provider_invalidation_flow"),
("authentik_providers_oauth2", "0021_oauth2provider_encryption_key_and_more"),
]
operations = [
migrations.RenameField(
model_name="accesstoken",
old_name="session_id",
new_name="session_id_old",
),
migrations.RenameField(
model_name="authorizationcode",
old_name="session_id",
new_name="session_id_old",
),
migrations.RenameField(
model_name="refreshtoken",
old_name="session_id",
new_name="session_id_old",
),
migrations.AddField(
model_name="accesstoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="authorizationcode",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="devicetoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.AddField(
model_name="refreshtoken",
name="session",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_core.authenticatedsession",
),
),
migrations.RunPython(migrate_session),
migrations.RemoveField(
model_name="accesstoken",
name="session_id_old",
),
migrations.RemoveField(
model_name="authorizationcode",
name="session_id_old",
),
migrations.RemoveField(
model_name="refreshtoken",
name="session_id_old",
),
]

View File

@ -18,21 +18,12 @@ from django.http import HttpRequest
from django.templatetags.static import static
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from jwcrypto.common import json_encode
from jwcrypto.jwe import JWE
from jwcrypto.jwk import JWK
from jwt import encode
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.brands.models import WebfingerProvider
from authentik.core.models import (
AuthenticatedSession,
ExpiringModel,
PropertyMapping,
Provider,
User,
)
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
from authentik.lib.models import SerializerModel
@ -215,19 +206,9 @@ class OAuth2Provider(WebfingerProvider, Provider):
verbose_name=_("Signing Key"),
on_delete=models.SET_NULL,
null=True,
help_text=_("Key used to sign the tokens."),
related_name="oauth2provider_signing_key_set",
)
encryption_key = models.ForeignKey(
CertificateKeyPair,
verbose_name=_("Encryption Key"),
on_delete=models.SET_NULL,
null=True,
help_text=_(
"Key used to encrypt the tokens. When set, "
"tokens will be encrypted and returned as JWEs."
"Key used to sign the tokens. Only required when JWT Algorithm is set to RS256."
),
related_name="oauth2provider_encryption_key_set",
)
jwks_sources = models.ManyToManyField(
@ -306,27 +287,7 @@ class OAuth2Provider(WebfingerProvider, Provider):
if self.signing_key:
headers["kid"] = self.signing_key.kid
key, alg = self.jwt_key
encoded = encode(payload, key, algorithm=alg, headers=headers)
if self.encryption_key:
return self.encrypt(encoded)
return encoded
def encrypt(self, raw: str) -> str:
"""Encrypt JWT"""
key = JWK.from_pem(self.encryption_key.certificate_data.encode())
jwe = JWE(
raw,
json_encode(
{
"alg": "RSA-OAEP-256",
"enc": "A256CBC-HS512",
"typ": "JWE",
"kid": self.encryption_key.kid,
}
),
)
jwe.add_recipient(key)
return jwe.serialize(compact=True)
return encode(payload, key, algorithm=alg, headers=headers)
def webfinger(self, resource: str, request: HttpRequest):
return {
@ -359,9 +320,7 @@ class BaseGrantModel(models.Model):
revoked = models.BooleanField(default=False)
_scope = models.TextField(default="", verbose_name=_("Scopes"))
auth_time = models.DateTimeField(verbose_name="Authentication time")
session = models.ForeignKey(
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
)
session_id = models.CharField(default="", blank=True)
class Meta:
abstract = True
@ -417,9 +376,6 @@ class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
_id_token = models.TextField()
class Meta:
indexes = [
models.Index(fields=["token", "provider"]),
]
verbose_name = _("OAuth2 Access Token")
verbose_name_plural = _("OAuth2 Access Tokens")
@ -463,9 +419,6 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
_id_token = models.TextField(verbose_name=_("ID Token"))
class Meta:
indexes = [
models.Index(fields=["token", "provider"]),
]
verbose_name = _("OAuth2 Refresh Token")
verbose_name_plural = _("OAuth2 Refresh Tokens")
@ -499,9 +452,6 @@ class DeviceToken(ExpiringModel):
device_code = models.TextField(default=generate_key)
user_code = models.TextField(default=generate_code_fixed_length)
_scope = models.TextField(default="", verbose_name=_("Scopes"))
session = models.ForeignKey(
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
)
@property
def scope(self) -> list[str]:

View File

@ -1,3 +1,5 @@
from hashlib import sha256
from django.contrib.auth.signals import user_logged_out
from django.dispatch import receiver
from django.http import HttpRequest
@ -11,4 +13,5 @@ def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User,
"""Revoke access tokens upon user logout"""
if not request.session or not request.session.session_key:
return
AccessToken.objects.filter(user=user, session__session_key=request.session.session_key).delete()
hashed_session_key = sha256(request.session.session_key.encode("ascii")).hexdigest()
AccessToken.objects.filter(user=user, session_id=hashed_session_key).delete()

View File

@ -412,73 +412,6 @@ class TestAuthorize(OAuthTestCase):
delta=5,
)
@apply_blueprint("system/providers-oauth2.yaml")
def test_full_implicit_enc(self):
"""Test full authorization with encryption"""
flow = create_test_flow()
provider: OAuth2Provider = OAuth2Provider.objects.create(
name=generate_id(),
client_id="test",
authorization_flow=flow,
redirect_uris="http://localhost",
signing_key=self.keypair,
encryption_key=self.keypair,
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
managed__in=[
"goauthentik.io/providers/oauth2/scope-openid",
"goauthentik.io/providers/oauth2/scope-email",
"goauthentik.io/providers/oauth2/scope-profile",
]
)
)
provider.property_mappings.add(
ScopeMapping.objects.create(
name=generate_id(), scope_name="test", expression="""return {"sub": "foo"}"""
)
)
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
state = generate_id()
user = create_test_admin_user()
self.client.force_login(user)
with patch(
"authentik.providers.oauth2.id_token.get_login_event",
MagicMock(
return_value=Event(
action=EventAction.LOGIN,
context={PLAN_CONTEXT_METHOD: "password"},
created=now(),
)
),
):
# Step 1, initiate params and get redirect to flow
self.client.get(
reverse("authentik_providers_oauth2:authorize"),
data={
"response_type": "id_token",
"client_id": "test",
"state": state,
"scope": "openid test",
"redirect_uri": "http://localhost",
"nonce": generate_id(),
},
)
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
token: AccessToken = AccessToken.objects.filter(user=user).first()
expires = timedelta_from_string(provider.access_token_validity).total_seconds()
jwt = self.validate_jwe(token, provider)
self.assertEqual(jwt["amr"], ["pwd"])
self.assertEqual(jwt["sub"], "foo")
self.assertAlmostEqual(
jwt["exp"] - now().timestamp(),
expires,
delta=5,
)
def test_full_fragment_code(self):
"""Test full authorization"""
flow = create_test_flow()

View File

@ -29,6 +29,7 @@ class TesOAuth2Introspection(OAuthTestCase):
self.app = Application.objects.create(
name=generate_id(), slug=generate_id(), provider=self.provider
)
self.app.save()
self.user = create_test_admin_user()
self.auth = b64encode(
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
@ -113,41 +114,6 @@ class TesOAuth2Introspection(OAuthTestCase):
},
)
def test_introspect_invalid_provider(self):
"""Test introspection (mismatched provider and token)"""
provider: OAuth2Provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris="",
signing_key=create_test_cert(),
)
auth = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
token: AccessToken = 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"),
)
),
)
res = self.client.post(
reverse("authentik_providers_oauth2:token-introspection"),
HTTP_AUTHORIZATION=f"Basic {auth}",
data={"token": token.token},
)
self.assertEqual(res.status_code, 200)
self.assertJSONEqual(
res.content.decode(),
{
"active": False,
},
)
def test_introspect_invalid_auth(self):
"""Test introspect (invalid auth)"""
res = self.client.post(

View File

@ -93,24 +93,6 @@ class TestJWKS(OAuthTestCase):
self.assertEqual(len(body["keys"]), 1)
PyJWKSet.from_dict(body)
def test_enc(self):
"""Test with JWE"""
provider = OAuth2Provider.objects.create(
name="test",
client_id="test",
authorization_flow=create_test_flow(),
redirect_uris="http://local.invalid",
signing_key=create_test_cert(PrivateKeyAlg.ECDSA),
encryption_key=create_test_cert(PrivateKeyAlg.ECDSA),
)
app = Application.objects.create(name="test", slug="test", provider=provider)
response = self.client.get(
reverse("authentik_providers_oauth2:jwks", kwargs={"application_slug": app.slug})
)
body = json.loads(response.content.decode())
self.assertEqual(len(body["keys"]), 2)
PyJWKSet.from_dict(body)
def test_ecdsa_coords_mismatched(self):
"""Test JWKS request with ES256"""
cert = CertificateKeyPair.objects.create(

View File

@ -152,36 +152,6 @@ class TestToken(OAuthTestCase):
)
self.validate_jwt(access, provider)
def test_auth_code_enc(self):
"""test request param"""
provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris="http://local.invalid",
signing_key=self.keypair,
encryption_key=self.keypair,
)
# Needs to be assigned to an application for iss to be set
self.app.provider = provider
self.app.save()
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user()
code = AuthorizationCode.objects.create(
code="foobar", provider=provider, user=user, auth_time=timezone.now()
)
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
data={
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
"code": code.code,
"redirect_uri": "http://local.invalid",
},
HTTP_AUTHORIZATION=f"Basic {header}",
)
self.assertEqual(response.status_code, 200)
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
self.validate_jwe(access, provider)
@apply_blueprint("system/providers-oauth2.yaml")
def test_refresh_token_view(self):
"""test request param"""

View File

@ -34,7 +34,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
self.factory = RequestFactory()
self.cert = create_test_cert()
jwk = JWKSView().get_jwk_for_key(self.cert, "sig")
jwk = JWKSView().get_jwk_for_key(self.cert)
self.source: OAuthSource = OAuthSource.objects.create(
name=generate_id(),
slug=generate_id(),

View File

@ -3,8 +3,6 @@
from typing import Any
from django.test import TestCase
from jwcrypto.jwe import JWE
from jwcrypto.jwk import JWK
from jwt import decode
from authentik.core.tests.utils import create_test_cert
@ -34,15 +32,6 @@ class OAuthTestCase(TestCase):
if key in container:
self.assertIsNotNone(container[key])
def validate_jwe(self, token: AccessToken, provider: OAuth2Provider) -> dict[str, Any]:
"""Validate JWEs"""
private_key = JWK.from_pem(provider.encryption_key.key_data.encode())
jwetoken = JWE()
jwetoken.deserialize(token.token, key=private_key)
token.token = jwetoken.payload.decode()
return self.validate_jwt(token, provider)
def validate_jwt(self, token: AccessToken, provider: OAuth2Provider) -> dict[str, Any]:
"""Validate that all required fields are set"""
key, alg = provider.jwt_key

View File

@ -12,7 +12,6 @@ from authentik.providers.oauth2.api.tokens import (
)
from authentik.providers.oauth2.views.authorize import AuthorizationFlowInitView
from authentik.providers.oauth2.views.device_backchannel import DeviceView
from authentik.providers.oauth2.views.end_session import EndSessionView
from authentik.providers.oauth2.views.introspection import TokenIntrospectionView
from authentik.providers.oauth2.views.jwks import JWKSView
from authentik.providers.oauth2.views.provider import ProviderInfoView
@ -45,7 +44,7 @@ urlpatterns = [
),
path(
"<slug:application_slug>/end-session/",
EndSessionView.as_view(),
RedirectView.as_view(pattern_name="authentik_core:if-session-end", query_string=True),
name="end-session",
),
path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"),

View File

@ -2,6 +2,7 @@
from dataclasses import InitVar, dataclass, field
from datetime import timedelta
from hashlib import sha256
from json import dumps
from re import error as RegexError
from re import fullmatch
@ -15,7 +16,7 @@ from django.utils import timezone
from django.utils.translation import gettext as _
from structlog.stdlib import get_logger
from authentik.core.models import Application, AuthenticatedSession
from authentik.core.models import Application
from authentik.events.models import Event, EventAction
from authentik.events.signals import get_login_event
from authentik.flows.challenge import (
@ -317,9 +318,7 @@ class OAuthAuthorizationParams:
expires=now + timedelta_from_string(self.provider.access_code_validity),
scope=self.scope,
nonce=self.nonce,
session=AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).first(),
session_id=sha256(request.session.session_key.encode("ascii")).hexdigest(),
)
if self.code_challenge and self.code_challenge_method:
@ -611,9 +610,7 @@ class OAuthFulfillmentStage(StageView):
expires=access_token_expiry,
provider=self.provider,
auth_time=auth_event.created if auth_event else now,
session=AuthenticatedSession.objects.filter(
session_key=self.request.session.session_key
).first(),
session_id=sha256(self.request.session.session_key.encode("ascii")).hexdigest(),
)
id_token = IDToken.new(self.provider, token, self.request)

View File

@ -1,45 +0,0 @@
"""oauth2 provider end_session Views"""
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from authentik.core.models import Application
from authentik.flows.models import Flow, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import SessionEndStage
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs
from authentik.policies.views import PolicyAccessView
class EndSessionView(PolicyAccessView):
"""Redirect to application's provider's invalidation flow"""
flow: Flow
def resolve_provider_application(self):
self.application = get_object_or_404(Application, slug=self.kwargs["application_slug"])
self.provider = self.application.get_provider()
if not self.provider:
raise Http404
self.flow = self.provider.invalidation_flow or self.request.brand.flow_invalidation
if not self.flow:
raise Http404
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Dispatch the flow planner for the invalidation flow"""
planner = FlowPlanner(self.flow)
planner.allow_empty_flows = True
plan = planner.plan(
request,
{
PLAN_CONTEXT_APPLICATION: self.application,
},
)
plan.insert_stage(in_memory_stage(SessionEndStage))
request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=self.flow.slug,
)

View File

@ -46,10 +46,10 @@ class TokenIntrospectionParams:
if not provider:
raise TokenIntrospectionError
access_token = AccessToken.objects.filter(token=raw_token, provider=provider).first()
access_token = AccessToken.objects.filter(token=raw_token).first()
if access_token:
return TokenIntrospectionParams(access_token, provider)
refresh_token = RefreshToken.objects.filter(token=raw_token, provider=provider).first()
refresh_token = RefreshToken.objects.filter(token=raw_token).first()
if refresh_token:
return TokenIntrospectionParams(refresh_token, provider)
LOGGER.debug("Token does not exist", token=raw_token)

View File

@ -64,42 +64,36 @@ def to_base64url_uint(val: int, min_length: int = 0) -> bytes:
class JWKSView(View):
"""Show RSA Key data for Provider"""
def get_jwk_for_key(self, key: CertificateKeyPair, use: str) -> dict | None:
def get_jwk_for_key(self, key: CertificateKeyPair) -> dict | None:
"""Convert a certificate-key pair into JWK"""
private_key = key.private_key
key_data = None
if not private_key:
return key_data
key_data = {}
if use == "sig":
if isinstance(private_key, RSAPrivateKey):
key_data["alg"] = JWTAlgorithms.RS256
elif isinstance(private_key, EllipticCurvePrivateKey):
key_data["alg"] = JWTAlgorithms.ES256
elif use == "enc":
key_data["alg"] = "RSA-OAEP-256"
key_data["enc"] = "A256CBC-HS512"
if isinstance(private_key, RSAPrivateKey):
public_key: RSAPublicKey = private_key.public_key()
public_numbers = public_key.public_numbers()
key_data["kid"] = key.kid
key_data["kty"] = "RSA"
key_data["use"] = use
key_data["n"] = to_base64url_uint(public_numbers.n).decode()
key_data["e"] = to_base64url_uint(public_numbers.e).decode()
key_data = {
"kid": key.kid,
"kty": "RSA",
"alg": JWTAlgorithms.RS256,
"use": "sig",
"n": to_base64url_uint(public_numbers.n).decode(),
"e": to_base64url_uint(public_numbers.e).decode(),
}
elif isinstance(private_key, EllipticCurvePrivateKey):
public_key: EllipticCurvePublicKey = private_key.public_key()
public_numbers = public_key.public_numbers()
curve_type = type(public_key.curve)
key_data["kid"] = key.kid
key_data["kty"] = "EC"
key_data["use"] = use
key_data["x"] = to_base64url_uint(public_numbers.x, min_length_map[curve_type]).decode()
key_data["y"] = to_base64url_uint(public_numbers.y, min_length_map[curve_type]).decode()
key_data["crv"] = ec_crv_map.get(curve_type, public_key.curve.name)
key_data = {
"kid": key.kid,
"kty": "EC",
"alg": JWTAlgorithms.ES256,
"use": "sig",
"x": to_base64url_uint(public_numbers.x, min_length_map[curve_type]).decode(),
"y": to_base64url_uint(public_numbers.y, min_length_map[curve_type]).decode(),
"crv": ec_crv_map.get(curve_type, public_key.curve.name),
}
else:
return key_data
key_data["x5c"] = [b64encode(key.certificate.public_bytes(Encoding.DER)).decode("utf-8")]
@ -119,19 +113,14 @@ class JWKSView(View):
"""Show JWK Key data for Provider"""
application = get_object_or_404(Application, slug=application_slug)
provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
signing_key: CertificateKeyPair = provider.signing_key
response_data = {}
if signing_key := provider.signing_key:
jwk = self.get_jwk_for_key(signing_key, "sig")
if signing_key:
jwk = self.get_jwk_for_key(signing_key)
if jwk:
response_data.setdefault("keys", [])
response_data["keys"].append(jwk)
if encryption_key := provider.encryption_key:
jwk = self.get_jwk_for_key(encryption_key, "enc")
if jwk:
response_data.setdefault("keys", [])
response_data["keys"].append(jwk)
response_data["keys"] = [jwk]
response = JsonResponse(response_data)
response["Access-Control-Allow-Origin"] = "*"

View File

@ -46,7 +46,7 @@ class ProviderInfoView(View):
if SCOPE_OPENID not in scopes:
scopes.append(SCOPE_OPENID)
_, supported_alg = provider.jwt_key
config = {
return {
"issuer": provider.get_issuer(self.request),
"authorization_endpoint": self.request.build_absolute_uri(
reverse("authentik_providers_oauth2:authorize")
@ -114,10 +114,6 @@ class ProviderInfoView(View):
"claims_parameter_supported": False,
"code_challenge_methods_supported": [PKCE_METHOD_PLAIN, PKCE_METHOD_S256],
}
if provider.encryption_key:
config["id_token_encryption_alg_values_supported"] = ["RSA-OAEP-256"]
config["id_token_encryption_enc_values_supported"] = ["A256CBC-HS512"]
return config
def get_claims(self, provider: OAuth2Provider) -> list[str]:
"""Get a list of supported claims based on configured scope mappings"""

View File

@ -439,14 +439,15 @@ class TokenParams:
# (22 chars being the length of the "template")
username=f"ak-{self.provider.name[:150-22]}-client_credentials",
defaults={
"attributes": {
USER_ATTRIBUTE_GENERATED: True,
},
"last_login": timezone.now(),
"name": f"Autogenerated user from application {app.name} (client credentials)",
"path": f"{USER_PATH_SYSTEM_PREFIX}/apps/{app.slug}",
"type": UserTypes.SERVICE_ACCOUNT,
},
)
self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
self.user.save()
self.__check_policy_access(app, request)
Event.new(
@ -470,6 +471,9 @@ class TokenParams:
self.user, created = User.objects.update_or_create(
username=f"{self.provider.name}-{token.get('sub')}",
defaults={
"attributes": {
USER_ATTRIBUTE_GENERATED: True,
},
"last_login": timezone.now(),
"name": (
f"Autogenerated user from application {app.name} (client credentials JWT)"
@ -478,8 +482,6 @@ class TokenParams:
"type": UserTypes.SERVICE_ACCOUNT,
},
)
self.user.attributes[USER_ATTRIBUTE_GENERATED] = True
self.user.save()
exp = token.get("exp")
if created and exp:
self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp
@ -550,7 +552,7 @@ class TokenView(View):
# Keep same scopes as previous token
scope=self.params.authorization_code.scope,
auth_time=self.params.authorization_code.auth_time,
session=self.params.authorization_code.session,
session_id=self.params.authorization_code.session_id,
)
access_id_token = IDToken.new(
self.provider,
@ -578,7 +580,7 @@ class TokenView(View):
expires=refresh_token_expiry,
provider=self.provider,
auth_time=self.params.authorization_code.auth_time,
session=self.params.authorization_code.session,
session_id=self.params.authorization_code.session_id,
)
id_token = IDToken.new(
self.provider,
@ -611,7 +613,7 @@ class TokenView(View):
# Keep same scopes as previous token
scope=self.params.refresh_token.scope,
auth_time=self.params.refresh_token.auth_time,
session=self.params.refresh_token.session,
session_id=self.params.refresh_token.session_id,
)
access_token.id_token = IDToken.new(
self.provider,
@ -627,7 +629,7 @@ class TokenView(View):
expires=refresh_token_expiry,
provider=self.provider,
auth_time=self.params.refresh_token.auth_time,
session=self.params.refresh_token.session,
session_id=self.params.refresh_token.session_id,
)
id_token = IDToken.new(
self.provider,
@ -685,14 +687,13 @@ class TokenView(View):
raise DeviceCodeError("authorization_pending")
now = timezone.now()
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
auth_event = get_login_event(self.params.device_code.session)
auth_event = get_login_event(self.request)
access_token = AccessToken(
provider=self.provider,
user=self.params.device_code.user,
expires=access_token_expiry,
scope=self.params.device_code.scope,
auth_time=auth_event.created if auth_event else now,
session=self.params.device_code.session,
)
access_token.id_token = IDToken.new(
self.provider,

View File

@ -28,7 +28,7 @@ class ProxyDockerController(DockerController):
labels = super()._get_labels()
labels["traefik.enable"] = "true"
labels[f"traefik.http.routers.{traefik_name}-router.rule"] = (
f"({' || '.join([f'Host({host})' for host in hosts])})"
f"({' || '.join([f'Host(`{host}`)' for host in hosts])})"
f" && PathPrefix(`/outpost.goauthentik.io`)"
)
labels[f"traefik.http.routers.{traefik_name}-router.tls"] = "true"

View File

@ -1,12 +1,13 @@
"""proxy provider tasks"""
from hashlib import sha256
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db import DatabaseError, InternalError, ProgrammingError
from authentik.outposts.consumer import OUTPOST_GROUP
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.oauth2.id_token import hash_session_key
from authentik.providers.proxy.models import ProxyProvider
from authentik.root.celery import CELERY_APP
@ -25,7 +26,7 @@ def proxy_set_defaults():
def proxy_on_logout(session_id: str):
"""Update outpost instances connected to a single outpost"""
layer = get_channel_layer()
hashed_session_id = hash_session_key(session_id)
hashed_session_id = sha256(session_id.encode("ascii")).hexdigest()
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
async_to_sync(layer.group_send)(

View File

@ -24,7 +24,6 @@ class ProxyProviderTests(APITestCase):
"name": generate_id(),
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
"internal_host": "http://localhost",
"basic_auth_enabled": True,
@ -42,7 +41,6 @@ class ProxyProviderTests(APITestCase):
"name": generate_id(),
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
"internal_host": "http://localhost",
"basic_auth_enabled": True,
@ -66,7 +64,6 @@ class ProxyProviderTests(APITestCase):
"name": generate_id(),
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
},
)
@ -85,7 +82,6 @@ class ProxyProviderTests(APITestCase):
"name": name,
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
"internal_host": "http://localhost",
},
@ -103,7 +99,6 @@ class ProxyProviderTests(APITestCase):
"name": name,
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
"internal_host": "http://localhost",
},
@ -119,7 +114,6 @@ class ProxyProviderTests(APITestCase):
"name": name,
"mode": ProxyMode.PROXY,
"authorization_flow": create_test_flow().pk.hex,
"invalidation_flow": create_test_flow().pk.hex,
"external_host": "http://localhost",
"internal_host": "http://localhost",
},

View File

@ -188,9 +188,6 @@ class SAMLProviderImportSerializer(PassiveSerializer):
authorization_flow = PrimaryKeyRelatedField(
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION),
)
invalidation_flow = PrimaryKeyRelatedField(
queryset=Flow.objects.filter(designation=FlowDesignation.INVALIDATION),
)
file = FileField()
@ -280,9 +277,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
try:
metadata = ServiceProviderMetadataParser().parse(file.read().decode())
metadata.to_provider(
data.validated_data["name"],
data.validated_data["authorization_flow"],
data.validated_data["invalidation_flow"],
data.validated_data["name"], data.validated_data["authorization_flow"]
)
except ValueError as exc: # pragma: no cover
LOGGER.warning(str(exc))

View File

@ -50,7 +50,6 @@ class AssertionProcessor:
_issue_instant: str
_assertion_id: str
_response_id: str
_valid_not_before: str
_session_not_on_or_after: str
@ -63,7 +62,6 @@ class AssertionProcessor:
self._issue_instant = get_time_string()
self._assertion_id = get_random_id()
self._response_id = get_random_id()
self._valid_not_before = get_time_string(
timedelta_from_string(self.provider.assertion_valid_not_before)
@ -132,9 +130,7 @@ class AssertionProcessor:
"""Generate AuthnStatement with AuthnContext and ContextClassRef Elements."""
auth_n_statement = Element(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
auth_n_statement.attrib["AuthnInstant"] = self._valid_not_before
auth_n_statement.attrib["SessionIndex"] = sha256(
self.http_request.session.session_key.encode("ascii")
).hexdigest()
auth_n_statement.attrib["SessionIndex"] = self._assertion_id
auth_n_statement.attrib["SessionNotOnOrAfter"] = self._session_not_on_or_after
auth_n_context = SubElement(auth_n_statement, f"{{{NS_SAML_ASSERTION}}}AuthnContext")
@ -289,7 +285,7 @@ class AssertionProcessor:
response.attrib["Version"] = "2.0"
response.attrib["IssueInstant"] = self._issue_instant
response.attrib["Destination"] = self.provider.acs_url
response.attrib["ID"] = self._response_id
response.attrib["ID"] = get_random_id()
if self.auth_n_request.id:
response.attrib["InResponseTo"] = self.auth_n_request.id
@ -312,7 +308,7 @@ class AssertionProcessor:
ref = xmlsec.template.add_reference(
signature_node,
digest_algorithm_transform,
uri="#" + element.attrib["ID"],
uri="#" + self._assertion_id,
)
xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)

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