From 3e530cf1b59d7a04466db2acd69467c679cc7c48 Mon Sep 17 00:00:00 2001 From: Jens L Date: Tue, 19 Dec 2023 13:32:10 +0100 Subject: [PATCH] flows: add "require outpost" authentication_requirement (#7921) * migrate get_client_ip to middleware Signed-off-by: Jens Langhammer * use middleware directly without wrapper Signed-off-by: Jens Langhammer * add require_outpost setting for flows Signed-off-by: Jens Langhammer * fix Signed-off-by: Jens Langhammer * add tests Signed-off-by: Jens Langhammer * update schema Signed-off-by: Jens Langhammer * update web ui Signed-off-by: Jens Langhammer * fixup Signed-off-by: Jens Langhammer * improve fallback Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/core/models.py | 5 +- authentik/events/models.py | 5 +- .../migrations/0027_auto_20231028_1424.py | 17 ++- authentik/flows/models.py | 1 + authentik/flows/planner.py | 5 + authentik/flows/tests/test_planner.py | 32 +++++ authentik/lib/tests/test_http.py | 24 ++-- authentik/lib/utils/http.py | 70 ----------- authentik/policies/expression/evaluator.py | 4 +- authentik/policies/reputation/models.py | 4 +- authentik/policies/reputation/signals.py | 4 +- authentik/policies/types.py | 5 +- authentik/root/middleware.py | 112 +++++++++++++++++- authentik/root/settings.py | 1 + .../authenticator_validate/challenge.py | 4 +- authentik/stages/captcha/stage.py | 5 +- authentik/stages/identification/stage.py | 4 +- authentik/stages/user_login/tests.py | 4 +- blueprints/schema.json | 3 +- schema.yml | 5 + web/src/admin/flows/FlowForm.ts | 7 ++ web/xliff/de.xlf | 3 + web/xliff/en.xlf | 3 + web/xliff/es.xlf | 3 + web/xliff/fr.xlf | 3 + web/xliff/pl.xlf | 3 + web/xliff/pseudo-LOCALE.xlf | 3 + web/xliff/tr.xlf | 3 + web/xliff/zh-Hans.xlf | 3 + web/xliff/zh-Hant.xlf | 3 + web/xliff/zh_TW.xlf | 3 + 31 files changed, 244 insertions(+), 107 deletions(-) diff --git a/authentik/core/models.py b/authentik/core/models.py index 7d11af3d6..125d5b0c8 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -30,7 +30,6 @@ from authentik.lib.models import ( DomainlessFormattedURLValidator, SerializerModel, ) -from authentik.lib.utils.http import get_client_ip from authentik.policies.models import PolicyBindingModel from authentik.root.install_id import get_install_id @@ -748,12 +747,14 @@ class AuthenticatedSession(ExpiringModel): @staticmethod def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]: """Create a new session from a http request""" + from authentik.root.middleware import ClientIPMiddleware + if not hasattr(request, "session") or not request.session.session_key: return None return AuthenticatedSession( session_key=request.session.session_key, user=user, - last_ip=get_client_ip(request), + last_ip=ClientIPMiddleware.get_client_ip(request), last_user_agent=request.META.get("HTTP_USER_AGENT", ""), expires=request.session.get_expiry_date(), ) diff --git a/authentik/events/models.py b/authentik/events/models.py index c43128419..03e9a939c 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -36,9 +36,10 @@ from authentik.events.utils import ( ) from authentik.lib.models import DomainlessURLValidator, SerializerModel from authentik.lib.sentry import SentryIgnoredException -from authentik.lib.utils.http import get_client_ip, get_http_session +from authentik.lib.utils.http import get_http_session from authentik.lib.utils.time import timedelta_from_string from authentik.policies.models import PolicyBindingModel +from authentik.root.middleware import ClientIPMiddleware from authentik.stages.email.utils import TemplateEmailMessage from authentik.tenants.models import Tenant from authentik.tenants.utils import DEFAULT_TENANT @@ -244,7 +245,7 @@ class Event(SerializerModel, ExpiringModel): self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]) self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER]) # User 255.255.255.255 as fallback if IP cannot be determined - self.client_ip = get_client_ip(request) + self.client_ip = ClientIPMiddleware.get_client_ip(request) # Apply GeoIP Data, when enabled self.with_geoip() # If there's no app set, we get it from the requests too diff --git a/authentik/flows/migrations/0027_auto_20231028_1424.py b/authentik/flows/migrations/0027_auto_20231028_1424.py index 856961a98..70784e6e3 100644 --- a/authentik/flows/migrations/0027_auto_20231028_1424.py +++ b/authentik/flows/migrations/0027_auto_20231028_1424.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.6 on 2023-10-28 14:24 from django.apps.registry import Apps -from django.db import migrations +from django.db import migrations, models from django.db.backends.base.schema import BaseDatabaseSchemaEditor @@ -31,4 +31,19 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(set_oobe_flow_authentication), + migrations.AlterField( + model_name="flow", + name="authentication", + field=models.TextField( + choices=[ + ("none", "None"), + ("require_authenticated", "Require Authenticated"), + ("require_unauthenticated", "Require Unauthenticated"), + ("require_superuser", "Require Superuser"), + ("require_outpost", "Require Outpost"), + ], + default="none", + help_text="Required level of authentication and authorization to access a flow.", + ), + ), ] diff --git a/authentik/flows/models.py b/authentik/flows/models.py index 67f7f0a9c..7ed744824 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -31,6 +31,7 @@ class FlowAuthenticationRequirement(models.TextChoices): REQUIRE_AUTHENTICATED = "require_authenticated" REQUIRE_UNAUTHENTICATED = "require_unauthenticated" REQUIRE_SUPERUSER = "require_superuser" + REQUIRE_OUTPOST = "require_outpost" class NotConfiguredAction(models.TextChoices): diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py index f80461490..a74c43275 100644 --- a/authentik/flows/planner.py +++ b/authentik/flows/planner.py @@ -23,6 +23,7 @@ from authentik.flows.models import ( ) from authentik.lib.config import CONFIG from authentik.policies.engine import PolicyEngine +from authentik.root.middleware import ClientIPMiddleware LOGGER = get_logger() PLAN_CONTEXT_PENDING_USER = "pending_user" @@ -141,6 +142,10 @@ class FlowPlanner: and not request.user.is_superuser ): raise FlowNonApplicableException() + if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST: + outpost_user = ClientIPMiddleware.get_outpost_user(request) + if not outpost_user: + raise FlowNonApplicableException() def plan( self, request: HttpRequest, default_context: Optional[dict[str, Any]] = None diff --git a/authentik/flows/tests/test_planner.py b/authentik/flows/tests/test_planner.py index 790f5bba4..f95462dc5 100644 --- a/authentik/flows/tests/test_planner.py +++ b/authentik/flows/tests/test_planner.py @@ -8,6 +8,7 @@ from django.test import RequestFactory, TestCase from django.urls import reverse from guardian.shortcuts import get_anonymous_user +from authentik.blueprints.tests import reconcile_app from authentik.core.models import User from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException @@ -15,9 +16,12 @@ from authentik.flows.markers import ReevaluateMarker, StageMarker from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation, FlowStageBinding from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key from authentik.lib.tests.utils import dummy_get_response +from authentik.outposts.apps import MANAGED_OUTPOST +from authentik.outposts.models import Outpost from authentik.policies.dummy.models import DummyPolicy from authentik.policies.models import PolicyBinding from authentik.policies.types import PolicyResult +from authentik.root.middleware import ClientIPMiddleware from authentik.stages.dummy.models import DummyStage POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) @@ -68,6 +72,34 @@ class TestFlowPlanner(TestCase): planner.allow_empty_flows = True planner.plan(request) + @reconcile_app("authentik_outposts") + def test_authentication_outpost(self): + """Test flow authentication (outpost)""" + flow = create_test_flow() + flow.authentication = FlowAuthenticationRequirement.REQUIRE_OUTPOST + request = self.request_factory.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + ) + request.user = AnonymousUser() + with self.assertRaises(FlowNonApplicableException): + planner = FlowPlanner(flow) + planner.allow_empty_flows = True + planner.plan(request) + + outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first() + request = self.request_factory.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + HTTP_X_AUTHENTIK_OUTPOST_TOKEN=outpost.token.key, + HTTP_X_AUTHENTIK_REMOTE_IP="1.2.3.4", + ) + request.user = AnonymousUser() + middleware = ClientIPMiddleware(dummy_get_response) + middleware(request) + + planner = FlowPlanner(flow) + planner.allow_empty_flows = True + planner.plan(request) + @patch( "authentik.policies.engine.PolicyEngine.result", POLICY_RETURN_FALSE, diff --git a/authentik/lib/tests/test_http.py b/authentik/lib/tests/test_http.py index e554088cc..92ec5b425 100644 --- a/authentik/lib/tests/test_http.py +++ b/authentik/lib/tests/test_http.py @@ -3,8 +3,8 @@ from django.test import RequestFactory, TestCase from authentik.core.models import Token, TokenIntents, UserTypes from authentik.core.tests.utils import create_test_admin_user -from authentik.lib.utils.http import OUTPOST_REMOTE_IP_HEADER, OUTPOST_TOKEN_HEADER, get_client_ip from authentik.lib.views import bad_request_message +from authentik.root.middleware import ClientIPMiddleware class TestHTTP(TestCase): @@ -22,12 +22,12 @@ class TestHTTP(TestCase): def test_normal(self): """Test normal request""" request = self.factory.get("/") - self.assertEqual(get_client_ip(request), "127.0.0.1") + self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1") def test_forward_for(self): """Test x-forwarded-for request""" request = self.factory.get("/", HTTP_X_FORWARDED_FOR="127.0.0.2") - self.assertEqual(get_client_ip(request), "127.0.0.2") + self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.2") def test_fake_outpost(self): """Test faked IP which is overridden by an outpost""" @@ -38,28 +38,28 @@ class TestHTTP(TestCase): request = self.factory.get( "/", **{ - OUTPOST_REMOTE_IP_HEADER: "1.2.3.4", - OUTPOST_TOKEN_HEADER: "abc", + ClientIPMiddleware.outpost_remote_ip_header: "1.2.3.4", + ClientIPMiddleware.outpost_token_header: "abc", }, ) - self.assertEqual(get_client_ip(request), "127.0.0.1") + self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1") # Invalid, user doesn't have permissions request = self.factory.get( "/", **{ - OUTPOST_REMOTE_IP_HEADER: "1.2.3.4", - OUTPOST_TOKEN_HEADER: token.key, + ClientIPMiddleware.outpost_remote_ip_header: "1.2.3.4", + ClientIPMiddleware.outpost_token_header: token.key, }, ) - self.assertEqual(get_client_ip(request), "127.0.0.1") + self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1") # Valid self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT self.user.save() request = self.factory.get( "/", **{ - OUTPOST_REMOTE_IP_HEADER: "1.2.3.4", - OUTPOST_TOKEN_HEADER: token.key, + ClientIPMiddleware.outpost_remote_ip_header: "1.2.3.4", + ClientIPMiddleware.outpost_token_header: token.key, }, ) - self.assertEqual(get_client_ip(request), "1.2.3.4") + self.assertEqual(ClientIPMiddleware.get_client_ip(request), "1.2.3.4") diff --git a/authentik/lib/utils/http.py b/authentik/lib/utils/http.py index adb3ec099..3d6638104 100644 --- a/authentik/lib/utils/http.py +++ b/authentik/lib/utils/http.py @@ -1,82 +1,12 @@ """http helpers""" -from typing import Any, Optional - -from django.http import HttpRequest from requests.sessions import Session -from sentry_sdk.hub import Hub from structlog.stdlib import get_logger from authentik import get_full_version -OUTPOST_REMOTE_IP_HEADER = "HTTP_X_AUTHENTIK_REMOTE_IP" -OUTPOST_TOKEN_HEADER = "HTTP_X_AUTHENTIK_OUTPOST_TOKEN" # nosec -DEFAULT_IP = "255.255.255.255" LOGGER = get_logger() -def _get_client_ip_from_meta(meta: dict[str, Any]) -> str: - """Attempt to get the client's IP by checking common HTTP Headers. - Returns none if no IP Could be found - - No additional validation is done here as requests are expected to only arrive here - via the go proxy, which deals with validating these headers for us""" - headers = ( - "HTTP_X_FORWARDED_FOR", - "REMOTE_ADDR", - ) - for _header in headers: - if _header in meta: - ips: list[str] = meta.get(_header).split(",") - return ips[0].strip() - return DEFAULT_IP - - -def _get_outpost_override_ip(request: HttpRequest) -> Optional[str]: - """Get the actual remote IP when set by an outpost. Only - allowed when the request is authenticated, by an outpost internal service account""" - from authentik.core.models import Token, TokenIntents, UserTypes - - if OUTPOST_REMOTE_IP_HEADER not in request.META or OUTPOST_TOKEN_HEADER not in request.META: - return None - fake_ip = request.META[OUTPOST_REMOTE_IP_HEADER] - token = ( - Token.filter_not_expired( - key=request.META.get(OUTPOST_TOKEN_HEADER), intent=TokenIntents.INTENT_API - ) - .select_related("user") - .first() - ) - if not token: - LOGGER.warning("Attempted remote-ip override without token", fake_ip=fake_ip) - return None - user = token.user - if user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT: - LOGGER.warning( - "Remote-IP override: user doesn't have permission", - user=user, - fake_ip=fake_ip, - ) - return None - # Update sentry scope to include correct IP - user = Hub.current.scope._user - if not user: - user = {} - user["ip_address"] = fake_ip - Hub.current.scope.set_user(user) - return fake_ip - - -def get_client_ip(request: Optional[HttpRequest]) -> str: - """Attempt to get the client's IP by checking common HTTP Headers. - Returns none if no IP Could be found""" - if not request: - return DEFAULT_IP - override = _get_outpost_override_ip(request) - if override: - return override - return _get_client_ip_from_meta(request.META) - - def authentik_user_agent() -> str: """Get a common user agent""" return f"authentik@{get_full_version()}" diff --git a/authentik/policies/expression/evaluator.py b/authentik/policies/expression/evaluator.py index 7617efdb3..4cc167f4b 100644 --- a/authentik/policies/expression/evaluator.py +++ b/authentik/policies/expression/evaluator.py @@ -7,9 +7,9 @@ from structlog.stdlib import get_logger from authentik.flows.planner import PLAN_CONTEXT_SSO from authentik.lib.expression.evaluator import BaseEvaluator -from authentik.lib.utils.http import get_client_ip from authentik.policies.exceptions import PolicyException from authentik.policies.types import PolicyRequest, PolicyResult +from authentik.root.middleware import ClientIPMiddleware LOGGER = get_logger() if TYPE_CHECKING: @@ -49,7 +49,7 @@ class PolicyEvaluator(BaseEvaluator): """Update context based on http request""" # update website/docs/expressions/_objects.md # update website/docs/expressions/_functions.md - self._context["ak_client_ip"] = ip_address(get_client_ip(request)) + self._context["ak_client_ip"] = ip_address(ClientIPMiddleware.get_client_ip(request)) self._context["http_request"] = request def handle_error(self, exc: Exception, expression_source: str): diff --git a/authentik/policies/reputation/models.py b/authentik/policies/reputation/models.py index 723614f51..7fccfa11a 100644 --- a/authentik/policies/reputation/models.py +++ b/authentik/policies/reputation/models.py @@ -13,9 +13,9 @@ from structlog import get_logger from authentik.core.models import ExpiringModel from authentik.lib.config import CONFIG from authentik.lib.models import SerializerModel -from authentik.lib.utils.http import get_client_ip from authentik.policies.models import Policy from authentik.policies.types import PolicyRequest, PolicyResult +from authentik.root.middleware import ClientIPMiddleware LOGGER = get_logger() CACHE_KEY_PREFIX = "goauthentik.io/policies/reputation/scores/" @@ -44,7 +44,7 @@ class ReputationPolicy(Policy): return "ak-policy-reputation-form" def passes(self, request: PolicyRequest) -> PolicyResult: - remote_ip = get_client_ip(request.http_request) + remote_ip = ClientIPMiddleware.get_client_ip(request.http_request) query = Q() if self.check_ip: query |= Q(ip=remote_ip) diff --git a/authentik/policies/reputation/signals.py b/authentik/policies/reputation/signals.py index 49b8cf011..5307672d4 100644 --- a/authentik/policies/reputation/signals.py +++ b/authentik/policies/reputation/signals.py @@ -7,9 +7,9 @@ from structlog.stdlib import get_logger from authentik.core.signals import login_failed from authentik.lib.config import CONFIG -from authentik.lib.utils.http import get_client_ip from authentik.policies.reputation.models import CACHE_KEY_PREFIX from authentik.policies.reputation.tasks import save_reputation +from authentik.root.middleware import ClientIPMiddleware from authentik.stages.identification.signals import identification_failed LOGGER = get_logger() @@ -18,7 +18,7 @@ CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_reputation") def update_score(request: HttpRequest, identifier: str, amount: int): """Update score for IP and User""" - remote_ip = get_client_ip(request) + remote_ip = ClientIPMiddleware.get_client_ip(request) try: # We only update the cache here, as its faster than writing to the DB diff --git a/authentik/policies/types.py b/authentik/policies/types.py index 29ac9c3c8..5e59dbbf0 100644 --- a/authentik/policies/types.py +++ b/authentik/policies/types.py @@ -9,7 +9,6 @@ from django.http import HttpRequest from structlog.stdlib import get_logger from authentik.events.geo import GEOIP_READER -from authentik.lib.utils.http import get_client_ip if TYPE_CHECKING: from authentik.core.models import User @@ -38,10 +37,12 @@ class PolicyRequest: def set_http_request(self, request: HttpRequest): # pragma: no cover """Load data from HTTP request, including geoip when enabled""" + from authentik.root.middleware import ClientIPMiddleware + self.http_request = request if not GEOIP_READER.enabled: return - client_ip = get_client_ip(request) + client_ip = ClientIPMiddleware.get_client_ip(request) if not client_ip: return self.context["geoip"] = GEOIP_READER.city(client_ip) diff --git a/authentik/root/middleware.py b/authentik/root/middleware.py index 8f97c3c9e..5eca4a6e5 100644 --- a/authentik/root/middleware.py +++ b/authentik/root/middleware.py @@ -2,7 +2,7 @@ from hashlib import sha512 from time import time from timeit import default_timer -from typing import Callable +from typing import Any, Callable, Optional from django.conf import settings from django.contrib.sessions.backends.base import UpdateError @@ -15,9 +15,10 @@ from django.middleware.csrf import CsrfViewMiddleware as UpstreamCsrfViewMiddlew from django.utils.cache import patch_vary_headers from django.utils.http import http_date from jwt import PyJWTError, decode, encode +from sentry_sdk.hub import Hub from structlog.stdlib import get_logger -from authentik.lib.utils.http import get_client_ip +from authentik.core.models import Token, TokenIntents, User, UserTypes LOGGER = get_logger("authentik.asgi") ACR_AUTHENTIK_SESSION = "goauthentik.io/core/default" @@ -156,6 +157,111 @@ class CsrfViewMiddleware(UpstreamCsrfViewMiddleware): patch_vary_headers(response, ("Cookie",)) +class ClientIPMiddleware: + """Set a "known-good" client IP on the request, by default based off of x-forwarded-for + which is set by the go proxy, but also allowing the remote IP to be overridden by an outpost + for protocols like LDAP""" + + get_response: Callable[[HttpRequest], HttpResponse] + outpost_remote_ip_header = "HTTP_X_AUTHENTIK_REMOTE_IP" + outpost_token_header = "HTTP_X_AUTHENTIK_OUTPOST_TOKEN" # nosec + default_ip = "255.255.255.255" + + request_attr_client_ip = "client_ip" + request_attr_outpost_user = "outpost_user" + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): + self.get_response = get_response + + def _get_client_ip_from_meta(self, meta: dict[str, Any]) -> str: + """Attempt to get the client's IP by checking common HTTP Headers. + Returns none if no IP Could be found + + No additional validation is done here as requests are expected to only arrive here + via the go proxy, which deals with validating these headers for us""" + headers = ( + "HTTP_X_FORWARDED_FOR", + "REMOTE_ADDR", + ) + for _header in headers: + if _header in meta: + ips: list[str] = meta.get(_header).split(",") + return ips[0].strip() + return self.default_ip + + # FIXME: this should probably not be in `root` but rather in a middleware in `outposts` + # but for now it's fine + def _get_outpost_override_ip(self, request: HttpRequest) -> Optional[str]: + """Get the actual remote IP when set by an outpost. Only + allowed when the request is authenticated, by an outpost internal service account""" + if ( + self.outpost_remote_ip_header not in request.META + or self.outpost_token_header not in request.META + ): + return None + delegated_ip = request.META[self.outpost_remote_ip_header] + token = ( + Token.filter_not_expired( + key=request.META.get(self.outpost_token_header), intent=TokenIntents.INTENT_API + ) + .select_related("user") + .first() + ) + if not token: + LOGGER.warning("Attempted remote-ip override without token", delegated_ip=delegated_ip) + return None + user: User = token.user + if user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT: + LOGGER.warning( + "Remote-IP override: user doesn't have permission", + user=user, + delegated_ip=delegated_ip, + ) + return None + # Update sentry scope to include correct IP + user = Hub.current.scope._user + if not user: + user = {} + user["ip_address"] = delegated_ip + Hub.current.scope.set_user(user) + # Set the outpost service account on the request + setattr(request, self.request_attr_outpost_user, user) + return delegated_ip + + def _get_client_ip(self, request: Optional[HttpRequest]) -> str: + """Attempt to get the client's IP by checking common HTTP Headers. + Returns none if no IP Could be found""" + if not request: + return self.default_ip + override = self._get_outpost_override_ip(request) + if override: + return override + return self._get_client_ip_from_meta(request.META) + + @staticmethod + def get_outpost_user(request: HttpRequest) -> Optional[User]: + """Get outpost user that authenticated this request""" + return getattr(request, ClientIPMiddleware.request_attr_outpost_user, None) + + @staticmethod + def get_client_ip(request: HttpRequest) -> str: + """Get correct client IP, including any overrides from outposts that + have the permission to do so""" + if request and not hasattr(request, ClientIPMiddleware.request_attr_client_ip): + ClientIPMiddleware(lambda request: request).set_ip(request) + return getattr( + request, ClientIPMiddleware.request_attr_client_ip, ClientIPMiddleware.default_ip + ) + + def set_ip(self, request: HttpRequest): + """Set the IP""" + setattr(request, self.request_attr_client_ip, self._get_client_ip(request)) + + def __call__(self, request: HttpRequest) -> HttpResponse: + self.set_ip(request) + return self.get_response(request) + + class ChannelsLoggingMiddleware: """Logging middleware for channels""" @@ -201,7 +307,7 @@ class LoggingMiddleware: """Log request""" LOGGER.info( request.get_full_path(), - remote=get_client_ip(request), + remote=ClientIPMiddleware.get_client_ip(request), method=request.method, scheme=request.scheme, status=status_code, diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 08959c127..526cf27ce 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -217,6 +217,7 @@ MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage" MIDDLEWARE = [ "authentik.root.middleware.LoggingMiddleware", "django_prometheus.middleware.PrometheusBeforeMiddleware", + "authentik.root.middleware.ClientIPMiddleware", "authentik.root.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "authentik.core.middleware.RequestIDMiddleware", diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index c1b2a7f1c..d883b903a 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -22,7 +22,7 @@ from authentik.core.signals import login_failed from authentik.events.models import Event, EventAction from authentik.flows.stage import StageView from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE -from authentik.lib.utils.http import get_client_ip +from authentik.root.middleware import ClientIPMiddleware from authentik.stages.authenticator import match_token from authentik.stages.authenticator.models import Device from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice @@ -197,7 +197,7 @@ def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) -> response = stage.auth_client().auth( "auto", user_id=device.duo_user_id, - ipaddr=get_client_ip(stage_view.request), + ipaddr=ClientIPMiddleware.get_client_ip(stage_view.request), type=__( "%(brand_name)s Login request" % { diff --git a/authentik/stages/captcha/stage.py b/authentik/stages/captcha/stage.py index e6782ef78..e0a8c20d6 100644 --- a/authentik/stages/captcha/stage.py +++ b/authentik/stages/captcha/stage.py @@ -12,7 +12,8 @@ from authentik.flows.challenge import ( WithUserInfoChallenge, ) from authentik.flows.stage import ChallengeStageView -from authentik.lib.utils.http import get_client_ip, get_http_session +from authentik.lib.utils.http import get_http_session +from authentik.root.middleware import ClientIPMiddleware from authentik.stages.captcha.models import CaptchaStage @@ -42,7 +43,7 @@ class CaptchaChallengeResponse(ChallengeResponse): data={ "secret": stage.private_key, "response": token, - "remoteip": get_client_ip(self.stage.request), + "remoteip": ClientIPMiddleware.get_client_ip(self.stage.request), }, ) response.raise_for_status() diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 568030af8..b71792ff6 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -26,8 +26,8 @@ from authentik.flows.models import FlowDesignation from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_GET -from authentik.lib.utils.http import get_client_ip from authentik.lib.utils.urls import reverse_with_qs +from authentik.root.middleware import ClientIPMiddleware from authentik.sources.oauth.types.apple import AppleLoginChallenge from authentik.sources.plex.models import PlexAuthenticationChallenge from authentik.stages.identification.models import IdentificationStage @@ -103,7 +103,7 @@ class IdentificationChallengeResponse(ChallengeResponse): self.stage.logger.info( "invalid_login", identifier=uid_field, - client_ip=get_client_ip(self.stage.request), + client_ip=ClientIPMiddleware.get_client_ip(self.stage.request), action="invalid_identifier", context={ "stage": sanitize_item(self.stage), diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py index fbf92b395..b61f89fd5 100644 --- a/authentik/stages/user_login/tests.py +++ b/authentik/stages/user_login/tests.py @@ -16,8 +16,8 @@ from authentik.flows.tests import FlowTestCase from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.lib.generators import generate_id -from authentik.lib.utils.http import DEFAULT_IP from authentik.lib.utils.time import timedelta_from_string +from authentik.root.middleware import ClientIPMiddleware from authentik.stages.user_login.models import UserLoginStage @@ -76,7 +76,7 @@ class TestUserLoginStage(FlowTestCase): other_session = AuthenticatedSession.objects.create( user=self.user, session_key=key, - last_ip=DEFAULT_IP, + last_ip=ClientIPMiddleware.default_ip, ) cache.set(f"{KEY_PREFIX}{other_session.session_key}", "foo") diff --git a/blueprints/schema.json b/blueprints/schema.json index 423094b60..bf66c94ed 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -3167,7 +3167,8 @@ "none", "require_authenticated", "require_unauthenticated", - "require_superuser" + "require_superuser", + "require_outpost" ], "title": "Authentication", "description": "Required level of authentication and authorization to access a flow." diff --git a/schema.yml b/schema.yml index 694eca69c..da1a90a18 100644 --- a/schema.yml +++ b/schema.yml @@ -28328,12 +28328,14 @@ components: - require_authenticated - require_unauthenticated - require_superuser + - require_outpost type: string description: |- * `none` - None * `require_authenticated` - Require Authenticated * `require_unauthenticated` - Require Unauthenticated * `require_superuser` - Require Superuser + * `require_outpost` - Require Outpost AuthenticatorAttachmentEnum: enum: - platform @@ -31328,6 +31330,7 @@ components: * `require_authenticated` - Require Authenticated * `require_unauthenticated` - Require Unauthenticated * `require_superuser` - Require Superuser + * `require_outpost` - Require Outpost required: - background - cache_count @@ -31566,6 +31569,7 @@ components: * `require_authenticated` - Require Authenticated * `require_unauthenticated` - Require Unauthenticated * `require_superuser` - Require Superuser + * `require_outpost` - Require Outpost required: - designation - name @@ -36514,6 +36518,7 @@ components: * `require_authenticated` - Require Authenticated * `require_unauthenticated` - Require Unauthenticated * `require_superuser` - Require Superuser + * `require_outpost` - Require Outpost PatchedFlowStageBindingRequest: type: object description: FlowStageBinding Serializer diff --git a/web/src/admin/flows/FlowForm.ts b/web/src/admin/flows/FlowForm.ts index fcb5f0cb7..1d279070f 100644 --- a/web/src/admin/flows/FlowForm.ts +++ b/web/src/admin/flows/FlowForm.ts @@ -196,6 +196,13 @@ export class FlowForm extends ModelForm { > ${msg("Require superuser.")} +

${msg("Required authentication level for this flow.")} diff --git a/web/xliff/de.xlf b/web/xliff/de.xlf index 85e253602..eab6455a7 100644 --- a/web/xliff/de.xlf +++ b/web/xliff/de.xlf @@ -6117,6 +6117,9 @@ Bindings to groups/users are checked against the user of the event. Event volume + + + Require Outpost (flow can only be executed from an outpost). diff --git a/web/xliff/en.xlf b/web/xliff/en.xlf index 2b236521f..6b64a65bc 100644 --- a/web/xliff/en.xlf +++ b/web/xliff/en.xlf @@ -6394,6 +6394,9 @@ Bindings to groups/users are checked against the user of the event. Event volume + + + Require Outpost (flow can only be executed from an outpost). diff --git a/web/xliff/es.xlf b/web/xliff/es.xlf index d9564b6ad..3b5585585 100644 --- a/web/xliff/es.xlf +++ b/web/xliff/es.xlf @@ -6033,6 +6033,9 @@ Bindings to groups/users are checked against the user of the event. Event volume + + + Require Outpost (flow can only be executed from an outpost). diff --git a/web/xliff/fr.xlf b/web/xliff/fr.xlf index e0976e5e8..b6451ddd5 100644 --- a/web/xliff/fr.xlf +++ b/web/xliff/fr.xlf @@ -8042,6 +8042,9 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Event volume Volume d'événements + + + Require Outpost (flow can only be executed from an outpost). diff --git a/web/xliff/pl.xlf b/web/xliff/pl.xlf index 7aae5340d..3032efbe7 100644 --- a/web/xliff/pl.xlf +++ b/web/xliff/pl.xlf @@ -6241,6 +6241,9 @@ Bindings to groups/users are checked against the user of the event. Event volume + + + Require Outpost (flow can only be executed from an outpost). diff --git a/web/xliff/pseudo-LOCALE.xlf b/web/xliff/pseudo-LOCALE.xlf index 3839422ba..4ba11cc6a 100644 --- a/web/xliff/pseudo-LOCALE.xlf +++ b/web/xliff/pseudo-LOCALE.xlf @@ -7981,4 +7981,7 @@ Bindings to groups/users are checked against the user of the event. Event volume + + Require Outpost (flow can only be executed from an outpost). + diff --git a/web/xliff/tr.xlf b/web/xliff/tr.xlf index 188474957..798c86b63 100644 --- a/web/xliff/tr.xlf +++ b/web/xliff/tr.xlf @@ -6026,6 +6026,9 @@ Bindings to groups/users are checked against the user of the event. Event volume + + + Require Outpost (flow can only be executed from an outpost). diff --git a/web/xliff/zh-Hans.xlf b/web/xliff/zh-Hans.xlf index efcf25576..3c3e42d2a 100644 --- a/web/xliff/zh-Hans.xlf +++ b/web/xliff/zh-Hans.xlf @@ -8044,6 +8044,9 @@ Bindings to groups/users are checked against the user of the event. Event volume 事件容量 + + + Require Outpost (flow can only be executed from an outpost). diff --git a/web/xliff/zh-Hant.xlf b/web/xliff/zh-Hant.xlf index 1b85d0ee3..63ba2b4ec 100644 --- a/web/xliff/zh-Hant.xlf +++ b/web/xliff/zh-Hant.xlf @@ -6074,6 +6074,9 @@ Bindings to groups/users are checked against the user of the event. Event volume + + + Require Outpost (flow can only be executed from an outpost). diff --git a/web/xliff/zh_TW.xlf b/web/xliff/zh_TW.xlf index 05818f770..376197f66 100644 --- a/web/xliff/zh_TW.xlf +++ b/web/xliff/zh_TW.xlf @@ -7965,6 +7965,9 @@ Bindings to groups/users are checked against the user of the event. Event volume + + + Require Outpost (flow can only be executed from an outpost).