events: add ASN Database reader (#7793)
* events: add ASN Database reader Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix test config generator Signed-off-by: Jens Langhammer <jens@goauthentik.io> * de-duplicate code Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add enrich_context Signed-off-by: Jens Langhammer <jens@goauthentik.io> * rename to context processors? Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix cache Signed-off-by: Jens Langhammer <jens@goauthentik.io> * use config deprecation system, update docs Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update more docs and tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add test asn db Signed-off-by: Jens Langhammer <jens@goauthentik.io> * re-build schema with latest versions Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
4ff3915d59
commit
50860d7ffe
|
@ -71,7 +71,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||||
# Stage 4: MaxMind GeoIP
|
# Stage 4: MaxMind GeoIP
|
||||||
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.0 as 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_VERBOSE="true"
|
||||||
ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID"
|
ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID"
|
||||||
ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY"
|
ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY"
|
||||||
|
|
|
@ -19,7 +19,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
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
|
from authentik.lib.config import CONFIG
|
||||||
|
|
||||||
capabilities = Signal()
|
capabilities = Signal()
|
||||||
|
@ -30,6 +30,7 @@ class Capabilities(models.TextChoices):
|
||||||
|
|
||||||
CAN_SAVE_MEDIA = "can_save_media"
|
CAN_SAVE_MEDIA = "can_save_media"
|
||||||
CAN_GEO_IP = "can_geo_ip"
|
CAN_GEO_IP = "can_geo_ip"
|
||||||
|
CAN_ASN = "can_asn"
|
||||||
CAN_IMPERSONATE = "can_impersonate"
|
CAN_IMPERSONATE = "can_impersonate"
|
||||||
CAN_DEBUG = "can_debug"
|
CAN_DEBUG = "can_debug"
|
||||||
IS_ENTERPRISE = "is_enterprise"
|
IS_ENTERPRISE = "is_enterprise"
|
||||||
|
@ -68,8 +69,9 @@ class ConfigView(APIView):
|
||||||
deb_test = settings.DEBUG or settings.TEST
|
deb_test = settings.DEBUG or settings.TEST
|
||||||
if Path(settings.MEDIA_ROOT).is_mount() or deb_test:
|
if Path(settings.MEDIA_ROOT).is_mount() or deb_test:
|
||||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||||
if GEOIP_READER.enabled:
|
for processor in get_context_processors():
|
||||||
caps.append(Capabilities.CAN_GEO_IP)
|
if cap := processor.capability():
|
||||||
|
caps.append(cap)
|
||||||
if CONFIG.get_bool("impersonation"):
|
if CONFIG.get_bool("impersonation"):
|
||||||
caps.append(Capabilities.CAN_IMPERSONATE)
|
caps.append(Capabilities.CAN_IMPERSONATE)
|
||||||
if settings.DEBUG: # pragma: no cover
|
if settings.DEBUG: # pragma: no cover
|
||||||
|
|
|
@ -14,7 +14,8 @@ from ua_parser import user_agent_parser
|
||||||
from authentik.api.authorization import OwnerSuperuserPermissions
|
from authentik.api.authorization import OwnerSuperuserPermissions
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.models import AuthenticatedSession
|
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):
|
class UserAgentDeviceDict(TypedDict):
|
||||||
|
@ -59,6 +60,7 @@ class AuthenticatedSessionSerializer(ModelSerializer):
|
||||||
current = SerializerMethodField()
|
current = SerializerMethodField()
|
||||||
user_agent = SerializerMethodField()
|
user_agent = SerializerMethodField()
|
||||||
geo_ip = SerializerMethodField()
|
geo_ip = SerializerMethodField()
|
||||||
|
asn = SerializerMethodField()
|
||||||
|
|
||||||
def get_current(self, instance: AuthenticatedSession) -> bool:
|
def get_current(self, instance: AuthenticatedSession) -> bool:
|
||||||
"""Check if session is currently active session"""
|
"""Check if session is currently active session"""
|
||||||
|
@ -70,8 +72,12 @@ class AuthenticatedSessionSerializer(ModelSerializer):
|
||||||
return user_agent_parser.Parse(instance.last_user_agent)
|
return user_agent_parser.Parse(instance.last_user_agent)
|
||||||
|
|
||||||
def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]: # pragma: no cover
|
def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]: # pragma: no cover
|
||||||
"""Get parsed user agent"""
|
"""Get GeoIP Data"""
|
||||||
return GEOIP_READER.city_dict(instance.last_ip)
|
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:
|
class Meta:
|
||||||
model = AuthenticatedSession
|
model = AuthenticatedSession
|
||||||
|
@ -80,6 +86,7 @@ class AuthenticatedSessionSerializer(ModelSerializer):
|
||||||
"current",
|
"current",
|
||||||
"user_agent",
|
"user_agent",
|
||||||
"geo_ip",
|
"geo_ip",
|
||||||
|
"asn",
|
||||||
"user",
|
"user",
|
||||||
"last_ip",
|
"last_ip",
|
||||||
"last_user_agent",
|
"last_user_agent",
|
||||||
|
|
|
@ -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()
|
|
@ -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
|
|
@ -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()
|
|
@ -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)
|
|
@ -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()
|
|
|
@ -26,7 +26,7 @@ from authentik.core.middleware import (
|
||||||
SESSION_KEY_IMPERSONATE_USER,
|
SESSION_KEY_IMPERSONATE_USER,
|
||||||
)
|
)
|
||||||
from authentik.core.models import ExpiringModel, Group, PropertyMapping, 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 (
|
from authentik.events.utils import (
|
||||||
cleanse_dict,
|
cleanse_dict,
|
||||||
get_user,
|
get_user,
|
||||||
|
@ -246,21 +246,15 @@ class Event(SerializerModel, ExpiringModel):
|
||||||
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 = ClientIPMiddleware.get_client_ip(request)
|
self.client_ip = ClientIPMiddleware.get_client_ip(request)
|
||||||
# Apply GeoIP Data, when enabled
|
# Enrich event data
|
||||||
self.with_geoip()
|
for processor in get_context_processors():
|
||||||
|
processor.enrich_event(self)
|
||||||
# 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
|
||||||
if not self.app:
|
if not self.app:
|
||||||
self.app = Event._get_app_from_request(request)
|
self.app = Event._get_app_from_request(request)
|
||||||
self.save()
|
self.save()
|
||||||
return self
|
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):
|
def save(self, *args, **kwargs):
|
||||||
if self._state.adding:
|
if self._state.adding:
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
|
|
|
@ -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",
|
||||||
|
},
|
||||||
|
)
|
|
@ -1,14 +1,14 @@
|
||||||
"""Test GeoIP Wrapper"""
|
"""Test GeoIP Wrapper"""
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from authentik.events.geo import GeoIPReader
|
from authentik.events.context_processors.geoip import GeoIPContextProcessor
|
||||||
|
|
||||||
|
|
||||||
class TestGeoIP(TestCase):
|
class TestGeoIP(TestCase):
|
||||||
"""Test GeoIP Wrapper"""
|
"""Test GeoIP Wrapper"""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.reader = GeoIPReader()
|
self.reader = GeoIPContextProcessor()
|
||||||
|
|
||||||
def test_simple(self):
|
def test_simple(self):
|
||||||
"""Test simple city wrapper"""
|
"""Test simple city wrapper"""
|
|
@ -17,12 +17,13 @@ from django.db.models.base import Model
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.debug import SafeExceptionReporterFilter
|
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 guardian.utils import get_anonymous_user
|
||||||
|
|
||||||
from authentik.blueprints.v1.common import YAMLTag
|
from authentik.blueprints.v1.common import YAMLTag
|
||||||
from authentik.core.models import User
|
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
|
from authentik.policies.types import PolicyRequest
|
||||||
|
|
||||||
# Special keys which are *not* cleaned, even when the default filter
|
# 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)):
|
if isinstance(value, (HttpRequest, WSGIRequest)):
|
||||||
return ...
|
return ...
|
||||||
if isinstance(value, City):
|
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):
|
if isinstance(value, Path):
|
||||||
return str(value)
|
return str(value)
|
||||||
if isinstance(value, Exception):
|
if isinstance(value, Exception):
|
||||||
|
|
|
@ -35,6 +35,7 @@ REDIS_ENV_KEYS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
DEPRECATIONS = {
|
DEPRECATIONS = {
|
||||||
|
"geoip": "events.context_processors.geoip",
|
||||||
"redis.broker_url": "broker.url",
|
"redis.broker_url": "broker.url",
|
||||||
"redis.broker_transport_options": "broker.transport_options",
|
"redis.broker_transport_options": "broker.transport_options",
|
||||||
"redis.cache_timeout": "cache.timeout",
|
"redis.cache_timeout": "cache.timeout",
|
||||||
|
|
|
@ -108,7 +108,10 @@ cookie_domain: null
|
||||||
disable_update_check: false
|
disable_update_check: false
|
||||||
disable_startup_analytics: false
|
disable_startup_analytics: false
|
||||||
avatars: env://AUTHENTIK_AUTHENTIK__AVATARS?gravatar,initials
|
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: []
|
footer_links: []
|
||||||
|
|
||||||
|
|
|
@ -47,6 +47,7 @@ class ReputationSerializer(ModelSerializer):
|
||||||
"identifier",
|
"identifier",
|
||||||
"ip",
|
"ip",
|
||||||
"ip_geo_data",
|
"ip_geo_data",
|
||||||
|
"ip_asn_data",
|
||||||
"score",
|
"score",
|
||||||
"updated",
|
"updated",
|
||||||
]
|
]
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -76,6 +76,7 @@ class Reputation(ExpiringModel, SerializerModel):
|
||||||
identifier = models.TextField()
|
identifier = models.TextField()
|
||||||
ip = models.GenericIPAddressField()
|
ip = models.GenericIPAddressField()
|
||||||
ip_geo_data = models.JSONField(default=dict)
|
ip_geo_data = models.JSONField(default=dict)
|
||||||
|
ip_asn_data = models.JSONField(default=dict)
|
||||||
score = models.BigIntegerField(default=0)
|
score = models.BigIntegerField(default=0)
|
||||||
|
|
||||||
expires = models.DateTimeField(default=reputation_expiry)
|
expires = models.DateTimeField(default=reputation_expiry)
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from structlog.stdlib import get_logger
|
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 (
|
from authentik.events.monitored_tasks import (
|
||||||
MonitoredTask,
|
MonitoredTask,
|
||||||
TaskResult,
|
TaskResult,
|
||||||
|
@ -26,7 +27,8 @@ def save_reputation(self: MonitoredTask):
|
||||||
ip=score["ip"],
|
ip=score["ip"],
|
||||||
identifier=score["identifier"],
|
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"]
|
rep.score = score["score"]
|
||||||
objects_to_update.append(rep)
|
objects_to_update.append(rep)
|
||||||
Reputation.objects.bulk_update(objects_to_update, ["score", "ip_geo_data"])
|
Reputation.objects.bulk_update(objects_to_update, ["score", "ip_geo_data"])
|
||||||
|
|
|
@ -8,7 +8,7 @@ from django.db.models import Model
|
||||||
from django.http import HttpRequest
|
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.context_processors.base import get_context_processors
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
|
@ -37,15 +37,9 @@ 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:
|
for processor in get_context_processors():
|
||||||
return
|
self.context.update(processor.enrich_context(request))
|
||||||
client_ip = ClientIPMiddleware.get_client_ip(request)
|
|
||||||
if not client_ip:
|
|
||||||
return
|
|
||||||
self.context["geoip"] = GEOIP_READER.city(client_ip)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_cache(self) -> bool:
|
def should_cache(self) -> bool:
|
||||||
|
|
|
@ -32,7 +32,8 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||||
settings.TEST = True
|
settings.TEST = True
|
||||||
settings.CELERY["task_always_eager"] = True
|
settings.CELERY["task_always_eager"] = True
|
||||||
CONFIG.set("avatars", "none")
|
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("blueprints_dir", "./blueprints")
|
||||||
CONFIG.set(
|
CONFIG.set(
|
||||||
"outposts.container_image_base",
|
"outposts.container_image_base",
|
||||||
|
|
|
@ -3756,6 +3756,11 @@
|
||||||
"additionalProperties": true,
|
"additionalProperties": true,
|
||||||
"title": "Ip geo data"
|
"title": "Ip geo data"
|
||||||
},
|
},
|
||||||
|
"ip_asn_data": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"title": "Ip asn data"
|
||||||
|
},
|
||||||
"score": {
|
"score": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": -9223372036854775808,
|
"minimum": -9223372036854775808,
|
||||||
|
|
24
schema.yml
24
schema.yml
|
@ -28280,7 +28280,7 @@ components:
|
||||||
readOnly: true
|
readOnly: true
|
||||||
geo_ip:
|
geo_ip:
|
||||||
type: object
|
type: object
|
||||||
description: Get parsed user agent
|
description: Get GeoIP Data
|
||||||
properties:
|
properties:
|
||||||
continent:
|
continent:
|
||||||
type: string
|
type: string
|
||||||
|
@ -28302,6 +28302,24 @@ components:
|
||||||
- long
|
- long
|
||||||
nullable: true
|
nullable: true
|
||||||
readOnly: 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:
|
user:
|
||||||
type: integer
|
type: integer
|
||||||
last_ip:
|
last_ip:
|
||||||
|
@ -28316,6 +28334,7 @@ components:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
required:
|
required:
|
||||||
|
- asn
|
||||||
- current
|
- current
|
||||||
- geo_ip
|
- geo_ip
|
||||||
- last_ip
|
- last_ip
|
||||||
|
@ -29283,6 +29302,7 @@ components:
|
||||||
enum:
|
enum:
|
||||||
- can_save_media
|
- can_save_media
|
||||||
- can_geo_ip
|
- can_geo_ip
|
||||||
|
- can_asn
|
||||||
- can_impersonate
|
- can_impersonate
|
||||||
- can_debug
|
- can_debug
|
||||||
- is_enterprise
|
- is_enterprise
|
||||||
|
@ -29290,6 +29310,7 @@ components:
|
||||||
description: |-
|
description: |-
|
||||||
* `can_save_media` - Can Save Media
|
* `can_save_media` - Can Save Media
|
||||||
* `can_geo_ip` - Can Geo Ip
|
* `can_geo_ip` - Can Geo Ip
|
||||||
|
* `can_asn` - Can Asn
|
||||||
* `can_impersonate` - Can Impersonate
|
* `can_impersonate` - Can Impersonate
|
||||||
* `can_debug` - Can Debug
|
* `can_debug` - Can Debug
|
||||||
* `is_enterprise` - Is Enterprise
|
* `is_enterprise` - Is Enterprise
|
||||||
|
@ -39667,6 +39688,7 @@ components:
|
||||||
ip:
|
ip:
|
||||||
type: string
|
type: string
|
||||||
ip_geo_data: {}
|
ip_geo_data: {}
|
||||||
|
ip_asn_data: {}
|
||||||
score:
|
score:
|
||||||
type: integer
|
type: integer
|
||||||
maximum: 9223372036854775807
|
maximum: 9223372036854775807
|
||||||
|
|
|
@ -17,7 +17,12 @@ with open("local.env.yml", "w", encoding="utf-8") as _config:
|
||||||
},
|
},
|
||||||
"blueprints_dir": "./blueprints",
|
"blueprints_dir": "./blueprints",
|
||||||
"cert_discovery_dir": "./certs",
|
"cert_discovery_dir": "./certs",
|
||||||
|
"events": {
|
||||||
|
"processors": {
|
||||||
"geoip": "tests/GeoLite2-City-Test.mmdb",
|
"geoip": "tests/GeoLite2-City-Test.mmdb",
|
||||||
|
"asn": "tests/GeoLite2-ASN-Test.mmdb",
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
_config,
|
_config,
|
||||||
default_flow_style=False,
|
default_flow_style=False,
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
|
@ -27,7 +27,8 @@ import TabItem from "@theme/TabItem";
|
||||||
Add the following block to your `.env` file:
|
Add the following block to your `.env` file:
|
||||||
|
|
||||||
```shell
|
```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.
|
Afterwards, run the upgrade commands from the latest release notes.
|
||||||
|
@ -38,7 +39,10 @@ Add the following block to your `values.yml` file:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
authentik:
|
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.
|
Afterwards, run the upgrade commands from the latest release notes.
|
||||||
|
@ -74,7 +78,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- "geoip:/usr/share/GeoIP"
|
- "geoip:/usr/share/GeoIP"
|
||||||
environment:
|
environment:
|
||||||
GEOIPUPDATE_EDITION_IDS: "GeoLite2-City"
|
GEOIPUPDATE_EDITION_IDS: "GeoLite2-City GeoLite2-ASN"
|
||||||
GEOIPUPDATE_FREQUENCY: "8"
|
GEOIPUPDATE_FREQUENCY: "8"
|
||||||
GEOIPUPDATE_ACCOUNT_ID: "*your account ID*"
|
GEOIPUPDATE_ACCOUNT_ID: "*your account ID*"
|
||||||
GEOIPUPDATE_LICENSE_KEY: "*your license key*"
|
GEOIPUPDATE_LICENSE_KEY: "*your license key*"
|
||||||
|
@ -94,7 +98,7 @@ geoip:
|
||||||
enabled: true
|
enabled: true
|
||||||
accountId: "*your account ID*"
|
accountId: "*your account ID*"
|
||||||
licenseKey: "*your license key*"
|
licenseKey: "*your license key*"
|
||||||
editionIds: "GeoLite2-City"
|
editionIds: "GeoLite2-City GeoLite2-ASN"
|
||||||
image: maxmindinc/geoipupdate:v4.8
|
image: maxmindinc/geoipupdate:v4.8
|
||||||
updateInterval: 8
|
updateInterval: 8
|
||||||
```
|
```
|
||||||
|
|
|
@ -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.
|
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`
|
### `AUTHENTIK_DISABLE_UPDATE_CHECK`
|
||||||
|
|
||||||
|
|
Reference in New Issue