flows: add "require outpost" authentication_requirement (#7921)

* migrate get_client_ip to middleware

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

* use middleware directly without wrapper

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

* add require_outpost setting for flows

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

* fix

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

* add tests

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

* update schema

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

* update web ui

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

* fixup

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

* improve fallback

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-12-19 13:32:10 +01:00 committed by GitHub
parent e86640e930
commit 3e530cf1b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 244 additions and 107 deletions

View File

@ -30,7 +30,6 @@ from authentik.lib.models import (
DomainlessFormattedURLValidator, DomainlessFormattedURLValidator,
SerializerModel, SerializerModel,
) )
from authentik.lib.utils.http import get_client_ip
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
from authentik.root.install_id import get_install_id from authentik.root.install_id import get_install_id
@ -748,12 +747,14 @@ class AuthenticatedSession(ExpiringModel):
@staticmethod @staticmethod
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]: def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
"""Create a new session from a http request""" """Create a new session from a http request"""
from authentik.root.middleware import ClientIPMiddleware
if not hasattr(request, "session") or not request.session.session_key: if not hasattr(request, "session") or not request.session.session_key:
return None return None
return AuthenticatedSession( return AuthenticatedSession(
session_key=request.session.session_key, session_key=request.session.session_key,
user=user, user=user,
last_ip=get_client_ip(request), last_ip=ClientIPMiddleware.get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""), last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
expires=request.session.get_expiry_date(), expires=request.session.get_expiry_date(),
) )

View File

@ -36,9 +36,10 @@ from authentik.events.utils import (
) )
from authentik.lib.models import DomainlessURLValidator, SerializerModel from authentik.lib.models import DomainlessURLValidator, SerializerModel
from authentik.lib.sentry import SentryIgnoredException 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.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
from authentik.tenants.utils import DEFAULT_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 = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER])
self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_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 # 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 # Apply GeoIP Data, when enabled
self.with_geoip() self.with_geoip()
# If there's no app set, we get it from the requests too # If there's no app set, we get it from the requests too

View File

@ -1,7 +1,7 @@
# Generated by Django 4.2.6 on 2023-10-28 14:24 # Generated by Django 4.2.6 on 2023-10-28 14:24
from django.apps.registry import Apps 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 from django.db.backends.base.schema import BaseDatabaseSchemaEditor
@ -31,4 +31,19 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython(set_oobe_flow_authentication), 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.",
),
),
] ]

View File

@ -31,6 +31,7 @@ class FlowAuthenticationRequirement(models.TextChoices):
REQUIRE_AUTHENTICATED = "require_authenticated" REQUIRE_AUTHENTICATED = "require_authenticated"
REQUIRE_UNAUTHENTICATED = "require_unauthenticated" REQUIRE_UNAUTHENTICATED = "require_unauthenticated"
REQUIRE_SUPERUSER = "require_superuser" REQUIRE_SUPERUSER = "require_superuser"
REQUIRE_OUTPOST = "require_outpost"
class NotConfiguredAction(models.TextChoices): class NotConfiguredAction(models.TextChoices):

View File

