From 50860d7ffe44a29cabe7407c2bf94facd689b5ca Mon Sep 17 00:00:00 2001 From: Jens L Date: Wed, 20 Dec 2023 22:16:50 +0100 Subject: [PATCH] events: add ASN Database reader (#7793) * events: add ASN Database reader Signed-off-by: Jens Langhammer * add tests Signed-off-by: Jens Langhammer * fix test config generator Signed-off-by: Jens Langhammer * de-duplicate code Signed-off-by: Jens Langhammer * add enrich_context Signed-off-by: Jens Langhammer * rename to context processors? Signed-off-by: Jens Langhammer * fix cache Signed-off-by: Jens Langhammer * use config deprecation system, update docs Signed-off-by: Jens Langhammer * update more docs and tests Signed-off-by: Jens Langhammer * add test asn db Signed-off-by: Jens Langhammer * re-build schema with latest versions Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- Dockerfile | 2 +- authentik/api/v3/config.py | 8 +- authentik/core/api/authenticated_sessions.py | 13 ++- .../events/context_processors/__init__.py | 0 authentik/events/context_processors/asn.py | 79 ++++++++++++++ authentik/events/context_processors/base.py | 43 ++++++++ authentik/events/context_processors/geoip.py | 84 +++++++++++++++ authentik/events/context_processors/mmdb.py | 54 ++++++++++ authentik/events/geo.py | 100 ------------------ authentik/events/models.py | 14 +-- authentik/events/tests/test_enrich_asn.py | 24 +++++ .../{test_geoip.py => test_enrich_geoip.py} | 4 +- authentik/events/utils.py | 9 +- authentik/lib/config.py | 1 + authentik/lib/default.yml | 5 +- authentik/policies/reputation/api.py | 1 + .../migrations/0006_reputation_ip_asn_data.py | 17 +++ authentik/policies/reputation/models.py | 1 + authentik/policies/reputation/tasks.py | 6 +- authentik/policies/types.py | 12 +-- authentik/root/test_runner.py | 3 +- blueprints/schema.json | 5 + schema.yml | 24 ++++- scripts/generate_config.py | 7 +- tests/GeoLite2-ASN-Test.mmdb | Bin 0 -> 12653 bytes website/docs/core/geoip.mdx | 12 ++- website/docs/installation/configuration.mdx | 8 +- 27 files changed, 393 insertions(+), 143 deletions(-) create mode 100644 authentik/events/context_processors/__init__.py create mode 100644 authentik/events/context_processors/asn.py create mode 100644 authentik/events/context_processors/base.py create mode 100644 authentik/events/context_processors/geoip.py create mode 100644 authentik/events/context_processors/mmdb.py delete mode 100644 authentik/events/geo.py create mode 100644 authentik/events/tests/test_enrich_asn.py rename authentik/events/tests/{test_geoip.py => test_enrich_geoip.py} (83%) create mode 100644 authentik/policies/reputation/migrations/0006_reputation_ip_asn_data.py create mode 100644 tests/GeoLite2-ASN-Test.mmdb diff --git a/Dockerfile b/Dockerfile index 629d3258b..114be6253 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,7 +71,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ # Stage 4: MaxMind GeoIP FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.0 as geoip -ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City" +ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" ENV GEOIPUPDATE_VERBOSE="true" ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID" ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY" diff --git a/authentik/api/v3/config.py b/authentik/api/v3/config.py index 0defd1a5b..93b783629 100644 --- a/authentik/api/v3/config.py +++ b/authentik/api/v3/config.py @@ -19,7 +19,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from authentik.core.api.utils import PassiveSerializer -from authentik.events.geo import GEOIP_READER +from authentik.events.context_processors.base import get_context_processors from authentik.lib.config import CONFIG capabilities = Signal() @@ -30,6 +30,7 @@ class Capabilities(models.TextChoices): CAN_SAVE_MEDIA = "can_save_media" CAN_GEO_IP = "can_geo_ip" + CAN_ASN = "can_asn" CAN_IMPERSONATE = "can_impersonate" CAN_DEBUG = "can_debug" IS_ENTERPRISE = "is_enterprise" @@ -68,8 +69,9 @@ class ConfigView(APIView): deb_test = settings.DEBUG or settings.TEST if Path(settings.MEDIA_ROOT).is_mount() or deb_test: caps.append(Capabilities.CAN_SAVE_MEDIA) - if GEOIP_READER.enabled: - caps.append(Capabilities.CAN_GEO_IP) + for processor in get_context_processors(): + if cap := processor.capability(): + caps.append(cap) if CONFIG.get_bool("impersonation"): caps.append(Capabilities.CAN_IMPERSONATE) if settings.DEBUG: # pragma: no cover diff --git a/authentik/core/api/authenticated_sessions.py b/authentik/core/api/authenticated_sessions.py index 03c1aeaf3..2d77937be 100644 --- a/authentik/core/api/authenticated_sessions.py +++ b/authentik/core/api/authenticated_sessions.py @@ -14,7 +14,8 @@ from ua_parser import user_agent_parser from authentik.api.authorization import OwnerSuperuserPermissions from authentik.core.api.used_by import UsedByMixin from authentik.core.models import AuthenticatedSession -from authentik.events.geo import GEOIP_READER, GeoIPDict +from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR, ASNDict +from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR, GeoIPDict class UserAgentDeviceDict(TypedDict): @@ -59,6 +60,7 @@ class AuthenticatedSessionSerializer(ModelSerializer): current = SerializerMethodField() user_agent = SerializerMethodField() geo_ip = SerializerMethodField() + asn = SerializerMethodField() def get_current(self, instance: AuthenticatedSession) -> bool: """Check if session is currently active session""" @@ -70,8 +72,12 @@ class AuthenticatedSessionSerializer(ModelSerializer): return user_agent_parser.Parse(instance.last_user_agent) def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]: # pragma: no cover - """Get parsed user agent""" - return GEOIP_READER.city_dict(instance.last_ip) + """Get GeoIP Data""" + return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip) + + def get_asn(self, instance: AuthenticatedSession) -> Optional[ASNDict]: # pragma: no cover + """Get ASN Data""" + return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip) class Meta: model = AuthenticatedSession @@ -80,6 +86,7 @@ class AuthenticatedSessionSerializer(ModelSerializer): "current", "user_agent", "geo_ip", + "asn", "user", "last_ip", "last_user_agent", diff --git a/authentik/events/context_processors/__init__.py b/authentik/events/context_processors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/events/context_processors/asn.py b/authentik/events/context_processors/asn.py new file mode 100644 index 000000000..afefbcbc6 --- /dev/null +++ b/authentik/events/context_processors/asn.py @@ -0,0 +1,79 @@ +"""ASN Enricher""" +from typing import TYPE_CHECKING, Optional, TypedDict + +from django.http import HttpRequest +from geoip2.errors import GeoIP2Error +from geoip2.models import ASN +from sentry_sdk import Hub + +from authentik.events.context_processors.mmdb import MMDBContextProcessor +from authentik.lib.config import CONFIG +from authentik.root.middleware import ClientIPMiddleware + +if TYPE_CHECKING: + from authentik.api.v3.config import Capabilities + from authentik.events.models import Event + + +class ASNDict(TypedDict): + """ASN Details""" + + asn: int + as_org: str | None + network: str | None + + +class ASNContextProcessor(MMDBContextProcessor): + """ASN Database reader wrapper""" + + def capability(self) -> Optional["Capabilities"]: + from authentik.api.v3.config import Capabilities + + return Capabilities.CAN_ASN + + def path(self) -> str | None: + return CONFIG.get("events.context_processors.asn") + + def enrich_event(self, event: "Event"): + asn = self.asn_dict(event.client_ip) + if not asn: + return + event.context["asn"] = asn + + def enrich_context(self, request: HttpRequest) -> dict: + return { + "asn": self.asn_dict(ClientIPMiddleware.get_client_ip(request)), + } + + def asn(self, ip_address: str) -> Optional[ASN]: + """Wrapper for Reader.asn""" + with Hub.current.start_span( + op="authentik.events.asn.asn", + description=ip_address, + ): + if not self.enabled: + return None + self.check_expired() + try: + return self.reader.asn(ip_address) + except (GeoIP2Error, ValueError): + return None + + def asn_to_dict(self, asn: ASN) -> ASNDict: + """Convert ASN to dict""" + asn_dict: ASNDict = { + "asn": asn.autonomous_system_number, + "as_org": asn.autonomous_system_organization, + "network": str(asn.network) if asn.network else None, + } + return asn_dict + + def asn_dict(self, ip_address: str) -> Optional[ASNDict]: + """Wrapper for self.asn that returns a dict""" + asn = self.asn(ip_address) + if not asn: + return None + return self.asn_to_dict(asn) + + +ASN_CONTEXT_PROCESSOR = ASNContextProcessor() diff --git a/authentik/events/context_processors/base.py b/authentik/events/context_processors/base.py new file mode 100644 index 000000000..96a46a65a --- /dev/null +++ b/authentik/events/context_processors/base.py @@ -0,0 +1,43 @@ +"""Base event enricher""" +from functools import cache +from typing import TYPE_CHECKING, Optional + +from django.http import HttpRequest + +if TYPE_CHECKING: + from authentik.api.v3.config import Capabilities + from authentik.events.models import Event + + +class EventContextProcessor: + """Base event enricher""" + + def capability(self) -> Optional["Capabilities"]: + """Return the capability this context processor provides""" + return None + + def configured(self) -> bool: + """Return true if this context processor is configured""" + return False + + def enrich_event(self, event: "Event"): + """Modify event""" + raise NotImplementedError + + def enrich_context(self, request: HttpRequest) -> dict: + """Modify context""" + raise NotImplementedError + + +@cache +def get_context_processors() -> list[EventContextProcessor]: + """Get a list of all configured context processors""" + from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR + from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR + + processors_types = [ASN_CONTEXT_PROCESSOR, GEOIP_CONTEXT_PROCESSOR] + processors = [] + for _type in processors_types: + if _type.configured(): + processors.append(_type) + return processors diff --git a/authentik/events/context_processors/geoip.py b/authentik/events/context_processors/geoip.py new file mode 100644 index 000000000..ca00a4c54 --- /dev/null +++ b/authentik/events/context_processors/geoip.py @@ -0,0 +1,84 @@ +"""events GeoIP Reader""" +from typing import TYPE_CHECKING, Optional, TypedDict + +from django.http import HttpRequest +from geoip2.errors import GeoIP2Error +from geoip2.models import City +from sentry_sdk.hub import Hub + +from authentik.events.context_processors.mmdb import MMDBContextProcessor +from authentik.lib.config import CONFIG +from authentik.root.middleware import ClientIPMiddleware + +if TYPE_CHECKING: + from authentik.api.v3.config import Capabilities + from authentik.events.models import Event + + +class GeoIPDict(TypedDict): + """GeoIP Details""" + + continent: str + country: str + lat: float + long: float + city: str + + +class GeoIPContextProcessor(MMDBContextProcessor): + """Slim wrapper around GeoIP API""" + + def capability(self) -> Optional["Capabilities"]: + from authentik.api.v3.config import Capabilities + + return Capabilities.CAN_GEO_IP + + def path(self) -> str | None: + return CONFIG.get("events.context_processors.geoip") + + def enrich_event(self, event: "Event"): + city = self.city_dict(event.client_ip) + if not city: + return + event.context["geo"] = city + + def enrich_context(self, request: HttpRequest) -> dict: + # Different key `geoip` vs `geo` for legacy reasons + return {"geoip": self.city(ClientIPMiddleware.get_client_ip(request))} + + def city(self, ip_address: str) -> Optional[City]: + """Wrapper for Reader.city""" + with Hub.current.start_span( + op="authentik.events.geo.city", + description=ip_address, + ): + if not self.enabled: + return None + self.check_expired() + try: + return self.reader.city(ip_address) + except (GeoIP2Error, ValueError): + return None + + def city_to_dict(self, city: City) -> GeoIPDict: + """Convert City to dict""" + city_dict: GeoIPDict = { + "continent": city.continent.code, + "country": city.country.iso_code, + "lat": city.location.latitude, + "long": city.location.longitude, + "city": "", + } + if city.city.name: + city_dict["city"] = city.city.name + return city_dict + + def city_dict(self, ip_address: str) -> Optional[GeoIPDict]: + """Wrapper for self.city that returns a dict""" + city = self.city(ip_address) + if not city: + return None + return self.city_to_dict(city) + + +GEOIP_CONTEXT_PROCESSOR = GeoIPContextProcessor() diff --git a/authentik/events/context_processors/mmdb.py b/authentik/events/context_processors/mmdb.py new file mode 100644 index 000000000..09c17f91f --- /dev/null +++ b/authentik/events/context_processors/mmdb.py @@ -0,0 +1,54 @@ +"""Common logic for reading MMDB files""" +from pathlib import Path +from typing import Optional + +from geoip2.database import Reader +from structlog.stdlib import get_logger + +from authentik.events.context_processors.base import EventContextProcessor + + +class MMDBContextProcessor(EventContextProcessor): + """Common logic for reading MaxMind DB files, including re-loading if the file has changed""" + + def __init__(self): + self.reader: Optional[Reader] = None + self._last_mtime: float = 0.0 + self.logger = get_logger() + self.open() + + def path(self) -> str | None: + """Get the path to the MMDB file to load""" + raise NotImplementedError + + def open(self): + """Get GeoIP Reader, if configured, otherwise none""" + path = self.path() + if path == "" or not path: + return + try: + self.reader = Reader(path) + self._last_mtime = Path(path).stat().st_mtime + self.logger.info("Loaded MMDB database", last_write=self._last_mtime, file=path) + except OSError as exc: + self.logger.warning("Failed to load MMDB database", exc=exc) + + def check_expired(self): + """Check if the modification date of the MMDB database has + changed, and reload it if so""" + path = self.path() + if path == "" or not path: + return + try: + mtime = Path(path).stat().st_mtime + diff = self._last_mtime < mtime + if diff > 0: + self.logger.info("Found new MMDB Database, reopening", diff=diff, path=path) + self.open() + except OSError as exc: + self.logger.warning("Failed to check MMDB age", exc=exc) + + @property + def enabled(self) -> bool: + """Check if MMDB is enabled""" + return bool(self.reader) diff --git a/authentik/events/geo.py b/authentik/events/geo.py deleted file mode 100644 index 95a28539c..000000000 --- a/authentik/events/geo.py +++ /dev/null @@ -1,100 +0,0 @@ -"""events GeoIP Reader""" -from os import stat -from typing import Optional, TypedDict - -from geoip2.database import Reader -from geoip2.errors import GeoIP2Error -from geoip2.models import City -from sentry_sdk.hub import Hub -from structlog.stdlib import get_logger - -from authentik.lib.config import CONFIG - -LOGGER = get_logger() - - -class GeoIPDict(TypedDict): - """GeoIP Details""" - - continent: str - country: str - lat: float - long: float - city: str - - -class GeoIPReader: - """Slim wrapper around GeoIP API""" - - def __init__(self): - self.__reader: Optional[Reader] = None - self.__last_mtime: float = 0.0 - self.__open() - - def __open(self): - """Get GeoIP Reader, if configured, otherwise none""" - path = CONFIG.get("geoip") - if path == "" or not path: - return - try: - self.__reader = Reader(path) - self.__last_mtime = stat(path).st_mtime - LOGGER.info("Loaded GeoIP database", last_write=self.__last_mtime) - except OSError as exc: - LOGGER.warning("Failed to load GeoIP database", exc=exc) - - def __check_expired(self): - """Check if the modification date of the GeoIP database has - changed, and reload it if so""" - path = CONFIG.get("geoip") - try: - mtime = stat(path).st_mtime - diff = self.__last_mtime < mtime - if diff > 0: - LOGGER.info("Found new GeoIP Database, reopening", diff=diff) - self.__open() - except OSError as exc: - LOGGER.warning("Failed to check GeoIP age", exc=exc) - return - - @property - def enabled(self) -> bool: - """Check if GeoIP is enabled""" - return bool(self.__reader) - - def city(self, ip_address: str) -> Optional[City]: - """Wrapper for Reader.city""" - with Hub.current.start_span( - op="authentik.events.geo.city", - description=ip_address, - ): - if not self.enabled: - return None - self.__check_expired() - try: - return self.__reader.city(ip_address) - except (GeoIP2Error, ValueError): - return None - - def city_to_dict(self, city: City) -> GeoIPDict: - """Convert City to dict""" - city_dict: GeoIPDict = { - "continent": city.continent.code, - "country": city.country.iso_code, - "lat": city.location.latitude, - "long": city.location.longitude, - "city": "", - } - if city.city.name: - city_dict["city"] = city.city.name - return city_dict - - def city_dict(self, ip_address: str) -> Optional[GeoIPDict]: - """Wrapper for self.city that returns a dict""" - city = self.city(ip_address) - if not city: - return None - return self.city_to_dict(city) - - -GEOIP_READER = GeoIPReader() diff --git a/authentik/events/models.py b/authentik/events/models.py index 03e9a939c..965e38cc2 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -26,7 +26,7 @@ from authentik.core.middleware import ( SESSION_KEY_IMPERSONATE_USER, ) from authentik.core.models import ExpiringModel, Group, PropertyMapping, User -from authentik.events.geo import GEOIP_READER +from authentik.events.context_processors.base import get_context_processors from authentik.events.utils import ( cleanse_dict, get_user, @@ -246,21 +246,15 @@ class Event(SerializerModel, ExpiringModel): 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 = ClientIPMiddleware.get_client_ip(request) - # Apply GeoIP Data, when enabled - self.with_geoip() + # Enrich event data + for processor in get_context_processors(): + processor.enrich_event(self) # If there's no app set, we get it from the requests too if not self.app: self.app = Event._get_app_from_request(request) self.save() return self - def with_geoip(self): # pragma: no cover - """Apply GeoIP Data, when enabled""" - city = GEOIP_READER.city_dict(self.client_ip) - if not city: - return - self.context["geo"] = city - def save(self, *args, **kwargs): if self._state.adding: LOGGER.info( diff --git a/authentik/events/tests/test_enrich_asn.py b/authentik/events/tests/test_enrich_asn.py new file mode 100644 index 000000000..2844c4591 --- /dev/null +++ b/authentik/events/tests/test_enrich_asn.py @@ -0,0 +1,24 @@ +"""Test ASN Wrapper""" +from django.test import TestCase + +from authentik.events.context_processors.asn import ASNContextProcessor + + +class TestASN(TestCase): + """Test ASN Wrapper""" + + def setUp(self) -> None: + self.reader = ASNContextProcessor() + + def test_simple(self): + """Test simple asn wrapper""" + # IPs from + # https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-ASN-Test.json + self.assertEqual( + self.reader.asn_dict("1.0.0.1"), + { + "asn": 15169, + "as_org": "Google Inc.", + "network": "1.0.0.0/24", + }, + ) diff --git a/authentik/events/tests/test_geoip.py b/authentik/events/tests/test_enrich_geoip.py similarity index 83% rename from authentik/events/tests/test_geoip.py rename to authentik/events/tests/test_enrich_geoip.py index 3120dacae..5317901a4 100644 --- a/authentik/events/tests/test_geoip.py +++ b/authentik/events/tests/test_enrich_geoip.py @@ -1,14 +1,14 @@ """Test GeoIP Wrapper""" from django.test import TestCase -from authentik.events.geo import GeoIPReader +from authentik.events.context_processors.geoip import GeoIPContextProcessor class TestGeoIP(TestCase): """Test GeoIP Wrapper""" def setUp(self) -> None: - self.reader = GeoIPReader() + self.reader = GeoIPContextProcessor() def test_simple(self): """Test simple city wrapper""" diff --git a/authentik/events/utils.py b/authentik/events/utils.py index a7c3bdf3e..cf1b1b78b 100644 --- a/authentik/events/utils.py +++ b/authentik/events/utils.py @@ -17,12 +17,13 @@ from django.db.models.base import Model from django.http.request import HttpRequest from django.utils import timezone from django.views.debug import SafeExceptionReporterFilter -from geoip2.models import City +from geoip2.models import ASN, City from guardian.utils import get_anonymous_user from authentik.blueprints.v1.common import YAMLTag from authentik.core.models import User -from authentik.events.geo import GEOIP_READER +from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR +from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR from authentik.policies.types import PolicyRequest # Special keys which are *not* cleaned, even when the default filter @@ -123,7 +124,9 @@ def sanitize_item(value: Any) -> Any: if isinstance(value, (HttpRequest, WSGIRequest)): return ... if isinstance(value, City): - return GEOIP_READER.city_to_dict(value) + return GEOIP_CONTEXT_PROCESSOR.city_to_dict(value) + if isinstance(value, ASN): + return ASN_CONTEXT_PROCESSOR.asn_to_dict(value) if isinstance(value, Path): return str(value) if isinstance(value, Exception): diff --git a/authentik/lib/config.py b/authentik/lib/config.py index dd5500f8e..3b977e8a2 100644 --- a/authentik/lib/config.py +++ b/authentik/lib/config.py @@ -35,6 +35,7 @@ REDIS_ENV_KEYS = [ ] DEPRECATIONS = { + "geoip": "events.context_processors.geoip", "redis.broker_url": "broker.url", "redis.broker_transport_options": "broker.transport_options", "redis.cache_timeout": "cache.timeout", diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 7573888ae..afb775c44 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -108,7 +108,10 @@ cookie_domain: null disable_update_check: false disable_startup_analytics: false avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar,initials -geoip: "/geoip/GeoLite2-City.mmdb" +events: + context_processors: + geoip: "/geoip/GeoLite2-City.mmdb" + asn: "/geoip/GeoLite2-ASN.mmdb" footer_links: [] diff --git a/authentik/policies/reputation/api.py b/authentik/policies/reputation/api.py index 9e9d95e13..885f6c162 100644 --- a/authentik/policies/reputation/api.py +++ b/authentik/policies/reputation/api.py @@ -47,6 +47,7 @@ class ReputationSerializer(ModelSerializer): "identifier", "ip", "ip_geo_data", + "ip_asn_data", "score", "updated", ] diff --git a/authentik/policies/reputation/migrations/0006_reputation_ip_asn_data.py b/authentik/policies/reputation/migrations/0006_reputation_ip_asn_data.py new file mode 100644 index 000000000..05557392e --- /dev/null +++ b/authentik/policies/reputation/migrations/0006_reputation_ip_asn_data.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2023-12-05 22:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_policies_reputation", "0005_reputation_expires_reputation_expiring"), + ] + + operations = [ + migrations.AddField( + model_name="reputation", + name="ip_asn_data", + field=models.JSONField(default=dict), + ), + ] diff --git a/authentik/policies/reputation/models.py b/authentik/policies/reputation/models.py index 7fccfa11a..ea8ac2bd6 100644 --- a/authentik/policies/reputation/models.py +++ b/authentik/policies/reputation/models.py @@ -76,6 +76,7 @@ class Reputation(ExpiringModel, SerializerModel): identifier = models.TextField() ip = models.GenericIPAddressField() ip_geo_data = models.JSONField(default=dict) + ip_asn_data = models.JSONField(default=dict) score = models.BigIntegerField(default=0) expires = models.DateTimeField(default=reputation_expiry) diff --git a/authentik/policies/reputation/tasks.py b/authentik/policies/reputation/tasks.py index 7fd7b775f..ac65d1748 100644 --- a/authentik/policies/reputation/tasks.py +++ b/authentik/policies/reputation/tasks.py @@ -2,7 +2,8 @@ from django.core.cache import cache from structlog.stdlib import get_logger -from authentik.events.geo import GEOIP_READER +from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR +from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR from authentik.events.monitored_tasks import ( MonitoredTask, TaskResult, @@ -26,7 +27,8 @@ def save_reputation(self: MonitoredTask): ip=score["ip"], identifier=score["identifier"], ) - rep.ip_geo_data = GEOIP_READER.city_dict(score["ip"]) or {} + rep.ip_geo_data = GEOIP_CONTEXT_PROCESSOR.city_dict(score["ip"]) or {} + rep.ip_asn_data = ASN_CONTEXT_PROCESSOR.asn_dict(score["ip"]) or {} rep.score = score["score"] objects_to_update.append(rep) Reputation.objects.bulk_update(objects_to_update, ["score", "ip_geo_data"]) diff --git a/authentik/policies/types.py b/authentik/policies/types.py index 5e59dbbf0..c9e2e8d86 100644 --- a/authentik/policies/types.py +++ b/authentik/policies/types.py @@ -8,7 +8,7 @@ from django.db.models import Model from django.http import HttpRequest from structlog.stdlib import get_logger -from authentik.events.geo import GEOIP_READER +from authentik.events.context_processors.base import get_context_processors if TYPE_CHECKING: from authentik.core.models import User @@ -37,15 +37,9 @@ 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 = ClientIPMiddleware.get_client_ip(request) - if not client_ip: - return - self.context["geoip"] = GEOIP_READER.city(client_ip) + for processor in get_context_processors(): + self.context.update(processor.enrich_context(request)) @property def should_cache(self) -> bool: diff --git a/authentik/root/test_runner.py b/authentik/root/test_runner.py index b2bf7a3d7..bc3b3b968 100644 --- a/authentik/root/test_runner.py +++ b/authentik/root/test_runner.py @@ -32,7 +32,8 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover settings.TEST = True settings.CELERY["task_always_eager"] = True CONFIG.set("avatars", "none") - CONFIG.set("geoip", "tests/GeoLite2-City-Test.mmdb") + CONFIG.set("events.context_processors.geoip", "tests/GeoLite2-City-Test.mmdb") + CONFIG.set("events.context_processors.asn", "tests/GeoLite2-ASN-Test.mmdb") CONFIG.set("blueprints_dir", "./blueprints") CONFIG.set( "outposts.container_image_base", diff --git a/blueprints/schema.json b/blueprints/schema.json index bf66c94ed..eaaed0a8d 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -3756,6 +3756,11 @@ "additionalProperties": true, "title": "Ip geo data" }, + "ip_asn_data": { + "type": "object", + "additionalProperties": true, + "title": "Ip asn data" + }, "score": { "type": "integer", "minimum": -9223372036854775808, diff --git a/schema.yml b/schema.yml index da1a90a18..fee6b8d3d 100644 --- a/schema.yml +++ b/schema.yml @@ -28280,7 +28280,7 @@ components: readOnly: true geo_ip: type: object - description: Get parsed user agent + description: Get GeoIP Data properties: continent: type: string @@ -28302,6 +28302,24 @@ components: - long nullable: true readOnly: true + asn: + type: object + description: Get ASN Data + properties: + asn: + type: integer + as_org: + type: string + nullable: true + network: + type: string + nullable: true + required: + - as_org + - asn + - network + nullable: true + readOnly: true user: type: integer last_ip: @@ -28316,6 +28334,7 @@ components: type: string format: date-time required: + - asn - current - geo_ip - last_ip @@ -29283,6 +29302,7 @@ components: enum: - can_save_media - can_geo_ip + - can_asn - can_impersonate - can_debug - is_enterprise @@ -29290,6 +29310,7 @@ components: description: |- * `can_save_media` - Can Save Media * `can_geo_ip` - Can Geo Ip + * `can_asn` - Can Asn * `can_impersonate` - Can Impersonate * `can_debug` - Can Debug * `is_enterprise` - Is Enterprise @@ -39667,6 +39688,7 @@ components: ip: type: string ip_geo_data: {} + ip_asn_data: {} score: type: integer maximum: 9223372036854775807 diff --git a/scripts/generate_config.py b/scripts/generate_config.py index 187eb3ba5..965e3e15e 100644 --- a/scripts/generate_config.py +++ b/scripts/generate_config.py @@ -17,7 +17,12 @@ with open("local.env.yml", "w", encoding="utf-8") as _config: }, "blueprints_dir": "./blueprints", "cert_discovery_dir": "./certs", - "geoip": "tests/GeoLite2-City-Test.mmdb", + "events": { + "processors": { + "geoip": "tests/GeoLite2-City-Test.mmdb", + "asn": "tests/GeoLite2-ASN-Test.mmdb", + } + }, }, _config, default_flow_style=False, diff --git a/tests/GeoLite2-ASN-Test.mmdb b/tests/GeoLite2-ASN-Test.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..3e5033144f505227ef872af685c3664a6d45d03b GIT binary patch literal 12653 zcmZvh2YeLO_Q%iMnGI`3fH>`d%J z>`LrLAXJC%PV7PKNmLS5L^V-E)DqK)I${P9v)JYsKRKCyt0&4OGPe&bcM*}d0WB8v|TB9WnNwlmm8?OhPalvj<{Y5cx3b> zK;^BJR)}CT1k=3(cwFR8;6A$CMcgd~I_*6o_nJv)Ne_QNln;wMke{wR$bg5WU^P8| zh07`tNb;D-Bfz7^Wzg`o=Q(H(#yuhOq=_5U!3<9$;8l@lfR{y{1zx1*b0W`&Xz8857%6CLw2i`Es!Vw(vO_8^l_HBd0tth=qyhpr`xDOb& ztH>IoEW|6lAs^v1ZY1)th=rwo0-(}A1-29U3>Yc$Il_Mu`GNso5?>jZC-$|-H&lL0 zd>7ij=RAKPekA@){Kuehw#d&&w28GCl+MF|Ui7liM#ia)u3(sj{Z5euQ zCDyo5+FGpfp)`Tv+Yl4Qn#57cLzfD%CS#ebGi!=iQ}Ys(*0y<1RI9Z;vNv+D9f%!? zoy6KX?`iEq<*s7w7Ai6M-4Q-rEcCy^_r%)Mz^uJetSTz2iJH(>YX;K+b!ITU5^IK7 z^`SIVtcJX#cc}?3`-#;IEEKCHoJ6Zwdxg?0&ND`|iG|wK;d79Efmm~ic?M>ldy6$c zl;E;YUeW>kB40bF`>_mvGjz~)s#sl2t?H;&?*$KWzDl#0ugh z#GNA6$(YjK%z&Ne$$>(w)5K!`(~=H91Ijb$$p=u>F4oylUM|)-z`15$wC8yYIN!j` z`T`SYT`1N?q4Hv}F5%-}nSaC<)_z%D8n}C~cI!%HxLK^LfE&cR8eqq>*#GpNT?gg$ zdiQiPoff-mvfn7yO-5O?+`zg8o~y*V6?j;z+o-%fA7|acxI4wVD^%Vs);;vRH&osy zMLO;M!~?{G#6t$Txv(i(tIf~_E5v#faes;R81S`Nj|0z%^#t%Vvp*>o+A+f6tYMb& zj9AYasptg*>v;s=4xwaR*c+{v#2UEIUZLl|!cku3V6TbwItTk9w7p5&TVlP2DTM7h03YK zv~@hUGm_a5o?Eqpp1YdW!p+3t?ZK{eLH|=1h}siaBB~NtAgT(e zqi40K8hX|e(+viFZ(s(SLC<l;#uKkZ%@c#YiTMV4F!Vo-g>dmiq5na`k0{>4YCkDnNtce$)+wqhlzav( z64g!5p3oK-wSOq}ib{l1ALEju0><@+wgW_^s6_wM6U-11){--nGfAOnS zt}rlZ`C$^LL!Cm~sR%zq)M=Fd8G4>B#k^6}nQ%Er)LC>n+n|KU*Nl0tsPo`*e(uAF zR-!)Dg$zLdQ$LHk82CukCBSo{2ALtIOs5h0nn zp16Uyk+_MtnYaZ*-zDl+O6-4%{ZHLN<(&q4^7uah#r~%yb+1vX`$XN(fCq>NiHGuW z>S21W5;af^kI?1O(B-jwh9^ShlcJsqrKd$b!?YM6nwxe*RNGiemp$|Doqkc~2dO{$~#s^(#;; z>NmQu|EWKq`G*eY+cZQ2~MBSvW#h?MLo_8_9rKu;G+ zjlxqIP$K$@wMz|Pu*)FrBsLC^y}j5&IoJk}Tm}pid&4m8Mq&@A%f_K{6MFLVyS=H{ zBSYILvC;oZkSI!QMr>|iCJ5UWVZa!%+5hYZ3SFwjuEAi_=~8P@(j)lMFHLs#Kf9jNOoIK-<_A=}iAwfAyMny%=(37fjlrG~`w=4<&qu|63`&0DvmY1x z2}n;d!;@0-n(-{%l-Qo5u>aZ5iIg^r{k#EQN$FCtU!==R80BS&@EMszuSn^Q#-?}4 zepPG^ZNF}m1D`wWH}UxQ5c@5T@;31f@h$3*`F zN?$QBqx{Ulei8duy8IT}ey8mZvHwfkpLrW5Y5#2`M+l2hgiXMREKY?qwvn7>$aLzbNTNC4n3B)$Uz&s~WDmNH>6Q#)* zdPi}lP?}0iBeo^BBT(gfE;|_LFZ8vv6R|U~3&C1)cB6!2uRV7+$gOBkQ>{)VQAJb} zHAF4rrW19TXOB2DDAf})p`0&H1EoetG44{6I5>DZZ41##>_yBnz*`^V(q;f>ab}A% zhswFcJYsKy!EaJp0MAY)+DDv)Mrp$L6{npp`wDU!Y`FFjmr%1C7*Kzr2ECWlAc#Vx=$QkME8nw4REtK*BTp!73Vr}t`DUf zbS`n&|D2no>}ig23zWBsbE}bZ)4d(ayTrMJE_c%Q8Kt|4dkl&mG#Ii0vAuyw^neVB z8OeEwco<{8BhD&Ht0BF_DLo?2qmZ5#=P`i&&v{&&C+PVk@sz<36s_K~XNYHs=L|}j z-Fbn^7Y&Bsf$M;mF*Luf z4}2re2UMrAl)fUqHW>J-+WD5s?+k{P zQTl=S5m4g%8)^R%=Rd$N;`~J0&jv#$Qu-CPKg1bG^gC_!wEdU(a~n=DLV%No-{>^l<|d zw>1OC6BCGSh>64`iIiiosS>H+D3ggP9PG_-uxS$6HZKkR*i=u1-+xB<_rC}~K}U9? zZD#_1|J&6p1)DFC-KfONb^m=h&&y_lWY8C6)9vYH>5Sjkp3L@j_^H*qRxY&uuQ5n< zd&&56FB1=v4Rt}#o$%fIWM{>PuC>~E%#F6R`H6HU<+-hyWo~1pYYp_H_J`H&a-&U^ zZR6V9n4el4@AT79uBvmRb7%NV5`HFA-s*MsdZ{kAI_T^7lFPK&cB2amo4j<|>+H#< z;gm+QbS9q3W_&kTpJ;@*ubZhU`E6#JHdsp^)eO^4Y#QpT7{0xVE;zs9JH&@rq zYMI^YR`JgY{E-vm(efjt)HEYFUA(c@hq|1)m|dLC`cvap6g}Y z7;@Ca{q8`!4;|-5XPDI_{ft&@_9NmW*tSkDjmdf)sFQU!F?=e^uflVy@#1|6Kjl{D z>(PywdeoySJsYbgs99?tF_S*CF>W>X!#A@op6f=ngq=)f))k|<)rIR{)E%EgN)_*LfL?^k8jn!s; z55RR)Q+56Nj=5UrV%@IE9g{tG4k}`KkaVlE>3Gslr*+x|FY4bbYV_|>@8~h-{e-ho z9V}gc$lPfgu?cb(SNn-XXW+JasZ0`2swdv>)>KvIQ_PL0P>^YT*7O04t#bhLPhjjB zZglK-I`xcWbm|dEJ*x^M$AWC8rvi^OU$?vAj4v#X1)XspM{qihNv9W2`)*g(o#l1K zgACI3_uwqKu?q7P4zS$l%*t2|e%y2_p@*L`2E{Tx<-?~krWGaQQ2Z%HQ1Q7=6On`5%`$l=(y`pEZq87t2U;a-DqP~%I`vHbWL+BtF&@lD;8W-m&JY1r4MOS z&=Ft1mhp%xaMzhCTA>gAYNy4G&Wp{SH66((xox-@n*B_VpTcaq^uri?uN!Tsu0{E@ zl($ySYSS?VM(72^=oCV>q9@? zi5qC|(XYbWiDi>@`nIiH?8Orv)}#-4u3X*OG4t?>UU#FlGh3VMO_h(FiN}Pvlz!mr zod|Q^tsZ+99`^ircX!}cRm?F>A+n*)hHEkI#ZVUL4E6f7Ce6{+H>;OQ#d&(iJqp*+ zZC-o{u9oTXrG8g#_1HpRmpOJXr^?OMZA}G><6VBrWE*z9zKrAD@eGE>?S%_3U%G{8 zHm&-8P6jEr8I8<$S$@uM$ZPYC(iw7x#+@6a5?xC$xZ9uT)LU1y2A)HjgC#C{OfrN2 z7{6oL!2LGv|B2L5H6?CzpBZsffLq_Xc!&JVq0seRF-bd(dk8}pH2X`;b(T#n!@L&- zdY7^G&K_N2xNcBn+PStI&Q2|^R_vr(l?pslv-yJ7&hXW)Zxz9HcyqkJA2*I(eRs<1 z?=d^6C7#u_>cnlG?lyWIx=2^Q(e6g))Hk=)&T6i0bDL&2w$)Fst*vpZTg)up*w2lQ zmoeJ2=wIke4K+3OuK88nGOM*^R%Kg#OS7@Qk2%<}pd6>ct<{fTZ(SeT*xC(l_43og z7QSwss~#!tAh{t9=w8&m{l65Af8 z_f0=0WWs9PIv+hRxyOu8JH4kji)a%e=XL`$*=N6uy!^ksHGS;O!a`_@GuB^f9z_JJ4KIz+0w=O&`zr)Q! z7`Tw~n>_X&q^q4<>tdtx%E8#{Hof!HTX0iVx;3?KZLGDj*<4}6cfzXC%epJ2vg;rx6Z+LP2)g^)s+#4938TXdASId03w`sRkNAcF&@Rb>~t z(W-iuG0BiduCa`-&p4djkr0dm1C0{ zy8Lu!D&DV`x~9rcE}WRN;___9^TnGZ-KTW4JARRewZoylb(*WkoaADDuD{gl>qpP* z&*H47(RTxM@d-2I{kbWvEU3q$@6LMNe%f-}#WRyZm*3tQWRscIk$*I#P_e15_H=x? IzjE~d04QnY!2kdN literal 0 HcmV?d00001 diff --git a/website/docs/core/geoip.mdx b/website/docs/core/geoip.mdx index b3cedbe47..ff73e7a39 100644 --- a/website/docs/core/geoip.mdx +++ b/website/docs/core/geoip.mdx @@ -27,7 +27,8 @@ import TabItem from "@theme/TabItem"; Add the following block to your `.env` file: ```shell -AUTHENTIK_GEOIP=/tmp/non-existent-file +AUTHENTIK_EVENTS__CONTEXT_PROCESSORS__GEOIP=/tmp/non-existent-file +AUTHENTIK_EVENTS__CONTEXT_PROCESSORS__ASN=/tmp/non-existent-file ``` Afterwards, run the upgrade commands from the latest release notes. @@ -38,7 +39,10 @@ Add the following block to your `values.yml` file: ```yaml authentik: - geoip: /tmp/non-existent-file + events: + context_processors: + geoip: "/tmp/non-existent-file" + asn: "/tmp/non-existent-file" ``` Afterwards, run the upgrade commands from the latest release notes. @@ -74,7 +78,7 @@ services: volumes: - "geoip:/usr/share/GeoIP" environment: - GEOIPUPDATE_EDITION_IDS: "GeoLite2-City" + GEOIPUPDATE_EDITION_IDS: "GeoLite2-City GeoLite2-ASN" GEOIPUPDATE_FREQUENCY: "8" GEOIPUPDATE_ACCOUNT_ID: "*your account ID*" GEOIPUPDATE_LICENSE_KEY: "*your license key*" @@ -94,7 +98,7 @@ geoip: enabled: true accountId: "*your account ID*" licenseKey: "*your license key*" - editionIds: "GeoLite2-City" + editionIds: "GeoLite2-City GeoLite2-ASN" image: maxmindinc/geoipupdate:v4.8 updateInterval: 8 ``` diff --git a/website/docs/installation/configuration.mdx b/website/docs/installation/configuration.mdx index 28d8e5f50..6201467f1 100644 --- a/website/docs/installation/configuration.mdx +++ b/website/docs/installation/configuration.mdx @@ -154,9 +154,13 @@ Defaults to `info`. Which domain the session cookie should be set to. By default, the cookie is set to the domain authentik is accessed under. -### `AUTHENTIK_GEOIP` +### `AUTHENTIK_EVENTS__CONTEXT_PROCESSORS__GEOIP` -Path to the GeoIP database. Defaults to `/geoip/GeoLite2-City.mmdb`. If the file is not found, authentik will skip GeoIP support. +Path to the GeoIP City database. Defaults to `/geoip/GeoLite2-City.mmdb`. If the file is not found, authentik will skip GeoIP support. + +### `AUTHENTIK_EVENTS__CONTEXT_PROCESSORS__ASN` + +Path to the GeoIP ASN database. Defaults to `/geoip/GeoLite2-ASN.mmdb`. If the file is not found, authentik will skip GeoIP support. ### `AUTHENTIK_DISABLE_UPDATE_CHECK`