Compare commits
133 Commits
better-ver
...
providers/
Author | SHA1 | Date | |
---|---|---|---|
faf8bf591f | |||
52115f9345 | |||
b476551f13 | |||
f9563c25cd | |||
0067e6e155 | |||
ce183929d4 | |||
2fdf345271 | |||
bbcf8418b4 | |||
dc57be46f4 | |||
d68b3ba516 | |||
a9c46cfcbd | |||
c50353ebf6 | |||
db6be9e1b6 | |||
a74892886d | |||
74cd4c2236 | |||
ef3bd7e77b | |||
3f5ad2baa4 | |||
24805f087b | |||
9464b422a3 | |||
da6d4ede51 | |||
cecad5bfd3 | |||
bc4b07d57b | |||
e85d2d0096 | |||
be1dd3103b | |||
5dfde5e1d3 | |||
7cb1e6d81e | |||
d7c3129b1c | |||
2a1d33021b | |||
f273e49ae6 | |||
cc31957900 | |||
b1ccdecc8e | |||
34031003a4 | |||
055e1d1025 | |||
59a804273e | |||
bce70a1796 | |||
e86c40a00c | |||
20e07486ee | |||
0cb7cf2c96 | |||
07736a90b2 | |||
ec28a86259 | |||
260800c60b | |||
ee4780394d | |||
23b746941f | |||
3c2ce40afd | |||
2aceed285e | |||
81e5fef667 | |||
7aa6593760 | |||
c40a17beb9 | |||
335c9fbc10 | |||
51b53caf61 | |||
989100a900 | |||
8e1531d051 | |||
f6f37d6d92 | |||
5b6ca70f22 | |||
a74674c3d6 | |||
f46984dec4 | |||
c7963e4af7 | |||
6e30b11974 | |||
13bd4069e4 | |||
3b913ac5ef | |||
fed094b317 | |||
88d83c10a4 | |||
5af2378738 | |||
6ec745ddc0 | |||
a44375a9d8 | |||
3bf9cf681a | |||
b8cf0e5dc4 | |||
2180bdf7c2 | |||
8af2d189d9 | |||
66f96a280e | |||
09d5c6fa43 | |||
6eb4e78b22 | |||
c3eada8d33 | |||
48130a7463 | |||
9ffb2443f6 | |||
0b4aed7a3d | |||
86dd1a96f6 | |||
0ce017b77e | |||
e22e79f310 | |||
ac575aecfa | |||
fd61fb31b5 | |||
d7f6e5b79d | |||
09ded2a19a | |||
6d4c9a3446 | |||
74b5380f32 | |||
c65b3e8ae5 | |||
88fa7e37dc | |||
8bfc9ab7c9 | |||
f145580dae | |||
31eb4a6315 | |||
48696e3d7d | |||
734db4dee6 | |||
856ac052e7 | |||
dea2d67ceb | |||
a844fb41d4 | |||
f4da22aea8 | |||
1464852b92 | |||
6aebf45aea | |||
fad6ac76af | |||
c60a145f95 | |||
652a32d968 | |||
bd5a66a3c7 | |||
a4c4b07614 | |||
aaf76bab92 | |||
814f3fc43d | |||
d017af1347 | |||
6e11554f62 | |||
14f2edc04b | |||
9f6173d8b2 | |||
52c3ba551d | |||
633c6ff245 | |||
e5cb925f70 | |||
a0b1327456 | |||
891907390b | |||
e1c47c0f00 | |||
0b71aa43d1 | |||
b4d26d5092 | |||
8a9f6fb1ce | |||
355f302cb7 | |||
11666f5658 | |||
d54fe15511 | |||
baa4deda99 | |||
b7417e77c7 | |||
a01bb551d0 | |||
9a03bdeaf1 | |||
6b530ff764 | |||
e77a3241f0 | |||
a718ff2617 | |||
969fa82b7f | |||
bb47a70310 | |||
a306cecb73 | |||
760879c3db | |||
ef5d3580e8 |
@ -1,16 +1,16 @@
|
||||
[bumpversion]
|
||||
current_version = 2025.6.0
|
||||
current_version = 2025.6.2
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
serialize =
|
||||
serialize =
|
||||
{major}.{minor}.{patch}-{rc_t}{rc_n}
|
||||
{major}.{minor}.{patch}
|
||||
message = release: {new_version}
|
||||
tag_name = version/{new_version}
|
||||
|
||||
[bumpversion:part:rc_t]
|
||||
values =
|
||||
values =
|
||||
rc
|
||||
final
|
||||
optional_value = final
|
||||
|
@ -5,8 +5,10 @@ dist/**
|
||||
build/**
|
||||
build_docs/**
|
||||
*Dockerfile
|
||||
**/*Dockerfile
|
||||
blueprints/local
|
||||
.git
|
||||
!gen-ts-api/node_modules
|
||||
!gen-ts-api/dist/**
|
||||
!gen-go-api/
|
||||
.venv
|
||||
|
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
@ -100,6 +100,13 @@ updates:
|
||||
goauthentik:
|
||||
patterns:
|
||||
- "@goauthentik/*"
|
||||
eslint:
|
||||
patterns:
|
||||
- "@eslint/*"
|
||||
- "@typescript-eslint/*"
|
||||
- "eslint-*"
|
||||
- "eslint"
|
||||
- "typescript-eslint"
|
||||
- package-ecosystem: npm
|
||||
directory: "/lifecycle/aws"
|
||||
schedule:
|
||||
|
2
.github/workflows/ci-main.yml
vendored
2
.github/workflows/ci-main.yml
vendored
@ -202,7 +202,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||
working-directory: web
|
||||
|
62
.github/workflows/ci-website.yml
vendored
62
.github/workflows/ci-website.yml
vendored
@ -41,32 +41,60 @@ jobs:
|
||||
- name: test
|
||||
working-directory: website/
|
||||
run: npm test
|
||||
build:
|
||||
build-container:
|
||||
runs-on: ubuntu-latest
|
||||
name: ${{ matrix.job }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- build
|
||||
permissions:
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
run: npm ci
|
||||
- name: build
|
||||
working-directory: website/
|
||||
run: npm run ${{ matrix.job }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.6.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-docs
|
||||
- name: Login to Container Registry
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: website/Dockerfile
|
||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
|
||||
- uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
ci-website-mark:
|
||||
if: always()
|
||||
needs:
|
||||
- lint
|
||||
- test
|
||||
- build
|
||||
- build-container
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@release/v1
|
||||
|
45
.github/workflows/release-publish.yml
vendored
45
.github/workflows/release-publish.yml
vendored
@ -20,6 +20,49 @@ jobs:
|
||||
release: true
|
||||
registry_dockerhub: true
|
||||
registry_ghcr: true
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.6.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/docs
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker Image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: website/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
- uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
if: true
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
build-outpost:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@ -193,6 +236,6 @@ jobs:
|
||||
SENTRY_ORG: authentik-security-inc
|
||||
SENTRY_PROJECT: authentik
|
||||
with:
|
||||
version: authentik@${{ steps.ev.outputs.version }}
|
||||
release: authentik@${{ steps.ev.outputs.version }}
|
||||
sourcemaps: "./web/dist"
|
||||
url_prefix: "~/static/dist"
|
||||
|
50
Dockerfile
50
Dockerfile
@ -1,26 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build website
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24 AS website-builder
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
WORKDIR /work/website
|
||||
|
||||
RUN --mount=type=bind,target=/work/website/package.json,src=./website/package.json \
|
||||
--mount=type=bind,target=/work/website/package-lock.json,src=./website/package-lock.json \
|
||||
--mount=type=cache,id=npm-website,sharing=shared,target=/root/.npm \
|
||||
npm ci --include=dev
|
||||
|
||||
COPY ./website /work/website/
|
||||
COPY ./blueprints /work/blueprints/
|
||||
COPY ./schema.yml /work/
|
||||
COPY ./SECURITY.md /work/
|
||||
|
||||
RUN npm run build-bundled
|
||||
|
||||
# Stage 2: Build webui
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24 AS web-builder
|
||||
# Stage 1: Build webui
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-slim AS node-builder
|
||||
|
||||
ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
@ -32,7 +13,7 @@ RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
|
||||
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
|
||||
--mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
|
||||
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
|
||||
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
|
||||
--mount=type=cache,id=npm-ak,sharing=shared,target=/root/.npm \
|
||||
npm ci --include=dev
|
||||
|
||||
COPY ./package.json /work
|
||||
@ -43,7 +24,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
|
||||
RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 3: Build go proxy
|
||||
# Stage 2: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
@ -68,8 +49,8 @@ RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
|
||||
COPY ./cmd /go/src/goauthentik.io/cmd
|
||||
COPY ./authentik/lib /go/src/goauthentik.io/authentik/lib
|
||||
COPY ./web/static.go /go/src/goauthentik.io/web/static.go
|
||||
COPY --from=web-builder /work/web/robots.txt /go/src/goauthentik.io/web/robots.txt
|
||||
COPY --from=web-builder /work/web/security.txt /go/src/goauthentik.io/web/security.txt
|
||||
COPY --from=node-builder /work/web/robots.txt /go/src/goauthentik.io/web/robots.txt
|
||||
COPY --from=node-builder /work/web/security.txt /go/src/goauthentik.io/web/security.txt
|
||||
COPY ./internal /go/src/goauthentik.io/internal
|
||||
COPY ./go.mod /go/src/goauthentik.io/go.mod
|
||||
COPY ./go.sum /go/src/goauthentik.io/go.sum
|
||||
@ -80,7 +61,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
|
||||
go build -o /go/authentik ./cmd/server
|
||||
|
||||
# Stage 4: MaxMind GeoIP
|
||||
# Stage 3: MaxMind GeoIP
|
||||
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip
|
||||
|
||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
|
||||
@ -93,10 +74,10 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
mkdir -p /usr/share/GeoIP && \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 5: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.7.11 AS uv
|
||||
# Stage 6: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.7.13 AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base
|
||||
|
||||
ENV VENV_PATH="/ak-root/.venv" \
|
||||
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
||||
@ -109,7 +90,7 @@ WORKDIR /ak-root/
|
||||
|
||||
COPY --from=uv /uv /uvx /bin/
|
||||
|
||||
# Stage 7: Python dependencies
|
||||
# Stage 6: Python dependencies
|
||||
FROM python-base AS python-deps
|
||||
|
||||
ARG TARGETARCH
|
||||
@ -144,7 +125,7 @@ RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
||||
--mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
# Stage 8: Run
|
||||
# Stage 7: Run
|
||||
FROM python-base AS final-image
|
||||
|
||||
ARG VERSION
|
||||
@ -187,9 +168,8 @@ COPY ./lifecycle/ /lifecycle
|
||||
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
|
||||
COPY --from=go-builder /go/authentik /bin/authentik
|
||||
COPY --from=python-deps /ak-root/.venv /ak-root/.venv
|
||||
COPY --from=web-builder /work/web/dist/ /web/dist/
|
||||
COPY --from=web-builder /work/web/authentik/ /web/authentik/
|
||||
COPY --from=website-builder /work/website/build/ /website/help/
|
||||
COPY --from=node-builder /work/web/dist/ /web/dist/
|
||||
COPY --from=node-builder /work/web/authentik/ /web/authentik/
|
||||
COPY --from=geoip /usr/share/GeoIP /geoip
|
||||
|
||||
USER 1000
|
||||
|
2
Makefile
2
Makefile
@ -94,7 +94,7 @@ gen-build: ## Extract the schema from the database
|
||||
AUTHENTIK_DEBUG=true \
|
||||
AUTHENTIK_TENANTS__ENABLED=true \
|
||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
||||
uv run ak make_blueprint_schema > blueprints/schema.json
|
||||
uv run ak make_blueprint_schema --file blueprints/schema.json
|
||||
AUTHENTIK_DEBUG=true \
|
||||
AUTHENTIK_TENANTS__ENABLED=true \
|
||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2025.6.0"
|
||||
__version__ = "2025.6.2"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -1,79 +0,0 @@
|
||||
"""authentik administration metrics"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models.functions import ExtractHour
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.fields import IntegerField, SerializerMethodField
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.events.models import EventAction
|
||||
|
||||
|
||||
class CoordinateSerializer(PassiveSerializer):
|
||||
"""Coordinates for diagrams"""
|
||||
|
||||
x_cord = IntegerField(read_only=True)
|
||||
y_cord = IntegerField(read_only=True)
|
||||
|
||||
|
||||
class LoginMetricsSerializer(PassiveSerializer):
|
||||
"""Login Metrics per 1h"""
|
||||
|
||||
logins = SerializerMethodField()
|
||||
logins_failed = SerializerMethodField()
|
||||
authorizations = SerializerMethodField()
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_logins(self, _):
|
||||
"""Get successful logins per 8 hours for the last 7 days"""
|
||||
user = self.context["user"]
|
||||
return (
|
||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||
action=EventAction.LOGIN
|
||||
)
|
||||
# 3 data points per day, so 8 hour spans
|
||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||
)
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_logins_failed(self, _):
|
||||
"""Get failed logins per 8 hours for the last 7 days"""
|
||||
user = self.context["user"]
|
||||
return (
|
||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||
action=EventAction.LOGIN_FAILED
|
||||
)
|
||||
# 3 data points per day, so 8 hour spans
|
||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||
)
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_authorizations(self, _):
|
||||
"""Get successful authorizations per 8 hours for the last 7 days"""
|
||||
user = self.context["user"]
|
||||
return (
|
||||
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||
action=EventAction.AUTHORIZE_APPLICATION
|
||||
)
|
||||
# 3 data points per day, so 8 hour spans
|
||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||
)
|
||||
|
||||
|
||||
class AdministrationMetricsViewSet(APIView):
|
||||
"""Login Metrics per 1h"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(responses={200: LoginMetricsSerializer(many=False)})
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Login Metrics per 1h"""
|
||||
serializer = LoginMetricsSerializer(True)
|
||||
serializer.context["user"] = request.user
|
||||
return Response(serializer.data)
|
@ -36,11 +36,6 @@ class TestAdminAPI(TestCase):
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body), 0)
|
||||
|
||||
def test_metrics(self):
|
||||
"""Test metrics API"""
|
||||
response = self.client.get(reverse("authentik_api:admin_metrics"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_apps(self):
|
||||
"""Test apps API"""
|
||||
response = self.client.get(reverse("authentik_api:apps-list"))
|
||||
|
@ -3,7 +3,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from authentik.admin.api.meta import AppsViewSet, ModelViewSet
|
||||
from authentik.admin.api.metrics import AdministrationMetricsViewSet
|
||||
from authentik.admin.api.system import SystemView
|
||||
from authentik.admin.api.version import VersionView
|
||||
from authentik.admin.api.version_history import VersionHistoryViewSet
|
||||
@ -12,11 +11,6 @@ from authentik.admin.api.workers import WorkerView
|
||||
api_urlpatterns = [
|
||||
("admin/apps", AppsViewSet, "apps"),
|
||||
("admin/models", ModelViewSet, "models"),
|
||||
path(
|
||||
"admin/metrics/",
|
||||
AdministrationMetricsViewSet.as_view(),
|
||||
name="admin_metrics",
|
||||
),
|
||||
path("admin/version/", VersionView.as_view(), name="admin_version"),
|
||||
("admin/version/history", VersionHistoryViewSet, "version_history"),
|
||||
path("admin/workers/", WorkerView.as_view(), name="admin_workers"),
|
||||
|
@ -72,20 +72,33 @@ class Command(BaseCommand):
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [],
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/blueprint_entry"},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/blueprint_entry"},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"$defs": {},
|
||||
"$defs": {"blueprint_entry": {"oneOf": []}},
|
||||
}
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--file", type=str)
|
||||
|
||||
@no_translations
|
||||
def handle(self, *args, **options):
|
||||
def handle(self, *args, file: str, **options):
|
||||
"""Generate JSON Schema for blueprints"""
|
||||
self.build()
|
||||
self.stdout.write(dumps(self.schema, indent=4, default=Command.json_default))
|
||||
with open(file, "w") as _schema:
|
||||
_schema.write(dumps(self.schema, indent=4, default=Command.json_default))
|
||||
|
||||
@staticmethod
|
||||
def json_default(value: Any) -> Any:
|
||||
@ -112,7 +125,7 @@ class Command(BaseCommand):
|
||||
}
|
||||
)
|
||||
model_path = f"{model._meta.app_label}.{model._meta.model_name}"
|
||||
self.schema["properties"]["entries"]["items"]["oneOf"].append(
|
||||
self.schema["$defs"]["blueprint_entry"]["oneOf"].append(
|
||||
self.template_entry(model_path, model, serializer)
|
||||
)
|
||||
|
||||
@ -134,7 +147,7 @@ class Command(BaseCommand):
|
||||
"id": {"type": "string"},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": [s.value for s in BlueprintEntryDesiredState],
|
||||
"enum": sorted([s.value for s in BlueprintEntryDesiredState]),
|
||||
"default": "present",
|
||||
},
|
||||
"conditions": {"type": "array", "items": {"type": "boolean"}},
|
||||
@ -205,7 +218,7 @@ class Command(BaseCommand):
|
||||
"type": "object",
|
||||
"required": ["permission"],
|
||||
"properties": {
|
||||
"permission": {"type": "string", "enum": perms},
|
||||
"permission": {"type": "string", "enum": sorted(perms)},
|
||||
"user": {"type": "integer"},
|
||||
"role": {"type": "string"},
|
||||
},
|
||||
|
@ -1,10 +1,11 @@
|
||||
version: 1
|
||||
entries:
|
||||
- identifiers:
|
||||
name: "%(id)s"
|
||||
slug: "%(id)s"
|
||||
model: authentik_flows.flow
|
||||
state: present
|
||||
attrs:
|
||||
designation: stage_configuration
|
||||
title: foo
|
||||
foo:
|
||||
- identifiers:
|
||||
name: "%(id)s"
|
||||
slug: "%(id)s"
|
||||
model: authentik_flows.flow
|
||||
state: present
|
||||
attrs:
|
||||
designation: stage_configuration
|
||||
title: foo
|
||||
|
@ -191,11 +191,18 @@ class Blueprint:
|
||||
"""Dataclass used for a full export"""
|
||||
|
||||
version: int = field(default=1)
|
||||
entries: list[BlueprintEntry] = field(default_factory=list)
|
||||
entries: list[BlueprintEntry] | dict[str, list[BlueprintEntry]] = field(default_factory=list)
|
||||
context: dict = field(default_factory=dict)
|
||||
|
||||
metadata: BlueprintMetadata | None = field(default=None)
|
||||
|
||||
def iter_entries(self) -> Iterable[BlueprintEntry]:
|
||||
if isinstance(self.entries, dict):
|
||||
for _section, entries in self.entries.items():
|
||||
yield from entries
|
||||
else:
|
||||
yield from self.entries
|
||||
|
||||
|
||||
class YAMLTag:
|
||||
"""Base class for all YAML Tags"""
|
||||
@ -226,7 +233,7 @@ class KeyOf(YAMLTag):
|
||||
self.id_from = node.value
|
||||
|
||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||
for _entry in blueprint.entries:
|
||||
for _entry in blueprint.iter_entries():
|
||||
if _entry.id == self.id_from and _entry._state.instance:
|
||||
# Special handling for PolicyBindingModels, as they'll have a different PK
|
||||
# which is used when creating policy bindings
|
||||
|
@ -384,7 +384,7 @@ class Importer:
|
||||
def _apply_models(self, raise_errors=False) -> bool:
|
||||
"""Apply (create/update) models yaml"""
|
||||
self.__pk_map = {}
|
||||
for entry in self._import.entries:
|
||||
for entry in self._import.iter_entries():
|
||||
model_app_label, model_name = entry.get_model(self._import).split(".")
|
||||
try:
|
||||
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
|
||||
|
@ -47,7 +47,7 @@ class MetaModelRegistry:
|
||||
models = apps.get_models()
|
||||
for _, value in self.models.items():
|
||||
models.append(value)
|
||||
return models
|
||||
return sorted(models, key=str)
|
||||
|
||||
def get_model(self, app_label: str, model_id: str) -> type[Model]:
|
||||
"""Get model checks if any virtual models are registered, and falls back
|
||||
|
@ -148,3 +148,14 @@ class TestBrands(APITestCase):
|
||||
"default_locale": "",
|
||||
},
|
||||
)
|
||||
|
||||
def test_custom_css(self):
|
||||
"""Test custom_css"""
|
||||
brand = create_test_brand()
|
||||
brand.branding_custom_css = """* {
|
||||
font-family: "Foo bar";
|
||||
}"""
|
||||
brand.save()
|
||||
res = self.client.get(reverse("authentik_core:if-user"))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn(brand.branding_custom_css, res.content.decode())
|
||||
|
@ -5,6 +5,8 @@ from typing import Any
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Value as V
|
||||
from django.http.request import HttpRequest
|
||||
from django.utils.html import _json_script_escapes
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from authentik import get_full_version
|
||||
from authentik.brands.models import Brand
|
||||
@ -32,8 +34,13 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
|
||||
"""Context Processor that injects brand object into every template"""
|
||||
brand = getattr(request, "brand", DEFAULT_BRAND)
|
||||
tenant = getattr(request, "tenant", Tenant())
|
||||
# similarly to `json_script` we escape everything HTML-related, however django
|
||||
# only directly exposes this as a function that also wraps it in a <script> tag
|
||||
# which we dont want for CSS
|
||||
brand_css = mark_safe(str(brand.branding_custom_css).translate(_json_script_escapes)) # nosec
|
||||
return {
|
||||
"brand": brand,
|
||||
"brand_css": brand_css,
|
||||
"footer_links": tenant.footer_links,
|
||||
"html_meta": {**get_http_meta()},
|
||||
"version": get_full_version(),
|
||||
|
@ -2,11 +2,9 @@
|
||||
|
||||
from collections.abc import Iterator
|
||||
from copy import copy
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models.functions import ExtractHour
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
@ -20,7 +18,6 @@ from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.api.metrics import CoordinateSerializer
|
||||
from authentik.api.pagination import Pagination
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
@ -28,7 +25,6 @@ from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||
from authentik.events.models import EventAction
|
||||
from authentik.lib.utils.file import (
|
||||
FilePathSerializer,
|
||||
FileUploadSerializer,
|
||||
@ -321,18 +317,3 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
"""Set application icon (as URL)"""
|
||||
app: Application = self.get_object()
|
||||
return set_file_url(request, app, "meta_icon")
|
||||
|
||||
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
|
||||
@extend_schema(responses={200: CoordinateSerializer(many=True)})
|
||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||
def metrics(self, request: Request, slug: str):
|
||||
"""Metrics for application logins"""
|
||||
app = self.get_object()
|
||||
return Response(
|
||||
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||
action=EventAction.AUTHORIZE_APPLICATION,
|
||||
context__authorized_application__pk=app.pk.hex,
|
||||
)
|
||||
# 3 data points per day, so 8 hour spans
|
||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||
)
|
||||
|
@ -6,7 +6,6 @@ from typing import Any
|
||||
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db.models.functions import ExtractHour
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django.urls import reverse_lazy
|
||||
@ -52,7 +51,6 @@ from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.api.metrics import CoordinateSerializer
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
@ -317,53 +315,6 @@ class SessionUserSerializer(PassiveSerializer):
|
||||
original = UserSelfSerializer(required=False)
|
||||
|
||||
|
||||
class UserMetricsSerializer(PassiveSerializer):
|
||||
"""User Metrics"""
|
||||
|
||||
logins = SerializerMethodField()
|
||||
logins_failed = SerializerMethodField()
|
||||
authorizations = SerializerMethodField()
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_logins(self, _):
|
||||
"""Get successful logins per 8 hours for the last 7 days"""
|
||||
user = self.context["user"]
|
||||
request = self.context["request"]
|
||||
return (
|
||||
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||
action=EventAction.LOGIN, user__pk=user.pk
|
||||
)
|
||||
# 3 data points per day, so 8 hour spans
|
||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||
)
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_logins_failed(self, _):
|
||||
"""Get failed logins per 8 hours for the last 7 days"""
|
||||
user = self.context["user"]
|
||||
request = self.context["request"]
|
||||
return (
|
||||
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||
action=EventAction.LOGIN_FAILED, context__username=user.username
|
||||
)
|
||||
# 3 data points per day, so 8 hour spans
|
||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||
)
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_authorizations(self, _):
|
||||
"""Get failed logins per 8 hours for the last 7 days"""
|
||||
user = self.context["user"]
|
||||
request = self.context["request"]
|
||||
return (
|
||||
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||
action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
|
||||
)
|
||||
# 3 data points per day, so 8 hour spans
|
||||
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||
)
|
||||
|
||||
|
||||
class UsersFilter(FilterSet):
|
||||
"""Filter for users"""
|
||||
|
||||
@ -607,17 +558,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
update_session_auth_hash(self.request, user)
|
||||
return Response(status=204)
|
||||
|
||||
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
|
||||
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
|
||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||
def metrics(self, request: Request, pk: int) -> Response:
|
||||
"""User metrics per 1h"""
|
||||
user: User = self.get_object()
|
||||
serializer = UserMetricsSerializer(instance={})
|
||||
serializer.context["user"] = user
|
||||
serializer.context["request"] = request
|
||||
return Response(serializer.data)
|
||||
|
||||
@permission_required("authentik_core.reset_user_password")
|
||||
@extend_schema(
|
||||
responses={
|
||||
|
@ -16,7 +16,7 @@
|
||||
{% block head_before %}
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||
<style>{{ brand.branding_custom_css }}</style>
|
||||
<style>{{ brand_css }}</style>
|
||||
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
|
||||
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
|
||||
{% block head %}
|
||||
|
@ -10,7 +10,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<ak-message-container></ak-message-container>
|
||||
<ak-message-container alignment="bottom"></ak-message-container>
|
||||
<ak-interface-admin>
|
||||
<ak-loading></ak-loading>
|
||||
</ak-interface-admin>
|
||||
|
@ -81,22 +81,6 @@ class TestUsersAPI(APITestCase):
|
||||
response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_metrics(self):
|
||||
"""Test user's metrics"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_metrics_denied(self):
|
||||
"""Test user's metrics (non-superuser)"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_recovery_no_flow(self):
|
||||
"""Test user recovery link (no recovery flow set)"""
|
||||
self.client.force_login(self.admin)
|
||||
|
@ -1,28 +1,36 @@
|
||||
"""Events API Views"""
|
||||
|
||||
from datetime import timedelta
|
||||
from json import loads
|
||||
|
||||
import django_filters
|
||||
from django.db.models.aggregates import Count
|
||||
from django.db.models import Count, ExpressionWrapper, F, QuerySet
|
||||
from django.db.models import DateTimeField as DjangoDateTimeField
|
||||
from django.db.models.fields.json import KeyTextTransform, KeyTransform
|
||||
from django.db.models.functions import ExtractDay, ExtractHour
|
||||
from django.db.models.functions import TruncHour
|
||||
from django.db.models.query_utils import Q
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import DictField, IntegerField
|
||||
from rest_framework.fields import ChoiceField, DateTimeField, DictField, IntegerField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.admin.api.metrics import CoordinateSerializer
|
||||
from authentik.core.api.object_types import TypeCreateSerializer
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
class EventVolumeSerializer(PassiveSerializer):
|
||||
"""Count of events of action created on day"""
|
||||
|
||||
action = ChoiceField(choices=EventAction.choices)
|
||||
time = DateTimeField()
|
||||
count = IntegerField()
|
||||
|
||||
|
||||
class EventSerializer(ModelSerializer):
|
||||
"""Event Serializer"""
|
||||
|
||||
@ -53,7 +61,7 @@ class EventsFilter(django_filters.FilterSet):
|
||||
"""Filter for events"""
|
||||
|
||||
username = django_filters.CharFilter(
|
||||
field_name="user", lookup_expr="username", label="Username"
|
||||
field_name="user", label="Username", method="filter_username"
|
||||
)
|
||||
context_model_pk = django_filters.CharFilter(
|
||||
field_name="context",
|
||||
@ -78,12 +86,19 @@ class EventsFilter(django_filters.FilterSet):
|
||||
field_name="action",
|
||||
lookup_expr="icontains",
|
||||
)
|
||||
actions = django_filters.MultipleChoiceFilter(
|
||||
field_name="action",
|
||||
choices=EventAction.choices,
|
||||
)
|
||||
brand_name = django_filters.CharFilter(
|
||||
field_name="brand",
|
||||
lookup_expr="name",
|
||||
label="Brand name",
|
||||
)
|
||||
|
||||
def filter_username(self, queryset, name, value):
|
||||
return queryset.filter(Q(user__username=value) | Q(context__username=value))
|
||||
|
||||
def filter_context_model_pk(self, queryset, name, value):
|
||||
"""Because we store the PK as UUID.hex,
|
||||
we need to remove the dashes that a client may send. We can't use a
|
||||
@ -156,45 +171,37 @@ class EventViewSet(ModelViewSet):
|
||||
return Response(EventTopPerUserSerializer(instance=events, many=True).data)
|
||||
|
||||
@extend_schema(
|
||||
responses={200: CoordinateSerializer(many=True)},
|
||||
)
|
||||
@action(detail=False, methods=["GET"], pagination_class=None)
|
||||
def volume(self, request: Request) -> Response:
|
||||
"""Get event volume for specified filters and timeframe"""
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
return Response(queryset.get_events_per(timedelta(days=7), ExtractHour, 7 * 3))
|
||||
|
||||
@extend_schema(
|
||||
responses={200: CoordinateSerializer(many=True)},
|
||||
filters=[],
|
||||
responses={200: EventVolumeSerializer(many=True)},
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
"action",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
),
|
||||
OpenApiParameter(
|
||||
"query",
|
||||
type=OpenApiTypes.STR,
|
||||
"history_days",
|
||||
type=OpenApiTypes.NUMBER,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=False,
|
||||
default=7,
|
||||
),
|
||||
],
|
||||
)
|
||||
@action(detail=False, methods=["GET"], pagination_class=None)
|
||||
def per_month(self, request: Request):
|
||||
"""Get the count of events per month"""
|
||||
filtered_action = request.query_params.get("action", EventAction.LOGIN)
|
||||
try:
|
||||
query = loads(request.query_params.get("query", "{}"))
|
||||
except ValueError:
|
||||
return Response(status=400)
|
||||
def volume(self, request: Request) -> Response:
|
||||
"""Get event volume for specified filters and timeframe"""
|
||||
queryset: QuerySet[Event] = self.filter_queryset(self.get_queryset())
|
||||
delta = timedelta(days=7)
|
||||
time_delta = request.query_params.get("history_days", 7)
|
||||
if time_delta:
|
||||
delta = timedelta(days=min(int(time_delta), 60))
|
||||
return Response(
|
||||
get_objects_for_user(request.user, "authentik_events.view_event")
|
||||
.filter(action=filtered_action)
|
||||
.filter(**query)
|
||||
.get_events_per(timedelta(weeks=4), ExtractDay, 30)
|
||||
queryset.filter(created__gte=now() - delta)
|
||||
.annotate(hour=TruncHour("created"))
|
||||
.annotate(
|
||||
time=ExpressionWrapper(
|
||||
F("hour") - (F("hour__hour") % 6) * timedelta(hours=1),
|
||||
output_field=DjangoDateTimeField(),
|
||||
)
|
||||
)
|
||||
.values("time", "action")
|
||||
.annotate(count=Count("pk"))
|
||||
.order_by("time", "action")
|
||||
)
|
||||
|
||||
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
||||
|
@ -1,7 +1,5 @@
|
||||
"""authentik events models"""
|
||||
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import timedelta
|
||||
from difflib import get_close_matches
|
||||
from functools import lru_cache
|
||||
@ -11,11 +9,6 @@ from uuid import uuid4
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import connection, models
|
||||
from django.db.models import Count, ExpressionWrapper, F
|
||||
from django.db.models.fields import DurationField
|
||||
from django.db.models.functions import Extract
|
||||
from django.db.models.manager import Manager
|
||||
from django.db.models.query import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.timezone import now
|
||||
@ -124,60 +117,6 @@ class EventAction(models.TextChoices):
|
||||
CUSTOM_PREFIX = "custom_"
|
||||
|
||||
|
||||
class EventQuerySet(QuerySet):
|
||||
"""Custom events query set with helper functions"""
|
||||
|
||||
def get_events_per(
|
||||
self,
|
||||
time_since: timedelta,
|
||||
extract: Extract,
|
||||
data_points: int,
|
||||
) -> list[dict[str, int]]:
|
||||
"""Get event count by hour in the last day, fill with zeros"""
|
||||
_now = now()
|
||||
max_since = timedelta(days=60)
|
||||
# Allow maximum of 60 days to limit load
|
||||
if time_since.total_seconds() > max_since.total_seconds():
|
||||
time_since = max_since
|
||||
date_from = _now - time_since
|
||||
result = (
|
||||
self.filter(created__gte=date_from)
|
||||
.annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField()))
|
||||
.annotate(age_interval=extract("age"))
|
||||
.values("age_interval")
|
||||
.annotate(count=Count("pk"))
|
||||
.order_by("age_interval")
|
||||
)
|
||||
data = Counter({int(d["age_interval"]): d["count"] for d in result})
|
||||
results = []
|
||||
interval_delta = time_since / data_points
|
||||
for interval in range(1, -data_points, -1):
|
||||
results.append(
|
||||
{
|
||||
"x_cord": time.mktime((_now + (interval_delta * interval)).timetuple()) * 1000,
|
||||
"y_cord": data[interval * -1],
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
class EventManager(Manager):
|
||||
"""Custom helper methods for Events"""
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
"""use custom queryset"""
|
||||
return EventQuerySet(self.model, using=self._db)
|
||||
|
||||
def get_events_per(
|
||||
self,
|
||||
time_since: timedelta,
|
||||
extract: Extract,
|
||||
data_points: int,
|
||||
) -> list[dict[str, int]]:
|
||||
"""Wrap method from queryset"""
|
||||
return self.get_queryset().get_events_per(time_since, extract, data_points)
|
||||
|
||||
|
||||
class Event(SerializerModel, ExpiringModel):
|
||||
"""An individual Audit/Metrics/Notification/Error Event"""
|
||||
|
||||
@ -193,8 +132,6 @@ class Event(SerializerModel, ExpiringModel):
|
||||
# Shadow the expires attribute from ExpiringModel to override the default duration
|
||||
expires = models.DateTimeField(default=default_event_duration)
|
||||
|
||||
objects = EventManager()
|
||||
|
||||
@staticmethod
|
||||
def _get_app_from_request(request: HttpRequest) -> str:
|
||||
if not isinstance(request, HttpRequest):
|
||||
|
@ -37,6 +37,9 @@ class WebsocketMessageInstruction(IntEnum):
|
||||
# Provider specific message
|
||||
PROVIDER_SPECIFIC = 3
|
||||
|
||||
# Session ended
|
||||
SESSION_END = 4
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WebsocketMessage:
|
||||
@ -145,6 +148,14 @@ class OutpostConsumer(JsonWebsocketConsumer):
|
||||
asdict(WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE))
|
||||
)
|
||||
|
||||
def event_session_end(self, event):
|
||||
"""Event handler which is called when a session is ended"""
|
||||
self.send_json(
|
||||
asdict(
|
||||
WebsocketMessage(instruction=WebsocketMessageInstruction.SESSION_END, args=event)
|
||||
)
|
||||
)
|
||||
|
||||
def event_provider_specific(self, event):
|
||||
"""Event handler which can be called by provider-specific
|
||||
implementations to send specific messages to the outpost"""
|
||||
|
@ -1,17 +1,24 @@
|
||||
"""authentik outpost signals"""
|
||||
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import Provider
|
||||
from authentik.core.models import AuthenticatedSession, Provider, User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.outposts.models import Outpost, OutpostServiceConnection
|
||||
from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save
|
||||
from authentik.outposts.tasks import (
|
||||
CACHE_KEY_OUTPOST_DOWN,
|
||||
outpost_controller,
|
||||
outpost_post_save,
|
||||
outpost_session_end,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
UPDATE_TRIGGERING_MODELS = (
|
||||
@ -73,3 +80,17 @@ def pre_delete_cleanup(sender, instance: Outpost, **_):
|
||||
instance.user.delete()
|
||||
cache.set(CACHE_KEY_OUTPOST_DOWN % instance.pk.hex, instance)
|
||||
outpost_controller.delay(instance.pk.hex, action="down", from_cache=True)
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def logout_revoke_direct(sender: type[User], request: HttpRequest, **_):
|
||||
"""Catch logout by direct logout and forward to providers"""
|
||||
if not request.session or not request.session.session_key:
|
||||
return
|
||||
outpost_session_end.delay(request.session.session_key)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
|
||||
"""Catch logout by expiring sessions being deleted"""
|
||||
outpost_session_end.delay(instance.session.session_key)
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""outpost tasks"""
|
||||
|
||||
from hashlib import sha256
|
||||
from os import R_OK, access
|
||||
from pathlib import Path
|
||||
from socket import gethostname
|
||||
@ -49,6 +50,11 @@ LOGGER = get_logger()
|
||||
CACHE_KEY_OUTPOST_DOWN = "goauthentik.io/outposts/teardown/%s"
|
||||
|
||||
|
||||
def hash_session_key(session_key: str) -> str:
|
||||
"""Hash the session key for sending session end signals"""
|
||||
return sha256(session_key.encode("ascii")).hexdigest()
|
||||
|
||||
|
||||
def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None:
|
||||
"""Get a controller for the outpost, when a service connection is defined"""
|
||||
if not outpost.service_connection:
|
||||
@ -289,3 +295,20 @@ def outpost_connection_discovery(self: SystemTask):
|
||||
url=unix_socket_path,
|
||||
)
|
||||
self.set_status(TaskStatus.SUCCESSFUL, *messages)
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def outpost_session_end(session_id: str):
|
||||
"""Update outpost instances connected to a single outpost"""
|
||||
layer = get_channel_layer()
|
||||
hashed_session_id = hash_session_key(session_id)
|
||||
for outpost in Outpost.objects.all():
|
||||
LOGGER.info("Sending session end signal to outpost", outpost=outpost)
|
||||
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
|
||||
async_to_sync(layer.group_send)(
|
||||
group,
|
||||
{
|
||||
"type": "event.session.end",
|
||||
"session_id": hashed_session_id,
|
||||
},
|
||||
)
|
||||
|
@ -1,11 +1,9 @@
|
||||
"""Websocket tests"""
|
||||
|
||||
from dataclasses import asdict
|
||||
from unittest.mock import patch
|
||||
|
||||
from channels.routing import URLRouter
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from authentik import __version__
|
||||
@ -16,12 +14,6 @@ from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.root import websocket
|
||||
|
||||
|
||||
def patched__get_ct_cached(app_label, codename):
|
||||
"""Caches `ContentType` instances like its `QuerySet` does."""
|
||||
return ContentType.objects.get(app_label=app_label, permission__codename=codename)
|
||||
|
||||
|
||||
@patch("guardian.shortcuts._get_ct_cached", patched__get_ct_cached)
|
||||
class TestOutpostWS(TransactionTestCase):
|
||||
"""Websocket tests"""
|
||||
|
||||
|
@ -387,8 +387,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
self.assertEqual(
|
||||
response.url,
|
||||
(
|
||||
f"http://localhost#access_token={token.token}"
|
||||
f"&id_token={provider.encode(token.id_token.to_dict())}"
|
||||
f"http://localhost#id_token={provider.encode(token.id_token.to_dict())}"
|
||||
f"&token_type={TOKEN_TYPE}"
|
||||
f"&expires_in={int(expires)}&state={state}"
|
||||
),
|
||||
@ -563,7 +562,6 @@ class TestAuthorize(OAuthTestCase):
|
||||
"url": "http://localhost",
|
||||
"title": f"Redirecting to {app.name}...",
|
||||
"attrs": {
|
||||
"access_token": token.token,
|
||||
"id_token": provider.encode(token.id_token.to_dict()),
|
||||
"token_type": TOKEN_TYPE,
|
||||
"expires_in": "3600",
|
||||
|
@ -150,12 +150,12 @@ class OAuthAuthorizationParams:
|
||||
self.check_redirect_uri()
|
||||
self.check_grant()
|
||||
self.check_scope(github_compat)
|
||||
self.check_nonce()
|
||||
self.check_code_challenge()
|
||||
if self.request:
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "request_not_supported", self.grant_type, self.state
|
||||
)
|
||||
self.check_nonce()
|
||||
self.check_code_challenge()
|
||||
|
||||
def check_grant(self):
|
||||
"""Check grant"""
|
||||
@ -630,7 +630,6 @@ class OAuthFulfillmentStage(StageView):
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
]:
|
||||
query_fragment["access_token"] = token.token
|
||||
|
@ -1,23 +0,0 @@
|
||||
"""Proxy provider signals"""
|
||||
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.db.models.signals import pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.providers.proxy.tasks import proxy_on_logout
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def logout_proxy_revoke_direct(sender: type[User], request: HttpRequest, **_):
|
||||
"""Catch logout by direct logout and forward to proxy providers"""
|
||||
if not request.session or not request.session.session_key:
|
||||
return
|
||||
proxy_on_logout.delay(request.session.session_key)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
|
||||
"""Catch logout by expiring sessions being deleted"""
|
||||
proxy_on_logout.delay(instance.session.session_key)
|
@ -1,26 +0,0 @@
|
||||
"""proxy provider tasks"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
from authentik.outposts.consumer import OUTPOST_GROUP
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.oauth2.id_token import hash_session_key
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
|
||||
@CELERY_APP.task()
|
||||
def proxy_on_logout(session_id: str):
|
||||
"""Update outpost instances connected to a single outpost"""
|
||||
layer = get_channel_layer()
|
||||
hashed_session_id = hash_session_key(session_id)
|
||||
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
|
||||
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
|
||||
async_to_sync(layer.group_send)(
|
||||
group,
|
||||
{
|
||||
"type": "event.provider.specific",
|
||||
"sub_type": "logout",
|
||||
"session_id": hashed_session_id,
|
||||
},
|
||||
)
|
@ -20,6 +20,9 @@ from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
PLAN_CONNECTION_SETTINGS = "connection_settings"
|
||||
|
||||
|
||||
class RACStartView(PolicyAccessView):
|
||||
@ -109,10 +112,15 @@ class RACFinalStage(RedirectStage):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
|
||||
settings = self.executor.plan.context.get(PLAN_CONNECTION_SETTINGS)
|
||||
if not settings:
|
||||
settings = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).get(
|
||||
PLAN_CONNECTION_SETTINGS
|
||||
)
|
||||
token = ConnectionToken.objects.create(
|
||||
provider=self.provider,
|
||||
endpoint=self.endpoint,
|
||||
settings=self.executor.plan.context.get("connection_settings", {}),
|
||||
settings=settings or {},
|
||||
session=self.request.session["authenticatedsession"],
|
||||
expires=now() + timedelta_from_string(self.provider.connection_expiry),
|
||||
expiring=True,
|
||||
|
@ -190,6 +190,7 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"sign_response",
|
||||
"sp_binding",
|
||||
"default_relay_state",
|
||||
"default_name_id_policy",
|
||||
"url_download_metadata",
|
||||
"url_sso_post",
|
||||
"url_sso_redirect",
|
||||
|
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-18 09:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_saml", "0018_alter_samlprovider_acs_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="default_name_id_policy",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "Email"),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "Persistent"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName", "X509"),
|
||||
(
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
|
||||
"Windows",
|
||||
),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:transient", "Transient"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", "Unspecified"),
|
||||
],
|
||||
default="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
|
||||
),
|
||||
),
|
||||
]
|
@ -12,6 +12,7 @@ from authentik.core.models import PropertyMapping, Provider
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DSA_SHA1,
|
||||
ECDSA_SHA1,
|
||||
@ -179,6 +180,9 @@ class SAMLProvider(Provider):
|
||||
default_relay_state = models.TextField(
|
||||
default="", blank=True, help_text=_("Default relay_state value for IDP-initiated logins")
|
||||
)
|
||||
default_name_id_policy = models.TextField(
|
||||
choices=SAMLNameIDPolicy.choices, default=SAMLNameIDPolicy.UNSPECIFIED
|
||||
)
|
||||
|
||||
sign_assertion = models.BooleanField(default=True)
|
||||
sign_response = models.BooleanField(default=False)
|
||||
|
@ -205,6 +205,13 @@ class AssertionProcessor:
|
||||
def get_name_id(self) -> Element:
|
||||
"""Get NameID Element"""
|
||||
name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID")
|
||||
# For requests that don't specify a NameIDPolicy, check if we
|
||||
# can fall back to the provider default
|
||||
if (
|
||||
self.auth_n_request.name_id_policy == SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
and self.provider.default_name_id_policy != SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
):
|
||||
self.auth_n_request.name_id_policy = self.provider.default_name_id_policy
|
||||
name_id.attrib["Format"] = self.auth_n_request.name_id_policy
|
||||
# persistent is used as a fallback, so always generate it
|
||||
persistent = self.http_request.user.uid
|
||||
|
@ -13,6 +13,7 @@ from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DSA_SHA1,
|
||||
NS_MAP,
|
||||
@ -175,7 +176,9 @@ class AuthNRequestParser:
|
||||
|
||||
def idp_initiated(self) -> AuthNRequest:
|
||||
"""Create IdP Initiated AuthNRequest"""
|
||||
relay_state = None
|
||||
request = AuthNRequest(relay_state=None)
|
||||
if self.provider.default_relay_state != "":
|
||||
relay_state = self.provider.default_relay_state
|
||||
return AuthNRequest(relay_state=relay_state)
|
||||
request.relay_state = self.provider.default_relay_state
|
||||
if self.provider.default_name_id_policy != SAMLNameIDPolicy.UNSPECIFIED:
|
||||
request.name_id_policy = self.provider.default_name_id_policy
|
||||
return request
|
||||
|
@ -13,6 +13,7 @@ from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import PEM_FOOTER, PEM_HEADER
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_METADATA,
|
||||
@ -46,6 +47,7 @@ class ServiceProviderMetadata:
|
||||
|
||||
auth_n_request_signed: bool
|
||||
assertion_signed: bool
|
||||
name_id_policy: SAMLNameIDPolicy
|
||||
|
||||
signing_keypair: CertificateKeyPair | None = None
|
||||
|
||||
@ -60,6 +62,7 @@ class ServiceProviderMetadata:
|
||||
provider.issuer = self.entity_id
|
||||
provider.sp_binding = self.acs_binding
|
||||
provider.acs_url = self.acs_location
|
||||
provider.default_name_id_policy = self.name_id_policy
|
||||
if self.signing_keypair and self.auth_n_request_signed:
|
||||
self.signing_keypair.name = f"Provider {name} - SAML Signing Certificate"
|
||||
self.signing_keypair.save()
|
||||
@ -148,6 +151,11 @@ class ServiceProviderMetadataParser:
|
||||
if signing_keypair:
|
||||
self.check_signature(root, signing_keypair)
|
||||
|
||||
name_id_format = descriptor.findall(f"{{{NS_SAML_METADATA}}}NameIDFormat")
|
||||
name_id_policy = SAMLNameIDPolicy.UNSPECIFIED
|
||||
if len(name_id_format) > 0:
|
||||
name_id_policy = SAMLNameIDPolicy(name_id_format[0].text)
|
||||
|
||||
return ServiceProviderMetadata(
|
||||
entity_id=entity_id,
|
||||
acs_binding=acs_binding,
|
||||
@ -155,4 +163,5 @@ class ServiceProviderMetadataParser:
|
||||
auth_n_request_signed=auth_n_request_signed,
|
||||
assertion_signed=assertion_signed,
|
||||
signing_keypair=signing_keypair,
|
||||
name_id_policy=name_id_policy,
|
||||
)
|
||||
|
@ -4,7 +4,7 @@
|
||||
cacheDuration="PT604800S"
|
||||
entityID="http://localhost:8080/saml/metadata">
|
||||
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="http://localhost:8080/saml/acs"
|
||||
index="1" />
|
||||
|
@ -14,6 +14,7 @@ from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||
from authentik.providers.saml.processors.metadata_parser import ServiceProviderMetadataParser
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import ECDSA_SHA256, NS_MAP, NS_SAML_METADATA
|
||||
|
||||
|
||||
@ -86,6 +87,7 @@ class TestServiceProviderMetadataParser(TestCase):
|
||||
self.assertEqual(provider.acs_url, "http://localhost:8080/saml/acs")
|
||||
self.assertEqual(provider.issuer, "http://localhost:8080/saml/metadata")
|
||||
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
|
||||
self.assertEqual(provider.default_name_id_policy, SAMLNameIDPolicy.EMAIL)
|
||||
self.assertEqual(
|
||||
len(provider.property_mappings.all()),
|
||||
len(SAMLPropertyMapping.objects.exclude(managed__isnull=True)),
|
||||
|
@ -1,12 +1,29 @@
|
||||
"""test decorators api"""
|
||||
|
||||
from django.urls import reverse
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import get_request
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
|
||||
class MVS(ModelViewSet):
|
||||
|
||||
queryset = Application.objects.all()
|
||||
lookup_field = "slug"
|
||||
|
||||
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
|
||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||
def test(self, request: Request, slug: str):
|
||||
self.get_object()
|
||||
return Response(status=200)
|
||||
|
||||
|
||||
class TestAPIDecorators(APITestCase):
|
||||
@ -18,41 +35,33 @@ class TestAPIDecorators(APITestCase):
|
||||
|
||||
def test_obj_perm_denied(self):
|
||||
"""Test object perm denied"""
|
||||
self.client.force_login(self.user)
|
||||
request = get_request("", user=self.user)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
|
||||
)
|
||||
response = MVS.as_view({"get": "test"})(request, slug=app.slug)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_obj_perm_global(self):
|
||||
"""Test object perm successful (global)"""
|
||||
assign_perm("authentik_core.view_application", self.user)
|
||||
assign_perm("authentik_events.view_event", self.user)
|
||||
self.client.force_login(self.user)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
request = get_request("", user=self.user)
|
||||
response = MVS.as_view({"get": "test"})(request, slug=app.slug)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
|
||||
def test_obj_perm_scoped(self):
|
||||
"""Test object perm successful (scoped)"""
|
||||
assign_perm("authentik_events.view_event", self.user)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
assign_perm("authentik_core.view_application", self.user, app)
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
|
||||
)
|
||||
request = get_request("", user=self.user)
|
||||
response = MVS.as_view({"get": "test"})(request, slug=app.slug)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_other_perm_denied(self):
|
||||
"""Test other perm denied"""
|
||||
self.client.force_login(self.user)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
assign_perm("authentik_core.view_application", self.user, app)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
|
||||
)
|
||||
request = get_request("", user=self.user)
|
||||
response = MVS.as_view({"get": "test"})(request, slug=app.slug)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
@ -166,6 +166,7 @@ SPECTACULAR_SETTINGS = {
|
||||
"UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification",
|
||||
"UserTypeEnum": "authentik.core.models.UserTypes",
|
||||
"OutgoingSyncDeleteAction": "authentik.lib.sync.outgoing.models.OutgoingSyncDeleteAction",
|
||||
"SAMLNameIDPolicyEnum": "authentik.sources.saml.models.SAMLNameIDPolicy",
|
||||
},
|
||||
"ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE": False,
|
||||
"ENUM_GENERATE_CHOICE_DESCRIPTION": False,
|
||||
|
@ -3,25 +3,44 @@
|
||||
import os
|
||||
from argparse import ArgumentParser
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test.runner import DiscoverRunner
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.sentry import sentry_init
|
||||
from authentik.root.signals import post_startup, pre_startup, startup
|
||||
from tests.e2e.utils import get_docker_tag
|
||||
|
||||
# globally set maxDiff to none to show full assert error
|
||||
TestCase.maxDiff = None
|
||||
|
||||
|
||||
def get_docker_tag() -> str:
|
||||
"""Get docker-tag based off of CI variables"""
|
||||
env_pr_branch = "GITHUB_HEAD_REF"
|
||||
default_branch = "GITHUB_REF"
|
||||
branch_name = os.environ.get(default_branch, "main")
|
||||
if os.environ.get(env_pr_branch, "") != "":
|
||||
branch_name = os.environ[env_pr_branch]
|
||||
branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
|
||||
return f"gh-{branch_name}"
|
||||
|
||||
|
||||
def patched__get_ct_cached(app_label, codename):
|
||||
"""Caches `ContentType` instances like its `QuerySet` does."""
|
||||
return ContentType.objects.get(app_label=app_label, permission__codename=codename)
|
||||
|
||||
|
||||
class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
"""Runs pytest to discover and run tests."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.logger = get_logger().bind(runner="pytest")
|
||||
|
||||
self.args = []
|
||||
if self.failfast:
|
||||
@ -34,22 +53,33 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
if kwargs.get("no_capture", False):
|
||||
self.args.append("--capture=no")
|
||||
|
||||
self._setup_test_environment()
|
||||
|
||||
def _setup_test_environment(self):
|
||||
"""Configure test environment settings"""
|
||||
settings.TEST = True
|
||||
settings.CELERY["task_always_eager"] = True
|
||||
CONFIG.set("events.context_processors.geoip", "tests/GeoLite2-City-Test.mmdb")
|
||||
CONFIG.set("events.context_processors.asn", "tests/GeoLite2-ASN-Test.mmdb")
|
||||
CONFIG.set("blueprints_dir", "./blueprints")
|
||||
CONFIG.set(
|
||||
"outposts.container_image_base",
|
||||
f"ghcr.io/goauthentik/dev-%(type)s:{get_docker_tag()}",
|
||||
)
|
||||
CONFIG.set("tenants.enabled", False)
|
||||
CONFIG.set("outposts.disable_embedded_outpost", False)
|
||||
CONFIG.set("error_reporting.sample_rate", 0)
|
||||
CONFIG.set("error_reporting.environment", "testing")
|
||||
CONFIG.set("error_reporting.send_pii", True)
|
||||
sentry_init()
|
||||
|
||||
# Test-specific configuration
|
||||
test_config = {
|
||||
"events.context_processors.geoip": "tests/GeoLite2-City-Test.mmdb",
|
||||
"events.context_processors.asn": "tests/GeoLite2-ASN-Test.mmdb",
|
||||
"blueprints_dir": "./blueprints",
|
||||
"outposts.container_image_base": f"ghcr.io/goauthentik/dev-%(type)s:{get_docker_tag()}",
|
||||
"tenants.enabled": False,
|
||||
"outposts.disable_embedded_outpost": False,
|
||||
"error_reporting.sample_rate": 0,
|
||||
"error_reporting.environment": "testing",
|
||||
"error_reporting.send_pii": True,
|
||||
}
|
||||
|
||||
for key, value in test_config.items():
|
||||
CONFIG.set(key, value)
|
||||
|
||||
sentry_init()
|
||||
self.logger.debug("Test environment configured")
|
||||
|
||||
# Send startup signals
|
||||
pre_startup.send(sender=self, mode="test")
|
||||
startup.send(sender=self, mode="test")
|
||||
post_startup.send(sender=self, mode="test")
|
||||
@ -72,7 +102,21 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
help="Disable any capturing of stdout/stderr during tests.",
|
||||
)
|
||||
|
||||
def run_tests(self, test_labels, extra_tests=None, **kwargs):
|
||||
def _validate_test_label(self, label: str) -> bool:
|
||||
"""Validate test label format"""
|
||||
if not label:
|
||||
return False
|
||||
|
||||
# Check for invalid characters, but allow forward slashes and colons
|
||||
# for paths and pytest markers
|
||||
invalid_chars = set('\\*?"<>|')
|
||||
if any(c in label for c in invalid_chars):
|
||||
self.logger.error("Invalid characters in test label", label=label)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def run_tests(self, test_labels: list[str], extra_tests=None, **kwargs):
|
||||
"""Run pytest and return the exitcode.
|
||||
|
||||
It translates some of Django's test command option to pytest's.
|
||||
@ -82,10 +126,17 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
The extra_tests argument has been deprecated since Django 5.x
|
||||
It is kept for compatibility with PyCharm's Django test runner.
|
||||
"""
|
||||
if not test_labels:
|
||||
self.logger.error("No test files specified")
|
||||
return 1
|
||||
|
||||
for label in test_labels:
|
||||
if not self._validate_test_label(label):
|
||||
return 1
|
||||
|
||||
valid_label_found = False
|
||||
label_as_path = os.path.abspath(label)
|
||||
|
||||
# File path has been specified
|
||||
if os.path.exists(label_as_path):
|
||||
self.args.append(label_as_path)
|
||||
@ -93,24 +144,31 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
elif "::" in label:
|
||||
self.args.append(label)
|
||||
valid_label_found = True
|
||||
# Convert dotted module path to file_path::class::method
|
||||
else:
|
||||
# Check if the label is a dotted module path
|
||||
path_pieces = label.split(".")
|
||||
# Check whether only class or class and method are specified
|
||||
for i in range(-1, -3, -1):
|
||||
path = os.path.join(*path_pieces[:i]) + ".py"
|
||||
label_as_path = os.path.abspath(path)
|
||||
if os.path.exists(label_as_path):
|
||||
path_method = label_as_path + "::" + "::".join(path_pieces[i:])
|
||||
self.args.append(path_method)
|
||||
valid_label_found = True
|
||||
break
|
||||
try:
|
||||
path = os.path.join(*path_pieces[:i]) + ".py"
|
||||
if os.path.exists(path):
|
||||
if i < -1:
|
||||
path_method = path + "::" + "::".join(path_pieces[i:])
|
||||
self.args.append(path_method)
|
||||
else:
|
||||
self.args.append(path)
|
||||
valid_label_found = True
|
||||
break
|
||||
except (TypeError, IndexError):
|
||||
continue
|
||||
|
||||
if not valid_label_found:
|
||||
raise RuntimeError(
|
||||
f"One of the test labels: {label!r}, "
|
||||
f"is not supported. Use a dotted module name or "
|
||||
f"path instead."
|
||||
)
|
||||
self.logger.error("Test file not found", label=label)
|
||||
return 1
|
||||
|
||||
return pytest.main(self.args)
|
||||
self.logger.info("Running tests", test_files=self.args)
|
||||
with patch("guardian.shortcuts._get_ct_cached", patched__get_ct_cached):
|
||||
try:
|
||||
return pytest.main(self.args)
|
||||
except Exception as e:
|
||||
self.logger.error("Error running tests", error=str(e), test_files=self.args)
|
||||
return 1
|
||||
|
@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-18 09:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_saml", "0019_migrate_usersamlsourceconnection_identifier"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="samlsource",
|
||||
name="name_id_policy",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "Email"),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "Persistent"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName", "X509"),
|
||||
(
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
|
||||
"Windows",
|
||||
),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:transient", "Transient"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", "Unspecified"),
|
||||
],
|
||||
default="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
|
||||
help_text="NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent.",
|
||||
),
|
||||
),
|
||||
]
|
@ -39,6 +39,7 @@ from authentik.sources.saml.processors.constants import (
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_PERSISTENT,
|
||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
SAML_NAME_ID_FORMAT_WINDOWS,
|
||||
SAML_NAME_ID_FORMAT_X509,
|
||||
SHA1,
|
||||
@ -73,6 +74,7 @@ class SAMLNameIDPolicy(models.TextChoices):
|
||||
X509 = SAML_NAME_ID_FORMAT_X509
|
||||
WINDOWS = SAML_NAME_ID_FORMAT_WINDOWS
|
||||
TRANSIENT = SAML_NAME_ID_FORMAT_TRANSIENT
|
||||
UNSPECIFIED = SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
|
||||
|
||||
class SAMLSource(Source):
|
||||
|
File diff suppressed because one or more lines are too long
@ -100,9 +100,11 @@ def send_mail(
|
||||
# Because we use the Message-ID as UID for the task, manually assign it
|
||||
message_object.extra_headers["Message-ID"] = message_id
|
||||
|
||||
# Add the logo (we can't add it in the previous message since MIMEImage
|
||||
# can't be converted to json)
|
||||
message_object.attach(logo_data())
|
||||
# Add the logo if it is used in the email body (we can't add it in the
|
||||
# previous message since MIMEImage can't be converted to json)
|
||||
body = get_email_body(message_object)
|
||||
if "cid:logo" in body:
|
||||
message_object.attach(logo_data())
|
||||
|
||||
if (
|
||||
message_object.to
|
||||
|
@ -96,7 +96,7 @@
|
||||
<table width="100%" style="background-color: #FFFFFF; border-spacing: 0; margin-top: 15px;">
|
||||
<tr height="80">
|
||||
<td align="center" style="padding: 20px 0;">
|
||||
<img src="{% block logo_url %}cid:logo.png{% endblock %}" border="0=" alt="authentik logo" class="flexibleImage logo">
|
||||
<img src="{% block logo_url %}cid:logo{% endblock %}" border="0=" alt="authentik logo" class="flexibleImage logo">
|
||||
</td>
|
||||
</tr>
|
||||
{% block content %}
|
||||
|
@ -19,7 +19,8 @@ def logo_data() -> MIMEImage:
|
||||
path = Path("web/dist/assets/icons/icon_left_brand.png")
|
||||
with open(path, "rb") as _logo_file:
|
||||
logo = MIMEImage(_logo_file.read())
|
||||
logo.add_header("Content-ID", "logo.png")
|
||||
logo.add_header("Content-ID", "<logo>")
|
||||
logo.add_header("Content-Disposition", "inline", filename="logo.png")
|
||||
return logo
|
||||
|
||||
|
||||
|
14171
blueprints/schema.json
14171
blueprints/schema.json
File diff suppressed because it is too large
Load Diff
@ -2,17 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/constants"
|
||||
"goauthentik.io/internal/debug"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/ak/entrypoint"
|
||||
"goauthentik.io/internal/outpost/ak/healthcheck"
|
||||
"goauthentik.io/internal/outpost/ldap"
|
||||
)
|
||||
@ -25,65 +21,15 @@ Required environment variables:
|
||||
- AUTHENTIK_INSECURE: Skip SSL Certificate verification`
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Long: helpMessage,
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
log.SetFormatter(&log.JSONFormatter{
|
||||
FieldMap: log.FieldMap{
|
||||
log.FieldKeyMsg: "event",
|
||||
log.FieldKeyTime: "timestamp",
|
||||
},
|
||||
DisableHTMLEscape: true,
|
||||
})
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
debug.EnableDebugServer()
|
||||
akURL := config.Get().AuthentikHost
|
||||
if akURL == "" {
|
||||
fmt.Println("env AUTHENTIK_HOST not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
akToken := config.Get().AuthentikToken
|
||||
if akToken == "" {
|
||||
fmt.Println("env AUTHENTIK_TOKEN not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
akURLActual, err := url.Parse(akURL)
|
||||
Long: helpMessage,
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: common.PreRun,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := entrypoint.OutpostMain("authentik.outpost.ldap", ldap.NewServer)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
go func() {
|
||||
for {
|
||||
<-ex
|
||||
os.Exit(0)
|
||||
}
|
||||
}()
|
||||
|
||||
ac := ak.NewAPIController(*akURLActual, akToken)
|
||||
if ac == nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ac.Shutdown()
|
||||
|
||||
ac.Server = ldap.NewServer(ac)
|
||||
|
||||
err = ac.Start()
|
||||
if err != nil {
|
||||
log.WithError(err).Panic("Failed to run server")
|
||||
}
|
||||
|
||||
for {
|
||||
<-ex
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -2,17 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/constants"
|
||||
"goauthentik.io/internal/debug"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/ak/entrypoint"
|
||||
"goauthentik.io/internal/outpost/ak/healthcheck"
|
||||
"goauthentik.io/internal/outpost/proxyv2"
|
||||
)
|
||||
@ -28,65 +24,15 @@ Optionally, you can set these:
|
||||
- AUTHENTIK_HOST_BROWSER: URL to use in the browser, when it differs from AUTHENTIK_HOST`
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Long: helpMessage,
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
log.SetFormatter(&log.JSONFormatter{
|
||||
FieldMap: log.FieldMap{
|
||||
log.FieldKeyMsg: "event",
|
||||
log.FieldKeyTime: "timestamp",
|
||||
},
|
||||
DisableHTMLEscape: true,
|
||||
})
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
debug.EnableDebugServer()
|
||||
akURL := config.Get().AuthentikHost
|
||||
if akURL == "" {
|
||||
fmt.Println("env AUTHENTIK_HOST not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
akToken := config.Get().AuthentikToken
|
||||
if akToken == "" {
|
||||
fmt.Println("env AUTHENTIK_TOKEN not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
akURLActual, err := url.Parse(akURL)
|
||||
Long: helpMessage,
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: common.PreRun,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := entrypoint.OutpostMain("authentik.outpost.proxy", proxyv2.NewProxyServer)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
go func() {
|
||||
for {
|
||||
<-ex
|
||||
os.Exit(0)
|
||||
}
|
||||
}()
|
||||
|
||||
ac := ak.NewAPIController(*akURLActual, akToken)
|
||||
if ac == nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ac.Shutdown()
|
||||
|
||||
ac.Server = proxyv2.NewProxyServer(ac)
|
||||
|
||||
err = ac.Start()
|
||||
if err != nil {
|
||||
log.WithError(err).Panic("Failed to run server")
|
||||
}
|
||||
|
||||
for {
|
||||
<-ex
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -2,16 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/constants"
|
||||
"goauthentik.io/internal/debug"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/ak/entrypoint"
|
||||
"goauthentik.io/internal/outpost/ak/healthcheck"
|
||||
"goauthentik.io/internal/outpost/rac"
|
||||
)
|
||||
@ -24,65 +21,15 @@ Required environment variables:
|
||||
- AUTHENTIK_INSECURE: Skip SSL Certificate verification`
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Long: helpMessage,
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
log.SetFormatter(&log.JSONFormatter{
|
||||
FieldMap: log.FieldMap{
|
||||
log.FieldKeyMsg: "event",
|
||||
log.FieldKeyTime: "timestamp",
|
||||
},
|
||||
DisableHTMLEscape: true,
|
||||
})
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
debug.EnableDebugServer()
|
||||
akURL, found := os.LookupEnv("AUTHENTIK_HOST")
|
||||
if !found {
|
||||
fmt.Println("env AUTHENTIK_HOST not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
akToken, found := os.LookupEnv("AUTHENTIK_TOKEN")
|
||||
if !found {
|
||||
fmt.Println("env AUTHENTIK_TOKEN not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
akURLActual, err := url.Parse(akURL)
|
||||
Long: helpMessage,
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: common.PreRun,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := entrypoint.OutpostMain("authentik.outpost.rac", rac.NewServer)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
go func() {
|
||||
for {
|
||||
<-ex
|
||||
os.Exit(0)
|
||||
}
|
||||
}()
|
||||
|
||||
ac := ak.NewAPIController(*akURLActual, akToken)
|
||||
if ac == nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ac.Shutdown()
|
||||
|
||||
ac.Server = rac.NewServer(ac)
|
||||
|
||||
err = ac.Start()
|
||||
if err != nil {
|
||||
log.WithError(err).Panic("Failed to run server")
|
||||
}
|
||||
|
||||
for {
|
||||
<-ex
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -2,16 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/constants"
|
||||
"goauthentik.io/internal/debug"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/ak/entrypoint"
|
||||
"goauthentik.io/internal/outpost/ak/healthcheck"
|
||||
"goauthentik.io/internal/outpost/radius"
|
||||
)
|
||||
@ -24,65 +21,15 @@ Required environment variables:
|
||||
- AUTHENTIK_INSECURE: Skip SSL Certificate verification`
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Long: helpMessage,
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
log.SetFormatter(&log.JSONFormatter{
|
||||
FieldMap: log.FieldMap{
|
||||
log.FieldKeyMsg: "event",
|
||||
log.FieldKeyTime: "timestamp",
|
||||
},
|
||||
DisableHTMLEscape: true,
|
||||
})
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
debug.EnableDebugServer()
|
||||
akURL, found := os.LookupEnv("AUTHENTIK_HOST")
|
||||
if !found {
|
||||
fmt.Println("env AUTHENTIK_HOST not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
akToken, found := os.LookupEnv("AUTHENTIK_TOKEN")
|
||||
if !found {
|
||||
fmt.Println("env AUTHENTIK_TOKEN not set!")
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
akURLActual, err := url.Parse(akURL)
|
||||
Long: helpMessage,
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: common.PreRun,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := entrypoint.OutpostMain("authentik.outpost.radius", radius.NewServer)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Println(helpMessage)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
go func() {
|
||||
for {
|
||||
<-ex
|
||||
os.Exit(0)
|
||||
}
|
||||
}()
|
||||
|
||||
ac := ak.NewAPIController(*akURLActual, akToken)
|
||||
if ac == nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ac.Shutdown()
|
||||
|
||||
ac.Server = radius.NewServer(ac)
|
||||
|
||||
err = ac.Start()
|
||||
if err != nil {
|
||||
log.WithError(err).Panic("Failed to run server")
|
||||
}
|
||||
|
||||
for {
|
||||
<-ex
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -22,21 +22,12 @@ import (
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "authentik",
|
||||
Short: "Start authentik instance",
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
log.SetFormatter(&log.JSONFormatter{
|
||||
FieldMap: log.FieldMap{
|
||||
log.FieldKeyMsg: "event",
|
||||
log.FieldKeyTime: "timestamp",
|
||||
},
|
||||
DisableHTMLEscape: true,
|
||||
})
|
||||
},
|
||||
Use: "authentik",
|
||||
Short: "Start authentik instance",
|
||||
Version: constants.FullVersion(),
|
||||
PersistentPreRun: common.PreRun,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
debug.EnableDebugServer()
|
||||
debug.EnableDebugServer("authentik.core")
|
||||
l := log.WithField("logger", "authentik.root")
|
||||
|
||||
if config.Get().ErrorReporting.Enabled {
|
||||
@ -99,7 +90,7 @@ func attemptProxyStart(ws *web.WebServer, u *url.URL) {
|
||||
})
|
||||
|
||||
srv := proxyv2.NewProxyServer(ac)
|
||||
ws.ProxyServer = srv
|
||||
ws.ProxyServer = srv.(*proxyv2.ProxyServer)
|
||||
ac.Server = srv
|
||||
l.Debug("attempting to start outpost")
|
||||
err := ac.StartBackgroundTasks()
|
||||
|
@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.0}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.2}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -55,7 +55,7 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.0}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.2}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
12
go.mod
12
go.mod
@ -4,6 +4,7 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
beryju.io/ldap v0.1.0
|
||||
github.com/avast/retry-go/v4 v4.6.1
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/getsentry/sentry-go v0.33.0
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
||||
@ -16,21 +17,22 @@ require (
|
||||
github.com/gorilla/securecookie v1.1.2
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/grafana/pyroscope-go v1.2.2
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||
github.com/pires/go-proxyproto v0.8.1
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/redis/go-redis/v9 v9.9.0
|
||||
github.com/redis/go-redis/v9 v9.10.0
|
||||
github.com/sethvargo/go-envconfig v1.3.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2025060.1
|
||||
goauthentik.io/api/v3 v3.2025062.1
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sync v0.14.0
|
||||
golang.org/x/sync v0.15.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
layeh.com/radius v0.0.0-20210819152912-ad72663a72ab
|
||||
)
|
||||
@ -58,8 +60,10 @@ require (
|
||||
github.com/go-openapi/strfmt v0.23.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-openapi/validate v0.24.0 // indirect
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
|
24
go.sum
24
go.sum
@ -41,6 +41,8 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
|
||||
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
@ -178,6 +180,10 @@ github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2e
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/pyroscope-go v1.2.2 h1:uvKCyZMD724RkaCEMrSTC38Yn7AnFe8S2wiAIYdDPCE=
|
||||
github.com/grafana/pyroscope-go v1.2.2/go.mod h1:zzT9QXQAp2Iz2ZdS216UiV8y9uXJYQiGE1q8v1FyhqU=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
@ -197,8 +203,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
@ -245,8 +251,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/redis/go-redis/v9 v9.9.0 h1:URbPQ4xVQSQhZ27WMQVmZSo3uT3pL+4IdHVcYq2nVfM=
|
||||
github.com/redis/go-redis/v9 v9.9.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs=
|
||||
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
@ -262,6 +268,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
@ -290,8 +298,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
|
||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
goauthentik.io/api/v3 v3.2025060.1 h1:H/TDuroJlQicuxrWEnLcO3lzQaHuR28xrUb1L2362Vo=
|
||||
goauthentik.io/api/v3 v3.2025060.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
goauthentik.io/api/v3 v3.2025062.1 h1:spvILDpDDWJNO3pM6QGqmryx6NvSchr1E8H60J/XUCA=
|
||||
goauthentik.io/api/v3 v3.2025062.1/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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
@ -376,8 +384,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
17
internal/common/prerun.go
Normal file
17
internal/common/prerun.go
Normal file
@ -0,0 +1,17 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func PreRun(cmd *cobra.Command, args []string) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
log.SetFormatter(&log.JSONFormatter{
|
||||
FieldMap: log.FieldMap{
|
||||
log.FieldKeyMsg: "event",
|
||||
log.FieldKeyTime: "timestamp",
|
||||
},
|
||||
DisableHTMLEscape: true,
|
||||
})
|
||||
}
|
@ -33,4 +33,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2025.6.0"
|
||||
const VERSION = "2025.6.2"
|
||||
|
@ -5,19 +5,24 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/grafana/pyroscope-go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/utils/web"
|
||||
)
|
||||
|
||||
func EnableDebugServer() {
|
||||
l := log.WithField("logger", "authentik.go_debugger")
|
||||
var l = log.WithField("logger", "authentik.debugger.go")
|
||||
|
||||
func EnableDebugServer(appName string) {
|
||||
if !config.Get().Debug {
|
||||
return
|
||||
}
|
||||
h := mux.NewRouter()
|
||||
enablePyroscope(appName)
|
||||
h.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
h.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
h.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
@ -54,3 +59,38 @@ func EnableDebugServer() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func enablePyroscope(appName string) {
|
||||
p, pok := os.LookupEnv("AUTHENTIK_PYROSCOPE_HOST")
|
||||
if !pok {
|
||||
return
|
||||
}
|
||||
l.Debug("Enabling pyroscope")
|
||||
runtime.SetMutexProfileFraction(5)
|
||||
runtime.SetBlockProfileRate(5)
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_, err = pyroscope.Start(pyroscope.Config{
|
||||
ApplicationName: appName,
|
||||
ServerAddress: p,
|
||||
Logger: pyroscope.StandardLogger,
|
||||
Tags: map[string]string{"hostname": hostname},
|
||||
ProfileTypes: []pyroscope.ProfileType{
|
||||
pyroscope.ProfileCPU,
|
||||
pyroscope.ProfileAllocObjects,
|
||||
pyroscope.ProfileAllocSpace,
|
||||
pyroscope.ProfileInuseObjects,
|
||||
pyroscope.ProfileInuseSpace,
|
||||
pyroscope.ProfileGoroutines,
|
||||
pyroscope.ProfileMutexCount,
|
||||
pyroscope.ProfileMutexDuration,
|
||||
pyroscope.ProfileBlockCount,
|
||||
pyroscope.ProfileBlockDuration,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/avast/retry-go/v4"
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
@ -25,8 +26,6 @@ import (
|
||||
"goauthentik.io/internal/utils/web"
|
||||
)
|
||||
|
||||
type WSHandler func(ctx context.Context, args map[string]interface{})
|
||||
|
||||
const ConfigLogLevel = "log_level"
|
||||
|
||||
// APIController main controller which connects to the authentik api via http and ws
|
||||
@ -43,12 +42,11 @@ type APIController struct {
|
||||
|
||||
reloadOffset time.Duration
|
||||
|
||||
wsConn *websocket.Conn
|
||||
lastWsReconnect time.Time
|
||||
wsIsReconnecting bool
|
||||
wsBackoffMultiplier int
|
||||
wsHandlers []WSHandler
|
||||
refreshHandlers []func()
|
||||
eventConn *websocket.Conn
|
||||
lastWsReconnect time.Time
|
||||
wsIsReconnecting bool
|
||||
eventHandlers []EventHandler
|
||||
refreshHandlers []func()
|
||||
|
||||
instanceUUID uuid.UUID
|
||||
}
|
||||
@ -83,20 +81,19 @@ func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
|
||||
// Because we don't know the outpost UUID, we simply do a list and pick the first
|
||||
// The service account this token belongs to should only have access to a single outpost
|
||||
var outposts *api.PaginatedOutpostList
|
||||
var err error
|
||||
for {
|
||||
outposts, _, err = apiClient.OutpostsApi.OutpostsInstancesList(context.Background()).Execute()
|
||||
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
log.WithError(err).Error("Failed to fetch outpost configuration, retrying in 3 seconds")
|
||||
time.Sleep(time.Second * 3)
|
||||
}
|
||||
outposts, _ := retry.DoWithData[*api.PaginatedOutpostList](
|
||||
func() (*api.PaginatedOutpostList, error) {
|
||||
outposts, _, err := apiClient.OutpostsApi.OutpostsInstancesList(context.Background()).Execute()
|
||||
return outposts, err
|
||||
},
|
||||
retry.Attempts(0),
|
||||
retry.Delay(time.Second*3),
|
||||
retry.OnRetry(func(attempt uint, err error) {
|
||||
log.WithError(err).Error("Failed to fetch outpost configuration, retrying in 3 seconds")
|
||||
}),
|
||||
)
|
||||
if len(outposts.Results) < 1 {
|
||||
panic("No outposts found with given token, ensure the given token corresponds to an authenitk Outpost")
|
||||
log.Panic("No outposts found with given token, ensure the given token corresponds to an authenitk Outpost")
|
||||
}
|
||||
outpost := outposts.Results[0]
|
||||
|
||||
@ -119,22 +116,25 @@ func NewAPIController(akURL url.URL, token string) *APIController {
|
||||
token: token,
|
||||
logger: log,
|
||||
|
||||
reloadOffset: time.Duration(rand.Intn(10)) * time.Second,
|
||||
instanceUUID: uuid.New(),
|
||||
Outpost: outpost,
|
||||
wsHandlers: []WSHandler{},
|
||||
wsBackoffMultiplier: 1,
|
||||
refreshHandlers: make([]func(), 0),
|
||||
reloadOffset: time.Duration(rand.Intn(10)) * time.Second,
|
||||
instanceUUID: uuid.New(),
|
||||
Outpost: outpost,
|
||||
eventHandlers: []EventHandler{},
|
||||
refreshHandlers: make([]func(), 0),
|
||||
}
|
||||
ac.logger.WithField("offset", ac.reloadOffset.String()).Debug("HA Reload offset")
|
||||
err = ac.initWS(akURL, outpost.Pk)
|
||||
err = ac.initEvent(akURL, outpost.Pk)
|
||||
if err != nil {
|
||||
go ac.reconnectWS()
|
||||
go ac.recentEvents()
|
||||
}
|
||||
ac.configureRefreshSignal()
|
||||
return ac
|
||||
}
|
||||
|
||||
func (a *APIController) Log() *log.Entry {
|
||||
return a.logger
|
||||
}
|
||||
|
||||
// Start Starts all handlers, non-blocking
|
||||
func (a *APIController) Start() error {
|
||||
err := a.Server.Refresh()
|
||||
@ -196,7 +196,7 @@ func (a *APIController) OnRefresh() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *APIController) getWebsocketPingArgs() map[string]interface{} {
|
||||
func (a *APIController) getEventPingArgs() map[string]interface{} {
|
||||
args := map[string]interface{}{
|
||||
"version": constants.VERSION,
|
||||
"buildHash": constants.BUILD(""),
|
||||
@ -222,12 +222,12 @@ func (a *APIController) StartBackgroundTasks() error {
|
||||
"build": constants.BUILD(""),
|
||||
}).Set(1)
|
||||
go func() {
|
||||
a.logger.Debug("Starting WS Handler...")
|
||||
a.startWSHandler()
|
||||
a.logger.Debug("Starting Event Handler...")
|
||||
a.startEventHandler()
|
||||
}()
|
||||
go func() {
|
||||
a.logger.Debug("Starting WS Health notifier...")
|
||||
a.startWSHealth()
|
||||
a.logger.Debug("Starting Event health notifier...")
|
||||
a.startEventHealth()
|
||||
}()
|
||||
go func() {
|
||||
a.logger.Debug("Starting Interval updater...")
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/avast/retry-go/v4"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"goauthentik.io/internal/config"
|
||||
@ -30,7 +31,7 @@ func (ac *APIController) getWebsocketURL(akURL url.URL, outpostUUID string, quer
|
||||
return wsUrl
|
||||
}
|
||||
|
||||
func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
|
||||
func (ac *APIController) initEvent(akURL url.URL, outpostUUID string) error {
|
||||
query := akURL.Query()
|
||||
query.Set("instance_uuid", ac.instanceUUID.String())
|
||||
|
||||
@ -57,19 +58,19 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ac.wsConn = ws
|
||||
ac.eventConn = ws
|
||||
// Send hello message with our version
|
||||
msg := websocketMessage{
|
||||
Instruction: WebsocketInstructionHello,
|
||||
Args: ac.getWebsocketPingArgs(),
|
||||
msg := Event{
|
||||
Instruction: EventKindHello,
|
||||
Args: ac.getEventPingArgs(),
|
||||
}
|
||||
err = ws.WriteJSON(msg)
|
||||
if err != nil {
|
||||
ac.logger.WithField("logger", "authentik.outpost.ak-ws").WithError(err).Warning("Failed to hello to authentik")
|
||||
ac.logger.WithField("logger", "authentik.outpost.events").WithError(err).Warning("Failed to hello to authentik")
|
||||
return err
|
||||
}
|
||||
ac.lastWsReconnect = time.Now()
|
||||
ac.logger.WithField("logger", "authentik.outpost.ak-ws").WithField("outpost", outpostUUID).Info("Successfully connected websocket")
|
||||
ac.logger.WithField("logger", "authentik.outpost.events").WithField("outpost", outpostUUID).Info("Successfully connected websocket")
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -77,19 +78,19 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
|
||||
func (ac *APIController) Shutdown() {
|
||||
// Cleanly close the connection by sending a close message and then
|
||||
// waiting (with timeout) for the server to close the connection.
|
||||
err := ac.wsConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
err := ac.eventConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
if err != nil {
|
||||
ac.logger.WithError(err).Warning("failed to write close message")
|
||||
return
|
||||
}
|
||||
err = ac.wsConn.Close()
|
||||
err = ac.eventConn.Close()
|
||||
if err != nil {
|
||||
ac.logger.WithError(err).Warning("failed to close websocket")
|
||||
}
|
||||
ac.logger.Info("finished shutdown")
|
||||
}
|
||||
|
||||
func (ac *APIController) reconnectWS() {
|
||||
func (ac *APIController) recentEvents() {
|
||||
if ac.wsIsReconnecting {
|
||||
return
|
||||
}
|
||||
@ -100,46 +101,47 @@ func (ac *APIController) reconnectWS() {
|
||||
Path: strings.ReplaceAll(ac.Client.GetConfig().Servers[0].URL, "api/v3", ""),
|
||||
}
|
||||
attempt := 1
|
||||
for {
|
||||
q := u.Query()
|
||||
q.Set("attempt", strconv.Itoa(attempt))
|
||||
u.RawQuery = q.Encode()
|
||||
err := ac.initWS(u, ac.Outpost.Pk)
|
||||
attempt += 1
|
||||
if err != nil {
|
||||
ac.logger.Infof("waiting %d seconds to reconnect", ac.wsBackoffMultiplier)
|
||||
time.Sleep(time.Duration(ac.wsBackoffMultiplier) * time.Second)
|
||||
ac.wsBackoffMultiplier = ac.wsBackoffMultiplier * 2
|
||||
// Limit to 300 seconds (5m)
|
||||
if ac.wsBackoffMultiplier >= 300 {
|
||||
ac.wsBackoffMultiplier = 300
|
||||
_ = retry.Do(
|
||||
func() error {
|
||||
q := u.Query()
|
||||
q.Set("attempt", strconv.Itoa(attempt))
|
||||
u.RawQuery = q.Encode()
|
||||
err := ac.initEvent(u, ac.Outpost.Pk)
|
||||
attempt += 1
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ac.wsIsReconnecting = false
|
||||
ac.wsBackoffMultiplier = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
retry.Delay(1*time.Second),
|
||||
retry.MaxDelay(5*time.Minute),
|
||||
retry.DelayType(retry.BackOffDelay),
|
||||
retry.Attempts(0),
|
||||
retry.OnRetry(func(attempt uint, err error) {
|
||||
ac.logger.Infof("waiting %d seconds to reconnect", attempt)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (ac *APIController) startWSHandler() {
|
||||
logger := ac.logger.WithField("loop", "ws-handler")
|
||||
func (ac *APIController) startEventHandler() {
|
||||
logger := ac.logger.WithField("loop", "event-handler")
|
||||
for {
|
||||
var wsMsg websocketMessage
|
||||
if ac.wsConn == nil {
|
||||
go ac.reconnectWS()
|
||||
var wsMsg Event
|
||||
if ac.eventConn == nil {
|
||||
go ac.recentEvents()
|
||||
time.Sleep(time.Second * 5)
|
||||
continue
|
||||
}
|
||||
err := ac.wsConn.ReadJSON(&wsMsg)
|
||||
err := ac.eventConn.ReadJSON(&wsMsg)
|
||||
if err != nil {
|
||||
ConnectionStatus.With(prometheus.Labels{
|
||||
"outpost_name": ac.Outpost.Name,
|
||||
"outpost_type": ac.Server.Type(),
|
||||
"uuid": ac.instanceUUID.String(),
|
||||
}).Set(0)
|
||||
logger.WithError(err).Warning("ws read error")
|
||||
go ac.reconnectWS()
|
||||
logger.WithError(err).Warning("event read error")
|
||||
go ac.recentEvents()
|
||||
time.Sleep(time.Second * 5)
|
||||
continue
|
||||
}
|
||||
@ -149,7 +151,8 @@ func (ac *APIController) startWSHandler() {
|
||||
"uuid": ac.instanceUUID.String(),
|
||||
}).Set(1)
|
||||
switch wsMsg.Instruction {
|
||||
case WebsocketInstructionTriggerUpdate:
|
||||
case EventKindAck:
|
||||
case EventKindTriggerUpdate:
|
||||
time.Sleep(ac.reloadOffset)
|
||||
logger.Debug("Got update trigger...")
|
||||
err := ac.OnRefresh()
|
||||
@ -164,30 +167,33 @@ func (ac *APIController) startWSHandler() {
|
||||
"build": constants.BUILD(""),
|
||||
}).SetToCurrentTime()
|
||||
}
|
||||
case WebsocketInstructionProviderSpecific:
|
||||
for _, h := range ac.wsHandlers {
|
||||
h(context.Background(), wsMsg.Args)
|
||||
default:
|
||||
for _, h := range ac.eventHandlers {
|
||||
err := h(context.Background(), wsMsg)
|
||||
if err != nil {
|
||||
ac.logger.WithError(err).Warning("failed to run event handler")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ac *APIController) startWSHealth() {
|
||||
func (ac *APIController) startEventHealth() {
|
||||
ticker := time.NewTicker(time.Second * 10)
|
||||
for ; true; <-ticker.C {
|
||||
if ac.wsConn == nil {
|
||||
go ac.reconnectWS()
|
||||
if ac.eventConn == nil {
|
||||
go ac.recentEvents()
|
||||
time.Sleep(time.Second * 5)
|
||||
continue
|
||||
}
|
||||
err := ac.SendWSHello(map[string]interface{}{})
|
||||
err := ac.SendEventHello(map[string]interface{}{})
|
||||
if err != nil {
|
||||
ac.logger.WithField("loop", "ws-health").WithError(err).Warning("ws write error")
|
||||
go ac.reconnectWS()
|
||||
ac.logger.WithField("loop", "event-health").WithError(err).Warning("event write error")
|
||||
go ac.recentEvents()
|
||||
time.Sleep(time.Second * 5)
|
||||
continue
|
||||
} else {
|
||||
ac.logger.WithField("loop", "ws-health").Trace("hello'd")
|
||||
ac.logger.WithField("loop", "event-health").Trace("hello'd")
|
||||
ConnectionStatus.With(prometheus.Labels{
|
||||
"outpost_name": ac.Outpost.Name,
|
||||
"outpost_type": ac.Server.Type(),
|
||||
@ -230,19 +236,19 @@ func (ac *APIController) startIntervalUpdater() {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *APIController) AddWSHandler(handler WSHandler) {
|
||||
a.wsHandlers = append(a.wsHandlers, handler)
|
||||
func (a *APIController) AddEventHandler(handler EventHandler) {
|
||||
a.eventHandlers = append(a.eventHandlers, handler)
|
||||
}
|
||||
|
||||
func (a *APIController) SendWSHello(args map[string]interface{}) error {
|
||||
allArgs := a.getWebsocketPingArgs()
|
||||
func (a *APIController) SendEventHello(args map[string]interface{}) error {
|
||||
allArgs := a.getEventPingArgs()
|
||||
for key, value := range args {
|
||||
allArgs[key] = value
|
||||
}
|
||||
aliveMsg := websocketMessage{
|
||||
Instruction: WebsocketInstructionHello,
|
||||
aliveMsg := Event{
|
||||
Instruction: EventKindHello,
|
||||
Args: allArgs,
|
||||
}
|
||||
err := a.wsConn.WriteJSON(aliveMsg)
|
||||
err := a.eventConn.WriteJSON(aliveMsg)
|
||||
return err
|
||||
}
|
37
internal/outpost/ak/api_event_msg.go
Normal file
37
internal/outpost/ak/api_event_msg.go
Normal file
@ -0,0 +1,37 @@
|
||||
package ak
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
type EventKind int
|
||||
|
||||
const (
|
||||
// Code used to acknowledge a previous message
|
||||
EventKindAck EventKind = 0
|
||||
// Code used to send a healthcheck keepalive
|
||||
EventKindHello EventKind = 1
|
||||
// Code received to trigger a config update
|
||||
EventKindTriggerUpdate EventKind = 2
|
||||
// Code received to trigger some provider specific function
|
||||
EventKindProviderSpecific EventKind = 3
|
||||
// Code received to identify the end of a session
|
||||
EventKindSessionEnd EventKind = 4
|
||||
)
|
||||
|
||||
type EventHandler func(ctx context.Context, msg Event) error
|
||||
|
||||
type Event struct {
|
||||
Instruction EventKind `json:"instruction"`
|
||||
Args interface{} `json:"args"`
|
||||
}
|
||||
|
||||
func (wm Event) ArgsAs(out interface{}) error {
|
||||
return mapstructure.Decode(wm.Args, out)
|
||||
}
|
||||
|
||||
type EventArgsSessionEnd struct {
|
||||
SessionID string `mapstructure:"session_id"`
|
||||
}
|
@ -15,7 +15,7 @@ func URLMustParse(u string) *url.URL {
|
||||
return ur
|
||||
}
|
||||
|
||||
func TestWebsocketURL(t *testing.T) {
|
||||
func TestEventWebsocketURL(t *testing.T) {
|
||||
u := URLMustParse("http://localhost:9000?foo=bar")
|
||||
uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77"
|
||||
ac := &APIController{}
|
||||
@ -23,7 +23,7 @@ func TestWebsocketURL(t *testing.T) {
|
||||
assert.Equal(t, "ws://localhost:9000/ws/outpost/23470845-7263-4fe3-bd79-ec1d7bf77d77/?foo=bar", nu.String())
|
||||
}
|
||||
|
||||
func TestWebsocketURL_Query(t *testing.T) {
|
||||
func TestEventWebsocketURL_Query(t *testing.T) {
|
||||
u := URLMustParse("http://localhost:9000?foo=bar")
|
||||
uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77"
|
||||
ac := &APIController{}
|
||||
@ -33,7 +33,7 @@ func TestWebsocketURL_Query(t *testing.T) {
|
||||
assert.Equal(t, "ws://localhost:9000/ws/outpost/23470845-7263-4fe3-bd79-ec1d7bf77d77/?bar=baz&foo=bar", nu.String())
|
||||
}
|
||||
|
||||
func TestWebsocketURL_Subpath(t *testing.T) {
|
||||
func TestEventWebsocketURL_Subpath(t *testing.T) {
|
||||
u := URLMustParse("http://localhost:9000/foo/bar/")
|
||||
uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77"
|
||||
ac := &APIController{}
|
@ -1,19 +0,0 @@
|
||||
package ak
|
||||
|
||||
type websocketInstruction int
|
||||
|
||||
const (
|
||||
// WebsocketInstructionAck Code used to acknowledge a previous message
|
||||
WebsocketInstructionAck websocketInstruction = 0
|
||||
// WebsocketInstructionHello Code used to send a healthcheck keepalive
|
||||
WebsocketInstructionHello websocketInstruction = 1
|
||||
// WebsocketInstructionTriggerUpdate Code received to trigger a config update
|
||||
WebsocketInstructionTriggerUpdate websocketInstruction = 2
|
||||
// WebsocketInstructionProviderSpecific Code received to trigger some provider specific function
|
||||
WebsocketInstructionProviderSpecific websocketInstruction = 3
|
||||
)
|
||||
|
||||
type websocketMessage struct {
|
||||
Instruction websocketInstruction `json:"instruction"`
|
||||
Args map[string]interface{} `json:"args"`
|
||||
}
|
51
internal/outpost/ak/entrypoint/entrypoint.go
Normal file
51
internal/outpost/ak/entrypoint/entrypoint.go
Normal file
@ -0,0 +1,51 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"goauthentik.io/internal/common"
|
||||
"goauthentik.io/internal/config"
|
||||
"goauthentik.io/internal/debug"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
)
|
||||
|
||||
func OutpostMain(appName string, server func(ac *ak.APIController) ak.Outpost) error {
|
||||
debug.EnableDebugServer(appName)
|
||||
akURL := config.Get().AuthentikHost
|
||||
if akURL == "" {
|
||||
return errors.New("environment variable `AUTHENTIK_HOST` not set")
|
||||
}
|
||||
akToken := config.Get().AuthentikToken
|
||||
if akToken == "" {
|
||||
return errors.New("environment variable `AUTHENTIK_TOKEN` not set")
|
||||
}
|
||||
|
||||
akURLActual, err := url.Parse(akURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ex := common.Init()
|
||||
defer common.Defer()
|
||||
|
||||
ac := ak.NewAPIController(*akURLActual, akToken)
|
||||
if ac == nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ac.Shutdown()
|
||||
|
||||
ac.Server = server(ac)
|
||||
|
||||
err = ac.Start()
|
||||
if err != nil {
|
||||
ac.Log().WithError(err).Panic("Failed to run server")
|
||||
return err
|
||||
}
|
||||
|
||||
for {
|
||||
<-ex
|
||||
return nil
|
||||
}
|
||||
}
|
@ -48,20 +48,20 @@ func doGlobalSetup(outpost api.Outpost, globalConfig *api.Config) {
|
||||
if globalConfig.ErrorReporting.Enabled {
|
||||
if !initialSetup {
|
||||
l.WithField("env", globalConfig.ErrorReporting.Environment).Debug("Error reporting enabled")
|
||||
}
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: globalConfig.ErrorReporting.SentryDsn,
|
||||
Environment: globalConfig.ErrorReporting.Environment,
|
||||
EnableTracing: true,
|
||||
TracesSampler: sentryutils.SamplerFunc(float64(globalConfig.ErrorReporting.TracesSampleRate)),
|
||||
Release: fmt.Sprintf("authentik@%s", constants.VERSION),
|
||||
HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgentOutpost(), http.DefaultTransport),
|
||||
IgnoreErrors: []string{
|
||||
http.ErrAbortHandler.Error(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
l.WithField("env", globalConfig.ErrorReporting.Environment).WithError(err).Warning("Failed to initialise sentry")
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: globalConfig.ErrorReporting.SentryDsn,
|
||||
Environment: globalConfig.ErrorReporting.Environment,
|
||||
EnableTracing: true,
|
||||
TracesSampler: sentryutils.SamplerFunc(float64(globalConfig.ErrorReporting.TracesSampleRate)),
|
||||
Release: fmt.Sprintf("authentik@%s", constants.VERSION),
|
||||
HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgentOutpost(), http.DefaultTransport),
|
||||
IgnoreErrors: []string{
|
||||
http.ErrAbortHandler.Error(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
l.WithField("env", globalConfig.ErrorReporting.Environment).WithError(err).Warning("Failed to initialise sentry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -55,11 +55,10 @@ func MockAK(outpost api.Outpost, globalConfig api.Config) *APIController {
|
||||
token: token,
|
||||
logger: log,
|
||||
|
||||
reloadOffset: time.Duration(rand.Intn(10)) * time.Second,
|
||||
instanceUUID: uuid.New(),
|
||||
Outpost: outpost,
|
||||
wsBackoffMultiplier: 1,
|
||||
refreshHandlers: make([]func(), 0),
|
||||
reloadOffset: time.Duration(rand.Intn(10)) * time.Second,
|
||||
instanceUUID: uuid.New(),
|
||||
Outpost: outpost,
|
||||
refreshHandlers: make([]func(), 0),
|
||||
}
|
||||
ac.logger.WithField("offset", ac.reloadOffset.String()).Debug("HA Reload offset")
|
||||
return ac
|
||||
|
@ -127,7 +127,7 @@ func (fe *FlowExecutor) getAnswer(stage StageComponent) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (fe *FlowExecutor) GetSession() *http.Cookie {
|
||||
func (fe *FlowExecutor) SessionCookie() *http.Cookie {
|
||||
return fe.session
|
||||
}
|
||||
|
||||
|
19
internal/outpost/flow/session.go
Normal file
19
internal/outpost/flow/session.go
Normal file
@ -0,0 +1,19 @@
|
||||
package flow
|
||||
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
type SessionCookieClaims struct {
|
||||
jwt.Claims
|
||||
|
||||
SessionID string `json:"sid"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
}
|
||||
|
||||
func (fe *FlowExecutor) Session() *jwt.Token {
|
||||
sc := fe.SessionCookie()
|
||||
if sc == nil {
|
||||
return nil
|
||||
}
|
||||
t, _, _ := jwt.NewParser().ParseUnverified(sc.Value, &SessionCookieClaims{})
|
||||
return t
|
||||
}
|
@ -38,7 +38,14 @@ func (ls *LDAPServer) Bind(bindDN string, bindPW string, conn net.Conn) (ldap.LD
|
||||
username, err := instance.binder.GetUsername(bindDN)
|
||||
if err == nil {
|
||||
selectedApp = instance.GetAppSlug()
|
||||
return instance.binder.Bind(username, req)
|
||||
c, err := instance.binder.Bind(username, req)
|
||||
if c == ldap.LDAPResultSuccess {
|
||||
f := instance.GetFlags(req.BindDN)
|
||||
ls.connectionsSync.Lock()
|
||||
ls.connections[f.SessionID()] = conn
|
||||
ls.connectionsSync.Unlock()
|
||||
}
|
||||
return c, err
|
||||
} else {
|
||||
req.Log().WithError(err).Debug("Username not for instance")
|
||||
}
|
||||
|
@ -27,8 +27,9 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
|
||||
|
||||
passed, err := fe.Execute()
|
||||
flags := flags.UserFlags{
|
||||
Session: fe.GetSession(),
|
||||
UserPk: flags.InvalidUserPK,
|
||||
Session: fe.SessionCookie(),
|
||||
SessionJWT: fe.Session(),
|
||||
UserPk: flags.InvalidUserPK,
|
||||
}
|
||||
// only set flags if we don't have flags for this DN yet
|
||||
// as flags are only checked during the bind, we can remember whether a certain DN
|
||||
|
20
internal/outpost/ldap/close.go
Normal file
20
internal/outpost/ldap/close.go
Normal file
@ -0,0 +1,20 @@
|
||||
package ldap
|
||||
|
||||
import "net"
|
||||
|
||||
func (ls *LDAPServer) Close(dn string, conn net.Conn) error {
|
||||
ls.connectionsSync.Lock()
|
||||
defer ls.connectionsSync.Unlock()
|
||||
key := ""
|
||||
for k, c := range ls.connections {
|
||||
if c == conn {
|
||||
key = k
|
||||
break
|
||||
}
|
||||
}
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
delete(ls.connections, key)
|
||||
return nil
|
||||
}
|
@ -1,16 +1,30 @@
|
||||
package flags
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"goauthentik.io/api/v3"
|
||||
"goauthentik.io/internal/outpost/flow"
|
||||
)
|
||||
|
||||
const InvalidUserPK = -1
|
||||
|
||||
type UserFlags struct {
|
||||
UserInfo *api.User
|
||||
UserPk int32
|
||||
CanSearch bool
|
||||
Session *http.Cookie
|
||||
UserInfo *api.User
|
||||
UserPk int32
|
||||
CanSearch bool
|
||||
Session *http.Cookie
|
||||
SessionJWT *jwt.Token
|
||||
}
|
||||
|
||||
func (uf UserFlags) SessionID() string {
|
||||
if uf.SessionJWT == nil {
|
||||
return ""
|
||||
}
|
||||
h := sha256.New()
|
||||
h.Write([]byte(uf.SessionJWT.Claims.(*flow.SessionCookieClaims).SessionID))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
@ -18,21 +18,26 @@ import (
|
||||
)
|
||||
|
||||
type LDAPServer struct {
|
||||
s *ldap.Server
|
||||
log *log.Entry
|
||||
ac *ak.APIController
|
||||
cs *ak.CryptoStore
|
||||
defaultCert *tls.Certificate
|
||||
providers []*ProviderInstance
|
||||
s *ldap.Server
|
||||
log *log.Entry
|
||||
ac *ak.APIController
|
||||
cs *ak.CryptoStore
|
||||
defaultCert *tls.Certificate
|
||||
providers []*ProviderInstance
|
||||
connections map[string]net.Conn
|
||||
connectionsSync sync.Mutex
|
||||
}
|
||||
|
||||
func NewServer(ac *ak.APIController) *LDAPServer {
|
||||
func NewServer(ac *ak.APIController) ak.Outpost {
|
||||
ls := &LDAPServer{
|
||||
log: log.WithField("logger", "authentik.outpost.ldap"),
|
||||
ac: ac,
|
||||
cs: ak.NewCryptoStore(ac.Client.CryptoApi),
|
||||
providers: []*ProviderInstance{},
|
||||
log: log.WithField("logger", "authentik.outpost.ldap"),
|
||||
ac: ac,
|
||||
cs: ak.NewCryptoStore(ac.Client.CryptoApi),
|
||||
providers: []*ProviderInstance{},
|
||||
connections: map[string]net.Conn{},
|
||||
connectionsSync: sync.Mutex{},
|
||||
}
|
||||
ac.AddEventHandler(ls.handleWSSessionEnd)
|
||||
s := ldap.NewServer()
|
||||
s.EnforceLDAP = true
|
||||
|
||||
@ -50,6 +55,7 @@ func NewServer(ac *ak.APIController) *LDAPServer {
|
||||
s.BindFunc("", ls)
|
||||
s.UnbindFunc("", ls)
|
||||
s.SearchFunc("", ls)
|
||||
s.CloseFunc("", ls)
|
||||
return ls
|
||||
}
|
||||
|
||||
@ -117,3 +123,23 @@ func (ls *LDAPServer) TimerFlowCacheExpiry(ctx context.Context) {
|
||||
p.binder.TimerFlowCacheExpiry(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (ls *LDAPServer) handleWSSessionEnd(ctx context.Context, msg ak.Event) error {
|
||||
if msg.Instruction != ak.EventKindSessionEnd {
|
||||
return nil
|
||||
}
|
||||
mmsg := ak.EventArgsSessionEnd{}
|
||||
err := msg.ArgsAs(&mmsg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ls.connectionsSync.Lock()
|
||||
defer ls.connectionsSync.Unlock()
|
||||
ls.log.Info("Disconnecting session due to session end event")
|
||||
conn, ok := ls.connections[mmsg.SessionID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
delete(ls.connections, mmsg.SessionID)
|
||||
return conn.Close()
|
||||
}
|
||||
|
@ -44,38 +44,40 @@ func (ds *DirectSearcher) SearchSubschema(req *search.Request) (ldap.ServerSearc
|
||||
{
|
||||
Name: "attributeTypes",
|
||||
Values: []string{
|
||||
"( 2.5.4.0 NAME 'objectClass' SYNTAX '1.3.6.1.4.1.1466.115.121.1.38' NO-USER-MODIFICATION )",
|
||||
"( 2.5.4.4 NAME 'sn' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.3 NAME 'cn' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.6 NAME 'c' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.7 NAME 'l' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.10 NAME 'o' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )",
|
||||
"( 2.5.4.11 NAME 'ou' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )",
|
||||
"( 2.5.4.12 NAME 'title' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.13 NAME 'description' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )",
|
||||
"( 2.5.4.20 NAME 'telephoneNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.31 NAME 'member' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' )",
|
||||
"( 2.5.4.42 NAME 'givenName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.21.2 NAME 'dITContentRules' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
|
||||
"( 2.5.21.5 NAME 'attributeTypes' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
|
||||
"( 2.5.21.6 NAME 'objectClasses' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
|
||||
"( 0.9.2342.19200300.100.1.1 NAME 'uid' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 0.9.2342.19200300.100.1.3 NAME 'mail' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 0.9.2342.19200300.100.1.41 NAME 'mobile' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.2.13 NAME 'displayName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.2.146 NAME 'company' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.2.102 NAME 'memberOf' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' NO-USER-MODIFICATION )",
|
||||
"( 1.2.840.113556.1.2.13 NAME 'displayName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.2.131 NAME 'co' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.2.141 NAME 'department' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.2.146 NAME 'company' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.4.1 NAME 'name' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE NO-USER-MODIFICATION )",
|
||||
"( 1.2.840.113556.1.4.44 NAME 'homeDirectory' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.4.221 NAME 'sAMAccountName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.4.261 NAME 'division' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.4.44 NAME 'homeDirectory' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.4.750 NAME 'groupType' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.4.782 NAME 'objectCategory' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' SINGLE-VALUE )",
|
||||
"( 1.3.6.1.1.1.1.0 NAME 'uidNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
|
||||
"( 1.3.6.1.1.1.1.1 NAME 'gidNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
|
||||
"( 1.3.6.1.1.1.1.12 NAME 'memberUid' SYNTAX '1.3.6.1.4.1.1466.115.121.1.26' )",
|
||||
"( 2.5.18.1 NAME 'createTimestamp' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION )",
|
||||
"( 2.5.18.2 NAME 'modifyTimestamp' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION )",
|
||||
"( 2.5.21.2 NAME 'dITContentRules' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
|
||||
"( 2.5.21.5 NAME 'attributeTypes' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
|
||||
"( 2.5.21.6 NAME 'objectClasses' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
|
||||
"( 2.5.4.0 NAME 'objectClass' SYNTAX '1.3.6.1.4.1.1466.115.121.1.38' NO-USER-MODIFICATION )",
|
||||
"( 2.5.4.10 NAME 'o' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )",
|
||||
"( 2.5.4.11 NAME 'ou' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )",
|
||||
"( 2.5.4.12 NAME 'title' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.13 NAME 'description' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )",
|
||||
"( 2.5.4.20 NAME 'telephoneNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.3 NAME 'cn' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.31 NAME 'member' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' )",
|
||||
"( 2.5.4.4 NAME 'sn' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.42 NAME 'givenName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.6 NAME 'c' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 2.5.4.7 NAME 'l' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
|
||||
// Custom attributes
|
||||
// Temporarily use 1.3.6.1.4.1.26027.1.1 as a base
|
||||
|
@ -53,6 +53,14 @@ func NewRequest(bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (*Re
|
||||
if err != nil && len(searchReq.Filter) > 0 {
|
||||
l.WithError(err).WithField("objectClass", filterOC).Warning("invalid filter object class")
|
||||
}
|
||||
|
||||
// Handle comma-separated attributes
|
||||
normalizedAttributes := normalizeAttributes(searchReq.Attributes)
|
||||
if len(normalizedAttributes) != len(searchReq.Attributes) {
|
||||
// Create a copy of the search request with normalized attributes
|
||||
searchReq.Attributes = normalizedAttributes
|
||||
}
|
||||
|
||||
return &Request{
|
||||
SearchRequest: searchReq,
|
||||
BindDN: bindDN,
|
||||
@ -64,6 +72,31 @@ func NewRequest(bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (*Re
|
||||
}, span
|
||||
}
|
||||
|
||||
// normalizeAttributes handles the case where attributes might be passed as comma-separated strings
|
||||
// rather than as individual array elements
|
||||
func normalizeAttributes(attributes []string) []string {
|
||||
if len(attributes) == 0 {
|
||||
return attributes
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(attributes))
|
||||
for _, attr := range attributes {
|
||||
if strings.Contains(attr, ",") {
|
||||
// Split comma-separated attributes and add them individually
|
||||
parts := strings.Split(attr, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result = append(result, attr)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *Request) Context() context.Context {
|
||||
return r.ctx
|
||||
}
|
||||
|
98
internal/outpost/ldap/search/request_test.go
Normal file
98
internal/outpost/ldap/search/request_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalizeAttributes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
expectedOutput []string
|
||||
}{
|
||||
{
|
||||
name: "Empty input",
|
||||
input: []string{},
|
||||
expectedOutput: []string{},
|
||||
},
|
||||
{
|
||||
name: "No commas",
|
||||
input: []string{"uid", "cn", "sn"},
|
||||
expectedOutput: []string{"uid", "cn", "sn"},
|
||||
},
|
||||
{
|
||||
name: "Single comma-separated string",
|
||||
input: []string{"uid,cn,sn"},
|
||||
expectedOutput: []string{"uid", "cn", "sn"},
|
||||
},
|
||||
{
|
||||
name: "Mixed input",
|
||||
input: []string{"uid,cn", "sn"},
|
||||
expectedOutput: []string{"uid", "cn", "sn"},
|
||||
},
|
||||
{
|
||||
name: "With spaces",
|
||||
input: []string{"uid, cn, sn"},
|
||||
expectedOutput: []string{"uid", "cn", "sn"},
|
||||
},
|
||||
{
|
||||
name: "Empty parts",
|
||||
input: []string{"uid,, cn"},
|
||||
expectedOutput: []string{"uid", "cn"},
|
||||
},
|
||||
{
|
||||
name: "Single element",
|
||||
input: []string{"uid"},
|
||||
expectedOutput: []string{"uid"},
|
||||
},
|
||||
{
|
||||
name: "Only commas",
|
||||
input: []string{",,,"},
|
||||
expectedOutput: []string{},
|
||||
},
|
||||
{
|
||||
name: "Multiple comma-separated attributes",
|
||||
input: []string{"uid,cn", "sn,mail", "givenName"},
|
||||
expectedOutput: []string{"uid", "cn", "sn", "mail", "givenName"},
|
||||
},
|
||||
{
|
||||
name: "Case preservation",
|
||||
input: []string{"uid,CN,sAMAccountName"},
|
||||
expectedOutput: []string{"uid", "CN", "sAMAccountName"},
|
||||
},
|
||||
{
|
||||
name: "Leading and trailing spaces",
|
||||
input: []string{" uid , cn , sn "},
|
||||
expectedOutput: []string{"uid", "cn", "sn"},
|
||||
},
|
||||
{
|
||||
name: "Real-world LDAP attribute examples",
|
||||
input: []string{"objectClass,memberOf,mail", "sAMAccountName,userPrincipalName"},
|
||||
expectedOutput: []string{"objectClass", "memberOf", "mail", "sAMAccountName", "userPrincipalName"},
|
||||
},
|
||||
{
|
||||
name: "Jira-style attribute format",
|
||||
input: []string{"uid,cn,sn"},
|
||||
expectedOutput: []string{"uid", "cn", "sn"},
|
||||
},
|
||||
{
|
||||
name: "Single string with single attribute",
|
||||
input: []string{"cn"},
|
||||
expectedOutput: []string{"cn"},
|
||||
},
|
||||
{
|
||||
name: "Mix of standard and operational attributes",
|
||||
input: []string{"uid,+", "createTimestamp"},
|
||||
expectedOutput: []string{"uid", "+", "createTimestamp"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := normalizeAttributes(tt.input)
|
||||
assert.Equal(t, tt.expectedOutput, result)
|
||||
})
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/gob"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
@ -118,8 +119,8 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old
|
||||
mux := mux.NewRouter()
|
||||
|
||||
// Save cookie name, based on hashed client ID
|
||||
h := sha256.New()
|
||||
bs := string(h.Sum([]byte(*p.ClientId)))
|
||||
hs := sha256.Sum256([]byte(*p.ClientId))
|
||||
bs := hex.EncodeToString(hs[:])
|
||||
sessionName := fmt.Sprintf("authentik_proxy_%s", bs[:8])
|
||||
|
||||
// When HOST_BROWSER is set, use that as Host header for token requests to make the issuer match
|
||||
|
@ -3,6 +3,7 @@ package application
|
||||
type ProxyClaims struct {
|
||||
UserAttributes map[string]interface{} `json:"user_attributes"`
|
||||
BackendOverride string `json:"backend_override"`
|
||||
HostHeader string `json:"host_header"`
|
||||
IsSuperuser bool `json:"is_superuser"`
|
||||
}
|
||||
|
||||
|
@ -74,13 +74,18 @@ func (a *Application) proxyModifyRequest(ou *url.URL) func(req *http.Request) {
|
||||
r.URL.Scheme = ou.Scheme
|
||||
r.URL.Host = ou.Host
|
||||
claims := a.getClaimsFromSession(r)
|
||||
if claims != nil && claims.Proxy != nil && claims.Proxy.BackendOverride != "" {
|
||||
u, err := url.Parse(claims.Proxy.BackendOverride)
|
||||
if err != nil {
|
||||
a.log.WithField("backend_override", claims.Proxy.BackendOverride).WithError(err).Warning("failed parse user backend override")
|
||||
} else {
|
||||
r.URL.Scheme = u.Scheme
|
||||
r.URL.Host = u.Host
|
||||
if claims != nil && claims.Proxy != nil {
|
||||
if claims.Proxy.BackendOverride != "" {
|
||||
u, err := url.Parse(claims.Proxy.BackendOverride)
|
||||
if err != nil {
|
||||
a.log.WithField("backend_override", claims.Proxy.BackendOverride).WithError(err).Warning("failed parse user backend override")
|
||||
} else {
|
||||
r.URL.Scheme = u.Scheme
|
||||
r.URL.Host = u.Host
|
||||
}
|
||||
}
|
||||
if claims.Proxy.HostHeader != "" {
|
||||
r.Host = claims.Proxy.HostHeader
|
||||
}
|
||||
}
|
||||
a.log.WithField("upstream_url", r.URL.String()).Trace("final upstream url")
|
||||
|
@ -35,7 +35,7 @@ type ProxyServer struct {
|
||||
akAPI *ak.APIController
|
||||
}
|
||||
|
||||
func NewProxyServer(ac *ak.APIController) *ProxyServer {
|
||||
func NewProxyServer(ac *ak.APIController) ak.Outpost {
|
||||
l := log.WithField("logger", "authentik.outpost.proxyv2")
|
||||
defaultCert, err := crypto.GenerateSelfSignedCert()
|
||||
if err != nil {
|
||||
@ -66,7 +66,7 @@ func NewProxyServer(ac *ak.APIController) *ProxyServer {
|
||||
globalMux.PathPrefix("/outpost.goauthentik.io/static").HandlerFunc(s.HandleStatic)
|
||||
globalMux.Path("/outpost.goauthentik.io/ping").HandlerFunc(sentryutils.SentryNoSample(s.HandlePing))
|
||||
rootMux.PathPrefix("/").HandlerFunc(s.Handle)
|
||||
ac.AddWSHandler(s.handleWSMessage)
|
||||
ac.AddEventHandler(s.handleWSMessage)
|
||||
return s
|
||||
}
|
||||
|
||||
|
@ -3,48 +3,27 @@ package proxyv2
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/proxyv2/application"
|
||||
)
|
||||
|
||||
type WSProviderSubType string
|
||||
|
||||
const (
|
||||
WSProviderSubTypeLogout WSProviderSubType = "logout"
|
||||
)
|
||||
|
||||
type WSProviderMsg struct {
|
||||
SubType WSProviderSubType `mapstructure:"sub_type"`
|
||||
SessionID string `mapstructure:"session_id"`
|
||||
}
|
||||
|
||||
func ParseWSProvider(args map[string]interface{}) (*WSProviderMsg, error) {
|
||||
msg := &WSProviderMsg{}
|
||||
err := mapstructure.Decode(args, &msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (ps *ProxyServer) handleWSMessage(ctx context.Context, msg ak.Event) error {
|
||||
if msg.Instruction != ak.EventKindSessionEnd {
|
||||
return nil
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (ps *ProxyServer) handleWSMessage(ctx context.Context, args map[string]interface{}) {
|
||||
msg, err := ParseWSProvider(args)
|
||||
mmsg := ak.EventArgsSessionEnd{}
|
||||
err := msg.ArgsAs(&mmsg)
|
||||
if err != nil {
|
||||
ps.log.WithError(err).Warning("invalid provider-specific ws message")
|
||||
return
|
||||
return err
|
||||
}
|
||||
switch msg.SubType {
|
||||
case WSProviderSubTypeLogout:
|
||||
for _, p := range ps.apps {
|
||||
ps.log.WithField("provider", p.Host).Debug("Logging out")
|
||||
err := p.Logout(ctx, func(c application.Claims) bool {
|
||||
return c.Sid == msg.SessionID
|
||||
})
|
||||
if err != nil {
|
||||
ps.log.WithField("provider", p.Host).WithError(err).Warning("failed to logout")
|
||||
}
|
||||
for _, p := range ps.apps {
|
||||
ps.log.WithField("provider", p.Host).Debug("Logging out")
|
||||
err := p.Logout(ctx, func(c application.Claims) bool {
|
||||
return c.Sid == mmsg.SessionID
|
||||
})
|
||||
if err != nil {
|
||||
ps.log.WithField("provider", p.Host).WithError(err).Warning("failed to logout")
|
||||
}
|
||||
default:
|
||||
ps.log.WithField("sub_type", msg.SubType).Warning("invalid sub_type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wwt/guac"
|
||||
|
||||
@ -23,14 +22,14 @@ type RACServer struct {
|
||||
conns map[string]connection.Connection
|
||||
}
|
||||
|
||||
func NewServer(ac *ak.APIController) *RACServer {
|
||||
func NewServer(ac *ak.APIController) ak.Outpost {
|
||||
rs := &RACServer{
|
||||
log: log.WithField("logger", "authentik.outpost.rac"),
|
||||
ac: ac,
|
||||
connm: sync.RWMutex{},
|
||||
conns: map[string]connection.Connection{},
|
||||
}
|
||||
ac.AddWSHandler(rs.wsHandler)
|
||||
ac.AddEventHandler(rs.wsHandler)
|
||||
return rs
|
||||
}
|
||||
|
||||
@ -52,12 +51,14 @@ func parseIntOrZero(input string) int {
|
||||
return x
|
||||
}
|
||||
|
||||
func (rs *RACServer) wsHandler(ctx context.Context, args map[string]interface{}) {
|
||||
func (rs *RACServer) wsHandler(ctx context.Context, msg ak.Event) error {
|
||||
if msg.Instruction != ak.EventKindProviderSpecific {
|
||||
return nil
|
||||
}
|
||||
wsm := WSMessage{}
|
||||
err := mapstructure.Decode(args, &wsm)
|
||||
err := msg.ArgsAs(&wsm)
|
||||
if err != nil {
|
||||
rs.log.WithError(err).Warning("invalid ws message")
|
||||
return
|
||||
return err
|
||||
}
|
||||
config := guac.NewGuacamoleConfiguration()
|
||||
config.Protocol = wsm.Protocol
|
||||
@ -71,23 +72,23 @@ func (rs *RACServer) wsHandler(ctx context.Context, args map[string]interface{})
|
||||
}
|
||||
cc, err := connection.NewConnection(rs.ac, wsm.DestChannelID, config)
|
||||
if err != nil {
|
||||
rs.log.WithError(err).Warning("failed to setup connection")
|
||||
return
|
||||
return err
|
||||
}
|
||||
cc.OnError = func(err error) {
|
||||
rs.connm.Lock()
|
||||
delete(rs.conns, wsm.ConnID)
|
||||
_ = rs.ac.SendWSHello(map[string]interface{}{
|
||||
_ = rs.ac.SendEventHello(map[string]interface{}{
|
||||
"active_connections": len(rs.conns),
|
||||
})
|
||||
rs.connm.Unlock()
|
||||
}
|
||||
rs.connm.Lock()
|
||||
rs.conns[wsm.ConnID] = *cc
|
||||
_ = rs.ac.SendWSHello(map[string]interface{}{
|
||||
_ = rs.ac.SendEventHello(map[string]interface{}{
|
||||
"active_connections": len(rs.conns),
|
||||
})
|
||||
rs.connm.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *RACServer) Start() error {
|
||||
|
@ -2,6 +2,7 @@ package radius
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
@ -68,7 +69,9 @@ func (rs *RadiusServer) ServeRADIUS(w radius.ResponseWriter, r *radius.Request)
|
||||
}
|
||||
}
|
||||
if pi == nil {
|
||||
nr.Log().WithField("hashed_secret", string(sha512.New().Sum(r.Secret))).Warning("No provider found")
|
||||
hs := sha512.Sum512([]byte(r.Secret))
|
||||
bs := hex.EncodeToString(hs[:])
|
||||
nr.Log().WithField("hashed_secret", bs).Warning("No provider found")
|
||||
_ = w.Write(r.Response(radius.CodeAccessReject))
|
||||
return
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ type RadiusServer struct {
|
||||
providers []*ProviderInstance
|
||||
}
|
||||
|
||||
func NewServer(ac *ak.APIController) *RadiusServer {
|
||||
func NewServer(ac *ak.APIController) ak.Outpost {
|
||||
rs := &RadiusServer{
|
||||
log: log.WithField("logger", "authentik.outpost.radius"),
|
||||
ac: ac,
|
||||
|
@ -2,7 +2,6 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
@ -20,7 +19,7 @@ func NewTracingTransport(ctx context.Context, inner http.RoundTripper) *tracingT
|
||||
func (tt *tracingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
span := sentry.StartSpan(tt.ctx, "authentik.go.http_request")
|
||||
r.Header.Set("sentry-trace", span.ToSentryTrace())
|
||||
span.Description = fmt.Sprintf("%s %s", r.Method, r.URL.String())
|
||||
span.Description = r.Method + " " + r.URL.String()
|
||||
span.SetTag("url", r.URL.String())
|
||||
span.SetTag("method", r.Method)
|
||||
defer span.Finish()
|
||||
|
@ -31,8 +31,6 @@ func (ws *WebServer) configureStatic() {
|
||||
return h
|
||||
}
|
||||
|
||||
helpHandler := http.FileServer(http.Dir("./website/help/"))
|
||||
|
||||
indexLessRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/static/dist/").Handler(pathStripper(
|
||||
distFs,
|
||||
"static/dist/",
|
||||
@ -78,13 +76,6 @@ func (ws *WebServer) configureStatic() {
|
||||
))
|
||||
}
|
||||
|
||||
staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/help/").Handler(pathStripper(
|
||||
helpHandler,
|
||||
config.Get().Web.Path,
|
||||
"/if/help/",
|
||||
))
|
||||
staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/help").Handler(http.RedirectHandler(fmt.Sprintf("%sif/help/", config.Get().Web.Path), http.StatusMovedPermanently))
|
||||
|
||||
staticRouter.PathPrefix(config.Get().Web.Path).Path("/robots.txt").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.Header()["Content-Type"] = []string{"text/plain"}
|
||||
rw.WriteHeader(200)
|
||||
|
10
lifecycle/aws/package-lock.json
generated
10
lifecycle/aws/package-lock.json
generated
@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1017.1",
|
||||
"aws-cdk": "^2.1018.1",
|
||||
"cross-env": "^7.0.3"
|
||||
},
|
||||
"engines": {
|
||||
@ -17,16 +17,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1017.1",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1017.1.tgz",
|
||||
"integrity": "sha512-KtDdkMhfVjDeexjpMrVoSlz2mTYI5BE/KotvJ7iFbZy1G0nkpW1ImZ54TdBefeeFmZ+8DAjU3I6nUFtymyOI1A==",
|
||||
"version": "2.1018.1",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1018.1.tgz",
|
||||
"integrity": "sha512-kFPRox5kSm+ktJ451o0ng9rD+60p5Kt1CZIWw8kXnvqbsxN2xv6qbmyWSXw7sGVXVwqrRKVj+71/JeDr+LMAZw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"cdk": "bin/cdk"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.15.0"
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
|
@ -10,7 +10,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1017.1",
|
||||
"aws-cdk": "^2.1018.1",
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2025.6.0
|
||||
Default: 2025.6.2
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
@ -6,18 +6,18 @@
|
||||
# Translators:
|
||||
# jcamat, 2022
|
||||
# Angel, 2024
|
||||
# Iamanaws, 2024
|
||||
# Marcelo Elizeche Landó, 2025
|
||||
# Jens L. <jens@goauthentik.io>, 2025
|
||||
# Iamanaws, 2025
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-05-28 11:25+0000\n"
|
||||
"POT-Creation-Date: 2025-06-04 00:12+0000\n"
|
||||
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
||||
"Last-Translator: Jens L. <jens@goauthentik.io>, 2025\n"
|
||||
"Last-Translator: Iamanaws, 2025\n"
|
||||
"Language-Team: Spanish (https://app.transifex.com/authentik/teams/119923/es/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@ -111,7 +111,7 @@ msgstr "Certificado Web usado por el servidor web Core de authentik"
|
||||
|
||||
#: authentik/brands/models.py
|
||||
msgid "Certificates used for client authentication."
|
||||
msgstr ""
|
||||
msgstr "Certificados utilizados para la autenticación del cliente."
|
||||
|
||||
#: authentik/brands/models.py
|
||||
msgid "Brand"
|
||||
@ -131,7 +131,7 @@ msgstr "Descripción adicional no disponible."
|
||||
|
||||
#: authentik/core/api/groups.py
|
||||
msgid "Cannot set group as parent of itself."
|
||||
msgstr "No se puede establecer el grupo como padre de sí mismo."
|
||||
msgstr "No se puede establecer un grupo como su propio padre."
|
||||
|
||||
#: authentik/core/api/providers.py
|
||||
msgid ""
|
||||
@ -183,11 +183,11 @@ msgstr "Remueve usuario del grupo"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Enable superuser status"
|
||||
msgstr "Habiliar estado de \"superusuario\""
|
||||
msgstr "Habilitar el estado de superusuario"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Disable superuser status"
|
||||
msgstr "Deshabiliar estado de \"superusuario\""
|
||||
msgstr "Deshabilitar el estado de superusuario"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "User's display name."
|
||||
@ -241,7 +241,7 @@ msgstr "Flujo utilizado al autorizar a este proveedor."
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Flow used ending the session from a provider."
|
||||
msgstr "Flujo usado para terminar la sesión de un proveedor."
|
||||
msgstr "Flujo utilizado para finalizar la sesión desde un proveedor."
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid ""
|
||||
@ -273,11 +273,11 @@ msgstr "Aplicaciones"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Application Entitlement"
|
||||
msgstr ""
|
||||
msgstr "Derecho de Aplicación"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Application Entitlements"
|
||||
msgstr ""
|
||||
msgstr "Derechos de Aplicación"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Use the source-specific identifier"
|
||||
@ -288,9 +288,9 @@ msgid ""
|
||||
"Link to a user with identical email address. Can have security implications "
|
||||
"when a source doesn't validate email addresses."
|
||||
msgstr ""
|
||||
"Apunta a un usuario con una dirección de correo electrónico idéntica. Puede "
|
||||
"tener implicaciones de seguridad cuando una fuente no valida la dirección de"
|
||||
" correo electrónico."
|
||||
"Enlace a un usuario con la misma dirección de correo electrónico. Puede "
|
||||
"tener implicaciones de seguridad cuando una fuente no valida las direcciones"
|
||||
" de correo electrónico."
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid ""
|
||||
@ -305,8 +305,8 @@ msgid ""
|
||||
"Link to a user with identical username. Can have security implications when "
|
||||
"a username is used with another source."
|
||||
msgstr ""
|
||||
"Enlace a un usuario con un nombre de usuario idéntico. Puede tener "
|
||||
"implicaciones de seguridad cuando se usa un nombre de usuario con otra "
|
||||
"Enlace a un usuario con el mismo nombre de usuario. Puede tener "
|
||||
"implicaciones de seguridad cuando un nombre de usuario se utiliza con otra "
|
||||
"fuente."
|
||||
|
||||
#: authentik/core/models.py
|
||||
@ -322,8 +322,8 @@ msgid ""
|
||||
"Link to a group with identical name. Can have security implications when a "
|
||||
"group name is used with another source."
|
||||
msgstr ""
|
||||
"Enlace a un grupo con un nombre idéntico. Puede tener implicaciones de "
|
||||
"seguridad cuando se utiliza un nombre de grupo con otra fuente."
|
||||
"Enlace a un grupo con el mismo nombre. Puede tener implicaciones de "
|
||||
"seguridad cuando un nombre de grupo se utiliza con otra fuente."
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Use the group name, but deny enrollment when the name already exists."
|
||||
@ -385,7 +385,7 @@ msgstr "Asignaciones de Propiedades"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "session data"
|
||||
msgstr ""
|
||||
msgstr "datos de sesión"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Session"
|
||||
@ -424,7 +424,7 @@ msgstr "¡Autenticado exitosamente con {source}!"
|
||||
#: authentik/core/sources/flow_manager.py
|
||||
#, python-brace-format
|
||||
msgid "Successfully linked {source}!"
|
||||
msgstr "¡{source} vinculado exitosamente!"
|
||||
msgstr "¡{source} enlazado correctamente!"
|
||||
|
||||
#: authentik/core/sources/flow_manager.py
|
||||
msgid "Source is not configured for enrollment."
|
||||
@ -476,11 +476,11 @@ msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Certificate-Key Pair"
|
||||
msgstr "Par de claves de certificado"
|
||||
msgstr "Par Certificado-Clave"
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Certificate-Key Pairs"
|
||||
msgstr "Pares de claves de certificado"
|
||||
msgstr "Pares Certificado-Clave"
|
||||
|
||||
#: authentik/enterprise/api.py
|
||||
msgid "Enterprise is required to create/update this object."
|
||||
@ -511,7 +511,7 @@ msgstr ""
|
||||
|
||||
#: authentik/enterprise/policies/unique_password/models.py
|
||||
msgid "Number of passwords to check against."
|
||||
msgstr ""
|
||||
msgstr "Número de contraseñas contra las que verificar."
|
||||
|
||||
#: authentik/enterprise/policies/unique_password/models.py
|
||||
#: authentik/policies/password/models.py
|
||||
@ -521,18 +521,20 @@ msgstr "La contraseña no se ha establecido en contexto"
|
||||
#: authentik/enterprise/policies/unique_password/models.py
|
||||
msgid "This password has been used previously. Please choose a different one."
|
||||
msgstr ""
|
||||
"Esta contraseña se ha utilizado anteriormente. Por favor, elija una "
|
||||
"diferente."
|
||||
|
||||
#: authentik/enterprise/policies/unique_password/models.py
|
||||
msgid "Password Uniqueness Policy"
|
||||
msgstr ""
|
||||
msgstr "Política de Unicidad de Contraseñas"
|
||||
|
||||
#: authentik/enterprise/policies/unique_password/models.py
|
||||
msgid "Password Uniqueness Policies"
|
||||
msgstr ""
|
||||
msgstr "Políticas de Unicidad de Contraseñas"
|
||||
|
||||
#: authentik/enterprise/policies/unique_password/models.py
|
||||
msgid "User Password History"
|
||||
msgstr ""
|
||||
msgstr "Historial de Contraseñas del Usuario"
|
||||
|
||||
#: authentik/enterprise/policy.py
|
||||
msgid "Enterprise required to access this feature."
|
||||
@ -617,39 +619,39 @@ msgstr "Clave de firma"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Key used to sign the SSF Events."
|
||||
msgstr ""
|
||||
msgstr "Clave utilizada para firmar los eventos SSF."
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Shared Signals Framework Provider"
|
||||
msgstr ""
|
||||
msgstr "Proveedor del Marco de Señales Compartidas"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Shared Signals Framework Providers"
|
||||
msgstr ""
|
||||
msgstr "Proveedores del Marco de Señales Compartidas"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "Add stream to SSF provider"
|
||||
msgstr ""
|
||||
msgstr "Agregar flujo de datos al proveedor SSF"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Stream"
|
||||
msgstr ""
|
||||
msgstr "Flujo de Datos SSF"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Streams"
|
||||
msgstr ""
|
||||
msgstr "Flujos de Datos SSF"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Stream Event"
|
||||
msgstr ""
|
||||
msgstr "Evento de Flujo de Datos SSF"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/models.py
|
||||
msgid "SSF Stream Events"
|
||||
msgstr ""
|
||||
msgstr "Eventos de Flujos de Datos SSF"
|
||||
|
||||
#: authentik/enterprise/providers/ssf/tasks.py
|
||||
msgid "Failed to send request"
|
||||
msgstr "Falló envio de petición"
|
||||
msgstr "Error al enviar la solicitud"
|
||||
|
||||
#: authentik/enterprise/stages/authenticator_endpoint_gdtc/models.py
|
||||
msgid "Endpoint Authenticator Google Device Trust Connector Stage"
|
||||
@ -681,26 +683,29 @@ msgid ""
|
||||
"option has a higher priority than the `client_certificate` option on "
|
||||
"`Brand`."
|
||||
msgstr ""
|
||||
"Configura las autoridades certificadoras para validar el certificado. Esta "
|
||||
"opción tiene una prioridad mayor que la opción `client_certificate` en "
|
||||
"`Brand`."
|
||||
|
||||
#: authentik/enterprise/stages/mtls/models.py
|
||||
msgid "Mutual TLS Stage"
|
||||
msgstr ""
|
||||
msgstr "Etapa de TLS mutuo"
|
||||
|
||||
#: authentik/enterprise/stages/mtls/models.py
|
||||
msgid "Mutual TLS Stages"
|
||||
msgstr ""
|
||||
msgstr "Etapas de TLS mutuo"
|
||||
|
||||
#: authentik/enterprise/stages/mtls/models.py
|
||||
msgid "Permissions to pass Certificates for outposts."
|
||||
msgstr ""
|
||||
msgstr "Permisos para pasar Certificados a los puestos avanzados."
|
||||
|
||||
#: authentik/enterprise/stages/mtls/stage.py
|
||||
msgid "Certificate required but no certificate was given."
|
||||
msgstr ""
|
||||
msgstr "Se requiere certificado, pero no se proporcionó ninguno."
|
||||
|
||||
#: authentik/enterprise/stages/mtls/stage.py
|
||||
msgid "No user found for certificate."
|
||||
msgstr ""
|
||||
msgstr "No se encontró usuario para el certificado."
|
||||
|
||||
#: authentik/enterprise/stages/source/models.py
|
||||
msgid ""
|
||||
@ -753,12 +758,16 @@ msgid ""
|
||||
"Customize the body of the request. Mapping should return data that is JSON-"
|
||||
"serializable."
|
||||
msgstr ""
|
||||
"Personaliza el cuerpo de la solicitud. El mapeo debe devolver datos que sean"
|
||||
" serializables en JSON."
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid ""
|
||||
"Configure additional headers to be sent. Mapping should return a dictionary "
|
||||
"of key-value pairs"
|
||||
msgstr ""
|
||||
"Configura encabezados adicionales para enviar. El mapeo debe devolver un "
|
||||
"diccionario de pares clave-valor"
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid ""
|
||||
@ -786,7 +795,7 @@ msgstr "Transporte de notificaciones"
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid "Notification Transports"
|
||||
msgstr "Transportes de notificación"
|
||||
msgstr "Medios de Notificación"
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid "Notice"
|
||||
@ -813,9 +822,9 @@ msgid ""
|
||||
"Select which transports should be used to notify the user. If none are "
|
||||
"selected, the notification will only be shown in the authentik UI."
|
||||
msgstr ""
|
||||
"Seleccione qué transportes se deben usar para notificar al usuario. Si no se"
|
||||
" selecciona ninguno, la notificación solo se mostrará en la interfaz de "
|
||||
"usuario de authentik."
|
||||
"Selecciona qué medios se deben usar para notificar al usuario. Si no se "
|
||||
"selecciona ninguno, la notificación solo se mostrará en la interfaz de "
|
||||
"authentik."
|
||||
|
||||
#: authentik/events/models.py
|
||||
msgid "Controls which severity level the created notifications will have."
|
||||
@ -987,7 +996,7 @@ msgstr "Evalúa políticas durante el proceso de planeación del Flujo."
|
||||
|
||||
#: authentik/flows/models.py
|
||||
msgid "Evaluate policies when the Stage is presented to the user."
|
||||
msgstr ""
|
||||
msgstr "Evaluar las políticas cuando la Etapa se presenta al usuario."
|
||||
|
||||
#: authentik/flows/models.py
|
||||
msgid ""
|
||||
@ -1034,6 +1043,8 @@ msgid ""
|
||||
"When enabled, provider will not modify or create objects in the remote "
|
||||
"system."
|
||||
msgstr ""
|
||||
"Cuando está habilitado, el proveedor no modificará ni creará objetos en el "
|
||||
"sistema remoto."
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
msgid "Starting full provider sync"
|
||||
@ -1041,20 +1052,21 @@ msgstr "Iniciando sincronización completa de proveedor"
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
msgid "Syncing users"
|
||||
msgstr ""
|
||||
msgstr "Sincronizando usuarios"
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
msgid "Syncing groups"
|
||||
msgstr ""
|
||||
msgstr "Sincronizando grupos"
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
#, python-brace-format
|
||||
msgid "Syncing page {page} of groups"
|
||||
msgstr "Sincronizando página {page} de grupos"
|
||||
msgid "Syncing page {page} of {object_type}"
|
||||
msgstr "Sincronizando página {page} de {object_type}"
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
msgid "Dropping mutating request due to dry run"
|
||||
msgstr ""
|
||||
"Descartando solicitud de mutación debido a ejecución en modo de simulación"
|
||||
|
||||
#: authentik/lib/sync/outgoing/tasks.py
|
||||
#, python-brace-format
|
||||
@ -1233,7 +1245,7 @@ msgstr ""
|
||||
|
||||
#: authentik/policies/expiry/models.py
|
||||
msgid "Password has expired."
|
||||
msgstr "La contraseña ha caducado."
|
||||
msgstr "La contraseña ha expirado."
|
||||
|
||||
#: authentik/policies/expiry/models.py
|
||||
msgid "Password Expiry Policy"
|
||||
@ -1271,7 +1283,7 @@ msgstr "La IP del cliente no está en un país permitido."
|
||||
|
||||
#: authentik/policies/geoip/models.py
|
||||
msgid "Distance from previous authentication is larger than threshold."
|
||||
msgstr "La distancia desde la autenticación previa es mayor que el límite."
|
||||
msgstr "La distancia desde la autenticación anterior es mayor que el umbral."
|
||||
|
||||
#: authentik/policies/geoip/models.py
|
||||
msgid "Distance is further than possible."
|
||||
@ -1320,7 +1332,7 @@ msgstr "Vinculación de Políticas"
|
||||
|
||||
#: authentik/policies/models.py
|
||||
msgid "Policy Bindings"
|
||||
msgstr "Vinculaciones de políticas"
|
||||
msgstr "Vinculaciones de Políticas"
|
||||
|
||||
#: authentik/policies/models.py
|
||||
msgid ""
|
||||
@ -1594,11 +1606,11 @@ msgstr "ES256 (Encriptación Asimétrica)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "ES384 (Asymmetric Encryption)"
|
||||
msgstr ""
|
||||
msgstr "ES384 (Encriptación Asimétrica)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "ES512 (Asymmetric Encryption)"
|
||||
msgstr ""
|
||||
msgstr "ES512 (Encriptación Asimétrica)"
|
||||
|
||||
#: authentik/providers/oauth2/models.py
|
||||
msgid "Scope used by the client"
|
||||
@ -1813,7 +1825,7 @@ msgstr "Valida Certificados SSL de servidores de origen"
|
||||
|
||||
#: authentik/providers/proxy/models.py
|
||||
msgid "Internal host SSL Validation"
|
||||
msgstr "Validación SSL de host interno"
|
||||
msgstr "Validación SSL del host interno"
|
||||
|
||||
#: authentik/providers/proxy/models.py
|
||||
msgid ""
|
||||
@ -2027,7 +2039,7 @@ msgstr ""
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid "AuthnContextClassRef Property Mapping"
|
||||
msgstr ""
|
||||
msgstr "Asignación de Propiedades de AuthnContextClassRef"
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid ""
|
||||
@ -2035,6 +2047,9 @@ msgid ""
|
||||
"empty, the AuthnContextClassRef will be set based on which authentication "
|
||||
"methods the user used to authenticate."
|
||||
msgstr ""
|
||||
"Configura cómo se creará el valor de AuthnContextClassRef. Si se deja vacío,"
|
||||
" el AuthnContextClassRef se establecerá según los métodos de autenticación "
|
||||
"que el usuario haya utilizado para autenticarse."
|
||||
|
||||
#: authentik/providers/saml/models.py
|
||||
msgid ""
|
||||
@ -2184,11 +2199,11 @@ msgstr "Predeterminado"
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "AWS"
|
||||
msgstr ""
|
||||
msgstr "AWS"
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "Slack"
|
||||
msgstr ""
|
||||
msgstr "Slack"
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "Base URL to SCIM requests, usually ends in /v2"
|
||||
@ -2200,11 +2215,13 @@ msgstr "Token de Autenticación"
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "SCIM Compatibility Mode"
|
||||
msgstr ""
|
||||
msgstr "Modo de Compatibilidad SCIM"
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "Alter authentik behavior for vendor-specific SCIM implementations."
|
||||
msgstr ""
|
||||
"Modificar el comportamiento de authentik para implementaciones SCIM "
|
||||
"específicas de proveedores."
|
||||
|
||||
#: authentik/providers/scim/models.py
|
||||
msgid "SCIM Provider"
|
||||
@ -2232,7 +2249,7 @@ msgstr "Roles"
|
||||
|
||||
#: authentik/rbac/models.py
|
||||
msgid "Initial Permissions"
|
||||
msgstr ""
|
||||
msgstr "Permisos Iniciales"
|
||||
|
||||
#: authentik/rbac/models.py
|
||||
msgid "System permission"
|
||||
@ -2270,7 +2287,7 @@ msgstr ""
|
||||
|
||||
#: authentik/recovery/views.py
|
||||
msgid "Used recovery-link to authenticate."
|
||||
msgstr "Se usó el enlace de recuperación para autenticarse."
|
||||
msgstr "Se utilizó un enlace de recuperación para autenticarse."
|
||||
|
||||
#: authentik/sources/kerberos/models.py
|
||||
msgid "Kerberos realm"
|
||||
@ -2282,7 +2299,7 @@ msgstr "krb5.conf personalizado a usar. Usa el del sistema por defecto."
|
||||
|
||||
#: authentik/sources/kerberos/models.py
|
||||
msgid "KAdmin server type"
|
||||
msgstr ""
|
||||
msgstr "Tipo de servidor KAdmin"
|
||||
|
||||
#: authentik/sources/kerberos/models.py
|
||||
msgid "Sync users from Kerberos into authentik"
|
||||
@ -2290,23 +2307,24 @@ msgstr "Sincronizar usuarios desde Kerberos hacia Authentik"
|
||||
|
||||
#: authentik/sources/kerberos/models.py
|
||||
msgid "When a user changes their password, sync it back to Kerberos"
|
||||
msgstr "Cuando un usuario cambia su contraseña, sincronizarlo hacia Kerberos"
|
||||
msgstr ""
|
||||
"Cuando un usuario cambie su contraseña, sincronizarla de vuelta a Kerberos."
|
||||
|
||||
#: authentik/sources/kerberos/models.py
|
||||
msgid "Principal to authenticate to kadmin for sync."
|
||||
msgstr "Principal para autenticarse como kadmin para la sincronización."
|
||||
msgstr "Principal para autenticarse en kadmin para la sincronización."
|
||||
|
||||
#: authentik/sources/kerberos/models.py
|
||||
msgid "Password to authenticate to kadmin for sync"
|
||||
msgstr "Contraseña para autenticarse como kadmin para la sincronización"
|
||||
msgstr "Contraseña para autenticarse en kadmin para la sincronización"
|
||||
|
||||
#: authentik/sources/kerberos/models.py
|
||||
msgid ""
|
||||
"Keytab to authenticate to kadmin for sync. Must be base64-encoded or in the "
|
||||
"form TYPE:residual"
|
||||
msgstr ""
|
||||
"Keytab para autenticarse como kadmin para la sincronización. Debe estar "
|
||||
"codificado en base64 o en el formato TIPO:residual"
|
||||
"Keytab para autenticarse en kadmin para la sincronización. Debe estar "
|
||||
"codificado en base64 o en el formato TIPO:residuo"
|
||||
|
||||
#: authentik/sources/kerberos/models.py
|
||||
msgid ""
|
||||
@ -2322,7 +2340,7 @@ msgid ""
|
||||
"HTTP@hostname"
|
||||
msgstr ""
|
||||
"Forzar el uso de un nombre de servidor específico para SPNEGO. Debe estar en"
|
||||
" el formato HTTP@nombredelservidor"
|
||||
" el formato HTTP@nombre_de_host"
|
||||
|
||||
#: authentik/sources/kerberos/models.py
|
||||
msgid "SPNEGO keytab base64-encoded or path to keytab in the form FILE:path"
|
||||
@ -2339,8 +2357,8 @@ msgid ""
|
||||
"If enabled, the authentik-stored password will be updated upon login with "
|
||||
"the Kerberos password backend"
|
||||
msgstr ""
|
||||
"Si está habilitado, la contraseña almacenada por authentik será actualizada "
|
||||
"al iniciar sesión con el backend de contraseñas Kerberos"
|
||||
"Si está habilitado, la contraseña almacenada en authentik se actualizará al "
|
||||
"iniciar sesión con el backend de contraseñas de Kerberos."
|
||||
|
||||
#: authentik/sources/kerberos/models.py
|
||||
msgid "Kerberos Source"
|
||||
@ -2388,7 +2406,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"\n"
|
||||
" Asegúrate de que tienes entradas válidas\n"
|
||||
" (se obtienen a través de kinit) \n"
|
||||
" (obtenibles mediante kinit) \n"
|
||||
" y de haber configurado correctamente el navegador.\n"
|
||||
" Por favor, contacta a tu administrador.\n"
|
||||
" "
|
||||
@ -2453,6 +2471,10 @@ msgstr "DN de grupo de adición"
|
||||
msgid "Consider Objects matching this filter to be Users."
|
||||
msgstr "Considere que los objetos que coinciden con este filtro son usuarios."
|
||||
|
||||
#: authentik/sources/ldap/models.py
|
||||
msgid "Attribute which matches the value of `group_membership_field`."
|
||||
msgstr "Atributo que coincide con el valor de `group_membership_field`."
|
||||
|
||||
#: authentik/sources/ldap/models.py
|
||||
msgid "Field which contains members of a group."
|
||||
msgstr "Campo que contiene los miembros de un grupo."
|
||||
@ -2485,12 +2507,17 @@ msgid ""
|
||||
"attribute. This allows nested group resolution on systems like FreeIPA and "
|
||||
"Active Directory"
|
||||
msgstr ""
|
||||
"Buscar la pertenencia a grupos basándose en un atributo del usuario en lugar"
|
||||
" de un atributo del grupo. Esto permite la resolución de grupos anidados en "
|
||||
"sistemas como FreeIPA y Active Directory"
|
||||
|
||||
#: authentik/sources/ldap/models.py
|
||||
msgid ""
|
||||
"Delete authentik users and groups which were previously supplied by this "
|
||||
"source, but are now missing from it."
|
||||
msgstr ""
|
||||
"Eliminar usuarios y grupos de authentik que fueron proporcionados "
|
||||
"previamente por esta fuente, pero que ahora están ausentes."
|
||||
|
||||
#: authentik/sources/ldap/models.py
|
||||
msgid "LDAP Source"
|
||||
@ -2512,22 +2539,24 @@ msgstr "Asignaciones de Propiedades de Fuente de LDAP"
|
||||
msgid ""
|
||||
"Unique ID used while checking if this object still exists in the directory."
|
||||
msgstr ""
|
||||
"ID único utilizado para verificar si este objeto aún existe en el "
|
||||
"directorio."
|
||||
|
||||
#: authentik/sources/ldap/models.py
|
||||
msgid "User LDAP Source Connection"
|
||||
msgstr ""
|
||||
msgstr "Conexión de Fuente LDAP de Usuario"
|
||||
|
||||
#: authentik/sources/ldap/models.py
|
||||
msgid "User LDAP Source Connections"
|
||||
msgstr ""
|
||||
msgstr "Conexiones de Fuente LDAP de Usuario"
|
||||
|
||||
#: authentik/sources/ldap/models.py
|
||||
msgid "Group LDAP Source Connection"
|
||||
msgstr ""
|
||||
msgstr "Conexión de Fuente LDAP de Grupo"
|
||||
|
||||
#: authentik/sources/ldap/models.py
|
||||
msgid "Group LDAP Source Connections"
|
||||
msgstr ""
|
||||
msgstr "Conexiones de Fuente LDAP de Grupo"
|
||||
|
||||
#: authentik/sources/ldap/signals.py
|
||||
msgid "Password does not match Active Directory Complexity."
|
||||
@ -2539,11 +2568,11 @@ msgstr "No se recibió ningún token."
|
||||
|
||||
#: authentik/sources/oauth/models.py
|
||||
msgid "HTTP Basic Authentication"
|
||||
msgstr ""
|
||||
msgstr "Autenticación Básica HTTP"
|
||||
|
||||
#: authentik/sources/oauth/models.py
|
||||
msgid "Include the client ID and secret as request parameters"
|
||||
msgstr ""
|
||||
msgstr "Incluir el ID de cliente y el secreto como parámetros de la solicitud"
|
||||
|
||||
#: authentik/sources/oauth/models.py
|
||||
msgid "Request Token URL"
|
||||
@ -2590,6 +2619,8 @@ msgid ""
|
||||
"How to perform authentication during an authorization_code token request "
|
||||
"flow"
|
||||
msgstr ""
|
||||
"Cómo realizar la autenticación durante un flujo de solicitud de token con "
|
||||
"authorization_code"
|
||||
|
||||
#: authentik/sources/oauth/models.py
|
||||
msgid "OAuth Source"
|
||||
@ -2907,7 +2938,7 @@ msgstr "Conexiones de Fuente de SAML de Grupo"
|
||||
#: authentik/sources/saml/views.py
|
||||
#, python-brace-format
|
||||
msgid "Continue to {source_name}"
|
||||
msgstr ""
|
||||
msgstr "Continuar a {source_name}"
|
||||
|
||||
#: authentik/sources/scim/models.py
|
||||
msgid "SCIM Source"
|
||||
@ -2943,7 +2974,7 @@ msgstr "Dispositivos Duo"
|
||||
|
||||
#: authentik/stages/authenticator_email/models.py
|
||||
msgid "Email OTP"
|
||||
msgstr ""
|
||||
msgstr "OTP por Correo Electrónico"
|
||||
|
||||
#: authentik/stages/authenticator_email/models.py
|
||||
#: authentik/stages/email/models.py
|
||||
@ -2964,11 +2995,11 @@ msgstr ""
|
||||
|
||||
#: authentik/stages/authenticator_email/models.py
|
||||
msgid "Email Authenticator Setup Stage"
|
||||
msgstr ""
|
||||
msgstr "Etapa de Configuración del Autenticador de Correo Electrónico"
|
||||
|
||||
#: authentik/stages/authenticator_email/models.py
|
||||
msgid "Email Authenticator Setup Stages"
|
||||
msgstr ""
|
||||
msgstr "Etapas de Configuración del Autenticador de Correo Electrónico"
|
||||
|
||||
#: authentik/stages/authenticator_email/models.py
|
||||
#: authentik/stages/authenticator_email/stage.py
|
||||
@ -2979,11 +3010,11 @@ msgstr ""
|
||||
|
||||
#: authentik/stages/authenticator_email/models.py
|
||||
msgid "Email Device"
|
||||
msgstr "Dispositivo de Email"
|
||||
msgstr "Dispositivo de correo electrónico"
|
||||
|
||||
#: authentik/stages/authenticator_email/models.py
|
||||
msgid "Email Devices"
|
||||
msgstr "Dispositivos de Email"
|
||||
msgstr "Dispositivos de correo electrónico"
|
||||
|
||||
#: authentik/stages/authenticator_email/stage.py
|
||||
#: authentik/stages/authenticator_sms/stage.py
|
||||
@ -2993,7 +3024,7 @@ msgstr "El código no coincide"
|
||||
|
||||
#: authentik/stages/authenticator_email/stage.py
|
||||
msgid "Invalid email"
|
||||
msgstr "Email Inválido"
|
||||
msgstr "Correo electrónico inválido"
|
||||
|
||||
#: authentik/stages/authenticator_email/templates/email/email_otp.html
|
||||
#: authentik/stages/email/templates/email/password_reset.html
|
||||
@ -3013,6 +3044,9 @@ msgid ""
|
||||
" Email MFA code.\n"
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
" Código MFA por correo electrónico.\n"
|
||||
" "
|
||||
|
||||
#: authentik/stages/authenticator_email/templates/email/email_otp.html
|
||||
#, python-format
|
||||
@ -3022,7 +3056,8 @@ msgid ""
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Si no solicitaste este código, por favor ignora este correo. El código anterior es válido por %(expires)s."
|
||||
" Si no solicitaste este código, por favor ignora este correo. El código anterior es válido por %(expires)s.\n"
|
||||
" "
|
||||
|
||||
#: authentik/stages/authenticator_email/templates/email/email_otp.txt
|
||||
#: authentik/stages/email/templates/email/password_reset.txt
|
||||
@ -3035,6 +3070,8 @@ msgid ""
|
||||
"\n"
|
||||
"Email MFA code\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Código MFA por correo electrónico\n"
|
||||
|
||||
#: authentik/stages/authenticator_email/templates/email/email_otp.txt
|
||||
#, python-format
|
||||
@ -3276,8 +3313,8 @@ msgstr "No se pudo validar el token"
|
||||
msgid ""
|
||||
"Offset after which consent expires. (Format: hours=1;minutes=2;seconds=3)."
|
||||
msgstr ""
|
||||
"Compensación después de la cual caduca el consentimiento. (Formato: horas = "
|
||||
"1; minutos = 2; segundos = 3)."
|
||||
"Desfase después del cual expira el consentimiento. (Formato: "
|
||||
"hours=1;minutes=2;seconds=3)."
|
||||
|
||||
#: authentik/stages/consent/models.py
|
||||
msgid "Consent Stage"
|
||||
@ -3297,7 +3334,7 @@ msgstr "Consentimientos del usuario"
|
||||
|
||||
#: authentik/stages/consent/stage.py
|
||||
msgid "Invalid consent token, re-showing prompt"
|
||||
msgstr ""
|
||||
msgstr "Token de consentimiento inválido, mostrando el aviso nuevamente"
|
||||
|
||||
#: authentik/stages/deny/models.py
|
||||
msgid "Deny Stage"
|
||||
@ -3317,11 +3354,11 @@ msgstr "Etapas ficticias"
|
||||
|
||||
#: authentik/stages/email/flow.py
|
||||
msgid "Continue to confirm this email address."
|
||||
msgstr ""
|
||||
msgstr "Continúa para confirmar esta dirección de correo electrónico."
|
||||
|
||||
#: authentik/stages/email/flow.py
|
||||
msgid "Link was already used, please request a new link."
|
||||
msgstr ""
|
||||
msgstr "El enlace ya fue utilizado, por favor, solícita uno nuevo."
|
||||
|
||||
#: authentik/stages/email/models.py
|
||||
msgid "Password Reset"
|
||||
@ -3445,7 +3482,8 @@ msgid ""
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
"Si no solicitaste un cambio de contraseña, por favor ignora este correo. El enlace anterior es válido por %(expires)s."
|
||||
" Si no solicitaste un cambio de contraseña, por favor ignora este correo. El enlace anterior es válido por %(expires)s.\n"
|
||||
" "
|
||||
|
||||
#: authentik/stages/email/templates/email/password_reset.txt
|
||||
msgid ""
|
||||
@ -3529,24 +3567,26 @@ msgid ""
|
||||
"Show the user the 'Remember me on this device' toggle, allowing repeat users"
|
||||
" to skip straight to entering their password."
|
||||
msgstr ""
|
||||
"Mostrar al usuario la opción \"Recordarme en este dispositivo\", permitiendo"
|
||||
" que los usuarios recurrentes pasen directamente a ingresar su contraseña."
|
||||
|
||||
#: authentik/stages/identification/models.py
|
||||
msgid "Optional enrollment flow, which is linked at the bottom of the page."
|
||||
msgstr ""
|
||||
"Flujo de inscripción opcional, que está vinculado en la parte inferior de la"
|
||||
" página."
|
||||
"Flujo de inscripción opcional, que se enlaza en la parte inferior de la "
|
||||
"página."
|
||||
|
||||
#: authentik/stages/identification/models.py
|
||||
msgid "Optional recovery flow, which is linked at the bottom of the page."
|
||||
msgstr ""
|
||||
"Flujo de recuperación opcional, que está vinculado en la parte inferior de "
|
||||
"la página."
|
||||
"Flujo de recuperación opcional, que se enlaza en la parte inferior de la "
|
||||
"página."
|
||||
|
||||
#: authentik/stages/identification/models.py
|
||||
msgid "Optional passwordless flow, which is linked at the bottom of the page."
|
||||
msgstr ""
|
||||
"Flujo sin contraseña opcional, el cual está vinculado en la parte inferior "
|
||||
"de la página."
|
||||
"Flujo opcional sin contraseña, que se enlaza en la parte inferior de la "
|
||||
"página."
|
||||
|
||||
#: authentik/stages/identification/models.py
|
||||
msgid "Specify which sources should be shown."
|
||||
@ -3780,11 +3820,11 @@ msgstr "Las contraseñas no coinciden."
|
||||
|
||||
#: authentik/stages/redirect/api.py
|
||||
msgid "Target URL should be present when mode is Static."
|
||||
msgstr ""
|
||||
msgstr "La URL de destino debe estar presente cuando el modo es Estático."
|
||||
|
||||
#: authentik/stages/redirect/api.py
|
||||
msgid "Target Flow should be present when mode is Flow."
|
||||
msgstr ""
|
||||
msgstr "El Flujo de Destino debe estar presente cuando el modo es Flujo."
|
||||
|
||||
#: authentik/stages/redirect/models.py
|
||||
msgid "Redirect Stage"
|
||||
@ -3841,10 +3881,6 @@ msgstr "Etapas de inicio de"
|
||||
msgid "No Pending user to login."
|
||||
msgstr "Ningún usuario pendiente para iniciar sesión."
|
||||
|
||||
#: authentik/stages/user_login/stage.py
|
||||
msgid "Successfully logged in!"
|
||||
msgstr "¡Se ha iniciado sesión correctamente!"
|
||||
|
||||
#: authentik/stages/user_logout/models.py
|
||||
msgid "User Logout Stage"
|
||||
msgstr "Etapa de cierre de sesión del usuario"
|
||||
@ -3920,10 +3956,12 @@ msgstr ""
|
||||
#: authentik/tenants/models.py
|
||||
msgid "Reputation cannot decrease lower than this value. Zero or negative."
|
||||
msgstr ""
|
||||
"La reputación no puede disminuir por debajo de este valor. Cero o negativo."
|
||||
|
||||
#: authentik/tenants/models.py
|
||||
msgid "Reputation cannot increase higher than this value. Zero or positive."
|
||||
msgstr ""
|
||||
"La reputación no puede aumentar por encima de este valor. Cero o positivo."
|
||||
|
||||
#: authentik/tenants/models.py
|
||||
msgid "The option configures the footer links on the flow executor pages."
|
||||
@ -3946,8 +3984,8 @@ msgstr "Personificación habilitada/deshabilitada globalmente."
|
||||
#: authentik/tenants/models.py
|
||||
msgid "Require administrators to provide a reason for impersonating a user."
|
||||
msgstr ""
|
||||
"Requerir a los administradores proporcionar una razón para suplantar un "
|
||||
"usuario."
|
||||
"Requerir que los administradores proporcionen una razón para personificar a "
|
||||
"un usuario."
|
||||
|
||||
#: authentik/tenants/models.py
|
||||
msgid "Default token duration"
|
||||
@ -3959,7 +3997,7 @@ msgstr "Longitud predeterminada del token"
|
||||
|
||||
#: authentik/tenants/models.py
|
||||
msgid "Tenant"
|
||||
msgstr "inquilino"
|
||||
msgstr "Inquilino"
|
||||
|
||||
#: authentik/tenants/models.py
|
||||
msgid "Tenants"
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user