Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

policies/geoip: distance + impossible travel #12541

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions authentik/policies/geoip/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ class Meta:
"asns",
"countries",
"countries_obj",
"check_history_distance",
"history_max_distance_km",
"distance_tolerance_km",
"history_login_count",
"check_impossible_travel",
"impossible_tolerance_km",
]


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 5.0.10 on 2025-01-02 20:40

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("authentik_policies_geoip", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="geoippolicy",
name="check_history_distance",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="geoippolicy",
name="check_impossible_travel",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="geoippolicy",
name="distance_tolerance_km",
field=models.PositiveIntegerField(default=50),
),
migrations.AddField(
model_name="geoippolicy",
name="history_login_count",
field=models.PositiveIntegerField(default=5),
),
migrations.AddField(
model_name="geoippolicy",
name="history_max_distance_km",
field=models.PositiveBigIntegerField(default=100),
),
migrations.AddField(
model_name="geoippolicy",
name="impossible_tolerance_km",
field=models.PositiveIntegerField(default=100),
),
]
73 changes: 65 additions & 8 deletions authentik/policies/geoip/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@

from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django_countries.fields import CountryField
from geopy import distance
from rest_framework.serializers import BaseSerializer

from authentik.events.context_processors.geoip import GeoIPDict
from authentik.events.models import Event, EventAction
from authentik.policies.exceptions import PolicyException
from authentik.policies.geoip.exceptions import GeoIPNotFoundException
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult

MAX_DISTANCE_HOUR_KM = 1000


class GeoIPPolicy(Policy):
"""Ensure the user satisfies requirements of geography or network topology, based on IP
Expand All @@ -21,6 +27,15 @@
asns = ArrayField(models.IntegerField(), blank=True, default=list)
countries = CountryField(multiple=True, blank=True)

distance_tolerance_km = models.PositiveIntegerField(default=50)

check_history_distance = models.BooleanField(default=False)
history_max_distance_km = models.PositiveBigIntegerField(default=100)
history_login_count = models.PositiveIntegerField(default=5)

check_impossible_travel = models.BooleanField(default=False)
impossible_tolerance_km = models.PositiveIntegerField(default=100)

@property
def serializer(self) -> type[BaseSerializer]:
from authentik.policies.geoip.api import GeoIPPolicySerializer
Expand All @@ -37,21 +52,27 @@
- the client IP is advertised by an autonomous system with ASN in the `asns`
- the client IP is geolocated in a country of `countries`
"""
results: list[PolicyResult] = []
static_results: list[PolicyResult] = []
dynamic_results: list[PolicyResult] = []

Check warning on line 56 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L55-L56

Added lines #L55 - L56 were not covered by tests

if self.asns:
results.append(self.passes_asn(request))
static_results.append(self.passes_asn(request))

Check warning on line 59 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L59

Added line #L59 was not covered by tests
if self.countries:
results.append(self.passes_country(request))
static_results.append(self.passes_country(request))

Check warning on line 61 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L61

Added line #L61 was not covered by tests

if not results:
if self.check_history_distance or self.check_impossible_travel:
dynamic_results.append(self.passes_distance(request))

Check warning on line 64 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L63-L64

Added lines #L63 - L64 were not covered by tests

if not static_results and not dynamic_results:

Check warning on line 66 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L66

Added line #L66 was not covered by tests
return PolicyResult(True)

passing = any(r.passing for r in results)
messages = chain(*[r.messages for r in results])
passing = any(r.passing for r in static_results) and all(r.passing for r in dynamic_results)
messages = chain(

Check warning on line 70 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L69-L70

Added lines #L69 - L70 were not covered by tests
*[r.messages for r in static_results], *[r.messages for r in dynamic_results]
)

result = PolicyResult(passing, *messages)
result.source_results = results
result.source_results = list(chain(static_results, dynamic_results))

Check warning on line 75 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L75

Added line #L75 was not covered by tests

