Skip to content

Commit

Permalink
events: add ASN Database reader (#7793)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
BeryJu authored Dec 20, 2023
1 parent 4ff3915 commit 50860d7
Show file tree
Hide file tree
Showing 27 changed files with 393 additions and 143 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 5 additions & 3 deletions authentik/api/v3/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
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()
Expand All @@ -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"
Expand Down Expand Up @@ -68,8 +69,9 @@ def get_capabilities(self) -> list[Capabilities]:
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
Expand Down
13 changes: 10 additions & 3 deletions authentik/core/api/authenticated_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
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):
Expand Down Expand Up @@ -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"""
Expand All @@ -70,8 +72,12 @@ def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict:
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
Expand All @@ -80,6 +86,7 @@ class Meta:
"current",
"user_agent",
"geo_ip",
"asn",
"user",
"last_ip",
"last_user_agent",
Expand Down
Empty file.
79 changes: 79 additions & 0 deletions authentik/events/context_processors/asn.py
Original file line number Diff line number Diff line change
@@ -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()
43 changes: 43 additions & 0 deletions authentik/events/context_processors/base.py
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions authentik/events/context_processors/geoip.py
Original file line number Diff line number Diff line change
@@ -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()
54 changes: 54 additions & 0 deletions authentik/events/context_processors/mmdb.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 50860d7

Please sign in to comment.