@ -23,6 +23,7 @@ from authentik.flows.models import (
) )
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.root.middleware import ClientIPMiddleware
LOGGER = get_logger() LOGGER = get_logger()
PLAN_CONTEXT_PENDING_USER = "pending_user" PLAN_CONTEXT_PENDING_USER = "pending_user"
@ -141,6 +142,10 @@ class FlowPlanner:
and not request.user.is_superuser and not request.user.is_superuser
): ):
raise FlowNonApplicableException() raise FlowNonApplicableException()
if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST:
outpost_user = ClientIPMiddleware.get_outpost_user(request)
if not outpost_user:
raise FlowNonApplicableException()
def plan( def plan(
self, request: HttpRequest, default_context: Optional[dict[str, Any]] = None self, request: HttpRequest, default_context: Optional[dict[str, Any]] = None

View File

@ -8,6 +8,7 @@ from django.test import RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException 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.models import FlowAuthenticationRequirement, FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
from authentik.lib.tests.utils import dummy_get_response 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.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
from authentik.policies.types import PolicyResult from authentik.policies.types import PolicyResult
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.dummy.models import DummyStage from authentik.stages.dummy.models import DummyStage
POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False)) POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False))
@ -68,6 +72,34 @@ class TestFlowPlanner(TestCase):
planner.allow_empty_flows = True planner.allow_empty_flows = True
planner.plan(request) 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( @patch(
"authentik.policies.engine.PolicyEngine.result", "authentik.policies.engine.PolicyEngine.result",
POLICY_RETURN_FALSE, POLICY_RETURN_FALSE,

View File

@ -3,8 +3,8 @@ from django.test import RequestFactory, TestCase
from authentik.core.models import Token, TokenIntents, UserTypes from authentik.core.models import Token, TokenIntents, UserTypes
from authentik.core.tests.utils import create_test_admin_user 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.lib.views import bad_request_message
from authentik.root.middleware import ClientIPMiddleware
class TestHTTP(TestCase): class TestHTTP(TestCase):
@ -22,12 +22,12 @@ class TestHTTP(TestCase):
def test_normal(self): def test_normal(self):
"""Test normal request""" """Test normal request"""
request = self.factory.get("/") 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): def test_forward_for(self):
"""Test x-forwarded-for request""" """Test x-forwarded-for request"""
request = self.factory.get("/", HTTP_X_FORWARDED_FOR="127.0.0.2") 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): def test_fake_outpost(self):
"""Test faked IP which is overridden by an outpost""" """Test faked IP which is overridden by an outpost"""
@ -38,28 +38,28 @@ class TestHTTP(TestCase):
request = self.factory.get( request = self.factory.get(
"/", "/",
**{ **{
OUTPOST_REMOTE_IP_HEADER: "1.2.3.4", ClientIPMiddleware.outpost_remote_ip_header: "1.2.3.4",
OUTPOST_TOKEN_HEADER: "abc", 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 # Invalid, user doesn't have permissions
request = self.factory.get( request = self.factory.get(
"/", "/",
**{ **{
OUTPOST_REMOTE_IP_HEADER: "1.2.3.4", ClientIPMiddleware.outpost_remote_ip_header: "1.2.3.4",
OUTPOST_TOKEN_HEADER: token.key, 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 # Valid
self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
self.user.save() self.user.save()
request = self.factory.get( request = self.factory.get(
"/", "/",
**{ **{
OUTPOST_REMOTE_IP_HEADER: "1.2.3.4", ClientIPMiddleware.outpost_remote_ip_header: "1.2.3.4",
OUTPOST_TOKEN_HEADER: token.key, 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")

View File

@ -1,82 +1,12 @@
"""http helpers""" """http helpers"""
from typing import Any, Optional
from django.http import HttpRequest
from requests.sessions import Session from requests.sessions import Session
from sentry_sdk.hub import Hub
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik import get_full_version 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() 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: def authentik_user_agent() -> str:
"""Get a common user agent""" """Get a common user agent"""
return f"authentik@{get_full_version()}" return f"authentik@{get_full_version()}"

View File

@ -7,9 +7,9 @@ from structlog.stdlib import get_logger
from authentik.flows.planner import PLAN_CONTEXT_SSO from authentik.flows.planner import PLAN_CONTEXT_SSO
from authentik.lib.expression.evaluator import BaseEvaluator 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.exceptions import PolicyException
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.root.middleware import ClientIPMiddleware
LOGGER = get_logger() LOGGER = get_logger()
if TYPE_CHECKING: if TYPE_CHECKING:
@ -49,7 +49,7 @@ class PolicyEvaluator(BaseEvaluator):
"""Update context based on http request""" """Update context based on http request"""
# update website/docs/expressions/_objects.md # update website/docs/expressions/_objects.md
# update website/docs/expressions/_functions.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 self._context["http_request"] = request
def handle_error(self, exc: Exception, expression_source: str): def handle_error(self, exc: Exception, expression_source: str):

View File

@ -13,9 +13,9 @@ from structlog import get_logger
from authentik.core.models import ExpiringModel from authentik.core.models import ExpiringModel
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
from authentik.lib.utils.http import get_client_ip
from authentik.policies.models import Policy from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.root.middleware import ClientIPMiddleware
LOGGER = get_logger() LOGGER = get_logger()
CACHE_KEY_PREFIX = "goauthentik.io/policies/reputation/scores/" CACHE_KEY_PREFIX = "goauthentik.io/policies/reputation/scores/"
@ -44,7 +44,7 @@ class ReputationPolicy(Policy):
return "ak-policy-reputation-form" return "ak-policy-reputation-form"
def passes(self, request: PolicyRequest) -> PolicyResult: 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() query = Q()
if self.check_ip: if self.check_ip:
query |= Q(ip=remote_ip) query |= Q(ip=remote_ip)

View File

@ -7,9 +7,9 @@ from structlog.stdlib import get_logger
from authentik.core.signals import login_failed from authentik.core.signals import login_failed
from authentik.lib.config import CONFIG 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.models import CACHE_KEY_PREFIX
from authentik.policies.reputation.tasks import save_reputation from authentik.policies.reputation.tasks import save_reputation
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.identification.signals import identification_failed from authentik.stages.identification.signals import identification_failed
LOGGER = get_logger() LOGGER = get_logger()
@ -18,7 +18,7 @@ CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_reputation")
def update_score(request: HttpRequest, identifier: str, amount: int): def update_score(request: HttpRequest, identifier: str, amount: int):
"""Update score for IP and User""" """Update score for IP and User"""
remote_ip = get_client_ip(request) remote_ip = ClientIPMiddleware.get_client_ip(request)
try: try:
# We only update the cache here, as its faster than writing to the DB # We only update the cache here, as its faster than writing to the DB

View File

@ -9,7 +9,6 @@ from django.http import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.events.geo import GEOIP_READER from authentik.events.geo import GEOIP_READER
from authentik.lib.utils.http import get_client_ip
if TYPE_CHECKING: if TYPE_CHECKING:
from authentik.core.models import User from authentik.core.models import User
@ -38,10 +37,12 @@ class PolicyRequest:
def set_http_request(self, request: HttpRequest): # pragma: no cover def set_http_request(self, request: HttpRequest): # pragma: no cover
"""Load data from HTTP request, including geoip when enabled""" """Load data from HTTP request, including geoip when enabled"""
from authentik.root.middleware import ClientIPMiddleware
self.http_request = request self.http_request = request
if not GEOIP_READER.enabled: if not GEOIP_READER.enabled:
return return
client_ip = get_client_ip(request) client_ip = ClientIPMiddleware.get_client_ip(request)
if not client_ip: if not client_ip:
return return
self.context["geoip"] = GEOIP_READER.city(client_ip) self.context["geoip"] = GEOIP_READER.city(client_ip)

View File

@ -2,7 +2,7 @@
from hashlib import sha512 from hashlib import sha512
from time import time from time import time
from timeit import default_timer from timeit import default_timer
from typing import Callable from typing import Any, Callable, Optional
from django.conf import settings from django.conf import settings
from django.contrib.sessions.backends.base import UpdateError 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.cache import patch_vary_headers
from django.utils.http import http_date from django.utils.http import http_date
from jwt import PyJWTError, decode, encode from jwt import PyJWTError, decode, encode
from sentry_sdk.hub import Hub
from structlog.stdlib import get_logger 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") LOGGER = get_logger("authentik.asgi")
ACR_AUTHENTIK_SESSION = "goauthentik.io/core/default" ACR_AUTHENTIK_SESSION = "goauthentik.io/core/default"
@ -156,6 +157,111 @@ class CsrfViewMiddleware(UpstreamCsrfViewMiddleware):
patch_vary_headers(response, ("Cookie",)) 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: class ChannelsLoggingMiddleware:
"""Logging middleware for channels""" """Logging middleware for channels"""
@ -201,7 +307,7 @@ class LoggingMiddleware:
"""Log request""" """Log request"""
LOGGER.info( LOGGER.info(
request.get_full_path(), request.get_full_path(),
remote=get_client_ip(request), remote=ClientIPMiddleware.get_client_ip(request),
method=request.method, method=request.method,
scheme=request.scheme, scheme=request.scheme,
status=status_code, status=status_code,

View File

@ -217,6 +217,7 @@ MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage"
MIDDLEWARE = [ MIDDLEWARE = [
"authentik.root.middleware.LoggingMiddleware", "authentik.root.middleware.LoggingMiddleware",
"django_prometheus.middleware.PrometheusBeforeMiddleware", "django_prometheus.middleware.PrometheusBeforeMiddleware",
"authentik.root.middleware.ClientIPMiddleware",
"authentik.root.middleware.SessionMiddleware", "authentik.root.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"authentik.core.middleware.RequestIDMiddleware", "authentik.core.middleware.RequestIDMiddleware",

View File

@ -22,7 +22,7 @@ from authentik.core.signals import login_failed
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE 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 import match_token
from authentik.stages.authenticator.models import Device from authentik.stages.authenticator.models import Device
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice 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( response = stage.auth_client().auth(
"auto", "auto",
user_id=device.duo_user_id, user_id=device.duo_user_id,
ipaddr=get_client_ip(stage_view.request), ipaddr=ClientIPMiddleware.get_client_ip(stage_view.request),
type=__( type=__(
"%(brand_name)s Login request" "%(brand_name)s Login request"
% { % {

View File

@ -12,7 +12,8 @@ from authentik.flows.challenge import (
WithUserInfoChallenge, WithUserInfoChallenge,
) )
from authentik.flows.stage import ChallengeStageView 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 from authentik.stages.captcha.models import CaptchaStage
@ -42,7 +43,7 @@ class CaptchaChallengeResponse(ChallengeResponse):
data={ data={
"secret": stage.private_key, "secret": stage.private_key,
"response": token, "response": token,
"remoteip": get_client_ip(self.stage.request), "remoteip": ClientIPMiddleware.get_client_ip(self.stage.request),
}, },
) )
response.raise_for_status() response.raise_for_status()

View File

@ -26,8 +26,8 @@ from authentik.flows.models import FlowDesignation
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView 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.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.lib.utils.urls import reverse_with_qs
from authentik.root.middleware import ClientIPMiddleware
from authentik.sources.oauth.types.apple import AppleLoginChallenge from authentik.sources.oauth.types.apple import AppleLoginChallenge
from authentik.sources.plex.models import PlexAuthenticationChallenge from authentik.sources.plex.models import PlexAuthenticationChallenge
from authentik.stages.identification.models import IdentificationStage from authentik.stages.identification.models import IdentificationStage
@ -103,7 +103,7 @@ class IdentificationChallengeResponse(ChallengeResponse):
self.stage.logger.info( self.stage.logger.info(
"invalid_login", "invalid_login",
identifier=uid_field, identifier=uid_field,
client_ip=get_client_ip(self.stage.request), client_ip=ClientIPMiddleware.get_client_ip(self.stage.request),
action="invalid_identifier", action="invalid_identifier",
context={ context={
"stage": sanitize_item(self.stage), "stage": sanitize_item(self.stage),

View File

@ -16,8 +16,8 @@ from authentik.flows.tests import FlowTestCase
from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK
from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id 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.lib.utils.time import timedelta_from_string
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.user_login.models import UserLoginStage from authentik.stages.user_login.models import UserLoginStage
@ -76,7 +76,7 @@ class TestUserLoginStage(FlowTestCase):
other_session = AuthenticatedSession.objects.create( other_session = AuthenticatedSession.objects.create(
user=self.user, user=self.user,
session_key=key, session_key=key,
last_ip=DEFAULT_IP, last_ip=ClientIPMiddleware.default_ip,
) )
cache.set(f"{KEY_PREFIX}{other_session.session_key}", "foo") cache.set(f"{KEY_PREFIX}{other_session.session_key}", "foo")

View File

@ -3167,7 +3167,8 @@
"none", "none",
"require_authenticated", "require_authenticated",
"require_unauthenticated", "require_unauthenticated",
"require_superuser" "require_superuser",
"require_outpost"
], ],
"title": "Authentication", "title": "Authentication",
"description": "Required level of authentication and authorization to access a flow." "description": "Required level of authentication and authorization to access a flow."

View File

@ -28328,12 +28328,14 @@ components:
- require_authenticated - require_authenticated
- require_unauthenticated - require_unauthenticated
- require_superuser - require_superuser
- require_outpost
type: string type: string
description: |- description: |-
* `none` - None * `none` - None
* `require_authenticated` - Require Authenticated * `require_authenticated` - Require Authenticated
* `require_unauthenticated` - Require Unauthenticated * `require_unauthenticated` - Require Unauthenticated
* `require_superuser` - Require Superuser * `require_superuser` - Require Superuser
* `require_outpost` - Require Outpost
AuthenticatorAttachmentEnum: AuthenticatorAttachmentEnum:
enum: enum:
- platform - platform
@ -31328,6 +31330,7 @@ components:
* `require_authenticated` - Require Authenticated * `require_authenticated` - Require Authenticated
* `require_unauthenticated` - Require Unauthenticated * `require_unauthenticated` - Require Unauthenticated
* `require_superuser` - Require Superuser * `require_superuser` - Require Superuser
* `require_outpost` - Require Outpost
required: required:
- background - background
- cache_count - cache_count
@ -31566,6 +31569,7 @@ components:
* `require_authenticated` - Require Authenticated * `require_authenticated` - Require Authenticated
* `require_unauthenticated` - Require Unauthenticated * `require_unauthenticated` - Require Unauthenticated
* `require_superuser` - Require Superuser * `require_superuser` - Require Superuser
* `require_outpost` - Require Outpost
required: required:
- designation - designation
- name - name
@ -36514,6 +36518,7 @@ components:
* `require_authenticated` - Require Authenticated * `require_authenticated` - Require Authenticated
* `require_unauthenticated` - Require Unauthenticated * `require_unauthenticated` - Require Unauthenticated
* `require_superuser` - Require Superuser * `require_superuser` - Require Superuser
* `require_outpost` - Require Outpost
PatchedFlowStageBindingRequest: PatchedFlowStageBindingRequest:
type: object type: object
description: FlowStageBinding Serializer description: FlowStageBinding Serializer

View File

@ -196,6 +196,13 @@ export class FlowForm extends ModelForm<Flow, string> {
> >
${msg("Require superuser.")} ${msg("Require superuser.")}
</option> </option>
<option
value=${AuthenticationEnum.RequireOutpost}
?selected=${this.instance?.authentication ===
AuthenticationEnum.RequireOutpost}
>
${msg("Require Outpost (flow can only be executed from an outpost).")}
</option>
</select> </select>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${msg("Required authentication level for this flow.")} ${msg("Required authentication level for this flow.")}

View File

@ -6117,6 +6117,9 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s7513372fe60f6387"> <trans-unit id="s7513372fe60f6387">
<source>Event volume</source> <source>Event volume</source>
</trans-unit>
<trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -6394,6 +6394,9 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s7513372fe60f6387"> <trans-unit id="s7513372fe60f6387">
<source>Event volume</source> <source>Event volume</source>
</trans-unit>
<trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -6033,6 +6033,9 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s7513372fe60f6387"> <trans-unit id="s7513372fe60f6387">
<source>Event volume</source> <source>Event volume</source>
</trans-unit>
<trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -8042,6 +8042,9 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
<trans-unit id="s7513372fe60f6387"> <trans-unit id="s7513372fe60f6387">
<source>Event volume</source> <source>Event volume</source>
<target>Volume d'événements</target> <target>Volume d'événements</target>
</trans-unit>
<trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -6241,6 +6241,9 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s7513372fe60f6387"> <trans-unit id="s7513372fe60f6387">
<source>Event volume</source> <source>Event volume</source>
</trans-unit>
<trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -7981,4 +7981,7 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s7513372fe60f6387"> <trans-unit id="s7513372fe60f6387">
<source>Event volume</source> <source>Event volume</source>
</trans-unit> </trans-unit>
<trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit>
</body></file></xliff> </body></file></xliff>

View File

@ -6026,6 +6026,9 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s7513372fe60f6387"> <trans-unit id="s7513372fe60f6387">
<source>Event volume</source> <source>Event volume</source>
</trans-unit>
<trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -8044,6 +8044,9 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s7513372fe60f6387"> <trans-unit id="s7513372fe60f6387">
<source>Event volume</source> <source>Event volume</source>
<target>事件容量</target> <target>事件容量</target>
</trans-unit>
<trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -6074,6 +6074,9 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s7513372fe60f6387"> <trans-unit id="s7513372fe60f6387">
<source>Event volume</source> <source>Event volume</source>
</trans-unit>
<trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>

View File

@ -7965,6 +7965,9 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit> </trans-unit>
<trans-unit id="s7513372fe60f6387"> <trans-unit id="s7513372fe60f6387">
<source>Event volume</source> <source>Event volume</source>
</trans-unit>
<trans-unit id="s047a5f0211fedc72">
<source>Require Outpost (flow can only be executed from an outpost).</source>
</trans-unit> </trans-unit>
</body> </body>
</file> </file>