Compare commits
268 Commits
fix/issue_
...
docs-test-
Author | SHA1 | Date | |
---|---|---|---|
b0679bb0fa | |||
153fc7cc3b | |||
5eb848e376 | |||
61a293daad | |||
edf3300944 | |||
5d9c40eac8 | |||
6ebfbcb66e | |||
bf0235c113 | |||
895cd23b57 | |||
c908d9e95e | |||
a07fd8d54b | |||
39a46a6dc4 | |||
ad71960d77 | |||
2a384511f5 | |||
4dcc104947 | |||
71fe526e47 | |||
03e3f516ac | |||
3b59333246 | |||
4e800c14cb | |||
789b29a3e7 | |||
857b6e63a0 | |||
edc937dd78 | |||
d98b6f29d4 | |||
53ba2a0ca8 | |||
ae364292e6 | |||
f15bc2df97 | |||
b27d49e55f | |||
e0d2beb225 | |||
2313b4755b | |||
1cffadecb0 | |||
5e163d6da1 | |||
0626e18674 | |||
e986a62a12 | |||
e25afcb84a | |||
bb95613104 | |||
89dfac2f57 | |||
31462b55e6 | |||
60337c1cf0 | |||
343d3bb1fb | |||
11fe86c4f6 | |||
963ce085e4 | |||
3642b89ab0 | |||
8cfb371ed3 | |||
6e74edb9f2 | |||
397905f8f0 | |||
7fd35b1dfc | |||
9ba03f5439 | |||
1139d6d27c | |||
077fd966c2 | |||
bd41822a57 | |||
dfd3d76434 | |||
397e98906d | |||
65d8da8c64 | |||
5b435297c5 | |||
f792fd42f6 | |||
70c0fdd5fa | |||
9b636eba01 | |||
a982224502 | |||
6a16cccb40 | |||
6dac91e2b4 | |||
3e2d0532d1 | |||
4e1300650b | |||
06b3ed0c9c | |||
395ad722b7 | |||
9917d81246 | |||
2a87687d34 | |||
a726c2260a | |||
44e0bfd4ef | |||
8d0b362c9c | |||
e5e53f034e | |||
71b87127d1 | |||
d5d67fe22d | |||
5d2685341d | |||
f1ac4ff9c9 | |||
79f4c66286 | |||
1f82094c0b | |||
35440acba3 | |||
eca9901704 | |||
6ddd5a3d5f | |||
5664e62eca | |||
1403f17d62 | |||
1ac8989e81 | |||
b0a1db77e3 | |||
46da4cb59e | |||
154df5cdf7 | |||
5b889456f6 | |||
3eaed82c48 | |||
feaf9d8bc9 | |||
2899668ae2 | |||
4c25e1bb24 | |||
464ff3f5b1 | |||
22eb5f56f1 | |||
7e48e87f49 | |||
8ce12f7850 | |||
2514baabeb | |||
945930a507 | |||
537a80ad97 | |||
5c993e23fe | |||
eb2db18494 | |||
12a46a8426 | |||
4a1213310a | |||
84c2097148 | |||
c05dedc573 | |||
18c197e75b | |||
0c26a0bce2 | |||
5fd6a4cead | |||
51fb1bd8e7 | |||
4a30f87a42 | |||
8e6b6ede30 | |||
af30c2a68e | |||
9b65627a3e | |||
4bad91c901 | |||
f3c479d077 | |||
b024df9903 | |||
f6a6458088 | |||
f0dc0e8900 | |||
79e89b0376 | |||
4cc7d91379 | |||
245909e31a | |||
997a1ddb3d | |||
42335a60bf | |||
fc539332e1 | |||
d9efb02078 | |||
6212250e19 | |||
c18beefc8f | |||
f23da6e402 | |||
e934b246c8 | |||
ead684a410 | |||
d782aadab7 | |||
4ac6f83aea | |||
6281d36a69 | |||
8129ad4ec0 | |||
24eea415b2 | |||
a615ce8e95 | |||
5b275cf7fb | |||
d6e91c119f | |||
7841e47e74 | |||
ad2a4bea3e | |||
a554c085c1 | |||
ff0d978754 | |||
de48e62819 | |||
e50e995d2f | |||
3bf4156cb3 | |||
89990facf5 | |||
48545950ed | |||
0544aa5fae | |||
5d69455b87 | |||
3d291cf4da | |||
44d7c42dc7 | |||
4ea4e925e3 | |||
169172c85f | |||
adea637fa4 | |||
0231277d9c | |||
45643ed1f6 | |||
3823d56dbd | |||
43cfd59ac0 | |||
c8555bbf59 | |||
a4251a3410 | |||
50985f9b0b | |||
9ec24528d4 | |||
5eac38c0cc | |||
010df0c31c | |||
7ba858eff3 | |||
817d2d5ff8 | |||
70e34e03b4 | |||
d61f9f6d57 | |||
bdf81706b8 | |||
7b56602fc9 | |||
7c6e25a996 | |||
0eeaeaf1ff | |||
9ce4337b11 | |||
c6a3c7371c | |||
42a7cf10f2 | |||
bb4f7b1193 | |||
3eecfb835b | |||
92ab856bd3 | |||
178549a756 | |||
67d178aa11 | |||
ef53abace9 | |||
5effb3a0f6 | |||
3a37916a8f | |||
428d5ac9cf | |||
7b4037fdda | |||
2c7bbcc27b | |||
19fb24de99 | |||
2709702896 | |||
7d0d5a7dc2 | |||
6a04a2ca69 | |||
ea561c9da6 | |||
9b9c55f17c | |||
bd5e78bd44 | |||
ab98028022 | |||
813ff64ba1 | |||
c99e742214 | |||
dac6ad3cd6 | |||
e4d2a53ccc | |||
3b6775fd9c | |||
5882e0b2cb | |||
65f0b471d8 | |||
7d054db1a5 | |||
cb75ba2e5e | |||
36cecc1391 | |||
81b91d8777 | |||
41dc23b3c2 | |||
370eff1494 | |||
0ff8def03b | |||
b01cafd9fe | |||
90aa8abb80 | |||
fd21aae4f9 | |||
360223a2ff | |||
0e83de2697 | |||
a23bac9d9b | |||
220378b3f2 | |||
363d655378 | |||
e93b2a1a75 | |||
76665cf65e | |||
3ad7f4dc24 | |||
c5045e8792 | |||
a8c9b3a8ba | |||
148506639a | |||
53814d9919 | |||
08b04c32f5 | |||
1c1d97339d | |||
cafa9c1737 | |||
5f64347ba1 | |||
45ef54480a | |||
a3dc8af4c6 | |||
36933a0aca | |||
8f689890df | |||
ec49b2e0e0 | |||
22ebe05706 | |||
f0e58a6f49 | |||
a3d642c08e | |||
5d42cb9185 | |||
1fd0cc5bb5 | |||
deef365ff5 | |||
d1ae6287f2 | |||
2e152cd264 | |||
f5941e403b | |||
ff3cf8c10e | |||
bfa6328172 | |||
4c9691c932 | |||
a0f1566b4c | |||
46261a4f42 | |||
8b42ff1e97 | |||
ca4cb0d251 | |||
a5a0fa79dd | |||
c06a871f61 | |||
4a3df67134 | |||
422ccf61fa | |||
d989f23907 | |||
059180edef | |||
22f30634a8 | |||
35ff418c42 | |||
7826e7a605 | |||
64f1b8207d | |||
b2c13f0614 | |||
6965628020 | |||
608f63e9a2 | |||
22fa3a7fba | |||
bcfd6fefa7 | |||
eae18d0016 | |||
4a12a57c5f | |||
71294b7deb | |||
5af907db0c | |||
63a118a2ba | |||
d9a3c34a44 | |||
23bdad7574 |
@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2025.2.2
|
current_version = 2025.2.4
|
||||||
tag = True
|
tag = True
|
||||||
commit = 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*))?
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||||
@ -17,6 +17,8 @@ optional_value = final
|
|||||||
|
|
||||||
[bumpversion:file:pyproject.toml]
|
[bumpversion:file:pyproject.toml]
|
||||||
|
|
||||||
|
[bumpversion:file:uv.lock]
|
||||||
|
|
||||||
[bumpversion:file:package.json]
|
[bumpversion:file:package.json]
|
||||||
|
|
||||||
[bumpversion:file:docker-compose.yml]
|
[bumpversion:file:docker-compose.yml]
|
||||||
|
1
.github/workflows/api-py-publish.yml
vendored
1
.github/workflows/api-py-publish.yml
vendored
@ -30,7 +30,6 @@ jobs:
|
|||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version-file: "pyproject.toml"
|
python-version-file: "pyproject.toml"
|
||||||
cache: "poetry"
|
|
||||||
- name: Generate API Client
|
- name: Generate API Client
|
||||||
run: make gen-client-py
|
run: make gen-client-py
|
||||||
- name: Publish package
|
- name: Publish package
|
||||||
|
45
.github/workflows/packages-npm-publish.yml
vendored
Normal file
45
.github/workflows/packages-npm-publish.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
name: authentik-packages-npm-publish
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- packages/docusaurus-config
|
||||||
|
- packages/eslint-config
|
||||||
|
- packages/prettier-config
|
||||||
|
- packages/tsconfig
|
||||||
|
workflow_dispatch:
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
package:
|
||||||
|
- docusaurus-config
|
||||||
|
- eslint-config
|
||||||
|
- prettier-config
|
||||||
|
- tsconfig
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: packages/${{ matrix.package }}/package.json
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
- name: Get changed files
|
||||||
|
id: changed-files
|
||||||
|
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
packages/${{ matrix.package }}/package.json
|
||||||
|
- name: Publish package
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
working-directory: packages/${{ matrix.package}}
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
npm publish
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -11,6 +11,10 @@ local_settings.py
|
|||||||
db.sqlite3
|
db.sqlite3
|
||||||
media
|
media
|
||||||
|
|
||||||
|
# Node
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
|
||||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||||
# in your Git repository. Update and uncomment the following line accordingly.
|
# in your Git repository. Update and uncomment the following line accordingly.
|
||||||
# <django-project-name>/staticfiles/
|
# <django-project-name>/staticfiles/
|
||||||
@ -33,6 +37,7 @@ eggs/
|
|||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
dist/
|
dist/
|
||||||
|
out/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
wheels/
|
wheels/
|
||||||
|
47
.prettierignore
Normal file
47
.prettierignore
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Prettier Ignorefile
|
||||||
|
|
||||||
|
## Static Files
|
||||||
|
**/LICENSE
|
||||||
|
|
||||||
|
authentik/stages/**/*
|
||||||
|
|
||||||
|
## Build asset directories
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
.docusaurus
|
||||||
|
website/docs/developer-docs/api/**/*
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
*.env
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
*.secrets
|
||||||
|
|
||||||
|
## Yarn
|
||||||
|
.yarn/**/*
|
||||||
|
|
||||||
|
## Node
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
|
||||||
|
## Configs
|
||||||
|
*.log
|
||||||
|
*.yaml
|
||||||
|
*.yml
|
||||||
|
|
||||||
|
# Templates
|
||||||
|
# TODO: Rename affected files to *.template.* or similar.
|
||||||
|
*.html
|
||||||
|
*.mdx
|
||||||
|
*.md
|
||||||
|
|
||||||
|
## Import order matters
|
||||||
|
poly.ts
|
||||||
|
src/locale-codes.ts
|
||||||
|
src/locales/
|
||||||
|
|
||||||
|
# Storybook
|
||||||
|
storybook-static/
|
||||||
|
.storybook/css-import-maps*
|
||||||
|
|
@ -23,6 +23,8 @@ docker-compose.yml @goauthentik/infrastructure
|
|||||||
Makefile @goauthentik/infrastructure
|
Makefile @goauthentik/infrastructure
|
||||||
.editorconfig @goauthentik/infrastructure
|
.editorconfig @goauthentik/infrastructure
|
||||||
CODEOWNERS @goauthentik/infrastructure
|
CODEOWNERS @goauthentik/infrastructure
|
||||||
|
# Web packages
|
||||||
|
packages/ @goauthentik/frontend
|
||||||
# Web
|
# Web
|
||||||
web/ @goauthentik/frontend
|
web/ @goauthentik/frontend
|
||||||
tests/wdio/ @goauthentik/frontend
|
tests/wdio/ @goauthentik/frontend
|
||||||
|
@ -43,7 +43,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Build go proxy
|
# Stage 3: Build go proxy
|
||||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS go-builder
|
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@ -76,7 +76,7 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum
|
|||||||
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||||
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
|
||||||
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
|
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||||
go build -o /go/authentik ./cmd/server
|
go build -o /go/authentik ./cmd/server
|
||||||
|
|
||||||
# Stage 4: MaxMind GeoIP
|
# Stage 4: MaxMind GeoIP
|
||||||
@ -94,9 +94,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
|||||||
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||||
|
|
||||||
# Stage 5: Download uv
|
# Stage 5: Download uv
|
||||||
FROM ghcr.io/astral-sh/uv:0.6.10 AS uv
|
FROM ghcr.io/astral-sh/uv:0.6.14 AS uv
|
||||||
# Stage 6: Base python image
|
# Stage 6: Base python image
|
||||||
FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-base
|
FROM ghcr.io/goauthentik/fips-python:3.12.10-slim-bookworm-fips AS python-base
|
||||||
|
|
||||||
ENV VENV_PATH="/ak-root/.venv" \
|
ENV VENV_PATH="/ak-root/.venv" \
|
||||||
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
__version__ = "2025.2.2"
|
__version__ = "2025.2.4"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
@ -36,6 +36,7 @@ from authentik.core.models import (
|
|||||||
GroupSourceConnection,
|
GroupSourceConnection,
|
||||||
PropertyMapping,
|
PropertyMapping,
|
||||||
Provider,
|
Provider,
|
||||||
|
Session,
|
||||||
Source,
|
Source,
|
||||||
User,
|
User,
|
||||||
UserSourceConnection,
|
UserSourceConnection,
|
||||||
@ -108,6 +109,7 @@ def excluded_models() -> list[type[Model]]:
|
|||||||
Policy,
|
Policy,
|
||||||
PolicyBindingModel,
|
PolicyBindingModel,
|
||||||
# Classes that have other dependencies
|
# Classes that have other dependencies
|
||||||
|
Session,
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
# Classes which are only internally managed
|
# Classes which are only internally managed
|
||||||
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
|
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
|
||||||
|
@ -46,7 +46,7 @@ LOGGER = get_logger()
|
|||||||
|
|
||||||
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
|
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
|
||||||
"""Cache key where application list for user is saved"""
|
"""Cache key where application list for user is saved"""
|
||||||
key = f"{CACHE_PREFIX}/app_access/{user_pk}"
|
key = f"{CACHE_PREFIX}app_access/{user_pk}"
|
||||||
if page_number:
|
if page_number:
|
||||||
key += f"/{page_number}"
|
key += f"/{page_number}"
|
||||||
return key
|
return key
|
||||||
|
@ -5,6 +5,7 @@ from typing import TypedDict
|
|||||||
from rest_framework import mixins
|
from rest_framework import mixins
|
||||||
from rest_framework.fields import SerializerMethodField
|
from rest_framework.fields import SerializerMethodField
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
|
from rest_framework.serializers import CharField, DateTimeField, IPAddressField
|
||||||
from rest_framework.viewsets import GenericViewSet
|
from rest_framework.viewsets import GenericViewSet
|
||||||
from ua_parser import user_agent_parser
|
from ua_parser import user_agent_parser
|
||||||
|
|
||||||
@ -54,6 +55,11 @@ class UserAgentDict(TypedDict):
|
|||||||
class AuthenticatedSessionSerializer(ModelSerializer):
|
class AuthenticatedSessionSerializer(ModelSerializer):
|
||||||
"""AuthenticatedSession Serializer"""
|
"""AuthenticatedSession Serializer"""
|
||||||
|
|
||||||
|
expires = DateTimeField(source="session.expires", read_only=True)
|
||||||
|
last_ip = IPAddressField(source="session.last_ip", read_only=True)
|
||||||
|
last_user_agent = CharField(source="session.last_user_agent", read_only=True)
|
||||||
|
last_used = DateTimeField(source="session.last_used", read_only=True)
|
||||||
|
|
||||||
current = SerializerMethodField()
|
current = SerializerMethodField()
|
||||||
user_agent = SerializerMethodField()
|
user_agent = SerializerMethodField()
|
||||||
geo_ip = SerializerMethodField()
|
geo_ip = SerializerMethodField()
|
||||||
@ -62,19 +68,19 @@ class AuthenticatedSessionSerializer(ModelSerializer):
|
|||||||
def get_current(self, instance: AuthenticatedSession) -> bool:
|
def get_current(self, instance: AuthenticatedSession) -> bool:
|
||||||
"""Check if session is currently active session"""
|
"""Check if session is currently active session"""
|
||||||
request: Request = self.context["request"]
|
request: Request = self.context["request"]
|
||||||
return request._request.session.session_key == instance.session_key
|
return request._request.session.session_key == instance.session.session_key
|
||||||
|
|
||||||
def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict:
|
def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict:
|
||||||
"""Get parsed user agent"""
|
"""Get parsed user agent"""
|
||||||
return user_agent_parser.Parse(instance.last_user_agent)
|
return user_agent_parser.Parse(instance.session.last_user_agent)
|
||||||
|
|
||||||
def get_geo_ip(self, instance: AuthenticatedSession) -> GeoIPDict | None: # pragma: no cover
|
def get_geo_ip(self, instance: AuthenticatedSession) -> GeoIPDict | None: # pragma: no cover
|
||||||
"""Get GeoIP Data"""
|
"""Get GeoIP Data"""
|
||||||
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip)
|
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.session.last_ip)
|
||||||
|
|
||||||
def get_asn(self, instance: AuthenticatedSession) -> ASNDict | None: # pragma: no cover
|
def get_asn(self, instance: AuthenticatedSession) -> ASNDict | None: # pragma: no cover
|
||||||
"""Get ASN Data"""
|
"""Get ASN Data"""
|
||||||
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip)
|
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.session.last_ip)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AuthenticatedSession
|
model = AuthenticatedSession
|
||||||
@ -90,6 +96,7 @@ class AuthenticatedSessionSerializer(ModelSerializer):
|
|||||||
"last_used",
|
"last_used",
|
||||||
"expires",
|
"expires",
|
||||||
]
|
]
|
||||||
|
extra_args = {"uuid": {"read_only": True}}
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatedSessionViewSet(
|
class AuthenticatedSessionViewSet(
|
||||||
@ -101,9 +108,10 @@ class AuthenticatedSessionViewSet(
|
|||||||
):
|
):
|
||||||
"""AuthenticatedSession Viewset"""
|
"""AuthenticatedSession Viewset"""
|
||||||
|
|
||||||
queryset = AuthenticatedSession.objects.all()
|
lookup_field = "uuid"
|
||||||
|
queryset = AuthenticatedSession.objects.select_related("session").all()
|
||||||
serializer_class = AuthenticatedSessionSerializer
|
serializer_class = AuthenticatedSessionSerializer
|
||||||
search_fields = ["user__username", "last_ip", "last_user_agent"]
|
search_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
|
||||||
filterset_fields = ["user__username", "last_ip", "last_user_agent"]
|
filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
|
||||||
ordering = ["user__username"]
|
ordering = ["user__username"]
|
||||||
owner_field = "user"
|
owner_field = "user"
|
||||||
|
@ -179,10 +179,13 @@ class UserSourceConnectionSerializer(SourceSerializer):
|
|||||||
"user",
|
"user",
|
||||||
"source",
|
"source",
|
||||||
"source_obj",
|
"source_obj",
|
||||||
|
"identifier",
|
||||||
"created",
|
"created",
|
||||||
|
"last_updated",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"created": {"read_only": True},
|
"created": {"read_only": True},
|
||||||
|
"last_updated": {"read_only": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -199,7 +202,7 @@ class UserSourceConnectionViewSet(
|
|||||||
queryset = UserSourceConnection.objects.all()
|
queryset = UserSourceConnection.objects.all()
|
||||||
serializer_class = UserSourceConnectionSerializer
|
serializer_class = UserSourceConnectionSerializer
|
||||||
filterset_fields = ["user", "source__slug"]
|
filterset_fields = ["user", "source__slug"]
|
||||||
search_fields = ["source__slug"]
|
search_fields = ["user__username", "source__slug", "identifier"]
|
||||||
ordering = ["source__slug", "pk"]
|
ordering = ["source__slug", "pk"]
|
||||||
owner_field = "user"
|
owner_field = "user"
|
||||||
|
|
||||||
@ -218,9 +221,11 @@ class GroupSourceConnectionSerializer(SourceSerializer):
|
|||||||
"source_obj",
|
"source_obj",
|
||||||
"identifier",
|
"identifier",
|
||||||
"created",
|
"created",
|
||||||
|
"last_updated",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"created": {"read_only": True},
|
"created": {"read_only": True},
|
||||||
|
"last_updated": {"read_only": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -237,6 +242,5 @@ class GroupSourceConnectionViewSet(
|
|||||||
queryset = GroupSourceConnection.objects.all()
|
queryset = GroupSourceConnection.objects.all()
|
||||||
serializer_class = GroupSourceConnectionSerializer
|
serializer_class = GroupSourceConnectionSerializer
|
||||||
filterset_fields = ["group", "source__slug"]
|
filterset_fields = ["group", "source__slug"]
|
||||||
search_fields = ["source__slug"]
|
search_fields = ["group__name", "source__slug", "identifier"]
|
||||||
ordering = ["source__slug", "pk"]
|
ordering = ["source__slug", "pk"]
|
||||||
owner_field = "user"
|
|
||||||
|
@ -6,8 +6,6 @@ from typing import Any
|
|||||||
|
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.db.models.functions import ExtractHour
|
from django.db.models.functions import ExtractHour
|
||||||
from django.db.transaction import atomic
|
from django.db.transaction import atomic
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
@ -71,8 +69,8 @@ from authentik.core.middleware import (
|
|||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||||
USER_PATH_SERVICE_ACCOUNT,
|
USER_PATH_SERVICE_ACCOUNT,
|
||||||
AuthenticatedSession,
|
|
||||||
Group,
|
Group,
|
||||||
|
Session,
|
||||||
Token,
|
Token,
|
||||||
TokenIntents,
|
TokenIntents,
|
||||||
User,
|
User,
|
||||||
@ -226,6 +224,7 @@ class UserSerializer(ModelSerializer):
|
|||||||
"name",
|
"name",
|
||||||
"is_active",
|
"is_active",
|
||||||
"last_login",
|
"last_login",
|
||||||
|
"date_joined",
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
"groups",
|
"groups",
|
||||||
"groups_obj",
|
"groups_obj",
|
||||||
@ -240,6 +239,7 @@ class UserSerializer(ModelSerializer):
|
|||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"name": {"allow_blank": True},
|
"name": {"allow_blank": True},
|
||||||
|
"date_joined": {"read_only": True},
|
||||||
"password_change_date": {"read_only": True},
|
"password_change_date": {"read_only": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,7 +373,7 @@ class UsersFilter(FilterSet):
|
|||||||
method="filter_attributes",
|
method="filter_attributes",
|
||||||
)
|
)
|
||||||
|
|
||||||
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
|
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser")
|
||||||
uuid = UUIDFilter(field_name="uuid")
|
uuid = UUIDFilter(field_name="uuid")
|
||||||
|
|
||||||
path = CharFilter(field_name="path")
|
path = CharFilter(field_name="path")
|
||||||
@ -391,6 +391,11 @@ class UsersFilter(FilterSet):
|
|||||||
queryset=Group.objects.all().order_by("name"),
|
queryset=Group.objects.all().order_by("name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def filter_is_superuser(self, queryset, name, value):
|
||||||
|
if value:
|
||||||
|
return queryset.filter(ak_groups__is_superuser=True).distinct()
|
||||||
|
return queryset.exclude(ak_groups__is_superuser=True).distinct()
|
||||||
|
|
||||||
def filter_attributes(self, queryset, name, value):
|
def filter_attributes(self, queryset, name, value):
|
||||||
"""Filter attributes by query args"""
|
"""Filter attributes by query args"""
|
||||||
try:
|
try:
|
||||||
@ -767,9 +772,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
response = super().partial_update(request, *args, **kwargs)
|
response = super().partial_update(request, *args, **kwargs)
|
||||||
instance: User = self.get_object()
|
instance: User = self.get_object()
|
||||||
if not instance.is_active:
|
if not instance.is_active:
|
||||||
sessions = AuthenticatedSession.objects.filter(user=instance)
|
Session.objects.filter(authenticatedsession__user=instance).delete()
|
||||||
session_ids = sessions.values_list("session_key", flat=True)
|
|
||||||
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
|
|
||||||
sessions.delete()
|
|
||||||
LOGGER.debug("Deleted user's sessions", user=instance.username)
|
LOGGER.debug("Deleted user's sessions", user=instance.username)
|
||||||
return response
|
return response
|
||||||
|
@ -24,6 +24,15 @@ class InbuiltBackend(ModelBackend):
|
|||||||
self.set_method("password", request)
|
self.set_method("password", request)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
async def aauthenticate(
|
||||||
|
self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
|
||||||
|
) -> User | None:
|
||||||
|
user = await super().aauthenticate(request, username=username, password=password, **kwargs)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
self.set_method("password", request)
|
||||||
|
return user
|
||||||
|
|
||||||
def set_method(self, method: str, request: HttpRequest | None, **kwargs):
|
def set_method(self, method: str, request: HttpRequest | None, **kwargs):
|
||||||
"""Set method data on current flow, if possbiel"""
|
"""Set method data on current flow, if possbiel"""
|
||||||
if not request:
|
if not request:
|
||||||
|
15
authentik/core/management/commands/clearsessions.py
Normal file
15
authentik/core/management/commands/clearsessions.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"""Change user type"""
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from authentik.tenants.management import TenantCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(TenantCommand):
|
||||||
|
"""Delete all sessions"""
|
||||||
|
|
||||||
|
def handle_per_tenant(self, **options):
|
||||||
|
engine = import_module(settings.SESSION_ENGINE)
|
||||||
|
engine.SessionStore.clear_expired()
|
@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
from functools import partial
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
|
from django.utils.functional import SimpleLazyObject
|
||||||
from django.utils.translation import override
|
from django.utils.translation import override
|
||||||
from sentry_sdk.api import set_tag
|
from sentry_sdk.api import set_tag
|
||||||
from structlog.contextvars import STRUCTLOG_KEY_PREFIX
|
from structlog.contextvars import STRUCTLOG_KEY_PREFIX
|
||||||
@ -20,6 +25,40 @@ CTX_HOST = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + "host", default=None)
|
|||||||
CTX_AUTH_VIA = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + KEY_AUTH_VIA, default=None)
|
CTX_AUTH_VIA = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + KEY_AUTH_VIA, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(request):
|
||||||
|
if not hasattr(request, "_cached_user"):
|
||||||
|
user = None
|
||||||
|
if (authenticated_session := request.session.get("authenticatedsession", None)) is not None:
|
||||||
|
user = authenticated_session.user
|
||||||
|
request._cached_user = user or AnonymousUser()
|
||||||
|
return request._cached_user
|
||||||
|
|
||||||
|
|
||||||
|
async def aget_user(request):
|
||||||
|
if not hasattr(request, "_cached_user"):
|
||||||
|
user = None
|
||||||
|
if (
|
||||||
|
authenticated_session := await request.session.aget("authenticatedsession", None)
|
||||||
|
) is not None:
|
||||||
|
user = authenticated_session.user
|
||||||
|
request._cached_user = user or AnonymousUser()
|
||||||
|
return request._cached_user
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationMiddleware(MiddlewareMixin):
|
||||||
|
def process_request(self, request):
|
||||||
|
if not hasattr(request, "session"):
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"The Django authentication middleware requires session "
|
||||||
|
"middleware to be installed. Edit your MIDDLEWARE setting to "
|
||||||
|
"insert "
|
||||||
|
"'authentik.root.middleware.SessionMiddleware' before "
|
||||||
|
"'authentik.core.middleware.AuthenticationMiddleware'."
|
||||||
|
)
|
||||||
|
request.user = SimpleLazyObject(lambda: get_user(request))
|
||||||
|
request.auser = partial(aget_user, request)
|
||||||
|
|
||||||
|
|
||||||
class ImpersonateMiddleware:
|
class ImpersonateMiddleware:
|
||||||
"""Middleware to impersonate users"""
|
"""Middleware to impersonate users"""
|
||||||
|
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.0.13 on 2025-04-07 14:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_core", "0043_alter_group_options"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="usersourceconnection",
|
||||||
|
name="new_identifier",
|
||||||
|
field=models.TextField(default=""),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,30 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_core", "0044_usersourceconnection_new_identifier"),
|
||||||
|
("authentik_sources_kerberos", "0003_migrate_userkerberossourceconnection_identifier"),
|
||||||
|
("authentik_sources_oauth", "0009_migrate_useroauthsourceconnection_identifier"),
|
||||||
|
("authentik_sources_plex", "0005_migrate_userplexsourceconnection_identifier"),
|
||||||
|
("authentik_sources_saml", "0019_migrate_usersamlsourceconnection_identifier"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="usersourceconnection",
|
||||||
|
old_name="new_identifier",
|
||||||
|
new_name="identifier",
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="usersourceconnection",
|
||||||
|
index=models.Index(fields=["identifier"], name="authentik_c_identif_59226f_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="usersourceconnection",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["source", "identifier"], name="authentik_c_source__649e04_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
238
authentik/core/migrations/0046_session_and_more.py
Normal file
238
authentik/core/migrations/0046_session_and_more.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
# Generated by Django 5.0.11 on 2025-01-27 12:58
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import pickle # nosec
|
||||||
|
from django.core import signing
|
||||||
|
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
|
from django.utils.timezone import now, timedelta
|
||||||
|
from authentik.lib.migrations import progress_bar
|
||||||
|
from authentik.root.middleware import ClientIPMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_CACHE_ALIAS = "default"
|
||||||
|
|
||||||
|
|
||||||
|
class PickleSerializer:
|
||||||
|
"""
|
||||||
|
Simple wrapper around pickle to be used in signing.dumps()/loads() and
|
||||||
|
cache backends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, protocol=None):
|
||||||
|
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
|
||||||
|
|
||||||
|
def dumps(self, obj):
|
||||||
|
"""Pickle data to be stored in redis"""
|
||||||
|
return pickle.dumps(obj, self.protocol)
|
||||||
|
|
||||||
|
def loads(self, data):
|
||||||
|
"""Unpickle data to be loaded from redis"""
|
||||||
|
return pickle.loads(data) # nosec
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_session(
|
||||||
|
apps,
|
||||||
|
db_alias,
|
||||||
|
session_key,
|
||||||
|
session_data,
|
||||||
|
expires,
|
||||||
|
):
|
||||||
|
Session = apps.get_model("authentik_core", "Session")
|
||||||
|
OldAuthenticatedSession = apps.get_model("authentik_core", "OldAuthenticatedSession")
|
||||||
|
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
|
||||||
|
|
||||||
|
old_auth_session = (
|
||||||
|
OldAuthenticatedSession.objects.using(db_alias).filter(session_key=session_key).first()
|
||||||
|
)
|
||||||
|
|
||||||
|
args = {
|
||||||
|
"session_key": session_key,
|
||||||
|
"expires": expires,
|
||||||
|
"last_ip": ClientIPMiddleware.default_ip,
|
||||||
|
"last_user_agent": "",
|
||||||
|
"session_data": {},
|
||||||
|
}
|
||||||
|
for k, v in session_data.items():
|
||||||
|
if k == "authentik/stages/user_login/last_ip":
|
||||||
|
args["last_ip"] = v
|
||||||
|
elif k in ["last_user_agent", "last_used"]:
|
||||||
|
args[k] = v
|
||||||
|
elif args in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY]:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
args["session_data"][k] = v
|
||||||
|
if old_auth_session:
|
||||||
|
args["last_user_agent"] = old_auth_session.last_user_agent
|
||||||
|
args["last_used"] = old_auth_session.last_used
|
||||||
|
|
||||||
|
args["session_data"] = pickle.dumps(args["session_data"])
|
||||||
|
session = Session.objects.using(db_alias).create(**args)
|
||||||
|
|
||||||
|
if old_auth_session:
|
||||||
|
AuthenticatedSession.objects.using(db_alias).create(
|
||||||
|
session=session,
|
||||||
|
user=old_auth_session.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_redis_sessions(apps, schema_editor):
|
||||||
|
from django.core.cache import caches
|
||||||
|
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
cache = caches[SESSION_CACHE_ALIAS]
|
||||||
|
|
||||||
|
# Not a redis cache, skipping
|
||||||
|
if not hasattr(cache, "keys"):
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\nMigrating Redis sessions to database, this might take a couple of minutes...")
|
||||||
|
for key, session_data in progress_bar(cache.get_many(cache.keys(f"{KEY_PREFIX}*")).items()):
|
||||||
|
_migrate_session(
|
||||||
|
apps=apps,
|
||||||
|
db_alias=db_alias,
|
||||||
|
session_key=key.removeprefix(KEY_PREFIX),
|
||||||
|
session_data=session_data,
|
||||||
|
expires=now() + timedelta(seconds=cache.ttl(key)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database_sessions(apps, schema_editor):
|
||||||
|
DjangoSession = apps.get_model("sessions", "Session")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
print("\nMigration database sessions, this might take a couple of minutes...")
|
||||||
|
for django_session in progress_bar(DjangoSession.objects.using(db_alias).all()):
|
||||||
|
session_data = signing.loads(
|
||||||
|
django_session.session_data,
|
||||||
|
salt="django.contrib.sessions.SessionStore",
|
||||||
|
serializer=PickleSerializer,
|
||||||
|
)
|
||||||
|
_migrate_session(
|
||||||
|
apps=apps,
|
||||||
|
db_alias=db_alias,
|
||||||
|
session_key=django_session.session_key,
|
||||||
|
session_data=session_data,
|
||||||
|
expires=django_session.expire_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("sessions", "0001_initial"),
|
||||||
|
("authentik_core", "0045_rename_new_identifier_usersourceconnection_identifier_and_more"),
|
||||||
|
("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"),
|
||||||
|
("authentik_providers_rac", "0006_connectiontoken_authentik_p_expires_91f148_idx_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Rename AuthenticatedSession to OldAuthenticatedSession
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name="AuthenticatedSession",
|
||||||
|
new_name="OldAuthenticatedSession",
|
||||||
|
),
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name="oldauthenticatedsession",
|
||||||
|
new_name="authentik_c_expires_cf4f72_idx",
|
||||||
|
old_name="authentik_c_expires_08251d_idx",
|
||||||
|
),
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name="oldauthenticatedsession",
|
||||||
|
new_name="authentik_c_expirin_c1f17f_idx",
|
||||||
|
old_name="authentik_c_expirin_9cd839_idx",
|
||||||
|
),
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name="oldauthenticatedsession",
|
||||||
|
new_name="authentik_c_expirin_e04f5d_idx",
|
||||||
|
old_name="authentik_c_expirin_195a84_idx",
|
||||||
|
),
|
||||||
|
migrations.RenameIndex(
|
||||||
|
model_name="oldauthenticatedsession",
|
||||||
|
new_name="authentik_c_session_a44819_idx",
|
||||||
|
old_name="authentik_c_session_d0f005_idx",
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql="ALTER INDEX authentik_core_authenticatedsession_user_id_5055b6cf RENAME TO authentik_core_oldauthenticatedsession_user_id_5055b6cf",
|
||||||
|
reverse_sql="ALTER INDEX authentik_core_oldauthenticatedsession_user_id_5055b6cf RENAME TO authentik_core_authenticatedsession_user_id_5055b6cf",
|
||||||
|
),
|
||||||
|
# Create new Session and AuthenticatedSession models
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Session",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"session_key",
|
||||||
|
models.CharField(
|
||||||
|
max_length=40, primary_key=True, serialize=False, verbose_name="session key"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("expires", models.DateTimeField(default=None, null=True)),
|
||||||
|
("expiring", models.BooleanField(default=True)),
|
||||||
|
("session_data", models.BinaryField(verbose_name="session data")),
|
||||||
|
("last_ip", models.GenericIPAddressField()),
|
||||||
|
("last_user_agent", models.TextField(blank=True)),
|
||||||
|
("last_used", models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"default_permissions": [],
|
||||||
|
"verbose_name": "Session",
|
||||||
|
"verbose_name_plural": "Sessions",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="session",
|
||||||
|
index=models.Index(fields=["expires"], name="authentik_c_expires_d2f607_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="session",
|
||||||
|
index=models.Index(fields=["expiring"], name="authentik_c_expirin_7c2cfb_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="session",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["expiring", "expires"], name="authentik_c_expirin_1ab2e4_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="session",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["expires", "session_key"], name="authentik_c_expires_c49143_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AuthenticatedSession",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"session",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="authentik_core.session",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Authenticated Session",
|
||||||
|
"verbose_name_plural": "Authenticated Sessions",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=migrate_redis_sessions,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=migrate_database_sessions,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.11 on 2025-01-27 13:02
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_core", "0046_session_and_more"),
|
||||||
|
("authentik_providers_rac", "0007_migrate_session"),
|
||||||
|
("authentik_providers_oauth2", "0028_migrate_session"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="OldAuthenticatedSession",
|
||||||
|
),
|
||||||
|
]
|
@ -1,6 +1,7 @@
|
|||||||
"""authentik core models"""
|
"""authentik core models"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from typing import Any, Optional, Self
|
from typing import Any, Optional, Self
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@ -9,6 +10,7 @@ from deepmerge import always_merger
|
|||||||
from django.contrib.auth.hashers import check_password
|
from django.contrib.auth.hashers import check_password
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||||
|
from django.contrib.sessions.base_session import AbstractBaseSession
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q, QuerySet, options
|
from django.db.models import Q, QuerySet, options
|
||||||
from django.db.models.constants import LOOKUP_SEP
|
from django.db.models.constants import LOOKUP_SEP
|
||||||
@ -646,19 +648,30 @@ class SourceUserMatchingModes(models.TextChoices):
|
|||||||
"""Different modes a source can handle new/returning users"""
|
"""Different modes a source can handle new/returning users"""
|
||||||
|
|
||||||
IDENTIFIER = "identifier", _("Use the source-specific identifier")
|
IDENTIFIER = "identifier", _("Use the source-specific identifier")
|
||||||
EMAIL_LINK = "email_link", _(
|
EMAIL_LINK = (
|
||||||
"Link to a user with identical email address. Can have security implications "
|
"email_link",
|
||||||
"when a source doesn't validate email addresses."
|
_(
|
||||||
|
"Link to a user with identical email address. Can have security implications "
|
||||||
|
"when a source doesn't validate email addresses."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
EMAIL_DENY = "email_deny", _(
|
EMAIL_DENY = (
|
||||||
"Use the user's email address, but deny enrollment when the email address already exists."
|
"email_deny",
|
||||||
|
_(
|
||||||
|
"Use the user's email address, but deny enrollment when the email address already "
|
||||||
|
"exists."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
USERNAME_LINK = "username_link", _(
|
USERNAME_LINK = (
|
||||||
"Link to a user with identical username. Can have security implications "
|
"username_link",
|
||||||
"when a username is used with another source."
|
_(
|
||||||
|
"Link to a user with identical username. Can have security implications "
|
||||||
|
"when a username is used with another source."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
USERNAME_DENY = "username_deny", _(
|
USERNAME_DENY = (
|
||||||
"Use the user's username, but deny enrollment when the username already exists."
|
"username_deny",
|
||||||
|
_("Use the user's username, but deny enrollment when the username already exists."),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -666,12 +679,16 @@ class SourceGroupMatchingModes(models.TextChoices):
|
|||||||
"""Different modes a source can handle new/returning groups"""
|
"""Different modes a source can handle new/returning groups"""
|
||||||
|
|
||||||
IDENTIFIER = "identifier", _("Use the source-specific identifier")
|
IDENTIFIER = "identifier", _("Use the source-specific identifier")
|
||||||
NAME_LINK = "name_link", _(
|
NAME_LINK = (
|
||||||
"Link to a group with identical name. Can have security implications "
|
"name_link",
|
||||||
"when a group name is used with another source."
|
_(
|
||||||
|
"Link to a group with identical name. Can have security implications "
|
||||||
|
"when a group name is used with another source."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
NAME_DENY = "name_deny", _(
|
NAME_DENY = (
|
||||||
"Use the group name, but deny enrollment when the name already exists."
|
"name_deny",
|
||||||
|
_("Use the group name, but deny enrollment when the name already exists."),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -730,8 +747,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
choices=SourceGroupMatchingModes.choices,
|
choices=SourceGroupMatchingModes.choices,
|
||||||
default=SourceGroupMatchingModes.IDENTIFIER,
|
default=SourceGroupMatchingModes.IDENTIFIER,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"How the source determines if an existing group should be used or "
|
"How the source determines if an existing group should be used or a new group created."
|
||||||
"a new group created."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -761,11 +777,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
@property
|
@property
|
||||||
def component(self) -> str:
|
def component(self) -> str:
|
||||||
"""Return component used to edit this object"""
|
"""Return component used to edit this object"""
|
||||||
|
if self.managed == self.MANAGED_INBUILT:
|
||||||
|
return ""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def property_mapping_type(self) -> "type[PropertyMapping]":
|
def property_mapping_type(self) -> "type[PropertyMapping]":
|
||||||
"""Return property mapping type used by this object"""
|
"""Return property mapping type used by this object"""
|
||||||
|
if self.managed == self.MANAGED_INBUILT:
|
||||||
|
from authentik.core.models import PropertyMapping
|
||||||
|
|
||||||
|
return PropertyMapping
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
|
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
|
||||||
@ -780,10 +802,14 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
|||||||
|
|
||||||
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
|
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
|
||||||
"""Get base properties for a user to build final properties upon."""
|
"""Get base properties for a user to build final properties upon."""
|
||||||
|
if self.managed == self.MANAGED_INBUILT:
|
||||||
|
return {}
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
|
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
|
||||||
"""Get base properties for a group to build final properties upon."""
|
"""Get base properties for a group to build final properties upon."""
|
||||||
|
if self.managed == self.MANAGED_INBUILT:
|
||||||
|
return {}
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -814,6 +840,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
|||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
source = models.ForeignKey(Source, on_delete=models.CASCADE)
|
source = models.ForeignKey(Source, on_delete=models.CASCADE)
|
||||||
|
identifier = models.TextField()
|
||||||
|
|
||||||
objects = InheritanceManager()
|
objects = InheritanceManager()
|
||||||
|
|
||||||
@ -827,6 +854,10 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (("user", "source"),)
|
unique_together = (("user", "source"),)
|
||||||
|
indexes = (
|
||||||
|
models.Index(fields=("identifier",)),
|
||||||
|
models.Index(fields=("source", "identifier")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
|
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
|
||||||
@ -997,45 +1028,75 @@ class PropertyMapping(SerializerModel, ManagedModel):
|
|||||||
verbose_name_plural = _("Property Mappings")
|
verbose_name_plural = _("Property Mappings")
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatedSession(ExpiringModel):
|
class Session(ExpiringModel, AbstractBaseSession):
|
||||||
"""Additional session class for authenticated users. Augments the standard django session
|
"""User session with extra fields for fast access"""
|
||||||
to achieve the following:
|
|
||||||
- Make it queryable by user
|
|
||||||
- Have a direct connection to user objects
|
|
||||||
- Allow users to view their own sessions and terminate them
|
|
||||||
- Save structured and well-defined information.
|
|
||||||
"""
|
|
||||||
|
|
||||||
uuid = models.UUIDField(default=uuid4, primary_key=True)
|
# Remove upstream field because we're using our own ExpiringModel
|
||||||
|
expire_date = None
|
||||||
|
session_data = models.BinaryField(_("session data"))
|
||||||
|
|
||||||
session_key = models.CharField(max_length=40)
|
# Keep in sync with Session.Keys
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
last_ip = models.GenericIPAddressField()
|
||||||
|
|
||||||
last_ip = models.TextField()
|
|
||||||
last_user_agent = models.TextField(blank=True)
|
last_user_agent = models.TextField(blank=True)
|
||||||
last_used = models.DateTimeField(auto_now=True)
|
last_used = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Session")
|
||||||
|
verbose_name_plural = _("Sessions")
|
||||||
|
indexes = ExpiringModel.Meta.indexes + [
|
||||||
|
models.Index(fields=["expires", "session_key"]),
|
||||||
|
]
|
||||||
|
default_permissions = []
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.session_key
|
||||||
|
|
||||||
|
class Keys(StrEnum):
|
||||||
|
"""
|
||||||
|
Keys to be set with the session interface for the fields above to be updated.
|
||||||
|
|
||||||
|
If a field is added here that needs to be initialized when the session is initialized,
|
||||||
|
it must also be reflected in authentik.root.middleware.SessionMiddleware.process_request
|
||||||
|
and in authentik.core.sessions.SessionStore.__init__
|
||||||
|
"""
|
||||||
|
|
||||||
|
LAST_IP = "last_ip"
|
||||||
|
LAST_USER_AGENT = "last_user_agent"
|
||||||
|
LAST_USED = "last_used"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_session_store_class(cls):
|
||||||
|
from authentik.core.sessions import SessionStore
|
||||||
|
|
||||||
|
return SessionStore
|
||||||
|
|
||||||
|
def get_decoded(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticatedSession(SerializerModel):
|
||||||
|
session = models.OneToOneField(Session, on_delete=models.CASCADE, primary_key=True)
|
||||||
|
# We use the session as primary key, but we need the API to be able to reference
|
||||||
|
# this object uniquely without exposing the session key
|
||||||
|
uuid = models.UUIDField(default=uuid4, unique=True)
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Authenticated Session")
|
verbose_name = _("Authenticated Session")
|
||||||
verbose_name_plural = _("Authenticated Sessions")
|
verbose_name_plural = _("Authenticated Sessions")
|
||||||
indexes = ExpiringModel.Meta.indexes + [
|
|
||||||
models.Index(fields=["session_key"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Authenticated Session {self.session_key[:10]}"
|
return f"Authenticated Session {str(self.pk)[:10]}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
|
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
|
||||||
"""Create a new session from a http request"""
|
"""Create a new session from a http request"""
|
||||||
from authentik.root.middleware import ClientIPMiddleware
|
if not hasattr(request, "session") or not request.session.exists(
|
||||||
|
request.session.session_key
|
||||||
if not hasattr(request, "session") or not request.session.session_key:
|
):
|
||||||
return None
|
return None
|
||||||
return AuthenticatedSession(
|
return AuthenticatedSession(
|
||||||
session_key=request.session.session_key,
|
session=Session.objects.filter(session_key=request.session.session_key).first(),
|
||||||
user=user,
|
user=user,
|
||||||
last_ip=ClientIPMiddleware.get_client_ip(request),
|
|
||||||
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
|
||||||
expires=request.session.get_expiry_date(),
|
|
||||||
)
|
)
|
||||||
|
168
authentik/core/sessions.py
Normal file
168
authentik/core/sessions.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"""authentik sessions engine"""
|
||||||
|
|
||||||
|
import pickle # nosec
|
||||||
|
|
||||||
|
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
|
||||||
|
from django.contrib.sessions.backends.db import SessionStore as SessionBase
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.root.middleware import ClientIPMiddleware
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStore(SessionBase):
|
||||||
|
def __init__(self, session_key=None, last_ip=None, last_user_agent=""):
|
||||||
|
super().__init__(session_key)
|
||||||
|
self._create_kwargs = {
|
||||||
|
"last_ip": last_ip or ClientIPMiddleware.default_ip,
|
||||||
|
"last_user_agent": last_user_agent,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_model_class(cls):
|
||||||
|
from authentik.core.models import Session
|
||||||
|
|
||||||
|
return Session
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def model_fields(self):
|
||||||
|
return [k.value for k in self.model.Keys]
|
||||||
|
|
||||||
|
def _get_session_from_db(self):
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
self.model.objects.select_related(
|
||||||
|
"authenticatedsession",
|
||||||
|
"authenticatedsession__user",
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
"authenticatedsession__user__groups",
|
||||||
|
"authenticatedsession__user__user_permissions",
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
session_key=self.session_key,
|
||||||
|
expires__gt=timezone.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
|
||||||
|
if isinstance(exc, SuspiciousOperation):
|
||||||
|
LOGGER.warning(str(exc))
|
||||||
|
self._session_key = None
|
||||||
|
|
||||||
|
async def _aget_session_from_db(self):
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
await self.model.objects.select_related(
|
||||||
|
"authenticatedsession",
|
||||||
|
"authenticatedsession__user",
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
"authenticatedsession__user__groups",
|
||||||
|
"authenticatedsession__user__user_permissions",
|
||||||
|
)
|
||||||
|
.aget(
|
||||||
|
session_key=self.session_key,
|
||||||
|
expires__gt=timezone.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
|
||||||
|
if isinstance(exc, SuspiciousOperation):
|
||||||
|
LOGGER.warning(str(exc))
|
||||||
|
self._session_key = None
|
||||||
|
|
||||||
|
def encode(self, session_dict):
|
||||||
|
return pickle.dumps(session_dict, protocol=pickle.HIGHEST_PROTOCOL)
|
||||||
|
|
||||||
|
def decode(self, session_data):
|
||||||
|
try:
|
||||||
|
return pickle.loads(session_data) # nosec
|
||||||
|
except pickle.PickleError:
|
||||||
|
# ValueError, unpickling exceptions. If any of these happen, just return an empty
|
||||||
|
# dictionary (an empty session)
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
s = self._get_session_from_db()
|
||||||
|
if s:
|
||||||
|
return {
|
||||||
|
"authenticatedsession": getattr(s, "authenticatedsession", None),
|
||||||
|
**{k: getattr(s, k) for k in self.model_fields},
|
||||||
|
**self.decode(s.session_data),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def aload(self):
|
||||||
|
s = await self._aget_session_from_db()
|
||||||
|
if s:
|
||||||
|
return {
|
||||||
|
"authenticatedsession": getattr(s, "authenticatedsession", None),
|
||||||
|
**{k: getattr(s, k) for k in self.model_fields},
|
||||||
|
**self.decode(s.session_data),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def create_model_instance(self, data):
|
||||||
|
args = {
|
||||||
|
"session_key": self._get_or_create_session_key(),
|
||||||
|
"expires": self.get_expiry_date(),
|
||||||
|
"session_data": {},
|
||||||
|
**self._create_kwargs,
|
||||||
|
}
|
||||||
|
for k, v in data.items():
|
||||||
|
# Don't save:
|
||||||
|
# - unused auth data
|
||||||
|
# - related models
|
||||||
|
if k in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY, "authenticatedsession"]:
|
||||||
|
pass
|
||||||
|
elif k in self.model_fields:
|
||||||
|
args[k] = v
|
||||||
|
else:
|
||||||
|
args["session_data"][k] = v
|
||||||
|
args["session_data"] = self.encode(args["session_data"])
|
||||||
|
return self.model(**args)
|
||||||
|
|
||||||
|
async def acreate_model_instance(self, data):
|
||||||
|
args = {
|
||||||
|
"session_key": await self._aget_or_create_session_key(),
|
||||||
|
"expires": await self.aget_expiry_date(),
|
||||||
|
"session_data": {},
|
||||||
|
**self._create_kwargs,
|
||||||
|
}
|
||||||
|
for k, v in data.items():
|
||||||
|
# Don't save:
|
||||||
|
# - unused auth data
|
||||||
|
# - related models
|
||||||
|
if k in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY, "authenticatedsession"]:
|
||||||
|
pass
|
||||||
|
elif k in self.model_fields:
|
||||||
|
args[k] = v
|
||||||
|
else:
|
||||||
|
args["session_data"][k] = v
|
||||||
|
args["session_data"] = self.encode(args["session_data"])
|
||||||
|
return self.model(**args)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_expired(cls):
|
||||||
|
cls.get_model_class().objects.filter(expires__lt=timezone.now()).delete()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def aclear_expired(cls):
|
||||||
|
await cls.get_model_class().objects.filter(expires__lt=timezone.now()).adelete()
|
||||||
|
|
||||||
|
def cycle_key(self):
|
||||||
|
data = self._session
|
||||||
|
key = self.session_key
|
||||||
|
self.create()
|
||||||
|
self._session_cache = data
|
||||||
|
if key:
|
||||||
|
self.delete(key)
|
||||||
|
if (authenticated_session := data.get("authenticatedsession")) is not None:
|
||||||
|
authenticated_session.session_id = self.session_key
|
||||||
|
authenticated_session.save(force_insert=True)
|
@ -1,11 +1,10 @@
|
|||||||
"""authentik core signals"""
|
"""authentik core signals"""
|
||||||
|
|
||||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
from django.contrib.auth.signals import user_logged_in
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.signals import Signal
|
from django.core.signals import Signal
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.db.models.signals import post_save, pre_delete, pre_save
|
from django.db.models.signals import post_delete, post_save, pre_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
@ -15,6 +14,7 @@ from authentik.core.models import (
|
|||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
BackchannelProvider,
|
BackchannelProvider,
|
||||||
ExpiringModel,
|
ExpiringModel,
|
||||||
|
Session,
|
||||||
User,
|
User,
|
||||||
default_token_duration,
|
default_token_duration,
|
||||||
)
|
)
|
||||||
@ -49,19 +49,10 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
|
|||||||
session.save()
|
session.save()
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
@receiver(post_delete, sender=AuthenticatedSession)
|
||||||
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
|
|
||||||
"""Delete AuthenticatedSession if it exists"""
|
|
||||||
if not request.session or not request.session.session_key:
|
|
||||||
return
|
|
||||||
AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
|
||||||
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
|
||||||
"""Delete session when authenticated session is deleted"""
|
"""Delete session when authenticated session is deleted"""
|
||||||
cache_key = f"{KEY_PREFIX}{instance.session_key}"
|
Session.objects.filter(session_key=instance.pk).delete()
|
||||||
cache.delete(cache_key)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_save)
|
@receiver(pre_save)
|
||||||
|
@ -2,22 +2,16 @@
|
|||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.conf import ImproperlyConfigured
|
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
|
||||||
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
USER_ATTRIBUTE_EXPIRES,
|
USER_ATTRIBUTE_EXPIRES,
|
||||||
USER_ATTRIBUTE_GENERATED,
|
USER_ATTRIBUTE_GENERATED,
|
||||||
AuthenticatedSession,
|
|
||||||
ExpiringModel,
|
ExpiringModel,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
|
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
|
||||||
from authentik.lib.config import CONFIG
|
|
||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
@ -38,40 +32,6 @@ def clean_expired_models(self: SystemTask):
|
|||||||
obj.expire_action()
|
obj.expire_action()
|
||||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||||
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||||
# Special case
|
|
||||||
amount = 0
|
|
||||||
|
|
||||||
for session in AuthenticatedSession.objects.all():
|
|
||||||
match CONFIG.get("session_storage", "cache"):
|
|
||||||
case "cache":
|
|
||||||
cache_key = f"{KEY_PREFIX}{session.session_key}"
|
|
||||||
value = None
|
|
||||||
try:
|
|
||||||
value = cache.get(cache_key)
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
LOGGER.debug("Failed to get session from cache", exc=exc)
|
|
||||||
if not value:
|
|
||||||
session.delete()
|
|
||||||
amount += 1
|
|
||||||
case "db":
|
|
||||||
if not (
|
|
||||||
DBSessionStore.get_model_class()
|
|
||||||
.objects.filter(session_key=session.session_key, expire_date__gt=now())
|
|
||||||
.exists()
|
|
||||||
):
|
|
||||||
session.delete()
|
|
||||||
amount += 1
|
|
||||||
case _:
|
|
||||||
# Should never happen, as we check for other values in authentik/root/settings.py
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
"Invalid session_storage setting, allowed values are db and cache"
|
|
||||||
)
|
|
||||||
if CONFIG.get("session_storage", "cache") == "db":
|
|
||||||
DBSessionStore.clear_expired()
|
|
||||||
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
|
|
||||||
|
|
||||||
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
|
|
||||||
self.set_status(TaskStatus.SUCCESSFUL, *messages)
|
self.set_status(TaskStatus.SUCCESSFUL, *messages)
|
||||||
|
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ from json import loads
|
|||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import AuthenticatedSession, Session, User
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
|
|
||||||
|
|
||||||
@ -30,3 +30,18 @@ class TestAuthenticatedSessionsAPI(APITestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
body = loads(response.content.decode())
|
body = loads(response.content.decode())
|
||||||
self.assertEqual(body["pagination"]["count"], 1)
|
self.assertEqual(body["pagination"]["count"], 1)
|
||||||
|
|
||||||
|
def test_delete(self):
|
||||||
|
"""Test deletion"""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.assertEqual(AuthenticatedSession.objects.all().count(), 1)
|
||||||
|
self.assertEqual(Session.objects.all().count(), 1)
|
||||||
|
response = self.client.delete(
|
||||||
|
reverse(
|
||||||
|
"authentik_api:authenticatedsession-detail",
|
||||||
|
kwargs={"uuid": AuthenticatedSession.objects.first().uuid},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.assertEqual(AuthenticatedSession.objects.all().count(), 0)
|
||||||
|
self.assertEqual(Session.objects.all().count(), 0)
|
||||||
|
19
authentik/core/tests/test_source_api.py
Normal file
19
authentik/core/tests/test_source_api.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from django.apps import apps
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceAPI(APITestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = create_test_admin_user()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_builtin_source_used_by(self):
|
||||||
|
"""Test Providers's types endpoint"""
|
||||||
|
apps.get_app_config("authentik_core").source_inbuilt()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:source-used-by", kwargs={"slug": "authentik-built-in"}),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
@ -1,9 +1,8 @@
|
|||||||
"""Test Users API"""
|
"""Test Users API"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from json import loads
|
||||||
|
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
@ -11,11 +10,17 @@ from authentik.brands.models import Brand
|
|||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
|
Session,
|
||||||
Token,
|
Token,
|
||||||
User,
|
User,
|
||||||
UserTypes,
|
UserTypes,
|
||||||
)
|
)
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
from authentik.core.tests.utils import (
|
||||||
|
create_test_admin_user,
|
||||||
|
create_test_brand,
|
||||||
|
create_test_flow,
|
||||||
|
create_test_user,
|
||||||
|
)
|
||||||
from authentik.flows.models import FlowDesignation
|
from authentik.flows.models import FlowDesignation
|
||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
@ -26,7 +31,7 @@ class TestUsersAPI(APITestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.admin = create_test_admin_user()
|
self.admin = create_test_admin_user()
|
||||||
self.user = User.objects.create(username="test-user")
|
self.user = create_test_user()
|
||||||
|
|
||||||
def test_filter_type(self):
|
def test_filter_type(self):
|
||||||
"""Test API filtering by type"""
|
"""Test API filtering by type"""
|
||||||
@ -41,6 +46,35 @@ class TestUsersAPI(APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_filter_is_superuser(self):
|
||||||
|
"""Test API filtering by superuser status"""
|
||||||
|
User.objects.all().delete()
|
||||||
|
admin = create_test_admin_user()
|
||||||
|
self.client.force_login(admin)
|
||||||
|
# Test superuser
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={
|
||||||
|
"is_superuser": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content)
|
||||||
|
self.assertEqual(len(body["results"]), 1)
|
||||||
|
self.assertEqual(body["results"][0]["username"], admin.username)
|
||||||
|
# Test non-superuser
|
||||||
|
user = create_test_user()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:user-list"),
|
||||||
|
data={
|
||||||
|
"is_superuser": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = loads(response.content)
|
||||||
|
self.assertEqual(len(body["results"]), 1, body)
|
||||||
|
self.assertEqual(body["results"][0]["username"], user.username)
|
||||||
|
|
||||||
def test_list_with_groups(self):
|
def test_list_with_groups(self):
|
||||||
"""Test listing with groups"""
|
"""Test listing with groups"""
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
@ -99,6 +133,8 @@ class TestUsersAPI(APITestCase):
|
|||||||
def test_recovery_email_no_flow(self):
|
def test_recovery_email_no_flow(self):
|
||||||
"""Test user recovery link (no recovery flow set)"""
|
"""Test user recovery link (no recovery flow set)"""
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
|
self.user.email = ""
|
||||||
|
self.user.save()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
|
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
|
||||||
)
|
)
|
||||||
@ -344,12 +380,15 @@ class TestUsersAPI(APITestCase):
|
|||||||
"""Ensure sessions are deleted when a user is deactivated"""
|
"""Ensure sessions are deleted when a user is deactivated"""
|
||||||
user = create_test_admin_user()
|
user = create_test_admin_user()
|
||||||
session_id = generate_id()
|
session_id = generate_id()
|
||||||
AuthenticatedSession.objects.create(
|
session = Session.objects.create(
|
||||||
user=user,
|
|
||||||
session_key=session_id,
|
session_key=session_id,
|
||||||
last_ip="",
|
last_ip="255.255.255.255",
|
||||||
|
last_user_agent="",
|
||||||
|
)
|
||||||
|
AuthenticatedSession.objects.create(
|
||||||
|
session=session,
|
||||||
|
user=user,
|
||||||
)
|
)
|
||||||
cache.set(KEY_PREFIX + session_id, "foo")
|
|
||||||
|
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
@ -360,5 +399,7 @@ class TestUsersAPI(APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
self.assertIsNone(cache.get(KEY_PREFIX + session_id))
|
self.assertFalse(Session.objects.filter(session_key=session_id).exists())
|
||||||
self.assertFalse(AuthenticatedSession.objects.filter(session_key=session_id).exists())
|
self.assertFalse(
|
||||||
|
AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
|
||||||
|
)
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
"""authentik URL Configuration"""
|
"""authentik URL Configuration"""
|
||||||
|
|
||||||
from channels.auth import AuthMiddleware
|
|
||||||
from channels.sessions import CookieMiddleware
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
@ -13,7 +11,11 @@ from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
|
|||||||
from authentik.core.api.groups import GroupViewSet
|
from authentik.core.api.groups import GroupViewSet
|
||||||
from authentik.core.api.property_mappings import PropertyMappingViewSet
|
from authentik.core.api.property_mappings import PropertyMappingViewSet
|
||||||
from authentik.core.api.providers import ProviderViewSet
|
from authentik.core.api.providers import ProviderViewSet
|
||||||
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
|
from authentik.core.api.sources import (
|
||||||
|
GroupSourceConnectionViewSet,
|
||||||
|
SourceViewSet,
|
||||||
|
UserSourceConnectionViewSet,
|
||||||
|
)
|
||||||
from authentik.core.api.tokens import TokenViewSet
|
from authentik.core.api.tokens import TokenViewSet
|
||||||
from authentik.core.api.transactional_applications import TransactionalApplicationView
|
from authentik.core.api.transactional_applications import TransactionalApplicationView
|
||||||
from authentik.core.api.users import UserViewSet
|
from authentik.core.api.users import UserViewSet
|
||||||
@ -25,7 +27,7 @@ from authentik.core.views.interface import (
|
|||||||
RootRedirectView,
|
RootRedirectView,
|
||||||
)
|
)
|
||||||
from authentik.flows.views.interface import FlowInterfaceView
|
from authentik.flows.views.interface import FlowInterfaceView
|
||||||
from authentik.root.asgi_middleware import SessionMiddleware
|
from authentik.root.asgi_middleware import AuthMiddlewareStack
|
||||||
from authentik.root.messages.consumer import MessageConsumer
|
from authentik.root.messages.consumer import MessageConsumer
|
||||||
from authentik.root.middleware import ChannelsLoggingMiddleware
|
from authentik.root.middleware import ChannelsLoggingMiddleware
|
||||||
|
|
||||||
@ -81,6 +83,7 @@ api_urlpatterns = [
|
|||||||
("core/tokens", TokenViewSet),
|
("core/tokens", TokenViewSet),
|
||||||
("sources/all", SourceViewSet),
|
("sources/all", SourceViewSet),
|
||||||
("sources/user_connections/all", UserSourceConnectionViewSet),
|
("sources/user_connections/all", UserSourceConnectionViewSet),
|
||||||
|
("sources/group_connections/all", GroupSourceConnectionViewSet),
|
||||||
("providers/all", ProviderViewSet),
|
("providers/all", ProviderViewSet),
|
||||||
("propertymappings/all", PropertyMappingViewSet),
|
("propertymappings/all", PropertyMappingViewSet),
|
||||||
("authenticators/all", DeviceViewSet, "device"),
|
("authenticators/all", DeviceViewSet, "device"),
|
||||||
@ -94,9 +97,7 @@ api_urlpatterns = [
|
|||||||
websocket_urlpatterns = [
|
websocket_urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"ws/client/",
|
"ws/client/",
|
||||||
ChannelsLoggingMiddleware(
|
ChannelsLoggingMiddleware(AuthMiddlewareStack(MessageConsumer.as_asgi())),
|
||||||
CookieMiddleware(SessionMiddleware(AuthMiddleware(MessageConsumer.as_asgi())))
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -102,7 +102,7 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi
|
|||||||
"format": "complex",
|
"format": "complex",
|
||||||
"session": {
|
"session": {
|
||||||
"format": "opaque",
|
"format": "opaque",
|
||||||
"id": sha256(instance.session_key.encode("ascii")).hexdigest(),
|
"id": sha256(instance.session.session_key.encode("ascii")).hexdigest(),
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"format": "email",
|
"format": "email",
|
||||||
|
@ -59,7 +59,7 @@ def get_login_event(request_or_session: HttpRequest | AuthenticatedSession | Non
|
|||||||
session = request_or_session.session
|
session = request_or_session.session
|
||||||
if isinstance(request_or_session, AuthenticatedSession):
|
if isinstance(request_or_session, AuthenticatedSession):
|
||||||
SessionStore = _session_engine.SessionStore
|
SessionStore = _session_engine.SessionStore
|
||||||
session = SessionStore(request_or_session.session_key)
|
session = SessionStore(request_or_session.session.session_key)
|
||||||
return session.get(SESSION_LOGIN_EVENT, None)
|
return session.get(SESSION_LOGIN_EVENT, None)
|
||||||
|
|
||||||
|
|
||||||
|
@ -69,6 +69,7 @@ SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
|
|||||||
SESSION_KEY_GET = "authentik/flows/get"
|
SESSION_KEY_GET = "authentik/flows/get"
|
||||||
SESSION_KEY_POST = "authentik/flows/post"
|
SESSION_KEY_POST = "authentik/flows/post"
|
||||||
SESSION_KEY_HISTORY = "authentik/flows/history"
|
SESSION_KEY_HISTORY = "authentik/flows/history"
|
||||||
|
SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started"
|
||||||
QS_KEY_TOKEN = "flow_token" # nosec
|
QS_KEY_TOKEN = "flow_token" # nosec
|
||||||
QS_QUERY = "query"
|
QS_QUERY = "query"
|
||||||
|
|
||||||
@ -453,6 +454,7 @@ class FlowExecutorView(APIView):
|
|||||||
SESSION_KEY_APPLICATION_PRE,
|
SESSION_KEY_APPLICATION_PRE,
|
||||||
SESSION_KEY_PLAN,
|
SESSION_KEY_PLAN,
|
||||||
SESSION_KEY_GET,
|
SESSION_KEY_GET,
|
||||||
|
SESSION_KEY_AUTH_STARTED,
|
||||||
# We might need the initial POST payloads for later requests
|
# We might need the initial POST payloads for later requests
|
||||||
# SESSION_KEY_POST,
|
# SESSION_KEY_POST,
|
||||||
# We don't delete the history on purpose, as a user might
|
# We don't delete the history on purpose, as a user might
|
||||||
|
@ -6,14 +6,22 @@ from django.shortcuts import get_object_or_404
|
|||||||
from ua_parser.user_agent_parser import Parse
|
from ua_parser.user_agent_parser import Parse
|
||||||
|
|
||||||
from authentik.core.views.interface import InterfaceView
|
from authentik.core.views.interface import InterfaceView
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow, FlowDesignation
|
||||||
|
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
|
||||||
|
|
||||||
|
|
||||||
class FlowInterfaceView(InterfaceView):
|
class FlowInterfaceView(InterfaceView):
|
||||||
"""Flow interface"""
|
"""Flow interface"""
|
||||||
|
|
||||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
||||||
|
kwargs["flow"] = flow
|
||||||
|
if (
|
||||||
|
not self.request.user.is_authenticated
|
||||||
|
and flow.designation == FlowDesignation.AUTHENTICATION
|
||||||
|
):
|
||||||
|
self.request.session[SESSION_KEY_AUTH_STARTED] = True
|
||||||
|
self.request.session.save()
|
||||||
kwargs["inspector"] = "inspector" in self.request.GET
|
kwargs["inspector"] = "inspector" in self.request.GET
|
||||||
return super().get_context_data(**kwargs)
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ from sentry_sdk import start_span
|
|||||||
from sentry_sdk.tracing import Span
|
from sentry_sdk.tracing import Span
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.models import AuthenticatedSession, User
|
from authentik.core.models import User
|
||||||
from authentik.events.models import Event
|
from authentik.events.models import Event
|
||||||
from authentik.lib.expression.exceptions import ControlFlowException
|
from authentik.lib.expression.exceptions import ControlFlowException
|
||||||
from authentik.lib.utils.http import get_http_session
|
from authentik.lib.utils.http import get_http_session
|
||||||
@ -203,9 +203,7 @@ class BaseEvaluator:
|
|||||||
provider = OAuth2Provider.objects.get(name=provider)
|
provider = OAuth2Provider.objects.get(name=provider)
|
||||||
session = None
|
session = None
|
||||||
if hasattr(request, "session") and request.session.session_key:
|
if hasattr(request, "session") and request.session.session_key:
|
||||||
session = AuthenticatedSession.objects.filter(
|
session = request.session["authenticatedsession"]
|
||||||
session_key=request.session.session_key
|
|
||||||
).first()
|
|
||||||
access_token = AccessToken(
|
access_token = AccessToken(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
user=user,
|
user=user,
|
||||||
|
@ -18,6 +18,15 @@ class SerializerModel(models.Model):
|
|||||||
@property
|
@property
|
||||||
def serializer(self) -> type[BaseSerializer]:
|
def serializer(self) -> type[BaseSerializer]:
|
||||||
"""Get serializer for this model"""
|
"""Get serializer for this model"""
|
||||||
|
# Special handling for built-in source
|
||||||
|
if (
|
||||||
|
hasattr(self, "managed")
|
||||||
|
and hasattr(self, "MANAGED_INBUILT")
|
||||||
|
and self.managed == self.MANAGED_INBUILT
|
||||||
|
):
|
||||||
|
from authentik.core.api.sources import SourceSerializer
|
||||||
|
|
||||||
|
return SourceSerializer
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@ -35,3 +35,4 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
|
|||||||
label = "authentik_policies"
|
label = "authentik_policies"
|
||||||
verbose_name = "authentik Policies"
|
verbose_name = "authentik Policies"
|
||||||
default = True
|
default = True
|
||||||
|
mountpoint = "policy/"
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from django.contrib.auth.signals import user_logged_in
|
from django.contrib.auth.signals import user_logged_in
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import F
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
@ -13,20 +12,29 @@ from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR
|
|||||||
from authentik.policies.reputation.models import Reputation, reputation_expiry
|
from authentik.policies.reputation.models import Reputation, reputation_expiry
|
||||||
from authentik.root.middleware import ClientIPMiddleware
|
from authentik.root.middleware import ClientIPMiddleware
|
||||||
from authentik.stages.identification.signals import identification_failed
|
from authentik.stages.identification.signals import identification_failed
|
||||||
|
from authentik.tenants.utils import get_current_tenant
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value, min, max):
|
||||||
|
return sorted([min, value, max])[1]
|
||||||
|
|
||||||
|
|
||||||
def update_score(request: HttpRequest, identifier: str, amount: int):
|
def update_score(request: HttpRequest, identifier: str, amount: int):
|
||||||
"""Update score for IP and User"""
|
"""Update score for IP and User"""
|
||||||
remote_ip = ClientIPMiddleware.get_client_ip(request)
|
remote_ip = ClientIPMiddleware.get_client_ip(request)
|
||||||
|
tenant = get_current_tenant()
|
||||||
|
new_score = clamp(amount, tenant.reputation_lower_limit, tenant.reputation_upper_limit)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
reputation, created = Reputation.objects.select_for_update().get_or_create(
|
reputation, created = Reputation.objects.select_for_update().get_or_create(
|
||||||
ip=remote_ip,
|
ip=remote_ip,
|
||||||
identifier=identifier,
|
identifier=identifier,
|
||||||
defaults={
|
defaults={
|
||||||
"score": amount,
|
"score": clamp(
|
||||||
|
amount, tenant.reputation_lower_limit, tenant.reputation_upper_limit
|
||||||
|
),
|
||||||
"ip_geo_data": GEOIP_CONTEXT_PROCESSOR.city_dict(remote_ip) or {},
|
"ip_geo_data": GEOIP_CONTEXT_PROCESSOR.city_dict(remote_ip) or {},
|
||||||
"ip_asn_data": ASN_CONTEXT_PROCESSOR.asn_dict(remote_ip) or {},
|
"ip_asn_data": ASN_CONTEXT_PROCESSOR.asn_dict(remote_ip) or {},
|
||||||
"expires": reputation_expiry(),
|
"expires": reputation_expiry(),
|
||||||
@ -34,9 +42,15 @@ def update_score(request: HttpRequest, identifier: str, amount: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not created:
|
if not created:
|
||||||
reputation.score = F("score") + amount
|
new_score = clamp(
|
||||||
|
reputation.score + amount,
|
||||||
|
tenant.reputation_lower_limit,
|
||||||
|
tenant.reputation_upper_limit,
|
||||||
|
)
|
||||||
|
reputation.score = new_score
|
||||||
reputation.save()
|
reputation.save()
|
||||||
LOGGER.info("Updated score", amount=amount, for_user=identifier, for_ip=remote_ip)
|
|
||||||
|
LOGGER.info("Updated score", amount=new_score, for_user=identifier, for_ip=remote_ip)
|
||||||
|
|
||||||
|
|
||||||
@receiver(login_failed)
|
@receiver(login_failed)
|
||||||
|
@ -6,9 +6,11 @@ from authentik.core.models import User
|
|||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.policies.reputation.api import ReputationPolicySerializer
|
from authentik.policies.reputation.api import ReputationPolicySerializer
|
||||||
from authentik.policies.reputation.models import Reputation, ReputationPolicy
|
from authentik.policies.reputation.models import Reputation, ReputationPolicy
|
||||||
|
from authentik.policies.reputation.signals import update_score
|
||||||
from authentik.policies.types import PolicyRequest
|
from authentik.policies.types import PolicyRequest
|
||||||
from authentik.stages.password import BACKEND_INBUILT
|
from authentik.stages.password import BACKEND_INBUILT
|
||||||
from authentik.stages.password.stage import authenticate
|
from authentik.stages.password.stage import authenticate
|
||||||
|
from authentik.tenants.models import DEFAULT_REPUTATION_LOWER_LIMIT, DEFAULT_REPUTATION_UPPER_LIMIT
|
||||||
|
|
||||||
|
|
||||||
class TestReputationPolicy(TestCase):
|
class TestReputationPolicy(TestCase):
|
||||||
@ -17,36 +19,48 @@ class TestReputationPolicy(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.request_factory = RequestFactory()
|
self.request_factory = RequestFactory()
|
||||||
self.request = self.request_factory.get("/")
|
self.request = self.request_factory.get("/")
|
||||||
self.test_ip = "127.0.0.1"
|
self.ip = "127.0.0.1"
|
||||||
self.test_username = "test"
|
self.username = "username"
|
||||||
|
self.password = generate_id()
|
||||||
# We need a user for the one-to-one in userreputation
|
# We need a user for the one-to-one in userreputation
|
||||||
self.user = User.objects.create(username=self.test_username)
|
self.user = User.objects.create(username=self.username)
|
||||||
|
self.user.set_password(self.password)
|
||||||
self.backends = [BACKEND_INBUILT]
|
self.backends = [BACKEND_INBUILT]
|
||||||
|
|
||||||
def test_ip_reputation(self):
|
def test_ip_reputation(self):
|
||||||
"""test IP reputation"""
|
"""test IP reputation"""
|
||||||
# Trigger negative reputation
|
# Trigger negative reputation
|
||||||
authenticate(
|
authenticate(self.request, self.backends, username=self.username, password=self.username)
|
||||||
self.request, self.backends, username=self.test_username, password=self.test_username
|
self.assertEqual(Reputation.objects.get(ip=self.ip).score, -1)
|
||||||
)
|
|
||||||
self.assertEqual(Reputation.objects.get(ip=self.test_ip).score, -1)
|
|
||||||
|
|
||||||
def test_user_reputation(self):
|
def test_user_reputation(self):
|
||||||
"""test User reputation"""
|
"""test User reputation"""
|
||||||
# Trigger negative reputation
|
# Trigger negative reputation
|
||||||
authenticate(
|
authenticate(self.request, self.backends, username=self.username, password=self.username)
|
||||||
self.request, self.backends, username=self.test_username, password=self.test_username
|
self.assertEqual(Reputation.objects.get(identifier=self.username).score, -1)
|
||||||
)
|
|
||||||
self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, -1)
|
|
||||||
|
|
||||||
def test_update_reputation(self):
|
def test_update_reputation(self):
|
||||||
"""test reputation update"""
|
"""test reputation update"""
|
||||||
Reputation.objects.create(identifier=self.test_username, ip=self.test_ip, score=43)
|
Reputation.objects.create(identifier=self.username, ip=self.ip, score=4)
|
||||||
# Trigger negative reputation
|
# Trigger negative reputation
|
||||||
authenticate(
|
authenticate(self.request, self.backends, username=self.username, password=self.username)
|
||||||
self.request, self.backends, username=self.test_username, password=self.test_username
|
self.assertEqual(Reputation.objects.get(identifier=self.username).score, 3)
|
||||||
|
|
||||||
|
def test_reputation_lower_limit(self):
|
||||||
|
"""test reputation lower limit"""
|
||||||
|
Reputation.objects.create(identifier=self.username, ip=self.ip)
|
||||||
|
update_score(self.request, identifier=self.username, amount=-1000)
|
||||||
|
self.assertEqual(
|
||||||
|
Reputation.objects.get(identifier=self.username).score, DEFAULT_REPUTATION_LOWER_LIMIT
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reputation_upper_limit(self):
|
||||||
|
"""test reputation upper limit"""
|
||||||
|
Reputation.objects.create(identifier=self.username, ip=self.ip)
|
||||||
|
update_score(self.request, identifier=self.username, amount=1000)
|
||||||
|
self.assertEqual(
|
||||||
|
Reputation.objects.get(identifier=self.username).score, DEFAULT_REPUTATION_UPPER_LIMIT
|
||||||
)
|
)
|
||||||
self.assertEqual(Reputation.objects.get(identifier=self.test_username).score, 42)
|
|
||||||
|
|
||||||
def test_policy(self):
|
def test_policy(self):
|
||||||
"""Test Policy"""
|
"""Test Policy"""
|
||||||
|
89
authentik/policies/templates/policies/buffer.html
Normal file
89
authentik/policies/templates/policies/buffer.html
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
{% extends 'login/base_full.html' %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
let redirecting = false;
|
||||||
|
const checkAuth = async () => {
|
||||||
|
if (redirecting) return true;
|
||||||
|
const url = "{{ check_auth_url }}";
|
||||||
|
console.debug("authentik/policies/buffer: Checking authentication...");
|
||||||
|
try {
|
||||||
|
const result = await fetch(url, {
|
||||||
|
method: "HEAD",
|
||||||
|
});
|
||||||
|
if (result.status >= 400) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
console.debug("authentik/policies/buffer: Continuing");
|
||||||
|
redirecting = true;
|
||||||
|
if ("{{ auth_req_method }}" === "post") {
|
||||||
|
document.querySelector("form").submit();
|
||||||
|
} else {
|
||||||
|
window.location.assign("{{ continue_url|escapejs }}");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let timeout = 100;
|
||||||
|
let offset = 20;
|
||||||
|
let attempt = 0;
|
||||||
|
const main = async () => {
|
||||||
|
attempt += 1;
|
||||||
|
await checkAuth();
|
||||||
|
console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`);
|
||||||
|
setTimeout(main, timeout);
|
||||||
|
timeout += (offset * attempt);
|
||||||
|
if (timeout >= 2000) {
|
||||||
|
timeout = 2000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("visibilitychange", async () => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
console.debug("authentik/policies/buffer: Checking authentication on tab activate...");
|
||||||
|
await checkAuth();
|
||||||
|
});
|
||||||
|
main();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% trans 'Waiting for authentication...' %} - {{ brand.branding_title }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block card_title %}
|
||||||
|
{% trans 'Waiting for authentication...' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block card %}
|
||||||
|
<form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}">
|
||||||
|
{% if auth_req_method == "post" %}
|
||||||
|
{% for key, value in auth_req_body.items %}
|
||||||
|
<input type="hidden" name="{{ key }}" value="{{ value }}" />
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="pf-c-empty-state">
|
||||||
|
<div class="pf-c-empty-state__content">
|
||||||
|
<div class="pf-c-empty-state__icon">
|
||||||
|
<span class="pf-c-spinner pf-m-xl" role="progressbar">
|
||||||
|
<span class="pf-c-spinner__clipper"></span>
|
||||||
|
<span class="pf-c-spinner__lead-ball"></span>
|
||||||
|
<span class="pf-c-spinner__tail-ball"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="pf-c-title pf-m-lg">
|
||||||
|
{% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pf-c-form__group pf-m-action">
|
||||||
|
<a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block">
|
||||||
|
{% trans "Authenticate in this tab" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
121
authentik/policies/tests/test_views.py
Normal file
121
authentik/policies/tests/test_views.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from authentik.core.models import Application, Provider
|
||||||
|
from authentik.core.tests.utils import create_test_flow, create_test_user
|
||||||
|
from authentik.flows.models import FlowDesignation
|
||||||
|
from authentik.flows.planner import FlowPlan
|
||||||
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
|
from authentik.lib.tests.utils import dummy_get_response
|
||||||
|
from authentik.policies.views import (
|
||||||
|
QS_BUFFER_ID,
|
||||||
|
SESSION_KEY_BUFFER,
|
||||||
|
BufferedPolicyAccessView,
|
||||||
|
BufferView,
|
||||||
|
PolicyAccessView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPolicyViews(TestCase):
|
||||||
|
"""Test PolicyAccessView"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.user = create_test_user()
|
||||||
|
|
||||||
|
def test_pav(self):
|
||||||
|
"""Test simple policy access view"""
|
||||||
|
provider = Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
)
|
||||||
|
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||||
|
|
||||||
|
class TestView(PolicyAccessView):
|
||||||
|
def resolve_provider_application(self):
|
||||||
|
self.provider = provider
|
||||||
|
self.application = app
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
return HttpResponse("foo")
|
||||||
|
|
||||||
|
req = self.factory.get("/")
|
||||||
|
req.user = self.user
|
||||||
|
res = TestView.as_view()(req)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.content, b"foo")
|
||||||
|
|
||||||
|
def test_pav_buffer(self):
|
||||||
|
"""Test simple policy access view"""
|
||||||
|
provider = Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
)
|
||||||
|
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||||
|
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||||
|
|
||||||
|
class TestView(BufferedPolicyAccessView):
|
||||||
|
def resolve_provider_application(self):
|
||||||
|
self.provider = provider
|
||||||
|
self.application = app
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
return HttpResponse("foo")
|
||||||
|
|
||||||
|
req = self.factory.get("/")
|
||||||
|
req.user = AnonymousUser()
|
||||||
|
middleware = SessionMiddleware(dummy_get_response)
|
||||||
|
middleware.process_request(req)
|
||||||
|
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
|
||||||
|
req.session.save()
|
||||||
|
res = TestView.as_view()(req)
|
||||||
|
self.assertEqual(res.status_code, 302)
|
||||||
|
self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer")))
|
||||||
|
|
||||||
|
def test_pav_buffer_skip(self):
|
||||||
|
"""Test simple policy access view (skip buffer)"""
|
||||||
|
provider = Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
)
|
||||||
|
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||||
|
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||||
|
|
||||||
|
class TestView(BufferedPolicyAccessView):
|
||||||
|
def resolve_provider_application(self):
|
||||||
|
self.provider = provider
|
||||||
|
self.application = app
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
return HttpResponse("foo")
|
||||||
|
|
||||||
|
req = self.factory.get("/?skip_buffer=true")
|
||||||
|
req.user = AnonymousUser()
|
||||||
|
middleware = SessionMiddleware(dummy_get_response)
|
||||||
|
middleware.process_request(req)
|
||||||
|
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
|
||||||
|
req.session.save()
|
||||||
|
res = TestView.as_view()(req)
|
||||||
|
self.assertEqual(res.status_code, 302)
|
||||||
|
self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication")))
|
||||||
|
|
||||||
|
def test_buffer(self):
|
||||||
|
"""Test buffer view"""
|
||||||
|
uid = generate_id()
|
||||||
|
req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}")
|
||||||
|
req.user = AnonymousUser()
|
||||||
|
middleware = SessionMiddleware(dummy_get_response)
|
||||||
|
middleware.process_request(req)
|
||||||
|
ts = generate_id()
|
||||||
|
req.session[SESSION_KEY_BUFFER % uid] = {
|
||||||
|
"method": "get",
|
||||||
|
"body": {},
|
||||||
|
"url": f"/{ts}",
|
||||||
|
}
|
||||||
|
req.session.save()
|
||||||
|
|
||||||
|
res = BufferView.as_view()(req)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertIn(ts, res.render().content.decode())
|
@ -1,7 +1,14 @@
|
|||||||
"""API URLs"""
|
"""API URLs"""
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
from authentik.policies.api.bindings import PolicyBindingViewSet
|
from authentik.policies.api.bindings import PolicyBindingViewSet
|
||||||
from authentik.policies.api.policies import PolicyViewSet
|
from authentik.policies.api.policies import PolicyViewSet
|
||||||
|
from authentik.policies.views import BufferView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("buffer", BufferView.as_view(), name="buffer"),
|
||||||
|
]
|
||||||
|
|
||||||
api_urlpatterns = [
|
api_urlpatterns = [
|
||||||
("policies/all", PolicyViewSet),
|
("policies/all", PolicyViewSet),
|
||||||
|
@ -1,23 +1,37 @@
|
|||||||
"""authentik access helper classes"""
|
"""authentik access helper classes"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import AccessMixin
|
from django.contrib.auth.mixins import AccessMixin
|
||||||
from django.contrib.auth.views import redirect_to_login
|
from django.contrib.auth.views import redirect_to_login
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse, QueryDict
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.http import urlencode
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.generic.base import View
|
from django.views.generic.base import TemplateView, View
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.models import Application, Provider, User
|
from authentik.core.models import Application, Provider, User
|
||||||
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
|
from authentik.flows.models import Flow, FlowDesignation
|
||||||
|
from authentik.flows.planner import FlowPlan
|
||||||
|
from authentik.flows.views.executor import (
|
||||||
|
SESSION_KEY_APPLICATION_PRE,
|
||||||
|
SESSION_KEY_AUTH_STARTED,
|
||||||
|
SESSION_KEY_PLAN,
|
||||||
|
SESSION_KEY_POST,
|
||||||
|
)
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.policies.denied import AccessDeniedResponse
|
from authentik.policies.denied import AccessDeniedResponse
|
||||||
from authentik.policies.engine import PolicyEngine
|
from authentik.policies.engine import PolicyEngine
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
QS_BUFFER_ID = "af_bf_id"
|
||||||
|
QS_SKIP_BUFFER = "skip_buffer"
|
||||||
|
SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s"
|
||||||
|
|
||||||
|
|
||||||
class RequestValidationError(SentryIgnoredException):
|
class RequestValidationError(SentryIgnoredException):
|
||||||
@ -125,3 +139,65 @@ class PolicyAccessView(AccessMixin, View):
|
|||||||
for message in result.messages:
|
for message in result.messages:
|
||||||
messages.error(self.request, _(message))
|
messages.error(self.request, _(message))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def url_with_qs(url: str, **kwargs):
|
||||||
|
"""Update/set querystring of `url` with the parameters in `kwargs`. Original query string
|
||||||
|
parameters are retained"""
|
||||||
|
if "?" not in url:
|
||||||
|
return url + f"?{urlencode(kwargs)}"
|
||||||
|
url, _, qs = url.partition("?")
|
||||||
|
qs = QueryDict(qs, mutable=True)
|
||||||
|
qs.update(kwargs)
|
||||||
|
return url + f"?{urlencode(qs.items())}"
|
||||||
|
|
||||||
|
|
||||||
|
class BufferView(TemplateView):
|
||||||
|
"""Buffer view"""
|
||||||
|
|
||||||
|
template_name = "policies/buffer.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
buf_id = self.request.GET.get(QS_BUFFER_ID)
|
||||||
|
buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id)
|
||||||
|
kwargs["auth_req_method"] = buffer["method"]
|
||||||
|
kwargs["auth_req_body"] = buffer["body"]
|
||||||
|
kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True})
|
||||||
|
kwargs["check_auth_url"] = reverse("authentik_api:user-me")
|
||||||
|
kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id})
|
||||||
|
return super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class BufferedPolicyAccessView(PolicyAccessView):
|
||||||
|
"""PolicyAccessView which buffers access requests in case the user is not logged in"""
|
||||||
|
|
||||||
|
def handle_no_permission(self):
|
||||||
|
plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN)
|
||||||
|
authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED)
|
||||||
|
if plan:
|
||||||
|
flow = Flow.objects.filter(pk=plan.flow_pk).first()
|
||||||
|
if not flow or flow.designation != FlowDesignation.AUTHENTICATION:
|
||||||
|
LOGGER.debug("Not buffering request, no flow or flow not for authentication")
|
||||||
|
return super().handle_no_permission()
|
||||||
|
if not plan and authenticating is None:
|
||||||
|
LOGGER.debug("Not buffering request, no flow plan active")
|
||||||
|
return super().handle_no_permission()
|
||||||
|
if self.request.GET.get(QS_SKIP_BUFFER):
|
||||||
|
LOGGER.debug("Not buffering request, explicit skip")
|
||||||
|
return super().handle_no_permission()
|
||||||
|
buffer_id = str(uuid4())
|
||||||
|
LOGGER.debug("Buffering access request", bf_id=buffer_id)
|
||||||
|
self.request.session[SESSION_KEY_BUFFER % buffer_id] = {
|
||||||
|
"body": self.request.POST,
|
||||||
|
"url": self.request.build_absolute_uri(self.request.get_full_path()),
|
||||||
|
"method": self.request.method.lower(),
|
||||||
|
}
|
||||||
|
return redirect(
|
||||||
|
url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id})
|
||||||
|
)
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
response = super().dispatch(request, *args, **kwargs)
|
||||||
|
if QS_BUFFER_ID in self.request.GET:
|
||||||
|
self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None)
|
||||||
|
return response
|
||||||
|
@ -126,7 +126,7 @@ class IDToken:
|
|||||||
id_token.iat = int(now.timestamp())
|
id_token.iat = int(now.timestamp())
|
||||||
id_token.auth_time = int(token.auth_time.timestamp())
|
id_token.auth_time = int(token.auth_time.timestamp())
|
||||||
if token.session:
|
if token.session:
|
||||||
id_token.sid = hash_session_key(token.session.session_key)
|
id_token.sid = hash_session_key(token.session.session.session_key)
|
||||||
|
|
||||||
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
|
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
|
||||||
auth_event = get_login_event(token.session)
|
auth_event = get_login_event(token.session)
|
||||||
|
116
authentik/providers/oauth2/migrations/0028_migrate_session.py
Normal file
116
authentik/providers/oauth2/migrations/0028_migrate_session.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# Generated by Django 5.0.11 on 2025-01-27 13:00
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_sessions(apps, schema_editor, model):
|
||||||
|
Model = apps.get_model("authentik_providers_oauth2", model)
|
||||||
|
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
for obj in Model.objects.using(db_alias).all():
|
||||||
|
if not obj.old_session:
|
||||||
|
continue
|
||||||
|
obj.session = (
|
||||||
|
AuthenticatedSession.objects.using(db_alias)
|
||||||
|
.filter(session__session_key=obj.old_session.session_key)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if obj.session:
|
||||||
|
obj.save()
|
||||||
|
else:
|
||||||
|
obj.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"),
|
||||||
|
("authentik_core", "0046_session_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="accesstoken",
|
||||||
|
old_name="session",
|
||||||
|
new_name="old_session",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="authorizationcode",
|
||||||
|
old_name="session",
|
||||||
|
new_name="old_session",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="devicetoken",
|
||||||
|
old_name="session",
|
||||||
|
new_name="old_session",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="refreshtoken",
|
||||||
|
old_name="session",
|
||||||
|
new_name="old_session",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="accesstoken",
|
||||||
|
name="session",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="authentik_core.authenticatedsession",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="authorizationcode",
|
||||||
|
name="session",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="authentik_core.authenticatedsession",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="devicetoken",
|
||||||
|
name="session",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||||
|
to="authentik_core.authenticatedsession",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="refreshtoken",
|
||||||
|
name="session",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
default=None,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||||
|
to="authentik_core.authenticatedsession",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(code=partial(migrate_sessions, model="AccessToken")),
|
||||||
|
migrations.RunPython(code=partial(migrate_sessions, model="AuthorizationCode")),
|
||||||
|
migrations.RunPython(code=partial(migrate_sessions, model="DeviceToken")),
|
||||||
|
migrations.RunPython(code=partial(migrate_sessions, model="RefreshToken")),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="accesstoken",
|
||||||
|
name="old_session",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="authorizationcode",
|
||||||
|
name="old_session",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="devicetoken",
|
||||||
|
name="old_session",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="refreshtoken",
|
||||||
|
name="old_session",
|
||||||
|
),
|
||||||
|
]
|
@ -1,18 +1,30 @@
|
|||||||
from django.contrib.auth.signals import user_logged_out
|
from django.contrib.auth.signals import user_logged_out
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save, pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import AuthenticatedSession, User
|
||||||
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
|
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
@receiver(user_logged_out)
|
||||||
def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User, **_):
|
def user_logged_out_oauth_tokens_removal(sender, request: HttpRequest, user: User, **_):
|
||||||
"""Revoke access tokens upon user logout"""
|
"""Revoke tokens upon user logout"""
|
||||||
if not request.session or not request.session.session_key:
|
if not request.session or not request.session.session_key:
|
||||||
return
|
return
|
||||||
AccessToken.objects.filter(user=user, session__session_key=request.session.session_key).delete()
|
AccessToken.objects.filter(
|
||||||
|
user=user,
|
||||||
|
session__session__session_key=request.session.session_key,
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||||
|
def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_):
|
||||||
|
"""Revoke tokens upon user logout"""
|
||||||
|
AccessToken.objects.filter(
|
||||||
|
user=instance.user,
|
||||||
|
session__session__session_key=instance.session.session_key,
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
@ -20,6 +32,6 @@ def user_deactivated(sender, instance: User, **_):
|
|||||||
"""Remove user tokens when deactivated"""
|
"""Remove user tokens when deactivated"""
|
||||||
if instance.is_active:
|
if instance.is_active:
|
||||||
return
|
return
|
||||||
AccessToken.objects.filter(session__user=instance).delete()
|
AccessToken.objects.filter(user=instance).delete()
|
||||||
RefreshToken.objects.filter(session__user=instance).delete()
|
RefreshToken.objects.filter(user=instance).delete()
|
||||||
DeviceToken.objects.filter(session__user=instance).delete()
|
DeviceToken.objects.filter(user=instance).delete()
|
||||||
|
@ -7,12 +7,13 @@ from dataclasses import asdict
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from authentik.core.models import Application
|
from authentik.core.models import Application, AuthenticatedSession, Session
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.providers.oauth2.models import (
|
from authentik.providers.oauth2.models import (
|
||||||
AccessToken,
|
AccessToken,
|
||||||
ClientTypes,
|
ClientTypes,
|
||||||
|
DeviceToken,
|
||||||
IDToken,
|
IDToken,
|
||||||
OAuth2Provider,
|
OAuth2Provider,
|
||||||
RedirectURI,
|
RedirectURI,
|
||||||
@ -20,6 +21,7 @@ from authentik.providers.oauth2.models import (
|
|||||||
RefreshToken,
|
RefreshToken,
|
||||||
)
|
)
|
||||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||||
|
from authentik.root.middleware import ClientIPMiddleware
|
||||||
|
|
||||||
|
|
||||||
class TesOAuth2Revoke(OAuthTestCase):
|
class TesOAuth2Revoke(OAuthTestCase):
|
||||||
@ -135,3 +137,86 @@ class TesOAuth2Revoke(OAuthTestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(res.status_code, 200)
|
self.assertEqual(res.status_code, 200)
|
||||||
|
|
||||||
|
def test_revoke_logout(self):
|
||||||
|
"""Test revoke on logout"""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
AccessToken.objects.create(
|
||||||
|
provider=self.provider,
|
||||||
|
user=self.user,
|
||||||
|
session=self.client.session["authenticatedsession"],
|
||||||
|
token=generate_id(),
|
||||||
|
auth_time=timezone.now(),
|
||||||
|
_scope="openid user profile",
|
||||||
|
_id_token=json.dumps(
|
||||||
|
asdict(
|
||||||
|
IDToken("foo", "bar"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.client.logout()
|
||||||
|
self.assertEqual(AccessToken.objects.all().count(), 0)
|
||||||
|
|
||||||
|
def test_revoke_session_delete(self):
|
||||||
|
"""Test revoke on logout"""
|
||||||
|
session = AuthenticatedSession.objects.create(
|
||||||
|
session=Session.objects.create(
|
||||||
|
session_key=generate_id(),
|
||||||
|
last_ip=ClientIPMiddleware.default_ip,
|
||||||
|
),
|
||||||
|
user=self.user,
|
||||||
|
)
|
||||||
|
AccessToken.objects.create(
|
||||||
|
provider=self.provider,
|
||||||
|
user=self.user,
|
||||||
|
session=session,
|
||||||
|
token=generate_id(),
|
||||||
|
auth_time=timezone.now(),
|
||||||
|
_scope="openid user profile",
|
||||||
|
_id_token=json.dumps(
|
||||||
|
asdict(
|
||||||
|
IDToken("foo", "bar"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.delete()
|
||||||
|
self.assertEqual(AccessToken.objects.all().count(), 0)
|
||||||
|
|
||||||
|
def test_revoke_user_deactivated(self):
|
||||||
|
"""Test revoke on logout"""
|
||||||
|
AccessToken.objects.create(
|
||||||
|
provider=self.provider,
|
||||||
|
user=self.user,
|
||||||
|
token=generate_id(),
|
||||||
|
auth_time=timezone.now(),
|
||||||
|
_scope="openid user profile",
|
||||||
|
_id_token=json.dumps(
|
||||||
|
asdict(
|
||||||
|
IDToken("foo", "bar"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
RefreshToken.objects.create(
|
||||||
|
provider=self.provider,
|
||||||
|
user=self.user,
|
||||||
|
token=generate_id(),
|
||||||
|
auth_time=timezone.now(),
|
||||||
|
_scope="openid user profile",
|
||||||
|
_id_token=json.dumps(
|
||||||
|
asdict(
|
||||||
|
IDToken("foo", "bar"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DeviceToken.objects.create(
|
||||||
|
provider=self.provider,
|
||||||
|
user=self.user,
|
||||||
|
_scope="openid user profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.user.is_active = False
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
self.assertEqual(AccessToken.objects.all().count(), 0)
|
||||||
|
self.assertEqual(RefreshToken.objects.all().count(), 0)
|
||||||
|
self.assertEqual(DeviceToken.objects.all().count(), 0)
|
||||||
|
@ -15,7 +15,7 @@ from django.utils import timezone
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.models import Application, AuthenticatedSession
|
from authentik.core.models import Application
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.events.signals import get_login_event
|
from authentik.events.signals import get_login_event
|
||||||
from authentik.flows.challenge import (
|
from authentik.flows.challenge import (
|
||||||
@ -30,7 +30,7 @@ from authentik.flows.stage import StageView
|
|||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.lib.views import bad_request_message
|
from authentik.lib.views import bad_request_message
|
||||||
from authentik.policies.types import PolicyRequest
|
from authentik.policies.types import PolicyRequest
|
||||||
from authentik.policies.views import PolicyAccessView, RequestValidationError
|
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError
|
||||||
from authentik.providers.oauth2.constants import (
|
from authentik.providers.oauth2.constants import (
|
||||||
PKCE_METHOD_PLAIN,
|
PKCE_METHOD_PLAIN,
|
||||||
PKCE_METHOD_S256,
|
PKCE_METHOD_S256,
|
||||||
@ -316,9 +316,7 @@ class OAuthAuthorizationParams:
|
|||||||
expires=now + timedelta_from_string(self.provider.access_code_validity),
|
expires=now + timedelta_from_string(self.provider.access_code_validity),
|
||||||
scope=self.scope,
|
scope=self.scope,
|
||||||
nonce=self.nonce,
|
nonce=self.nonce,
|
||||||
session=AuthenticatedSession.objects.filter(
|
session=request.session["authenticatedsession"],
|
||||||
session_key=request.session.session_key
|
|
||||||
).first(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.code_challenge and self.code_challenge_method:
|
if self.code_challenge and self.code_challenge_method:
|
||||||
@ -328,7 +326,7 @@ class OAuthAuthorizationParams:
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationFlowInitView(PolicyAccessView):
|
class AuthorizationFlowInitView(BufferedPolicyAccessView):
|
||||||
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||||
|
|
||||||
params: OAuthAuthorizationParams
|
params: OAuthAuthorizationParams
|
||||||
@ -615,9 +613,7 @@ class OAuthFulfillmentStage(StageView):
|
|||||||
expires=access_token_expiry,
|
expires=access_token_expiry,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
auth_time=auth_event.created if auth_event else now,
|
auth_time=auth_event.created if auth_event else now,
|
||||||
session=AuthenticatedSession.objects.filter(
|
session=self.request.session["authenticatedsession"],
|
||||||
session_key=self.request.session.session_key
|
|
||||||
).first(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
id_token = IDToken.new(self.provider, token, self.request)
|
id_token = IDToken.new(self.provider, token, self.request)
|
||||||
|
@ -20,4 +20,4 @@ def logout_proxy_revoke_direct(sender: type[User], request: HttpRequest, **_):
|
|||||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||||
def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
|
def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
|
||||||
"""Catch logout by expiring sessions being deleted"""
|
"""Catch logout by expiring sessions being deleted"""
|
||||||
proxy_on_logout.delay(instance.session_key)
|
proxy_on_logout.delay(instance.session.session_key)
|
||||||
|
60
authentik/providers/rac/migrations/0007_migrate_session.py
Normal file
60
authentik/providers/rac/migrations/0007_migrate_session.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Generated by Django 5.0.11 on 2025-01-27 12:59
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_sessions(apps, schema_editor):
|
||||||
|
ConnectionToken = apps.get_model("authentik_providers_rac", "ConnectionToken")
|
||||||
|
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
for token in ConnectionToken.objects.using(db_alias).all():
|
||||||
|
token.session = (
|
||||||
|
AuthenticatedSession.objects.using(db_alias)
|
||||||
|
.filter(session_key=token.old_session.session_key)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if token.session:
|
||||||
|
token.save()
|
||||||
|
else:
|
||||||
|
token.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_providers_rac", "0006_connectiontoken_authentik_p_expires_91f148_idx_and_more"),
|
||||||
|
("authentik_core", "0046_session_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="connectiontoken",
|
||||||
|
old_name="session",
|
||||||
|
new_name="old_session",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="connectiontoken",
|
||||||
|
name="session",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="authentik_core.authenticatedsession",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(code=migrate_sessions),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="connectiontoken",
|
||||||
|
name="session",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="authentik_core.authenticatedsession",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="connectiontoken",
|
||||||
|
name="old_session",
|
||||||
|
),
|
||||||
|
]
|
@ -8,7 +8,7 @@ from django.db.models.signals import post_delete, post_save, pre_delete
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from authentik.core.models import User
|
from authentik.core.models import AuthenticatedSession, User
|
||||||
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
|
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
|
||||||
from authentik.providers.rac.consumer_client import (
|
from authentik.providers.rac.consumer_client import (
|
||||||
RAC_CLIENT_GROUP_SESSION,
|
RAC_CLIENT_GROUP_SESSION,
|
||||||
@ -32,6 +32,18 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||||
|
def user_session_deleted(sender, instance: AuthenticatedSession, **_):
|
||||||
|
layer = get_channel_layer()
|
||||||
|
async_to_sync(layer.group_send)(
|
||||||
|
RAC_CLIENT_GROUP_SESSION
|
||||||
|
% {
|
||||||
|
"session": instance.session.session_key,
|
||||||
|
},
|
||||||
|
{"type": "event.disconnect", "reason": "session_logout"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=ConnectionToken)
|
@receiver(pre_delete, sender=ConnectionToken)
|
||||||
def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_):
|
def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_):
|
||||||
"""Disconnect session when connection token is deleted"""
|
"""Disconnect session when connection token is deleted"""
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from django.test import TransactionTestCase
|
from django.test import TransactionTestCase
|
||||||
|
|
||||||
from authentik.core.models import Application, AuthenticatedSession
|
from authentik.core.models import Application, AuthenticatedSession, Session
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.providers.rac.models import (
|
from authentik.providers.rac.models import (
|
||||||
@ -36,13 +36,15 @@ class TestModels(TransactionTestCase):
|
|||||||
|
|
||||||
def test_settings_merge(self):
|
def test_settings_merge(self):
|
||||||
"""Test settings merge"""
|
"""Test settings merge"""
|
||||||
|
session = Session.objects.create(
|
||||||
|
session_key=generate_id(),
|
||||||
|
last_ip="255.255.255.255",
|
||||||
|
)
|
||||||
|
auth_session = AuthenticatedSession.objects.create(session=session, user=self.user)
|
||||||
token = ConnectionToken.objects.create(
|
token = ConnectionToken.objects.create(
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
endpoint=self.endpoint,
|
endpoint=self.endpoint,
|
||||||
session=AuthenticatedSession.objects.create(
|
session=auth_session,
|
||||||
user=self.user,
|
|
||||||
session_key=generate_id(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
path = f"/tmp/connection/{token.token}" # nosec
|
path = f"/tmp/connection/{token.token}" # nosec
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
"""rac urls"""
|
"""rac urls"""
|
||||||
|
|
||||||
from channels.auth import AuthMiddleware
|
|
||||||
from channels.sessions import CookieMiddleware
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from authentik.outposts.channels import TokenOutpostMiddleware
|
from authentik.outposts.channels import TokenOutpostMiddleware
|
||||||
@ -12,7 +10,7 @@ from authentik.providers.rac.api.providers import RACProviderViewSet
|
|||||||
from authentik.providers.rac.consumer_client import RACClientConsumer
|
from authentik.providers.rac.consumer_client import RACClientConsumer
|
||||||
from authentik.providers.rac.consumer_outpost import RACOutpostConsumer
|
from authentik.providers.rac.consumer_outpost import RACOutpostConsumer
|
||||||
from authentik.providers.rac.views import RACInterface, RACStartView
|
from authentik.providers.rac.views import RACInterface, RACStartView
|
||||||
from authentik.root.asgi_middleware import SessionMiddleware
|
from authentik.root.asgi_middleware import AuthMiddlewareStack
|
||||||
from authentik.root.middleware import ChannelsLoggingMiddleware
|
from authentik.root.middleware import ChannelsLoggingMiddleware
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -31,9 +29,7 @@ urlpatterns = [
|
|||||||
websocket_urlpatterns = [
|
websocket_urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"ws/rac/<str:token>/",
|
"ws/rac/<str:token>/",
|
||||||
ChannelsLoggingMiddleware(
|
ChannelsLoggingMiddleware(AuthMiddlewareStack(RACClientConsumer.as_asgi())),
|
||||||
CookieMiddleware(SessionMiddleware(AuthMiddleware(RACClientConsumer.as_asgi())))
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"ws/outpost_rac/<str:channel>/",
|
"ws/outpost_rac/<str:channel>/",
|
||||||
|
@ -8,7 +8,7 @@ from django.urls import reverse
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from authentik.core.models import Application, AuthenticatedSession
|
from authentik.core.models import Application
|
||||||
from authentik.core.views.interface import InterfaceView
|
from authentik.core.views.interface import InterfaceView
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.flows.challenge import RedirectChallenge
|
from authentik.flows.challenge import RedirectChallenge
|
||||||
@ -18,11 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
|||||||
from authentik.flows.stage import RedirectStage
|
from authentik.flows.stage import RedirectStage
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.policies.engine import PolicyEngine
|
from authentik.policies.engine import PolicyEngine
|
||||||
from authentik.policies.views import PolicyAccessView
|
from authentik.policies.views import BufferedPolicyAccessView
|
||||||
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
|
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
|
||||||
|
|
||||||
|
|
||||||
class RACStartView(PolicyAccessView):
|
class RACStartView(BufferedPolicyAccessView):
|
||||||
"""Start a RAC connection by checking access and creating a connection token"""
|
"""Start a RAC connection by checking access and creating a connection token"""
|
||||||
|
|
||||||
endpoint: Endpoint
|
endpoint: Endpoint
|
||||||
@ -113,9 +113,7 @@ class RACFinalStage(RedirectStage):
|
|||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
endpoint=self.endpoint,
|
endpoint=self.endpoint,
|
||||||
settings=self.executor.plan.context.get("connection_settings", {}),
|
settings=self.executor.plan.context.get("connection_settings", {}),
|
||||||
session=AuthenticatedSession.objects.filter(
|
session=self.request.session["authenticatedsession"],
|
||||||
session_key=self.request.session.session_key
|
|
||||||
).first(),
|
|
||||||
expires=now() + timedelta_from_string(self.provider.connection_expiry),
|
expires=now() + timedelta_from_string(self.provider.connection_expiry),
|
||||||
expiring=True,
|
expiring=True,
|
||||||
)
|
)
|
||||||
|
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.0.13 on 2025-03-31 13:50
|
||||||
|
|
||||||
|
import authentik.lib.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_providers_saml", "0017_samlprovider_authn_context_class_ref_mapping"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="samlprovider",
|
||||||
|
name="acs_url",
|
||||||
|
field=models.TextField(
|
||||||
|
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
|
||||||
|
verbose_name="ACS URL",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -10,6 +10,7 @@ from structlog.stdlib import get_logger
|
|||||||
from authentik.core.api.object_types import CreatableType
|
from authentik.core.api.object_types import CreatableType
|
||||||
from authentik.core.models import PropertyMapping, Provider
|
from authentik.core.models import PropertyMapping, Provider
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
from authentik.lib.models import DomainlessURLValidator
|
||||||
from authentik.lib.utils.time import timedelta_string_validator
|
from authentik.lib.utils.time import timedelta_string_validator
|
||||||
from authentik.sources.saml.processors.constants import (
|
from authentik.sources.saml.processors.constants import (
|
||||||
DSA_SHA1,
|
DSA_SHA1,
|
||||||
@ -40,7 +41,9 @@ class SAMLBindings(models.TextChoices):
|
|||||||
class SAMLProvider(Provider):
|
class SAMLProvider(Provider):
|
||||||
"""SAML 2.0 Endpoint for applications which support SAML."""
|
"""SAML 2.0 Endpoint for applications which support SAML."""
|
||||||
|
|
||||||
acs_url = models.URLField(verbose_name=_("ACS URL"))
|
acs_url = models.TextField(
|
||||||
|
validators=[DomainlessURLValidator(schemes=("http", "https"))], verbose_name=_("ACS URL")
|
||||||
|
)
|
||||||
audience = models.TextField(
|
audience = models.TextField(
|
||||||
default="",
|
default="",
|
||||||
blank=True,
|
blank=True,
|
||||||
|
@ -15,7 +15,7 @@ from authentik.flows.models import in_memory_stage
|
|||||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
|
||||||
from authentik.flows.views.executor import SESSION_KEY_POST
|
from authentik.flows.views.executor import SESSION_KEY_POST
|
||||||
from authentik.lib.views import bad_request_message
|
from authentik.lib.views import bad_request_message
|
||||||
from authentik.policies.views import PolicyAccessView
|
from authentik.policies.views import BufferedPolicyAccessView
|
||||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||||
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
|
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
|
||||||
@ -35,7 +35,7 @@ from authentik.stages.consent.stage import (
|
|||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
class SAMLSSOView(PolicyAccessView):
|
class SAMLSSOView(BufferedPolicyAccessView):
|
||||||
"""SAML SSO Base View, which plans a flow and injects our final stage.
|
"""SAML SSO Base View, which plans a flow and injects our final stage.
|
||||||
Calls get/post handler."""
|
Calls get/post handler."""
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ class SAMLSSOView(PolicyAccessView):
|
|||||||
|
|
||||||
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||||
"""GET and POST use the same handler, but we can't
|
"""GET and POST use the same handler, but we can't
|
||||||
override .dispatch easily because PolicyAccessView's dispatch"""
|
override .dispatch easily because BufferedPolicyAccessView's dispatch"""
|
||||||
return self.get(request, application_slug)
|
return self.get(request, application_slug)
|
||||||
|
|
||||||
|
|
||||||
|
@ -50,7 +50,7 @@ class TestRecovery(TestCase):
|
|||||||
)
|
)
|
||||||
token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user)
|
token = Token.objects.get(intent=TokenIntents.INTENT_RECOVERY, user=self.user)
|
||||||
self.client.get(reverse("authentik_recovery:use-token", kwargs={"key": token.key}))
|
self.client.get(reverse("authentik_recovery:use-token", kwargs={"key": token.key}))
|
||||||
self.assertEqual(int(self.client.session["_auth_user_id"]), token.user.pk)
|
self.assertEqual(self.client.session["authenticatedsession"].user.pk, token.user.pk)
|
||||||
|
|
||||||
def test_recovery_view_invalid(self):
|
def test_recovery_view_invalid(self):
|
||||||
"""Test recovery view with invalid token"""
|
"""Test recovery view with invalid token"""
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
"""ASGI middleware"""
|
"""ASGI middleware"""
|
||||||
|
|
||||||
|
from channels.auth import UserLazyObject
|
||||||
from channels.db import database_sync_to_async
|
from channels.db import database_sync_to_async
|
||||||
|
from channels.middleware import BaseMiddleware
|
||||||
|
from channels.sessions import CookieMiddleware
|
||||||
from channels.sessions import InstanceSessionWrapper as UpstreamInstanceSessionWrapper
|
from channels.sessions import InstanceSessionWrapper as UpstreamInstanceSessionWrapper
|
||||||
from channels.sessions import SessionMiddleware as UpstreamSessionMiddleware
|
from channels.sessions import SessionMiddleware as UpstreamSessionMiddleware
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
from authentik.root.middleware import SessionMiddleware as HTTPSessionMiddleware
|
from authentik.root.middleware import SessionMiddleware as HTTPSessionMiddleware
|
||||||
|
|
||||||
@ -33,3 +37,48 @@ class SessionMiddleware(UpstreamSessionMiddleware):
|
|||||||
await wrapper.resolve_session()
|
await wrapper.resolve_session()
|
||||||
|
|
||||||
return await self.inner(wrapper.scope, receive, wrapper.send)
|
return await self.inner(wrapper.scope, receive, wrapper.send)
|
||||||
|
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_user(scope):
|
||||||
|
"""
|
||||||
|
Return the user model instance associated with the given scope.
|
||||||
|
If no user is retrieved, return an instance of `AnonymousUser`.
|
||||||
|
"""
|
||||||
|
if "session" not in scope:
|
||||||
|
raise ValueError(
|
||||||
|
"Cannot find session in scope. You should wrap your consumer in SessionMiddleware."
|
||||||
|
)
|
||||||
|
user = None
|
||||||
|
if (authenticated_session := scope["session"].get("authenticated_session", None)) is not None:
|
||||||
|
user = authenticated_session.user
|
||||||
|
return user or AnonymousUser()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMiddleware(BaseMiddleware):
|
||||||
|
def populate_scope(self, scope):
|
||||||
|
# Make sure we have a session
|
||||||
|
if "session" not in scope:
|
||||||
|
raise ValueError(
|
||||||
|
"AuthMiddleware cannot find session in scope. SessionMiddleware must be above it."
|
||||||
|
)
|
||||||
|
# Add it to the scope if it's not there already
|
||||||
|
if "user" not in scope:
|
||||||
|
scope["user"] = UserLazyObject()
|
||||||
|
|
||||||
|
async def resolve_scope(self, scope):
|
||||||
|
scope["user"]._wrapped = await get_user(scope)
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
scope = dict(scope)
|
||||||
|
# Scope injection/mutation per this middleware's needs.
|
||||||
|
self.populate_scope(scope)
|
||||||
|
# Grab the finalized/resolved scope
|
||||||
|
await self.resolve_scope(scope)
|
||||||
|
|
||||||
|
return await super().__call__(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
# Handy shortcut for applying all three layers at once
|
||||||
|
def AuthMiddlewareStack(inner):
|
||||||
|
return CookieMiddleware(SessionMiddleware(AuthMiddleware(inner)))
|
||||||
|
@ -49,7 +49,7 @@ class SessionMiddleware(UpstreamSessionMiddleware):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decode_session_key(key: str) -> str:
|
def decode_session_key(key: str | None) -> str | None:
|
||||||
"""Decode raw session cookie, and parse JWT"""
|
"""Decode raw session cookie, and parse JWT"""
|
||||||
# We need to support the standard django format of just a session key
|
# We need to support the standard django format of just a session key
|
||||||
# for testing setups, where the session is directly set
|
# for testing setups, where the session is directly set
|
||||||
@ -64,7 +64,11 @@ class SessionMiddleware(UpstreamSessionMiddleware):
|
|||||||
def process_request(self, request: HttpRequest):
|
def process_request(self, request: HttpRequest):
|
||||||
raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
|
raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
|
||||||
session_key = SessionMiddleware.decode_session_key(raw_session)
|
session_key = SessionMiddleware.decode_session_key(raw_session)
|
||||||
request.session = self.SessionStore(session_key)
|
request.session = self.SessionStore(
|
||||||
|
session_key,
|
||||||
|
last_ip=ClientIPMiddleware.get_client_ip(request),
|
||||||
|
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||||
|
)
|
||||||
|
|
||||||
def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse:
|
def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse:
|
||||||
"""
|
"""
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
"""
|
|
||||||
Module for abstract serializer/unserializer base classes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pickle # nosec
|
|
||||||
|
|
||||||
|
|
||||||
class PickleSerializer:
|
|
||||||
"""
|
|
||||||
Simple wrapper around pickle to be used in signing.dumps()/loads() and
|
|
||||||
cache backends.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, protocol=None):
|
|
||||||
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
|
|
||||||
|
|
||||||
def dumps(self, obj):
|
|
||||||
"""Pickle data to be stored in redis"""
|
|
||||||
return pickle.dumps(obj, self.protocol)
|
|
||||||
|
|
||||||
def loads(self, data):
|
|
||||||
"""Unpickle data to be loaded from redis"""
|
|
||||||
return pickle.loads(data) # nosec
|
|
@ -7,7 +7,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
from django.conf import ImproperlyConfigured
|
|
||||||
from sentry_sdk import set_tag
|
from sentry_sdk import set_tag
|
||||||
from xmlsec import enable_debug_trace
|
from xmlsec import enable_debug_trace
|
||||||
|
|
||||||
@ -43,7 +42,6 @@ SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None)
|
|||||||
APPEND_SLASH = False
|
APPEND_SLASH = False
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
|
||||||
BACKEND_INBUILT,
|
BACKEND_INBUILT,
|
||||||
BACKEND_APP_PASSWORD,
|
BACKEND_APP_PASSWORD,
|
||||||
BACKEND_LDAP,
|
BACKEND_LDAP,
|
||||||
@ -229,17 +227,7 @@ CACHES = {
|
|||||||
DJANGO_REDIS_SCAN_ITERSIZE = 1000
|
DJANGO_REDIS_SCAN_ITERSIZE = 1000
|
||||||
DJANGO_REDIS_IGNORE_EXCEPTIONS = True
|
DJANGO_REDIS_IGNORE_EXCEPTIONS = True
|
||||||
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
|
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
|
||||||
match CONFIG.get("session_storage", "cache"):
|
SESSION_ENGINE = "authentik.core.sessions"
|
||||||
case "cache":
|
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
|
||||||
case "db":
|
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.db"
|
|
||||||
case _:
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
"Invalid session_storage setting, allowed values are db and cache"
|
|
||||||
)
|
|
||||||
SESSION_SERIALIZER = "authentik.root.sessions.pickle.PickleSerializer"
|
|
||||||
SESSION_CACHE_ALIAS = "default"
|
|
||||||
# Configured via custom SessionMiddleware
|
# Configured via custom SessionMiddleware
|
||||||
# SESSION_COOKIE_SAMESITE = "None"
|
# SESSION_COOKIE_SAMESITE = "None"
|
||||||
# SESSION_COOKIE_SECURE = True
|
# SESSION_COOKIE_SECURE = True
|
||||||
@ -256,7 +244,7 @@ MIDDLEWARE = [
|
|||||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||||
"authentik.root.middleware.ClientIPMiddleware",
|
"authentik.root.middleware.ClientIPMiddleware",
|
||||||
"authentik.stages.user_login.middleware.BoundSessionMiddleware",
|
"authentik.stages.user_login.middleware.BoundSessionMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"authentik.core.middleware.AuthenticationMiddleware",
|
||||||
"authentik.core.middleware.RequestIDMiddleware",
|
"authentik.core.middleware.RequestIDMiddleware",
|
||||||
"authentik.brands.middleware.BrandMiddleware",
|
"authentik.brands.middleware.BrandMiddleware",
|
||||||
"authentik.events.middleware.AuditMiddleware",
|
"authentik.events.middleware.AuditMiddleware",
|
||||||
|
@ -28,8 +28,8 @@ def pytest_report_header(*_, **__):
|
|||||||
|
|
||||||
|
|
||||||
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
||||||
current_id = int(environ.get("CI_RUN_ID", 0)) - 1
|
current_id = int(environ.get("CI_RUN_ID", "0")) - 1
|
||||||
total_ids = int(environ.get("CI_TOTAL_RUNS", 0))
|
total_ids = int(environ.get("CI_TOTAL_RUNS", "0"))
|
||||||
|
|
||||||
if total_ids:
|
if total_ids:
|
||||||
num_tests = len(items)
|
num_tests = len(items)
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
"""Kerberos Source Serializer"""
|
|
||||||
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.sources import (
|
from authentik.core.api.sources import (
|
||||||
GroupSourceConnectionSerializer,
|
GroupSourceConnectionSerializer,
|
||||||
GroupSourceConnectionViewSet,
|
GroupSourceConnectionViewSet,
|
||||||
UserSourceConnectionSerializer,
|
UserSourceConnectionSerializer,
|
||||||
|
UserSourceConnectionViewSet,
|
||||||
)
|
)
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
|
||||||
from authentik.sources.kerberos.models import (
|
from authentik.sources.kerberos.models import (
|
||||||
GroupKerberosSourceConnection,
|
GroupKerberosSourceConnection,
|
||||||
UserKerberosSourceConnection,
|
UserKerberosSourceConnection,
|
||||||
@ -15,33 +13,20 @@ from authentik.sources.kerberos.models import (
|
|||||||
|
|
||||||
|
|
||||||
class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer):
|
class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||||
"""Kerberos Source Serializer"""
|
class Meta(UserSourceConnectionSerializer.Meta):
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = UserKerberosSourceConnection
|
model = UserKerberosSourceConnection
|
||||||
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
|
|
||||||
|
|
||||||
|
|
||||||
class UserKerberosSourceConnectionViewSet(UsedByMixin, ModelViewSet):
|
class UserKerberosSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Source Viewset"""
|
|
||||||
|
|
||||||
queryset = UserKerberosSourceConnection.objects.all()
|
queryset = UserKerberosSourceConnection.objects.all()
|
||||||
serializer_class = UserKerberosSourceConnectionSerializer
|
serializer_class = UserKerberosSourceConnectionSerializer
|
||||||
filterset_fields = ["source__slug"]
|
|
||||||
search_fields = ["source__slug"]
|
|
||||||
ordering = ["source__slug"]
|
|
||||||
owner_field = "user"
|
|
||||||
|
|
||||||
|
|
||||||
class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||||
"""OAuth Group-Source connection Serializer"""
|
|
||||||
|
|
||||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||||
model = GroupKerberosSourceConnection
|
model = GroupKerberosSourceConnection
|
||||||
|
|
||||||
|
|
||||||
class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet):
|
class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Group-source connection Viewset"""
|
|
||||||
|
|
||||||
queryset = GroupKerberosSourceConnection.objects.all()
|
queryset = GroupKerberosSourceConnection.objects.all()
|
||||||
serializer_class = GroupKerberosSourceConnectionSerializer
|
serializer_class = GroupKerberosSourceConnectionSerializer
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_identifier(apps, schema_editor):
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
UserKerberosSourceConnection = apps.get_model(
|
||||||
|
"authentik_sources_kerberos", "UserKerberosSourceConnection"
|
||||||
|
)
|
||||||
|
|
||||||
|
for connection in UserKerberosSourceConnection.objects.using(db_alias).all():
|
||||||
|
connection.new_identifier = connection.identifier
|
||||||
|
connection.save(using=db_alias)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_sources_kerberos", "0002_kerberossource_kadmin_type"),
|
||||||
|
("authentik_core", "0044_usersourceconnection_new_identifier"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="userkerberossourceconnection",
|
||||||
|
name="identifier",
|
||||||
|
),
|
||||||
|
]
|
@ -372,8 +372,6 @@ class KerberosSourcePropertyMapping(PropertyMapping):
|
|||||||
class UserKerberosSourceConnection(UserSourceConnection):
|
class UserKerberosSourceConnection(UserSourceConnection):
|
||||||
"""Connection to configured Kerberos Sources."""
|
"""Connection to configured Kerberos Sources."""
|
||||||
|
|
||||||
identifier = models.TextField()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> type[Serializer]:
|
||||||
from authentik.sources.kerberos.api.source_connection import (
|
from authentik.sources.kerberos.api.source_connection import (
|
||||||
|
@ -15,11 +15,22 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer
|
from authentik.core.api.property_mappings import PropertyMappingFilterSet, PropertyMappingSerializer
|
||||||
from authentik.core.api.sources import SourceSerializer
|
from authentik.core.api.sources import (
|
||||||
|
GroupSourceConnectionSerializer,
|
||||||
|
GroupSourceConnectionViewSet,
|
||||||
|
SourceSerializer,
|
||||||
|
UserSourceConnectionSerializer,
|
||||||
|
UserSourceConnectionViewSet,
|
||||||
|
)
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.lib.sync.outgoing.api import SyncStatusSerializer
|
from authentik.lib.sync.outgoing.api import SyncStatusSerializer
|
||||||
from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
|
from authentik.sources.ldap.models import (
|
||||||
|
GroupLDAPSourceConnection,
|
||||||
|
LDAPSource,
|
||||||
|
LDAPSourcePropertyMapping,
|
||||||
|
UserLDAPSourceConnection,
|
||||||
|
)
|
||||||
from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES
|
from authentik.sources.ldap.tasks import CACHE_KEY_STATUS, SYNC_CLASSES
|
||||||
|
|
||||||
|
|
||||||
@ -99,6 +110,7 @@ class LDAPSourceSerializer(SourceSerializer):
|
|||||||
"sync_groups",
|
"sync_groups",
|
||||||
"sync_parent_group",
|
"sync_parent_group",
|
||||||
"connectivity",
|
"connectivity",
|
||||||
|
"lookup_groups_from_user",
|
||||||
]
|
]
|
||||||
extra_kwargs = {"bind_password": {"write_only": True}}
|
extra_kwargs = {"bind_password": {"write_only": True}}
|
||||||
|
|
||||||
@ -134,6 +146,7 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"sync_parent_group",
|
"sync_parent_group",
|
||||||
"user_property_mappings",
|
"user_property_mappings",
|
||||||
"group_property_mappings",
|
"group_property_mappings",
|
||||||
|
"lookup_groups_from_user",
|
||||||
]
|
]
|
||||||
search_fields = ["name", "slug"]
|
search_fields = ["name", "slug"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
@ -219,3 +232,23 @@ class LDAPSourcePropertyMappingViewSet(UsedByMixin, ModelViewSet):
|
|||||||
filterset_class = LDAPSourcePropertyMappingFilter
|
filterset_class = LDAPSourcePropertyMappingFilter
|
||||||
search_fields = ["name"]
|
search_fields = ["name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
class UserLDAPSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||||
|
class Meta(UserSourceConnectionSerializer.Meta):
|
||||||
|
model = UserLDAPSourceConnection
|
||||||
|
|
||||||
|
|
||||||
|
class UserLDAPSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||||
|
queryset = UserLDAPSourceConnection.objects.all()
|
||||||
|
serializer_class = UserLDAPSourceConnectionSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class GroupLDAPSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||||
|
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||||
|
model = GroupLDAPSourceConnection
|
||||||
|
|
||||||
|
|
||||||
|
class GroupLDAPSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||||
|
queryset = GroupLDAPSourceConnection.objects.all()
|
||||||
|
serializer_class = GroupLDAPSourceConnectionSerializer
|
||||||
|
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.0.13 on 2025-03-26 17:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"authentik_sources_ldap",
|
||||||
|
"0006_rename_ldappropertymapping_ldapsourcepropertymapping_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ldapsource",
|
||||||
|
name="lookup_groups_from_user",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Lookup group membership based on a user attribute instead of a group attribute. This allows nested group resolution on systems like FreeIPA and Active Directory",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,57 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2025-04-11 11:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_core", "0047_delete_oldauthenticatedsession"),
|
||||||
|
("authentik_sources_ldap", "0007_ldapsource_lookup_groups_from_user"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="GroupLDAPSourceConnection",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"groupsourceconnection_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="authentik_core.groupsourceconnection",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Group LDAP Source Connection",
|
||||||
|
"verbose_name_plural": "Group LDAP Source Connections",
|
||||||
|
},
|
||||||
|
bases=("authentik_core.groupsourceconnection",),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserLDAPSourceConnection",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"usersourceconnection_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="authentik_core.usersourceconnection",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "User LDAP Source Connection",
|
||||||
|
"verbose_name_plural": "User LDAP Source Connections",
|
||||||
|
},
|
||||||
|
bases=("authentik_core.usersourceconnection",),
|
||||||
|
),
|
||||||
|
]
|
@ -15,7 +15,13 @@ from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls
|
|||||||
from ldap3.core.exceptions import LDAPException, LDAPInsufficientAccessRightsResult, LDAPSchemaError
|
from ldap3.core.exceptions import LDAPException, LDAPInsufficientAccessRightsResult, LDAPSchemaError
|
||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
|
|
||||||
from authentik.core.models import Group, PropertyMapping, Source
|
from authentik.core.models import (
|
||||||
|
Group,
|
||||||
|
GroupSourceConnection,
|
||||||
|
PropertyMapping,
|
||||||
|
Source,
|
||||||
|
UserSourceConnection,
|
||||||
|
)
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.models import DomainlessURLValidator
|
from authentik.lib.models import DomainlessURLValidator
|
||||||
@ -123,6 +129,14 @@ class LDAPSource(Source):
|
|||||||
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
|
Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
|
||||||
)
|
)
|
||||||
|
|
||||||
|
lookup_groups_from_user = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"Lookup group membership based on a user attribute instead of a group attribute. "
|
||||||
|
"This allows nested group resolution on systems like FreeIPA and Active Directory"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def component(self) -> str:
|
def component(self) -> str:
|
||||||
return "ak-source-ldap-form"
|
return "ak-source-ldap-form"
|
||||||
@ -304,3 +318,31 @@ class LDAPSourcePropertyMapping(PropertyMapping):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("LDAP Source Property Mapping")
|
verbose_name = _("LDAP Source Property Mapping")
|
||||||
verbose_name_plural = _("LDAP Source Property Mappings")
|
verbose_name_plural = _("LDAP Source Property Mappings")
|
||||||
|
|
||||||
|
|
||||||
|
class UserLDAPSourceConnection(UserSourceConnection):
|
||||||
|
@property
|
||||||
|
def serializer(self) -> type[Serializer]:
|
||||||
|
from authentik.sources.ldap.api import (
|
||||||
|
UserLDAPSourceConnectionSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
return UserLDAPSourceConnectionSerializer
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("User LDAP Source Connection")
|
||||||
|
verbose_name_plural = _("User LDAP Source Connections")
|
||||||
|
|
||||||
|
|
||||||
|
class GroupLDAPSourceConnection(GroupSourceConnection):
|
||||||
|
@property
|
||||||
|
def serializer(self) -> type[Serializer]:
|
||||||
|
from authentik.sources.ldap.api import (
|
||||||
|
GroupLDAPSourceConnectionSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
return GroupLDAPSourceConnectionSerializer
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Group LDAP Source Connection")
|
||||||
|
verbose_name_plural = _("Group LDAP Source Connections")
|
||||||
|
@ -14,7 +14,12 @@ from authentik.core.models import Group
|
|||||||
from authentik.core.sources.mapper import SourceMapper
|
from authentik.core.sources.mapper import SourceMapper
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.lib.sync.outgoing.exceptions import StopSync
|
from authentik.lib.sync.outgoing.exceptions import StopSync
|
||||||
from authentik.sources.ldap.models import LDAP_UNIQUENESS, LDAPSource, flatten
|
from authentik.sources.ldap.models import (
|
||||||
|
LDAP_UNIQUENESS,
|
||||||
|
GroupLDAPSourceConnection,
|
||||||
|
LDAPSource,
|
||||||
|
flatten,
|
||||||
|
)
|
||||||
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
|
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
|
||||||
|
|
||||||
|
|
||||||
@ -89,6 +94,12 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
defaults,
|
defaults,
|
||||||
)
|
)
|
||||||
self._logger.debug("Created group with attributes", **defaults)
|
self._logger.debug("Created group with attributes", **defaults)
|
||||||
|
if not GroupLDAPSourceConnection.objects.filter(
|
||||||
|
source=self._source, identifier=uniq
|
||||||
|
):
|
||||||
|
GroupLDAPSourceConnection.objects.create(
|
||||||
|
source=self._source, group=ak_group, identifier=uniq
|
||||||
|
)
|
||||||
except SkipObjectException:
|
except SkipObjectException:
|
||||||
continue
|
continue
|
||||||
except PropertyMappingExpressionException as exc:
|
except PropertyMappingExpressionException as exc:
|
||||||
|
@ -28,15 +28,17 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
if not self._source.sync_groups:
|
if not self._source.sync_groups:
|
||||||
self.message("Group syncing is disabled for this Source")
|
self.message("Group syncing is disabled for this Source")
|
||||||
return iter(())
|
return iter(())
|
||||||
|
|
||||||
|
# If we are looking up groups from users, we don't need to fetch the group membership field
|
||||||
|
attributes = [self._source.object_uniqueness_field, LDAP_DISTINGUISHED_NAME]
|
||||||
|
if not self._source.lookup_groups_from_user:
|
||||||
|
attributes.append(self._source.group_membership_field)
|
||||||
|
|
||||||
return self.search_paginator(
|
return self.search_paginator(
|
||||||
search_base=self.base_dn_groups,
|
search_base=self.base_dn_groups,
|
||||||
search_filter=self._source.group_object_filter,
|
search_filter=self._source.group_object_filter,
|
||||||
search_scope=SUBTREE,
|
search_scope=SUBTREE,
|
||||||
attributes=[
|
attributes=attributes,
|
||||||
self._source.group_membership_field,
|
|
||||||
self._source.object_uniqueness_field,
|
|
||||||
LDAP_DISTINGUISHED_NAME,
|
|
||||||
],
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -47,9 +49,24 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
return -1
|
return -1
|
||||||
membership_count = 0
|
membership_count = 0
|
||||||
for group in page_data:
|
for group in page_data:
|
||||||
if "attributes" not in group:
|
if self._source.lookup_groups_from_user:
|
||||||
continue
|
group_dn = group.get("dn", {})
|
||||||
members = group.get("attributes", {}).get(self._source.group_membership_field, [])
|
group_filter = f"({self._source.group_membership_field}={group_dn})"
|
||||||
|
group_members = self._source.connection().extend.standard.paged_search(
|
||||||
|
search_base=self.base_dn_users,
|
||||||
|
search_filter=group_filter,
|
||||||
|
search_scope=SUBTREE,
|
||||||
|
attributes=[self._source.object_uniqueness_field],
|
||||||
|
)
|
||||||
|
members = []
|
||||||
|
for group_member in group_members:
|
||||||
|
group_member_dn = group_member.get("dn", {})
|
||||||
|
members.append(group_member_dn)
|
||||||
|
else:
|
||||||
|
if "attributes" not in group:
|
||||||
|
continue
|
||||||
|
members = group.get("attributes", {}).get(self._source.group_membership_field, [])
|
||||||
|
|
||||||
ak_group = self.get_group(group)
|
ak_group = self.get_group(group)
|
||||||
if not ak_group:
|
if not ak_group:
|
||||||
continue
|
continue
|
||||||
@ -68,7 +85,7 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
"ak_groups__in": [ak_group],
|
"ak_groups__in": [ak_group],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
).distinct()
|
||||||
membership_count += 1
|
membership_count += 1
|
||||||
membership_count += users.count()
|
membership_count += users.count()
|
||||||
ak_group.users.set(users)
|
ak_group.users.set(users)
|
||||||
|
@ -14,7 +14,12 @@ from authentik.core.models import User
|
|||||||
from authentik.core.sources.mapper import SourceMapper
|
from authentik.core.sources.mapper import SourceMapper
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.lib.sync.outgoing.exceptions import StopSync
|
from authentik.lib.sync.outgoing.exceptions import StopSync
|
||||||
from authentik.sources.ldap.models import LDAP_UNIQUENESS, LDAPSource, flatten
|
from authentik.sources.ldap.models import (
|
||||||
|
LDAP_UNIQUENESS,
|
||||||
|
LDAPSource,
|
||||||
|
UserLDAPSourceConnection,
|
||||||
|
flatten,
|
||||||
|
)
|
||||||
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
|
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
|
||||||
from authentik.sources.ldap.sync.vendor.freeipa import FreeIPA
|
from authentik.sources.ldap.sync.vendor.freeipa import FreeIPA
|
||||||
from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory
|
from authentik.sources.ldap.sync.vendor.ms_ad import MicrosoftActiveDirectory
|
||||||
@ -85,6 +90,12 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
|||||||
ak_user, created = User.update_or_create_attributes(
|
ak_user, created = User.update_or_create_attributes(
|
||||||
{f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults
|
{f"attributes__{LDAP_UNIQUENESS}": uniq}, defaults
|
||||||
)
|
)
|
||||||
|
if not UserLDAPSourceConnection.objects.filter(
|
||||||
|
source=self._source, identifier=uniq
|
||||||
|
):
|
||||||
|
UserLDAPSourceConnection.objects.create(
|
||||||
|
source=self._source, user=ak_user, identifier=uniq
|
||||||
|
)
|
||||||
except PropertyMappingExpressionException as exc:
|
except PropertyMappingExpressionException as exc:
|
||||||
raise StopSync(exc, None, exc.mapping) from exc
|
raise StopSync(exc, None, exc.mapping) from exc
|
||||||
except SkipObjectException:
|
except SkipObjectException:
|
||||||
|
@ -96,6 +96,26 @@ def mock_freeipa_connection(password: str) -> Connection:
|
|||||||
"objectClass": "posixAccount",
|
"objectClass": "posixAccount",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# User with groups in memberOf attribute
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=user4,ou=users,dc=goauthentik,dc=io",
|
||||||
|
{
|
||||||
|
"name": "user4_sn",
|
||||||
|
"uid": "user4_sn",
|
||||||
|
"objectClass": "person",
|
||||||
|
"memberOf": [
|
||||||
|
"cn=reverse-lookup-group,ou=groups,dc=goauthentik,dc=io",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
connection.strategy.add_entry(
|
||||||
|
"cn=reverse-lookup-group,ou=groups,dc=goauthentik,dc=io",
|
||||||
|
{
|
||||||
|
"cn": "reverse-lookup-group",
|
||||||
|
"uid": "reverse-lookup-group",
|
||||||
|
"objectClass": "groupOfNames",
|
||||||
|
},
|
||||||
|
)
|
||||||
# Locked out user
|
# Locked out user
|
||||||
connection.strategy.add_entry(
|
connection.strategy.add_entry(
|
||||||
"cn=user-nsaccountlock,ou=users,dc=goauthentik,dc=io",
|
"cn=user-nsaccountlock,ou=users,dc=goauthentik,dc=io",
|
||||||
|
@ -162,6 +162,43 @@ class LDAPSyncTests(TestCase):
|
|||||||
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
self.assertFalse(User.objects.filter(username="user1_sn").exists())
|
||||||
self.assertFalse(User.objects.get(username="user-nsaccountlock").is_active)
|
self.assertFalse(User.objects.get(username="user-nsaccountlock").is_active)
|
||||||
|
|
||||||
|
def test_sync_groups_freeipa_memberOf(self):
|
||||||
|
"""Test group sync when membership is derived from memberOf user attribute"""
|
||||||
|
self.source.object_uniqueness_field = "uid"
|
||||||
|
self.source.group_object_filter = "(objectClass=groupOfNames)"
|
||||||
|
self.source.lookup_groups_from_user = True
|
||||||
|
self.source.group_membership_field = "memberOf"
|
||||||
|
self.source.user_property_mappings.set(
|
||||||
|
LDAPSourcePropertyMapping.objects.filter(
|
||||||
|
Q(managed__startswith="goauthentik.io/sources/ldap/default")
|
||||||
|
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.source.group_property_mappings.set(
|
||||||
|
LDAPSourcePropertyMapping.objects.filter(
|
||||||
|
managed="goauthentik.io/sources/ldap/openldap-cn"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection = MagicMock(return_value=mock_freeipa_connection(LDAP_PASSWORD))
|
||||||
|
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
|
||||||
|
user_sync = UserLDAPSynchronizer(self.source)
|
||||||
|
user_sync.sync_full()
|
||||||
|
group_sync = GroupLDAPSynchronizer(self.source)
|
||||||
|
group_sync.sync_full()
|
||||||
|
membership_sync = MembershipLDAPSynchronizer(self.source)
|
||||||
|
membership_sync.sync_full()
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
User.objects.filter(username="user4_sn").exists(), "User does not exist"
|
||||||
|
)
|
||||||
|
# Test if membership mapping based on memberOf works.
|
||||||
|
memberof_group = Group.objects.filter(name="reverse-lookup-group")
|
||||||
|
self.assertTrue(memberof_group.exists(), "Group does not exist")
|
||||||
|
self.assertTrue(
|
||||||
|
memberof_group.first().users.filter(username="user4_sn").exists(),
|
||||||
|
"User not a member of the group",
|
||||||
|
)
|
||||||
|
|
||||||
def test_sync_groups_ad(self):
|
def test_sync_groups_ad(self):
|
||||||
"""Test group sync"""
|
"""Test group sync"""
|
||||||
self.source.user_property_mappings.set(
|
self.source.user_property_mappings.set(
|
||||||
|
@ -1,8 +1,15 @@
|
|||||||
"""API URLs"""
|
"""API URLs"""
|
||||||
|
|
||||||
from authentik.sources.ldap.api import LDAPSourcePropertyMappingViewSet, LDAPSourceViewSet
|
from authentik.sources.ldap.api import (
|
||||||
|
GroupLDAPSourceConnectionViewSet,
|
||||||
|
LDAPSourcePropertyMappingViewSet,
|
||||||
|
LDAPSourceViewSet,
|
||||||
|
UserLDAPSourceConnectionViewSet,
|
||||||
|
)
|
||||||
|
|
||||||
api_urlpatterns = [
|
api_urlpatterns = [
|
||||||
("propertymappings/source/ldap", LDAPSourcePropertyMappingViewSet),
|
("propertymappings/source/ldap", LDAPSourcePropertyMappingViewSet),
|
||||||
("sources/ldap", LDAPSourceViewSet),
|
("sources/ldap", LDAPSourceViewSet),
|
||||||
|
("sources/user_connections/ldap", UserLDAPSourceConnectionViewSet),
|
||||||
|
("sources/group_connections/ldap", GroupLDAPSourceConnectionViewSet),
|
||||||
]
|
]
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
"""OAuth Source Serializer"""
|
|
||||||
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.sources import (
|
from authentik.core.api.sources import (
|
||||||
@ -12,11 +10,9 @@ from authentik.sources.oauth.models import GroupOAuthSourceConnection, UserOAuth
|
|||||||
|
|
||||||
|
|
||||||
class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
|
class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||||
"""OAuth Source Serializer"""
|
|
||||||
|
|
||||||
class Meta(UserSourceConnectionSerializer.Meta):
|
class Meta(UserSourceConnectionSerializer.Meta):
|
||||||
model = UserOAuthSourceConnection
|
model = UserOAuthSourceConnection
|
||||||
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier", "access_token"]
|
fields = UserSourceConnectionSerializer.Meta.fields + ["access_token"]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
**UserSourceConnectionSerializer.Meta.extra_kwargs,
|
**UserSourceConnectionSerializer.Meta.extra_kwargs,
|
||||||
"access_token": {"write_only": True},
|
"access_token": {"write_only": True},
|
||||||
@ -24,21 +20,15 @@ class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Source Viewset"""
|
|
||||||
|
|
||||||
queryset = UserOAuthSourceConnection.objects.all()
|
queryset = UserOAuthSourceConnection.objects.all()
|
||||||
serializer_class = UserOAuthSourceConnectionSerializer
|
serializer_class = UserOAuthSourceConnectionSerializer
|
||||||
|
|
||||||
|
|
||||||
class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||||
"""OAuth Group-Source connection Serializer"""
|
|
||||||
|
|
||||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||||
model = GroupOAuthSourceConnection
|
model = GroupOAuthSourceConnection
|
||||||
|
|
||||||
|
|
||||||
class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Group-source connection Viewset"""
|
|
||||||
|
|
||||||
queryset = GroupOAuthSourceConnection.objects.all()
|
queryset = GroupOAuthSourceConnection.objects.all()
|
||||||
serializer_class = GroupOAuthSourceConnectionSerializer
|
serializer_class = GroupOAuthSourceConnectionSerializer
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_identifier(apps, schema_editor):
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
UserOAuthSourceConnection = apps.get_model(
|
||||||
|
"authentik_sources_oauth", "UserOAuthSourceConnection"
|
||||||
|
)
|
||||||
|
|
||||||
|
for connection in UserOAuthSourceConnection.objects.using(db_alias).all():
|
||||||
|
connection.new_identifier = connection.identifier
|
||||||
|
connection.save(using=db_alias)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_sources_oauth", "0008_groupoauthsourceconnection_and_more"),
|
||||||
|
("authentik_core", "0044_usersourceconnection_new_identifier"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="useroauthsourceconnection",
|
||||||
|
name="identifier",
|
||||||
|
),
|
||||||
|
]
|
@ -286,7 +286,6 @@ class OAuthSourcePropertyMapping(PropertyMapping):
|
|||||||
class UserOAuthSourceConnection(UserSourceConnection):
|
class UserOAuthSourceConnection(UserSourceConnection):
|
||||||
"""Authorized remote OAuth provider."""
|
"""Authorized remote OAuth provider."""
|
||||||
|
|
||||||
identifier = models.CharField(max_length=255)
|
|
||||||
access_token = models.TextField(blank=True, null=True, default=None)
|
access_token = models.TextField(blank=True, null=True, default=None)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
"""Plex Source connection Serializer"""
|
|
||||||
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.sources import (
|
from authentik.core.api.sources import (
|
||||||
@ -12,14 +10,9 @@ from authentik.sources.plex.models import GroupPlexSourceConnection, UserPlexSou
|
|||||||
|
|
||||||
|
|
||||||
class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
|
class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||||
"""Plex Source connection Serializer"""
|
|
||||||
|
|
||||||
class Meta(UserSourceConnectionSerializer.Meta):
|
class Meta(UserSourceConnectionSerializer.Meta):
|
||||||
model = UserPlexSourceConnection
|
model = UserPlexSourceConnection
|
||||||
fields = UserSourceConnectionSerializer.Meta.fields + [
|
fields = UserSourceConnectionSerializer.Meta.fields + ["plex_token"]
|
||||||
"identifier",
|
|
||||||
"plex_token",
|
|
||||||
]
|
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
**UserSourceConnectionSerializer.Meta.extra_kwargs,
|
**UserSourceConnectionSerializer.Meta.extra_kwargs,
|
||||||
"plex_token": {"write_only": True},
|
"plex_token": {"write_only": True},
|
||||||
@ -27,21 +20,15 @@ class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Plex Source connection Serializer"""
|
|
||||||
|
|
||||||
queryset = UserPlexSourceConnection.objects.all()
|
queryset = UserPlexSourceConnection.objects.all()
|
||||||
serializer_class = UserPlexSourceConnectionSerializer
|
serializer_class = UserPlexSourceConnectionSerializer
|
||||||
|
|
||||||
|
|
||||||
class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||||
"""Plex Group-Source connection Serializer"""
|
|
||||||
|
|
||||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||||
model = GroupPlexSourceConnection
|
model = GroupPlexSourceConnection
|
||||||
|
|
||||||
|
|
||||||
class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Group-source connection Viewset"""
|
|
||||||
|
|
||||||
queryset = GroupPlexSourceConnection.objects.all()
|
queryset = GroupPlexSourceConnection.objects.all()
|
||||||
serializer_class = GroupPlexSourceConnectionSerializer
|
serializer_class = GroupPlexSourceConnectionSerializer
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_identifier(apps, schema_editor):
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
UserPlexSourceConnection = apps.get_model("authentik_sources_plex", "UserPlexSourceConnection")
|
||||||
|
|
||||||
|
for connection in UserPlexSourceConnection.objects.using(db_alias).all():
|
||||||
|
connection.new_identifier = connection.identifier
|
||||||
|
connection.save(using=db_alias)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"authentik_sources_plex",
|
||||||
|
"0004_groupplexsourceconnection_plexsourcepropertymapping_and_more",
|
||||||
|
),
|
||||||
|
("authentik_core", "0044_usersourceconnection_new_identifier"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="userplexsourceconnection",
|
||||||
|
name="identifier",
|
||||||
|
),
|
||||||
|
]
|
@ -141,7 +141,6 @@ class UserPlexSourceConnection(UserSourceConnection):
|
|||||||
"""Connect user and plex source"""
|
"""Connect user and plex source"""
|
||||||
|
|
||||||
plex_token = models.TextField()
|
plex_token = models.TextField()
|
||||||
identifier = models.TextField()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[Serializer]:
|
def serializer(self) -> type[Serializer]:
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
"""SAML Source Serializer"""
|
|
||||||
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.sources import (
|
from authentik.core.api.sources import (
|
||||||
@ -12,29 +10,20 @@ from authentik.sources.saml.models import GroupSAMLSourceConnection, UserSAMLSou
|
|||||||
|
|
||||||
|
|
||||||
class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer):
|
class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||||
"""SAML Source Serializer"""
|
|
||||||
|
|
||||||
class Meta(UserSourceConnectionSerializer.Meta):
|
class Meta(UserSourceConnectionSerializer.Meta):
|
||||||
model = UserSAMLSourceConnection
|
model = UserSAMLSourceConnection
|
||||||
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
|
|
||||||
|
|
||||||
|
|
||||||
class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Source Viewset"""
|
|
||||||
|
|
||||||
queryset = UserSAMLSourceConnection.objects.all()
|
queryset = UserSAMLSourceConnection.objects.all()
|
||||||
serializer_class = UserSAMLSourceConnectionSerializer
|
serializer_class = UserSAMLSourceConnectionSerializer
|
||||||
|
|
||||||
|
|
||||||
class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||||
"""OAuth Group-Source connection Serializer"""
|
|
||||||
|
|
||||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||||
model = GroupSAMLSourceConnection
|
model = GroupSAMLSourceConnection
|
||||||
|
|
||||||
|
|
||||||
class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet):
|
class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||||
"""Group-source connection Viewset"""
|
|
||||||
|
|
||||||
queryset = GroupSAMLSourceConnection.objects.all()
|
queryset = GroupSAMLSourceConnection.objects.all()
|
||||||
serializer_class = GroupSAMLSourceConnectionSerializer
|
serializer_class = GroupSAMLSourceConnectionSerializer
|
||||||
|
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 5.0.13 on 2025-03-31 13:53
|
||||||
|
|
||||||
|
import authentik.lib.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_sources_saml", "0017_fix_x509subjectname"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="samlsource",
|
||||||
|
name="slo_url",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Optional URL if your IDP supports Single-Logout.",
|
||||||
|
null=True,
|
||||||
|
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
|
||||||
|
verbose_name="SLO URL",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="samlsource",
|
||||||
|
name="sso_url",
|
||||||
|
field=models.TextField(
|
||||||
|
help_text="URL that the initial Login request is sent to.",
|
||||||
|
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
|
||||||
|
verbose_name="SSO URL",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,26 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_identifier(apps, schema_editor):
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
UserSAMLSourceConnection = apps.get_model("authentik_sources_saml", "UserSAMLSourceConnection")
|
||||||
|
|
||||||
|
for connection in UserSAMLSourceConnection.objects.using(db_alias).all():
|
||||||
|
connection.new_identifier = connection.identifier
|
||||||
|
connection.save(using=db_alias)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_sources_saml", "0018_alter_samlsource_slo_url_alter_samlsource_sso_url"),
|
||||||
|
("authentik_core", "0044_usersourceconnection_new_identifier"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="usersamlsourceconnection",
|
||||||
|
name="identifier",
|
||||||
|
),
|
||||||
|
]
|
@ -20,6 +20,7 @@ from authentik.crypto.models import CertificateKeyPair
|
|||||||
from authentik.flows.challenge import RedirectChallenge
|
from authentik.flows.challenge import RedirectChallenge
|
||||||
from authentik.flows.models import Flow
|
from authentik.flows.models import Flow
|
||||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||||
|
from authentik.lib.models import DomainlessURLValidator
|
||||||
from authentik.lib.utils.time import timedelta_string_validator
|
from authentik.lib.utils.time import timedelta_string_validator
|
||||||
from authentik.sources.saml.processors.constants import (
|
from authentik.sources.saml.processors.constants import (
|
||||||
DSA_SHA1,
|
DSA_SHA1,
|
||||||
@ -91,11 +92,13 @@ class SAMLSource(Source):
|
|||||||
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
|
help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
|
||||||
)
|
)
|
||||||
|
|
||||||
sso_url = models.URLField(
|
sso_url = models.TextField(
|
||||||
|
validators=[DomainlessURLValidator(schemes=("http", "https"))],
|
||||||
verbose_name=_("SSO URL"),
|
verbose_name=_("SSO URL"),
|
||||||
help_text=_("URL that the initial Login request is sent to."),
|
help_text=_("URL that the initial Login request is sent to."),
|
||||||
)
|
)
|
||||||
slo_url = models.URLField(
|
slo_url = models.TextField(
|
||||||
|
validators=[DomainlessURLValidator(schemes=("http", "https"))],
|
||||||
default=None,
|
default=None,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
@ -315,8 +318,6 @@ class SAMLSourcePropertyMapping(PropertyMapping):
|
|||||||
class UserSAMLSourceConnection(UserSourceConnection):
|
class UserSAMLSourceConnection(UserSourceConnection):
|
||||||
"""Connection to configured SAML Sources."""
|
"""Connection to configured SAML Sources."""
|
||||||
|
|
||||||
identifier = models.TextField()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> Serializer:
|
def serializer(self) -> Serializer:
|
||||||
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer
|
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer
|
||||||
|
@ -255,6 +255,7 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
|||||||
)
|
)
|
||||||
masked_email = mask_email(self.user.email)
|
masked_email = mask_email(self.user.email)
|
||||||
self.assertEqual(masked_email, response.json()["email"])
|
self.assertEqual(masked_email, response.json()["email"])
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
# Test without email
|
# Test without email
|
||||||
self.client.force_login(self.user_noemail)
|
self.client.force_login(self.user_noemail)
|
||||||
|
File diff suppressed because one or more lines are too long
@ -104,6 +104,13 @@ def send_mail(
|
|||||||
# can't be converted to json)
|
# can't be converted to json)
|
||||||
message_object.attach(logo_data())
|
message_object.attach(logo_data())
|
||||||
|
|
||||||
|
if (
|
||||||
|
message_object.to
|
||||||
|
and isinstance(message_object.to[0], str)
|
||||||
|
and "=?utf-8?" in message_object.to[0]
|
||||||
|
):
|
||||||
|
message_object.to = [message_object.to[0].split("<")[-1].replace(">", "")]
|
||||||
|
|
||||||
LOGGER.debug("Sending mail", to=message_object.to)
|
LOGGER.debug("Sending mail", to=message_object.to)
|
||||||
backend.send_messages([message_object])
|
backend.send_messages([message_object])
|
||||||
Event.new(
|
Event.new(
|
||||||
|
@ -97,6 +97,37 @@ class TestEmailStageSending(FlowTestCase):
|
|||||||
self.assertEqual(mail.outbox[0].subject, "authentik")
|
self.assertEqual(mail.outbox[0].subject, "authentik")
|
||||||
self.assertEqual(mail.outbox[0].to, [f"Test User Many Words <{long_user.email}>"])
|
self.assertEqual(mail.outbox[0].to, [f"Test User Many Words <{long_user.email}>"])
|
||||||
|
|
||||||
|
def test_utf8_name(self):
|
||||||
|
"""Test with pending user"""
|
||||||
|
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||||
|
utf8_user = create_test_user()
|
||||||
|
utf8_user.name = "Cirilo ЉМНЊ el cirilico И̂ӢЙӤ "
|
||||||
|
utf8_user.email = "cyrillic@authentik.local"
|
||||||
|
utf8_user.save()
|
||||||
|
plan.context[PLAN_CONTEXT_PENDING_USER] = utf8_user
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
Event.objects.filter(action=EventAction.EMAIL_SENT).delete()
|
||||||
|
|
||||||
|
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||||
|
with patch(
|
||||||
|
"authentik.stages.email.models.EmailStage.backend_class",
|
||||||
|
PropertyMock(return_value=EmailBackend),
|
||||||
|
):
|
||||||
|
response = self.client.post(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertStageResponse(
|
||||||
|
response,
|
||||||
|
self.flow,
|
||||||
|
response_errors={
|
||||||
|
"non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(mail.outbox[0].subject, "authentik")
|
||||||
|
self.assertEqual(mail.outbox[0].to, [f"{utf8_user.email}"])
|
||||||
|
|
||||||
def test_pending_fake_user(self):
|
def test_pending_fake_user(self):
|
||||||
"""Test with pending (fake) user"""
|
"""Test with pending (fake) user"""
|
||||||
self.flow.designation = FlowDesignation.RECOVERY
|
self.flow.designation = FlowDesignation.RECOVERY
|
||||||
|
@ -142,35 +142,38 @@ class IdentificationChallengeResponse(ChallengeResponse):
|
|||||||
raise ValidationError("Failed to authenticate.")
|
raise ValidationError("Failed to authenticate.")
|
||||||
self.pre_user = pre_user
|
self.pre_user = pre_user
|
||||||
|
|
||||||
# Password check
|
|
||||||
if current_stage.password_stage:
|
|
||||||
password = attrs.get("password", None)
|
|
||||||
if not password:
|
|
||||||
self.stage.logger.warning("Password not set for ident+auth attempt")
|
|
||||||
try:
|
|
||||||
with start_span(
|
|
||||||
op="authentik.stages.identification.authenticate",
|
|
||||||
name="User authenticate call (combo stage)",
|
|
||||||
):
|
|
||||||
user = authenticate(
|
|
||||||
self.stage.request,
|
|
||||||
current_stage.password_stage.backends,
|
|
||||||
current_stage,
|
|
||||||
username=self.pre_user.username,
|
|
||||||
password=password,
|
|
||||||
)
|
|
||||||
if not user:
|
|
||||||
raise ValidationError("Failed to authenticate.")
|
|
||||||
self.pre_user = user
|
|
||||||
except PermissionDenied as exc:
|
|
||||||
raise ValidationError(str(exc)) from exc
|
|
||||||
|
|
||||||
# Captcha check
|
# Captcha check
|
||||||
if captcha_stage := current_stage.captcha_stage:
|
if captcha_stage := current_stage.captcha_stage:
|
||||||
captcha_token = attrs.get("captcha_token", None)
|
captcha_token = attrs.get("captcha_token", None)
|
||||||
if not captcha_token:
|
if not captcha_token:
|
||||||
self.stage.logger.warning("Token not set for captcha attempt")
|
self.stage.logger.warning("Token not set for captcha attempt")
|
||||||
verify_captcha_token(captcha_stage, captcha_token, client_ip)
|
verify_captcha_token(captcha_stage, captcha_token, client_ip)
|
||||||
|
|
||||||
|
# Password check
|
||||||
|
if not current_stage.password_stage:
|
||||||
|
# No password stage select, don't validate the password
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
password = attrs.get("password", None)
|
||||||
|
if not password:
|
||||||
|
self.stage.logger.warning("Password not set for ident+auth attempt")
|
||||||
|
try:
|
||||||
|
with start_span(
|
||||||
|
op="authentik.stages.identification.authenticate",
|
||||||
|
name="User authenticate call (combo stage)",
|
||||||
|
):
|
||||||
|
user = authenticate(
|
||||||
|
self.stage.request,
|
||||||
|
current_stage.password_stage.backends,
|
||||||
|
current_stage,
|
||||||
|
username=self.pre_user.username,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
raise ValidationError("Failed to authenticate.")
|
||||||
|
self.pre_user = user
|
||||||
|
except PermissionDenied as exc:
|
||||||
|
raise ValidationError(str(exc)) from exc
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,14 +6,12 @@ from django.contrib.auth.views import redirect_to_login
|
|||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.models import AuthenticatedSession
|
|
||||||
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR
|
from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR
|
||||||
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR
|
from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.root.middleware import ClientIPMiddleware, SessionMiddleware
|
from authentik.root.middleware import ClientIPMiddleware, SessionMiddleware
|
||||||
from authentik.stages.user_login.models import GeoIPBinding, NetworkBinding
|
from authentik.stages.user_login.models import GeoIPBinding, NetworkBinding
|
||||||
|
|
||||||
SESSION_KEY_LAST_IP = "authentik/stages/user_login/last_ip"
|
|
||||||
SESSION_KEY_BINDING_NET = "authentik/stages/user_login/binding/net"
|
SESSION_KEY_BINDING_NET = "authentik/stages/user_login/binding/net"
|
||||||
SESSION_KEY_BINDING_GEO = "authentik/stages/user_login/binding/geo"
|
SESSION_KEY_BINDING_GEO = "authentik/stages/user_login/binding/geo"
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
@ -91,7 +89,7 @@ class BoundSessionMiddleware(SessionMiddleware):
|
|||||||
|
|
||||||
def recheck_session(self, request: HttpRequest):
|
def recheck_session(self, request: HttpRequest):
|
||||||
"""Check if a session is still valid with a changed IP"""
|
"""Check if a session is still valid with a changed IP"""
|
||||||
last_ip = request.session.get(SESSION_KEY_LAST_IP)
|
last_ip = request.session.get(request.session.model.Keys.LAST_IP)
|
||||||
new_ip = ClientIPMiddleware.get_client_ip(request)
|
new_ip = ClientIPMiddleware.get_client_ip(request)
|
||||||
# Check changed IP
|
# Check changed IP
|
||||||
if new_ip == last_ip:
|
if new_ip == last_ip:
|
||||||
@ -111,10 +109,7 @@ class BoundSessionMiddleware(SessionMiddleware):
|
|||||||
if SESSION_KEY_BINDING_NET in request.session or SESSION_KEY_BINDING_GEO in request.session:
|
if SESSION_KEY_BINDING_NET in request.session or SESSION_KEY_BINDING_GEO in request.session:
|
||||||
# Only set the last IP in the session if there's a binding specified
|
# Only set the last IP in the session if there's a binding specified
|
||||||
# (== basically requires the user to be logged in)
|
# (== basically requires the user to be logged in)
|
||||||
request.session[SESSION_KEY_LAST_IP] = new_ip
|
request.session[request.session.model.Keys.LAST_IP] = new_ip
|
||||||
AuthenticatedSession.objects.filter(session_key=request.session.session_key).update(
|
|
||||||
last_ip=new_ip, last_user_agent=request.META.get("HTTP_USER_AGENT", "")
|
|
||||||
)
|
|
||||||
|
|
||||||
def recheck_session_net(self, binding: NetworkBinding, last_ip: str, new_ip: str):
|
def recheck_session_net(self, binding: NetworkBinding, last_ip: str, new_ip: str):
|
||||||
"""Check network/ASN binding"""
|
"""Check network/ASN binding"""
|
||||||
|
@ -8,7 +8,7 @@ from django.http import HttpRequest, HttpResponse
|
|||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from rest_framework.fields import BooleanField, CharField
|
from rest_framework.fields import BooleanField, CharField
|
||||||
|
|
||||||
from authentik.core.models import AuthenticatedSession, User
|
from authentik.core.models import Session, User
|
||||||
from authentik.events.middleware import audit_ignore
|
from authentik.events.middleware import audit_ignore
|
||||||
from authentik.flows.challenge import ChallengeResponse, WithUserInfoChallenge
|
from authentik.flows.challenge import ChallengeResponse, WithUserInfoChallenge
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE
|
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE
|
||||||
@ -20,7 +20,6 @@ from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
|||||||
from authentik.stages.user_login.middleware import (
|
from authentik.stages.user_login.middleware import (
|
||||||
SESSION_KEY_BINDING_GEO,
|
SESSION_KEY_BINDING_GEO,
|
||||||
SESSION_KEY_BINDING_NET,
|
SESSION_KEY_BINDING_NET,
|
||||||
SESSION_KEY_LAST_IP,
|
|
||||||
)
|
)
|
||||||
from authentik.stages.user_login.models import UserLoginStage
|
from authentik.stages.user_login.models import UserLoginStage
|
||||||
|
|
||||||
@ -73,7 +72,9 @@ class UserLoginStageView(ChallengeStageView):
|
|||||||
"""Set the sessions' last IP and session bindings"""
|
"""Set the sessions' last IP and session bindings"""
|
||||||
stage: UserLoginStage = self.executor.current_stage
|
stage: UserLoginStage = self.executor.current_stage
|
||||||
|
|
||||||
self.request.session[SESSION_KEY_LAST_IP] = ClientIPMiddleware.get_client_ip(self.request)
|
self.request.session[self.request.session.model.Keys.LAST_IP] = (
|
||||||
|
ClientIPMiddleware.get_client_ip(self.request)
|
||||||
|
)
|
||||||
self.request.session[SESSION_KEY_BINDING_NET] = stage.network_binding
|
self.request.session[SESSION_KEY_BINDING_NET] = stage.network_binding
|
||||||
self.request.session[SESSION_KEY_BINDING_GEO] = stage.geoip_binding
|
self.request.session[SESSION_KEY_BINDING_GEO] = stage.geoip_binding
|
||||||
|
|
||||||
@ -112,7 +113,7 @@ class UserLoginStageView(ChallengeStageView):
|
|||||||
if not self.executor.plan.context.get(PLAN_CONTEXT_SOURCE, None):
|
if not self.executor.plan.context.get(PLAN_CONTEXT_SOURCE, None):
|
||||||
messages.success(self.request, _("Successfully logged in!"))
|
messages.success(self.request, _("Successfully logged in!"))
|
||||||
if self.executor.current_stage.terminate_other_sessions:
|
if self.executor.current_stage.terminate_other_sessions:
|
||||||
AuthenticatedSession.objects.filter(
|
Session.objects.filter(
|
||||||
user=user,
|
authenticatedsession__user=user,
|
||||||
).exclude(session_key=self.request.session.session_key).delete()
|
).exclude(session_key=self.request.session.session_key).delete()
|
||||||
return self.executor.stage_ok()
|
return self.executor.stage_ok()
|
||||||
|
@ -3,12 +3,10 @@
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession, Session
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
from authentik.flows.markers import StageMarker
|
from authentik.flows.markers import StageMarker
|
||||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||||
@ -74,12 +72,13 @@ class TestUserLoginStage(FlowTestCase):
|
|||||||
session.save()
|
session.save()
|
||||||
|
|
||||||
key = generate_id()
|
key = generate_id()
|
||||||
other_session = AuthenticatedSession.objects.create(
|
AuthenticatedSession.objects.create(
|
||||||
|
session=Session.objects.create(
|
||||||
|
session_key=key,
|
||||||
|
last_ip=ClientIPMiddleware.default_ip,
|
||||||
|
),
|
||||||
user=self.user,
|
user=self.user,
|
||||||
session_key=key,
|
|
||||||
last_ip=ClientIPMiddleware.default_ip,
|
|
||||||
)
|
)
|
||||||
cache.set(f"{KEY_PREFIX}{other_session.session_key}", "foo")
|
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||||
@ -87,8 +86,8 @@ class TestUserLoginStage(FlowTestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
||||||
self.assertFalse(AuthenticatedSession.objects.filter(session_key=key))
|
self.assertFalse(AuthenticatedSession.objects.filter(session__session_key=key))
|
||||||
self.assertFalse(cache.has_key(f"{KEY_PREFIX}{key}"))
|
self.assertFalse(Session.objects.filter(session_key=key).exists())
|
||||||
|
|
||||||
def test_expiry(self):
|
def test_expiry(self):
|
||||||
"""Test with expiry"""
|
"""Test with expiry"""
|
||||||
@ -108,7 +107,7 @@ class TestUserLoginStage(FlowTestCase):
|
|||||||
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
||||||
self.assertNotEqual(list(self.client.session.keys()), [])
|
self.assertNotEqual(list(self.client.session.keys()), [])
|
||||||
session_key = self.client.session.session_key
|
session_key = self.client.session.session_key
|
||||||
session = AuthenticatedSession.objects.filter(session_key=session_key).first()
|
session = Session.objects.filter(session_key=session_key).first()
|
||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
session.expires.timestamp() - before_request.timestamp(),
|
session.expires.timestamp() - before_request.timestamp(),
|
||||||
timedelta_from_string(self.stage.session_duration).total_seconds(),
|
timedelta_from_string(self.stage.session_duration).total_seconds(),
|
||||||
@ -143,7 +142,7 @@ class TestUserLoginStage(FlowTestCase):
|
|||||||
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
|
||||||
self.assertNotEqual(list(self.client.session.keys()), [])
|
self.assertNotEqual(list(self.client.session.keys()), [])
|
||||||
session_key = self.client.session.session_key
|
session_key = self.client.session.session_key
|
||||||
session = AuthenticatedSession.objects.filter(session_key=session_key).first()
|
session = Session.objects.filter(session_key=session_key).first()
|
||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
session.expires.timestamp() - _now,
|
session.expires.timestamp() - _now,
|
||||||
timedelta_from_string(self.stage.session_duration).total_seconds()
|
timedelta_from_string(self.stage.session_duration).total_seconds()
|
||||||
|
@ -20,6 +20,8 @@ class SettingsSerializer(ModelSerializer):
|
|||||||
"default_user_change_email",
|
"default_user_change_email",
|
||||||
"default_user_change_username",
|
"default_user_change_username",
|
||||||
"event_retention",
|
"event_retention",
|
||||||
|
"reputation_lower_limit",
|
||||||
|
"reputation_upper_limit",
|
||||||
"footer_links",
|
"footer_links",
|
||||||
"gdpr_compliance",
|
"gdpr_compliance",
|
||||||
"impersonation",
|
"impersonation",
|
||||||
|
@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.0.14 on 2025-04-14 07:50
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_tenants", "0004_tenant_impersonation_require_reason"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="tenant",
|
||||||
|
name="reputation_lower_limit",
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=-5,
|
||||||
|
help_text="Reputation cannot decrease lower than this value. Zero or negative.",
|
||||||
|
validators=[django.core.validators.MaxValueValidator(0)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="tenant",
|
||||||
|
name="reputation_upper_limit",
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=5,
|
||||||
|
help_text="Reputation cannot increase higher than this value. Zero or positive.",
|
||||||
|
validators=[django.core.validators.MinValueValidator(0)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -5,7 +5,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@ -25,6 +25,8 @@ VALID_SCHEMA_NAME = re.compile(r"^t_[a-z0-9]{1,61}$")
|
|||||||
|
|
||||||
DEFAULT_TOKEN_DURATION = "days=1" # nosec
|
DEFAULT_TOKEN_DURATION = "days=1" # nosec
|
||||||
DEFAULT_TOKEN_LENGTH = 60
|
DEFAULT_TOKEN_LENGTH = 60
|
||||||
|
DEFAULT_REPUTATION_LOWER_LIMIT = -5
|
||||||
|
DEFAULT_REPUTATION_UPPER_LIMIT = 5
|
||||||
|
|
||||||
|
|
||||||
def _validate_schema_name(name):
|
def _validate_schema_name(name):
|
||||||
@ -70,6 +72,16 @@ class Tenant(TenantMixin, SerializerModel):
|
|||||||
"Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2)."
|
"Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2)."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
reputation_lower_limit = models.IntegerField(
|
||||||
|
help_text=_("Reputation cannot decrease lower than this value. Zero or negative."),
|
||||||
|
default=DEFAULT_REPUTATION_LOWER_LIMIT,
|
||||||
|
validators=[MaxValueValidator(0)],
|
||||||
|
)
|
||||||
|
reputation_upper_limit = models.IntegerField(
|
||||||
|
help_text=_("Reputation cannot increase higher than this value. Zero or positive."),
|
||||||
|
default=DEFAULT_REPUTATION_UPPER_LIMIT,
|
||||||
|
validators=[MinValueValidator(0)],
|
||||||
|
)
|
||||||
footer_links = models.JSONField(
|
footer_links = models.JSONField(
|
||||||
help_text=_("The option configures the footer links on the flow executor pages."),
|
help_text=_("The option configures the footer links on the flow executor pages."),
|
||||||
default=list,
|
default=list,
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"$schema": "http://json-schema.org/draft-07/schema",
|
"$schema": "http://json-schema.org/draft-07/schema",
|
||||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "authentik 2025.2.2 Blueprint schema",
|
"title": "authentik 2025.2.4 Blueprint schema",
|
||||||
"required": [
|
"required": [
|
||||||
"version",
|
"version",
|
||||||
"entries"
|
"entries"
|
||||||
@ -1441,6 +1441,86 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"model",
|
||||||
|
"identifiers"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"model": {
|
||||||
|
"const": "authentik_sources_ldap.userldapsourceconnection"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"absent",
|
||||||
|
"present",
|
||||||
|
"created",
|
||||||
|
"must_created"
|
||||||
|
],
|
||||||
|
"default": "present"
|
||||||
|
},
|
||||||
|
"conditions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"$ref": "#/$defs/model_authentik_sources_ldap.userldapsourceconnection_permissions"
|
||||||
|
},
|
||||||
|
"attrs": {
|
||||||
|
"$ref": "#/$defs/model_authentik_sources_ldap.userldapsourceconnection"
|
||||||
|
},
|
||||||
|
"identifiers": {
|
||||||
|
"$ref": "#/$defs/model_authentik_sources_ldap.userldapsourceconnection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"model",
|
||||||
|
"identifiers"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"model": {
|
||||||
|
"const": "authentik_sources_ldap.groupldapsourceconnection"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"absent",
|
||||||
|
"present",
|
||||||
|
"created",
|
||||||
|
"must_created"
|
||||||
|
],
|
||||||
|
"default": "present"
|
||||||
|
},
|
||||||
|
"conditions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"$ref": "#/$defs/model_authentik_sources_ldap.groupldapsourceconnection_permissions"
|
||||||
|
},
|
||||||
|
"attrs": {
|
||||||
|
"$ref": "#/$defs/model_authentik_sources_ldap.groupldapsourceconnection"
|
||||||
|
},
|
||||||
|
"identifiers": {
|
||||||
|
"$ref": "#/$defs/model_authentik_sources_ldap.groupldapsourceconnection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@ -4754,6 +4834,8 @@
|
|||||||
"authentik_sources_kerberos.groupkerberossourceconnection",
|
"authentik_sources_kerberos.groupkerberossourceconnection",
|
||||||
"authentik_sources_ldap.ldapsource",
|
"authentik_sources_ldap.ldapsource",
|
||||||
"authentik_sources_ldap.ldapsourcepropertymapping",
|
"authentik_sources_ldap.ldapsourcepropertymapping",
|
||||||
|
"authentik_sources_ldap.userldapsourceconnection",
|
||||||
|
"authentik_sources_ldap.groupldapsourceconnection",
|
||||||
"authentik_sources_oauth.oauthsource",
|
"authentik_sources_oauth.oauthsource",
|
||||||
"authentik_sources_oauth.oauthsourcepropertymapping",
|
"authentik_sources_oauth.oauthsourcepropertymapping",
|
||||||
"authentik_sources_oauth.useroauthsourceconnection",
|
"authentik_sources_oauth.useroauthsourceconnection",
|
||||||
@ -6423,8 +6505,6 @@
|
|||||||
},
|
},
|
||||||
"acs_url": {
|
"acs_url": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uri",
|
|
||||||
"maxLength": 200,
|
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "ACS URL"
|
"title": "ACS URL"
|
||||||
},
|
},
|
||||||
@ -7114,14 +7194,22 @@
|
|||||||
"authentik_sources_kerberos.view_kerberossource",
|
"authentik_sources_kerberos.view_kerberossource",
|
||||||
"authentik_sources_kerberos.view_kerberossourcepropertymapping",
|
"authentik_sources_kerberos.view_kerberossourcepropertymapping",
|
||||||
"authentik_sources_kerberos.view_userkerberossourceconnection",
|
"authentik_sources_kerberos.view_userkerberossourceconnection",
|
||||||
|
"authentik_sources_ldap.add_groupldapsourceconnection",
|
||||||
"authentik_sources_ldap.add_ldapsource",
|
"authentik_sources_ldap.add_ldapsource",
|
||||||
"authentik_sources_ldap.add_ldapsourcepropertymapping",
|
"authentik_sources_ldap.add_ldapsourcepropertymapping",
|
||||||
|
"authentik_sources_ldap.add_userldapsourceconnection",
|
||||||
|
"authentik_sources_ldap.change_groupldapsourceconnection",
|
||||||
"authentik_sources_ldap.change_ldapsource",
|
"authentik_sources_ldap.change_ldapsource",
|
||||||
"authentik_sources_ldap.change_ldapsourcepropertymapping",
|
"authentik_sources_ldap.change_ldapsourcepropertymapping",
|
||||||
|
"authentik_sources_ldap.change_userldapsourceconnection",
|
||||||
|
"authentik_sources_ldap.delete_groupldapsourceconnection",
|
||||||
"authentik_sources_ldap.delete_ldapsource",
|
"authentik_sources_ldap.delete_ldapsource",
|
||||||
"authentik_sources_ldap.delete_ldapsourcepropertymapping",
|
"authentik_sources_ldap.delete_ldapsourcepropertymapping",
|
||||||
|
"authentik_sources_ldap.delete_userldapsourceconnection",
|
||||||
|
"authentik_sources_ldap.view_groupldapsourceconnection",
|
||||||
"authentik_sources_ldap.view_ldapsource",
|
"authentik_sources_ldap.view_ldapsource",
|
||||||
"authentik_sources_ldap.view_ldapsourcepropertymapping",
|
"authentik_sources_ldap.view_ldapsourcepropertymapping",
|
||||||
|
"authentik_sources_ldap.view_userldapsourceconnection",
|
||||||
"authentik_sources_oauth.add_groupoauthsourceconnection",
|
"authentik_sources_oauth.add_groupoauthsourceconnection",
|
||||||
"authentik_sources_oauth.add_oauthsource",
|
"authentik_sources_oauth.add_oauthsource",
|
||||||
"authentik_sources_oauth.add_oauthsourcepropertymapping",
|
"authentik_sources_oauth.add_oauthsourcepropertymapping",
|
||||||
@ -7887,6 +7975,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"title": "Sync parent group"
|
"title": "Sync parent group"
|
||||||
|
},
|
||||||
|
"lookup_groups_from_user": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Lookup groups from user",
|
||||||
|
"description": "Lookup group membership based on a user attribute instead of a group attribute. This allows nested group resolution on systems like FreeIPA and Active Directory"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": []
|
"required": []
|
||||||
@ -7968,6 +8061,107 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"model_authentik_sources_ldap.userldapsourceconnection": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"user": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "User"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Source"
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Identifier"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Icon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
},
|
||||||
|
"model_authentik_sources_ldap.userldapsourceconnection_permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"permission"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"permission": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"add_userldapsourceconnection",
|
||||||
|
"change_userldapsourceconnection",
|
||||||
|
"delete_userldapsourceconnection",
|
||||||
|
"view_userldapsourceconnection"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_authentik_sources_ldap.groupldapsourceconnection": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"group": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Group"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Source"
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Identifier"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Icon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
},
|
||||||
|
"model_authentik_sources_ldap.groupldapsourceconnection_permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"permission"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"permission": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"add_groupldapsourceconnection",
|
||||||
|
"change_groupldapsourceconnection",
|
||||||
|
"delete_groupldapsourceconnection",
|
||||||
|
"view_groupldapsourceconnection"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"model_authentik_sources_oauth.oauthsource": {
|
"model_authentik_sources_oauth.oauthsource": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -8233,7 +8427,6 @@
|
|||||||
},
|
},
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 255,
|
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Identifier"
|
"title": "Identifier"
|
||||||
},
|
},
|
||||||
@ -8733,8 +8926,6 @@
|
|||||||
},
|
},
|
||||||
"sso_url": {
|
"sso_url": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uri",
|
|
||||||
"maxLength": 200,
|
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "SSO URL",
|
"title": "SSO URL",
|
||||||
"description": "URL that the initial Login request is sent to."
|
"description": "URL that the initial Login request is sent to."
|
||||||
@ -8744,8 +8935,6 @@
|
|||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
],
|
],
|
||||||
"format": "uri",
|
|
||||||
"maxLength": 200,
|
|
||||||
"title": "SLO URL",
|
"title": "SLO URL",
|
||||||
"description": "Optional URL if your IDP supports Single-Logout."
|
"description": "Optional URL if your IDP supports Single-Logout."
|
||||||
},
|
},
|
||||||
@ -13629,14 +13818,22 @@
|
|||||||
"authentik_sources_kerberos.view_kerberossource",
|
"authentik_sources_kerberos.view_kerberossource",
|
||||||
"authentik_sources_kerberos.view_kerberossourcepropertymapping",
|
"authentik_sources_kerberos.view_kerberossourcepropertymapping",
|
||||||
"authentik_sources_kerberos.view_userkerberossourceconnection",
|
"authentik_sources_kerberos.view_userkerberossourceconnection",
|
||||||
|
"authentik_sources_ldap.add_groupldapsourceconnection",
|
||||||
"authentik_sources_ldap.add_ldapsource",
|
"authentik_sources_ldap.add_ldapsource",
|
||||||
"authentik_sources_ldap.add_ldapsourcepropertymapping",
|
"authentik_sources_ldap.add_ldapsourcepropertymapping",
|
||||||
|
"authentik_sources_ldap.add_userldapsourceconnection",
|
||||||
|
"authentik_sources_ldap.change_groupldapsourceconnection",
|
||||||
"authentik_sources_ldap.change_ldapsource",
|
"authentik_sources_ldap.change_ldapsource",
|
||||||
"authentik_sources_ldap.change_ldapsourcepropertymapping",
|
"authentik_sources_ldap.change_ldapsourcepropertymapping",
|
||||||
|
"authentik_sources_ldap.change_userldapsourceconnection",
|
||||||
|
"authentik_sources_ldap.delete_groupldapsourceconnection",
|
||||||
"authentik_sources_ldap.delete_ldapsource",
|
"authentik_sources_ldap.delete_ldapsource",
|
||||||
"authentik_sources_ldap.delete_ldapsourcepropertymapping",
|
"authentik_sources_ldap.delete_ldapsourcepropertymapping",
|
||||||
|
"authentik_sources_ldap.delete_userldapsourceconnection",
|
||||||
|
"authentik_sources_ldap.view_groupldapsourceconnection",
|
||||||
"authentik_sources_ldap.view_ldapsource",
|
"authentik_sources_ldap.view_ldapsource",
|
||||||
"authentik_sources_ldap.view_ldapsourcepropertymapping",
|
"authentik_sources_ldap.view_ldapsourcepropertymapping",
|
||||||
|
"authentik_sources_ldap.view_userldapsourceconnection",
|
||||||
"authentik_sources_oauth.add_groupoauthsourceconnection",
|
"authentik_sources_oauth.add_groupoauthsourceconnection",
|
||||||
"authentik_sources_oauth.add_oauthsource",
|
"authentik_sources_oauth.add_oauthsource",
|
||||||
"authentik_sources_oauth.add_oauthsourcepropertymapping",
|
"authentik_sources_oauth.add_oauthsourcepropertymapping",
|
||||||
|
@ -31,10 +31,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis:/data
|
- redis:/data
|
||||||
server:
|
server:
|
||||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.2}
|
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.4}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: server
|
command: server
|
||||||
environment:
|
environment:
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||||
AUTHENTIK_REDIS__HOST: redis
|
AUTHENTIK_REDIS__HOST: redis
|
||||||
AUTHENTIK_POSTGRESQL__HOST: postgresql
|
AUTHENTIK_POSTGRESQL__HOST: postgresql
|
||||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||||
@ -54,10 +55,11 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
worker:
|
worker:
|
||||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.2}
|
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.4}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: worker
|
command: worker
|
||||||
environment:
|
environment:
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||||
AUTHENTIK_REDIS__HOST: redis
|
AUTHENTIK_REDIS__HOST: redis
|
||||||
AUTHENTIK_POSTGRESQL__HOST: postgresql
|
AUTHENTIK_POSTGRESQL__HOST: postgresql
|
||||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||||
|
21
go.mod
21
go.mod
@ -1,13 +1,11 @@
|
|||||||
module goauthentik.io
|
module goauthentik.io
|
||||||
|
|
||||||
go 1.23.0
|
go 1.24.0
|
||||||
|
|
||||||
toolchain go1.24.0
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
beryju.io/ldap v0.1.0
|
beryju.io/ldap v0.1.0
|
||||||
github.com/coreos/go-oidc/v3 v3.13.0
|
github.com/coreos/go-oidc/v3 v3.14.1
|
||||||
github.com/getsentry/sentry-go v0.31.1
|
github.com/getsentry/sentry-go v0.32.0
|
||||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
||||||
github.com/go-ldap/ldap/v3 v3.4.10
|
github.com/go-ldap/ldap/v3 v3.4.10
|
||||||
github.com/go-openapi/runtime v0.28.0
|
github.com/go-openapi/runtime v0.28.0
|
||||||
@ -22,17 +20,17 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||||
github.com/pires/go-proxyproto v0.8.0
|
github.com/pires/go-proxyproto v0.8.0
|
||||||
github.com/prometheus/client_golang v1.21.1
|
github.com/prometheus/client_golang v1.22.0
|
||||||
github.com/redis/go-redis/v9 v9.7.3
|
github.com/redis/go-redis/v9 v9.7.3
|
||||||
github.com/sethvargo/go-envconfig v1.1.1
|
github.com/sethvargo/go-envconfig v1.2.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/wwt/guac v1.3.2
|
github.com/wwt/guac v1.3.2
|
||||||
goauthentik.io/api/v3 v3.2025022.6
|
goauthentik.io/api/v3 v3.2025024.4
|
||||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||||
golang.org/x/oauth2 v0.28.0
|
golang.org/x/oauth2 v0.29.0
|
||||||
golang.org/x/sync v0.12.0
|
golang.org/x/sync v0.13.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
layeh.com/radius v0.0.0-20210819152912-ad72663a72ab
|
layeh.com/radius v0.0.0-20210819152912-ad72663a72ab
|
||||||
)
|
)
|
||||||
@ -62,7 +60,6 @@ require (
|
|||||||
github.com/go-openapi/validate v0.24.0 // indirect
|
github.com/go-openapi/validate v0.24.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/klauspost/compress v1.17.11 // indirect
|
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/oklog/ulid v1.3.1 // indirect
|
github.com/oklog/ulid v1.3.1 // indirect
|
||||||
@ -79,6 +76,6 @@ require (
|
|||||||
golang.org/x/crypto v0.36.0 // indirect
|
golang.org/x/crypto v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.1 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
39
go.sum
39
go.sum
@ -55,8 +55,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
|
|||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/coreos/go-oidc/v3 v3.13.0 h1:M66zd0pcc5VxvBNM4pB331Wrsanby+QomQYjN8HamW8=
|
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||||
github.com/coreos/go-oidc/v3 v3.13.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@ -69,8 +69,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
|
|||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4=
|
github.com/getsentry/sentry-go v0.32.0 h1:YKs+//QmwE3DcYtfKRH8/KyOOF/I6Qnx7qYGNHCGmCY=
|
||||||
github.com/getsentry/sentry-go v0.31.1/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
|
github.com/getsentry/sentry-go v0.32.0/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
@ -148,8 +148,9 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
@ -207,8 +208,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF
|
|||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@ -239,8 +240,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
@ -254,8 +255,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
|
|||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sethvargo/go-envconfig v1.1.1 h1:JDu8Q9baIzJf47NPkzhIB6aLYL0vQ+pPypoYrejS9QY=
|
github.com/sethvargo/go-envconfig v1.2.0 h1:q3XkOZWkC+G1sMLCrw9oPGTjYexygLOXDmGUit1ti8Q=
|
||||||
github.com/sethvargo/go-envconfig v1.1.1/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw=
|
github.com/sethvargo/go-envconfig v1.2.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw=
|
||||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
@ -299,8 +300,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
|
|||||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
goauthentik.io/api/v3 v3.2025022.6 h1:M5M8Cd/1N7E8KLkvYYh7VdcdKz5nfzjKPFLK+YOtOVg=
|
goauthentik.io/api/v3 v3.2025024.4 h1:fD4K6YcCTdwtkqKbYBdJk3POHVzw+LDdJdZSbOAKbX4=
|
||||||
goauthentik.io/api/v3 v3.2025022.6/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
goauthentik.io/api/v3 v3.2025024.4/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
@ -395,8 +396,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr
|
|||||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@ -411,8 +412,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@ -599,8 +600,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
|||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
|||||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERSION = "2025.2.2"
|
const VERSION = "2025.2.4"
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user