return result

Expand All @@ -73,7 +94,7 @@

def passes_country(self, request: PolicyRequest) -> PolicyResult:
# This is not a single get chain because `request.context` can contain `{ "geoip": None }`.
geoip_data = request.context.get("geoip")
geoip_data: GeoIPDict | None = request.context.get("geoip")

Check warning on line 97 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L97

Added line #L97 was not covered by tests
country = geoip_data.get("country") if geoip_data else None

if not country:
Expand All @@ -87,6 +108,42 @@

return PolicyResult(True)

def passes_distance(self, request: PolicyRequest) -> PolicyResult:
"""Check if current policy execution is out of distance range compared
to previous authentication requests"""
# Get previous login event and GeoIP data
previous_logins = Event.objects.filter(

Check warning on line 115 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L115

Added line #L115 was not covered by tests
action=EventAction.LOGIN, user__pk=request.user.pk, context__geo__isnull=False
).order_by("-created")[: self.history_login_count]
_now = now()
geoip_data: GeoIPDict | None = request.context.get("geoip")
if not geoip_data:
return PolicyResult(False)
for previous_login in previous_logins:
previous_login_geoip: GeoIPDict = previous_login.context.get("geo")

Check warning on line 123 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L118-L123

Added lines #L118 - L123 were not covered by tests

# Figure out distance
dist = distance.geodesic(

Check warning on line 126 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L126

Added line #L126 was not covered by tests
(previous_login_geoip["lat"], previous_login_geoip["long"]),
(geoip_data["lat"], geoip_data["long"]),
)
if self.check_history_distance and dist.km >= (

Check warning on line 130 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L130

Added line #L130 was not covered by tests
self.history_max_distance_km - self.distance_tolerance_km
):
return PolicyResult(

Check warning on line 133 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L133

Added line #L133 was not covered by tests
False, _("Distance from previous authentication is larger than threshold.")
)
# Check if distance between `previous_login` and now is more
# than max distance per hour times the amount of hours since the previous login
# (round down to the lowest closest time of hours)
# clamped to be at least 1 hour
rel_time_hours = max(int((_now - previous_login.created).total_seconds() / 86400), 1)
if self.check_impossible_travel and dist.km >= (

Check warning on line 141 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L140-L141

Added lines #L140 - L141 were not covered by tests
(MAX_DISTANCE_HOUR_KM * rel_time_hours) - self.distance_tolerance_km
):
return PolicyResult(False, _("Distance is further than possible."))
return PolicyResult(True)

Check warning on line 145 in authentik/policies/geoip/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/models.py#L144-L145

Added lines #L144 - L145 were not covered by tests

