diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8c39866c21..f21fe6c788 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2025.2.2 +current_version = 2025.2.3 tag = True commit = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P[a-zA-Z-]+)(?P[1-9]\\d*))? @@ -17,6 +17,8 @@ optional_value = final [bumpversion:file:pyproject.toml] +[bumpversion:file:uv.lock] + [bumpversion:file:package.json] [bumpversion:file:docker-compose.yml] diff --git a/authentik/__init__.py b/authentik/__init__.py index c470b358f5..208476fa0f 100644 --- a/authentik/__init__.py +++ b/authentik/__init__.py @@ -2,7 +2,7 @@ from os import environ -__version__ = "2025.2.2" +__version__ = "2025.2.3" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index a1bef44dc5..b2f69de3aa 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -1,13 +1,14 @@ """User API Views""" from datetime import timedelta +from importlib import import_module from json import loads from typing import Any +from django.conf import settings from django.contrib.auth import update_session_auth_hash from django.contrib.auth.models import Permission -from django.contrib.sessions.backends.cache import KEY_PREFIX -from django.core.cache import cache +from django.contrib.sessions.backends.base import SessionBase from django.db.models.functions import ExtractHour from django.db.transaction import atomic from django.db.utils import IntegrityError @@ -91,6 +92,7 @@ from authentik.stages.email.tasks import send_mails from authentik.stages.email.utils import TemplateEmailMessage LOGGER = get_logger() +SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore class UserGroupSerializer(ModelSerializer): @@ -373,7 +375,7 @@ class UsersFilter(FilterSet): method="filter_attributes", ) - is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser") + is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser") uuid = UUIDFilter(field_name="uuid") path = CharFilter(field_name="path") @@ -391,6 +393,11 @@ class UsersFilter(FilterSet): queryset=Group.objects.all().order_by("name"), ) + def filter_is_superuser(self, queryset, name, value): + if value: + return queryset.filter(ak_groups__is_superuser=True).distinct() + return queryset.exclude(ak_groups__is_superuser=True).distinct() + def filter_attributes(self, queryset, name, value): """Filter attributes by query args""" try: @@ -769,7 +776,8 @@ class UserViewSet(UsedByMixin, ModelViewSet): if not instance.is_active: sessions = AuthenticatedSession.objects.filter(user=instance) session_ids = sessions.values_list("session_key", flat=True) - cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids) + for session in session_ids: + SessionStore(session).delete() sessions.delete() LOGGER.debug("Deleted user's sessions", user=instance.username) return response diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 3c103b169f..8632376ed9 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -1,7 +1,10 @@ """authentik core signals""" +from importlib import import_module + +from django.conf import settings from django.contrib.auth.signals import user_logged_in, user_logged_out -from django.contrib.sessions.backends.cache import KEY_PREFIX +from django.contrib.sessions.backends.base import SessionBase from django.core.cache import cache from django.core.signals import Signal from django.db.models import Model @@ -25,6 +28,7 @@ password_changed = Signal() login_failed = Signal() LOGGER = get_logger() +SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore @receiver(post_save, sender=Application) @@ -60,8 +64,7 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_): @receiver(pre_delete, sender=AuthenticatedSession) def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): """Delete session when authenticated session is deleted""" - cache_key = f"{KEY_PREFIX}{instance.session_key}" - cache.delete(cache_key) + SessionStore(instance.session_key).delete() @receiver(pre_save) diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py index 58546f4973..bbd57dbf31 100644 --- a/authentik/core/sources/flow_manager.py +++ b/authentik/core/sources/flow_manager.py @@ -36,6 +36,7 @@ from authentik.flows.planner import ( ) from authentik.flows.stage import StageView from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET +from authentik.lib.utils.urls import is_url_absolute from authentik.lib.views import bad_request_message from authentik.policies.denied import AccessDeniedResponse from authentik.policies.utils import delete_none_values @@ -48,6 +49,7 @@ LOGGER = get_logger() PLAN_CONTEXT_SOURCE_GROUPS = "source_groups" SESSION_KEY_SOURCE_FLOW_STAGES = "authentik/flows/source_flow_stages" +SESSION_KEY_SOURCE_FLOW_CONTEXT = "authentik/flows/source_flow_context" SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token" # nosec @@ -208,6 +210,8 @@ class SourceFlowManager: final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( NEXT_ARG_NAME, "authentik_core:if-user" ) + if not is_url_absolute(final_redirect): + final_redirect = "authentik_core:if-user" flow_context.update( { # Since we authenticate the user by their token, they have no backend set @@ -261,6 +265,7 @@ class SourceFlowManager: plan.append_stage(stage) for stage in self.request.session.get(SESSION_KEY_SOURCE_FLOW_STAGES, []): plan.append_stage(stage) + plan.context.update(self.request.session.get(SESSION_KEY_SOURCE_FLOW_CONTEXT, {})) return plan.to_redirect(self.request, flow) def handle_auth( diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index 140746f7c7..bd88dae787 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -1,6 +1,7 @@ """Test Users API""" from datetime import datetime +from json import loads from django.contrib.sessions.backends.cache import KEY_PREFIX from django.core.cache import cache @@ -15,7 +16,11 @@ from authentik.core.models import ( User, UserTypes, ) -from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow +from authentik.core.tests.utils import ( + create_test_admin_user, + create_test_brand, + create_test_flow, +) from authentik.flows.models import FlowDesignation from authentik.lib.generators import generate_id, generate_key from authentik.stages.email.models import EmailStage @@ -41,6 +46,32 @@ class TestUsersAPI(APITestCase): ) self.assertEqual(response.status_code, 200) + def test_filter_is_superuser(self): + """Test API filtering by superuser status""" + self.client.force_login(self.admin) + # Test superuser + response = self.client.get( + reverse("authentik_api:user-list"), + data={ + "is_superuser": True, + }, + ) + self.assertEqual(response.status_code, 200) + body = loads(response.content) + self.assertEqual(len(body["results"]), 1) + self.assertEqual(body["results"][0]["username"], self.admin.username) + # Test non-superuser + response = self.client.get( + reverse("authentik_api:user-list"), + data={ + "is_superuser": False, + }, + ) + self.assertEqual(response.status_code, 200) + body = loads(response.content) + self.assertEqual(len(body["results"]), 1, body) + self.assertEqual(body["results"][0]["username"], self.user.username) + def test_list_with_groups(self): """Test listing with groups""" self.client.force_login(self.admin) diff --git a/authentik/enterprise/stages/source/stage.py b/authentik/enterprise/stages/source/stage.py index 02f2637936..082d0aa7ee 100644 --- a/authentik/enterprise/stages/source/stage.py +++ b/authentik/enterprise/stages/source/stage.py @@ -11,13 +11,14 @@ from guardian.shortcuts import get_anonymous_user from authentik.core.models import Source, User from authentik.core.sources.flow_manager import ( SESSION_KEY_OVERRIDE_FLOW_TOKEN, + SESSION_KEY_SOURCE_FLOW_CONTEXT, SESSION_KEY_SOURCE_FLOW_STAGES, ) from authentik.core.types import UILoginButton from authentik.enterprise.stages.source.models import SourceStage from authentik.flows.challenge import Challenge, ChallengeResponse from authentik.flows.models import FlowToken, in_memory_stage -from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED +from authentik.flows.planner import PLAN_CONTEXT_IS_REDIRECTED, PLAN_CONTEXT_IS_RESTORED from authentik.flows.stage import ChallengeStageView, StageView from authentik.lib.utils.time import timedelta_from_string @@ -53,6 +54,9 @@ class SourceStageView(ChallengeStageView): resume_token = self.create_flow_token() self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] = resume_token self.request.session[SESSION_KEY_SOURCE_FLOW_STAGES] = [in_memory_stage(SourceStageFinal)] + self.request.session[SESSION_KEY_SOURCE_FLOW_CONTEXT] = { + PLAN_CONTEXT_IS_REDIRECTED: self.executor.flow, + } return self.login_button.challenge def create_flow_token(self) -> FlowToken: diff --git a/authentik/sources/saml/views.py b/authentik/sources/saml/views.py index 07fc6f859c..3f54f029fc 100644 --- a/authentik/sources/saml/views.py +++ b/authentik/sources/saml/views.py @@ -33,6 +33,7 @@ from authentik.flows.planner import ( ) from authentik.flows.stage import ChallengeStageView from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN +from authentik.lib.utils.urls import is_url_absolute from authentik.lib.views import bad_request_message from authentik.providers.saml.utils.encoding import nice64 from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat @@ -73,6 +74,8 @@ class InitiateView(View): final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( NEXT_ARG_NAME, "authentik_core:if-user" ) + if not is_url_absolute(final_redirect): + final_redirect = "authentik_core:if-user" kwargs.update( { PLAN_CONTEXT_SSO: True, diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 02546a0c2d..e8841bcd58 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -142,35 +142,38 @@ class IdentificationChallengeResponse(ChallengeResponse): raise ValidationError("Failed to authenticate.") self.pre_user = pre_user - # Password check - if current_stage.password_stage: - password = attrs.get("password", None) - if not password: - self.stage.logger.warning("Password not set for ident+auth attempt") - try: - with start_span( - op="authentik.stages.identification.authenticate", - name="User authenticate call (combo stage)", - ): - user = authenticate( - self.stage.request, - current_stage.password_stage.backends, - current_stage, - username=self.pre_user.username, - password=password, - ) - if not user: - raise ValidationError("Failed to authenticate.") - self.pre_user = user - except PermissionDenied as exc: - raise ValidationError(str(exc)) from exc - # Captcha check if captcha_stage := current_stage.captcha_stage: captcha_token = attrs.get("captcha_token", None) if not captcha_token: self.stage.logger.warning("Token not set for captcha attempt") verify_captcha_token(captcha_stage, captcha_token, client_ip) + + # Password check + if not current_stage.password_stage: + # No password stage select, don't validate the password + return attrs + + password = attrs.get("password", None) + if not password: + self.stage.logger.warning("Password not set for ident+auth attempt") + try: + with start_span( + op="authentik.stages.identification.authenticate", + name="User authenticate call (combo stage)", + ): + user = authenticate( + self.stage.request, + current_stage.password_stage.backends, + current_stage, + username=self.pre_user.username, + password=password, + ) + if not user: + raise ValidationError("Failed to authenticate.") + self.pre_user = user + except PermissionDenied as exc: + raise ValidationError(str(exc)) from exc return attrs diff --git a/blueprints/schema.json b/blueprints/schema.json index 72274bc11f..8156c8895c 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://goauthentik.io/blueprints/schema.json", "type": "object", - "title": "authentik 2025.2.2 Blueprint schema", + "title": "authentik 2025.2.3 Blueprint schema", "required": [ "version", "entries" diff --git a/docker-compose.yml b/docker-compose.yml index e8d28ced89..75c8b755c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,7 +31,7 @@ services: volumes: - redis:/data server: - image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.2} + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.3} restart: unless-stopped command: server environment: @@ -54,7 +54,7 @@ services: redis: condition: service_healthy worker: - image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.2} + image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.3} restart: unless-stopped command: worker environment: diff --git a/internal/constants/constants.go b/internal/constants/constants.go index cb46dcad36..746244f91b 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -29,4 +29,4 @@ func UserAgent() string { return fmt.Sprintf("authentik@%s", FullVersion()) } -const VERSION = "2025.2.2" +const VERSION = "2025.2.3" diff --git a/lifecycle/aws/template.yaml b/lifecycle/aws/template.yaml index 85d14c5a98..8517b6794d 100644 --- a/lifecycle/aws/template.yaml +++ b/lifecycle/aws/template.yaml @@ -26,7 +26,7 @@ Parameters: Description: authentik Docker image AuthentikVersion: Type: String - Default: 2025.2.2 + Default: 2025.2.3 Description: authentik Docker image tag AuthentikServerCPU: Type: Number diff --git a/package.json b/package.json index a52ee85c90..a6e7f89fa3 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "name": "@goauthentik/authentik", - "version": "2025.2.2", + "version": "2025.2.3", "private": true } diff --git a/pyproject.toml b/pyproject.toml index 434bfd2ce7..5f26b045d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "authentik" -version = "2025.2.2" +version = "2025.2.3" description = "" authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }] requires-python = "==3.12.*" diff --git a/schema.yml b/schema.yml index 1ccf744a03..5fb542195a 100644 --- a/schema.yml +++ b/schema.yml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: authentik - version: 2025.2.2 + version: 2025.2.3 description: Making authentication simple. contact: email: hello@goauthentik.io diff --git a/uv.lock b/uv.lock index 2afa008cc5..83b3832451 100644 --- a/uv.lock +++ b/uv.lock @@ -162,7 +162,7 @@ wheels = [ [[package]] name = "authentik" -version = "2025.2.2" +version = "2025.2.3" source = { editable = "." } dependencies = [ { name = "argon2-cffi" }, diff --git a/web/src/common/constants.ts b/web/src/common/constants.ts index 7e7c73a111..713f5c61e6 100644 --- a/web/src/common/constants.ts +++ b/web/src/common/constants.ts @@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success"; export const ERROR_CLASS = "pf-m-danger"; export const PROGRESS_CLASS = "pf-m-in-progress"; export const CURRENT_CLASS = "pf-m-current"; -export const VERSION = "2025.2.2"; +export const VERSION = "2025.2.3"; export const TITLE_DEFAULT = "authentik"; export const ROUTE_SEPARATOR = ";"; diff --git a/web/src/flow/stages/base.ts b/web/src/flow/stages/base.ts index 35ae392ab3..921f5a2a93 100644 --- a/web/src/flow/stages/base.ts +++ b/web/src/flow/stages/base.ts @@ -72,7 +72,9 @@ export class BaseStage< } return this.host?.submit(object as unknown as Tout).then((successful) => { if (successful) { - this.cleanup(); + this.onSubmitSuccess(); + } else { + this.onSubmitFailure(); } return successful; }); @@ -124,7 +126,11 @@ export class BaseStage< `; } - cleanup(): void { + onSubmitSuccess(): void { + // Method that can be overridden by stages + return; + } + onSubmitFailure(): void { // Method that can be overridden by stages return; } diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index 14c7f6ee7f..ec29889a95 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -9,7 +9,7 @@ import { randomId } from "@goauthentik/elements/utils/randomId"; import "@goauthentik/flow/FormStatic"; import { BaseStage } from "@goauthentik/flow/stages/base"; import { P, match } from "ts-pattern"; -import type { TurnstileObject } from "turnstile-types"; +import type * as _ from "turnstile-types"; import { msg } from "@lit/localize"; import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; @@ -24,10 +24,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api"; -interface TurnstileWindow extends Window { - turnstile: TurnstileObject; -} - type TokenHandler = (token: string) => void; type Dims = { height: number }; @@ -52,6 +48,8 @@ type CaptchaHandler = { name: string; interactive: () => Promise; execute: () => Promise; + refreshInteractive: () => Promise; + refresh: () => Promise; }; // A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces @@ -119,6 +117,12 @@ export class CaptchaStage extends BaseStage Object.hasOwn(window, name)); let lastError = undefined; let found = false; - for (const { name, interactive, execute } of handlers) { - console.debug(`authentik/stages/captcha: trying handler ${name}`); + for (const handler of handlers) { + console.debug(`authentik/stages/captcha: trying handler ${handler.name}`); try { - const runner = this.challenge.interactive ? interactive : execute; + const runner = this.challenge.interactive + ? handler.interactive + : handler.execute; await runner.apply(this); - console.debug(`authentik/stages/captcha[${name}]: handler succeeded`); + console.debug(`authentik/stages/captcha[${handler.name}]: handler succeeded`); found = true; + this.activeHandler = handler; break; } catch (exc) { - console.debug(`authentik/stages/captcha[${name}]: handler failed`); + console.debug(`authentik/stages/captcha[${handler.name}]: handler failed`); console.debug(exc); lastError = exc; } @@ -370,6 +406,19 @@ export class CaptchaStage extends BaseStage) { + if (!changedProperties.has("refreshedAt") || !this.challenge) { + return; + } + + console.debug("authentik/stages/captcha: refresh triggered"); + if (this.challenge.interactive) { + this.activeHandler?.refreshInteractive.apply(this); + } else { + this.activeHandler?.refresh.apply(this); + } + } } declare global { diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts index 8fa21810fb..8f13a2bf90 100644 --- a/web/src/flow/stages/identification/IdentificationStage.ts +++ b/web/src/flow/stages/identification/IdentificationStage.ts @@ -49,6 +49,8 @@ export class IdentificationStage extends BaseStage< @state() captchaToken = ""; + @state() + captchaRefreshedAt = new Date(); static get styles(): CSSResult[] { return [ @@ -179,12 +181,16 @@ export class IdentificationStage extends BaseStage< this.form.appendChild(totp); } - cleanup(): void { + onSubmitSuccess(): void { if (this.form) { this.form.remove(); } } + onSubmitFailure(): void { + this.captchaRefreshedAt = new Date(); + } + renderSource(source: LoginSource): TemplateResult { const icon = renderSourceIcon(source.name, source.iconUrl); return html`