class Meta(Policy.PolicyMeta):
verbose_name = _("GeoIP Policy")
verbose_name_plural = _("GeoIP Policies")
75 changes: 72 additions & 3 deletions authentik/policies/geoip/tests.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""geoip policy tests"""

from django.test import TestCase
from guardian.shortcuts import get_anonymous_user

from authentik.core.tests.utils import create_test_user
from authentik.events.models import Event, EventAction
from authentik.events.utils import get_user

Check warning on line 7 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L5-L7

Added lines #L5 - L7 were not covered by tests
from authentik.policies.engine import PolicyRequest, PolicyResult
from authentik.policies.exceptions import PolicyException
from authentik.policies.geoip.exceptions import GeoIPNotFoundException
Expand All @@ -14,8 +16,8 @@

def setUp(self):
super().setUp()

self.request = PolicyRequest(get_anonymous_user())
self.user = create_test_user()
self.request = PolicyRequest(self.user)

Check warning on line 20 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L19-L20

Added lines #L19 - L20 were not covered by tests

self.context_disabled_geoip = {}
self.context_unknown_ip = {"asn": None, "geoip": None}
Expand Down Expand Up @@ -126,3 +128,70 @@
result: PolicyResult = policy.passes(self.request)

self.assertTrue(result.passing)

def test_history(self):

Check warning on line 132 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L132

Added line #L132 was not covered by tests
"""Test history checks"""
Event.objects.create(

Check warning on line 134 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L134

Added line #L134 was not covered by tests
action=EventAction.LOGIN,
user=get_user(self.user),
context={
# Random location in Canada
"geo": {"lat": 55.868351, "long": -104.441011},
},
)
# Random location in Poland
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}

Check warning on line 143 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L143

Added line #L143 was not covered by tests

policy = GeoIPPolicy.objects.create(check_history_distance=True)

Check warning on line 145 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L145

Added line #L145 was not covered by tests

result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)

Check warning on line 148 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L147-L148

Added lines #L147 - L148 were not covered by tests

def test_history_no_data(self):

Check warning on line 150 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L150

Added line #L150 was not covered by tests
"""Test history checks (with no geoip data in context)"""
Event.objects.create(

Check warning on line 152 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L152

Added line #L152 was not covered by tests
action=EventAction.LOGIN,
user=get_user(self.user),
context={
# Random location in Canada
"geo": {"lat": 55.868351, "long": -104.441011},
},
)

policy = GeoIPPolicy.objects.create(check_history_distance=True)

Check warning on line 161 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L161

Added line #L161 was not covered by tests

result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)

Check warning on line 164 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L163-L164

Added lines #L163 - L164 were not covered by tests

def test_history_impossible_travel(self):

Check warning on line 166 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L166

Added line #L166 was not covered by tests
"""Test history checks"""
Event.objects.create(

Check warning on line 168 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L168

Added line #L168 was not covered by tests
action=EventAction.LOGIN,
user=get_user(self.user),
context={
# Random location in Canada
"geo": {"lat": 55.868351, "long": -104.441011},
},
)
# Random location in Poland
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}

Check warning on line 177 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L177

Added line #L177 was not covered by tests

policy = GeoIPPolicy.objects.create(check_impossible_travel=True)

Check warning on line 179 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L179

Added line #L179 was not covered by tests

result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)

Check warning on line 182 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L181-L182

Added lines #L181 - L182 were not covered by tests

def test_history_no_geoip(self):

Check warning on line 184 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L184

Added line #L184 was not covered by tests
"""Test history checks (previous login with no geoip data)"""
Event.objects.create(

Check warning on line 186 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L186

Added line #L186 was not covered by tests
action=EventAction.LOGIN,
user=get_user(self.user),
context={},
)
# Random location in Poland
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}

Check warning on line 192 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L192

Added line #L192 was not covered by tests

policy = GeoIPPolicy.objects.create(check_history_distance=True)

Check warning on line 194 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L194

Added line #L194 was not covered by tests

result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)

Check warning on line 197 in authentik/policies/geoip/tests.py

View check run for this annotation

Codecov / codecov/patch

authentik/policies/geoip/tests.py#L196-L197

Added lines #L196 - L197 were not covered by tests
32 changes: 32 additions & 0 deletions blueprints/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5107,6 +5107,38 @@
},
"maxItems": 249,
"title": "Countries"
},
"check_history_distance": {
"type": "boolean",
"title": "Check history distance"
},
"history_max_distance_km": {
"type": "integer",
"minimum": 0,
"maximum": 9223372036854775807,
"title": "History max distance km"
},
"distance_tolerance_km": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"title": "Distance tolerance km"
},
"history_login_count": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"title": "History login count"
},
"check_impossible_travel": {
"type": "boolean",
"title": "Check impossible travel"
},
"impossible_tolerance_km": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"title": "Impossible tolerance km"
}
},
"required": []
Expand Down
36 changes: 35 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ duo-client = "*"
fido2 = "*"
flower = "*"
geoip2 = "*"
geopy = "*"
google-api-python-client = "*"
gunicorn = "*"
gssapi = "*"
Expand Down
Loading
Loading