Skip to content

Commit ab8f5a2

Browse files
authored
policies/geoip: distance + impossible travel (#12541)
* add history distance checks Signed-off-by: Jens Langhammer <[email protected]> * start impossible travel Signed-off-by: Jens Langhammer <[email protected]> * optimise Signed-off-by: Jens Langhammer <[email protected]> * ui start Signed-off-by: Jens Langhammer <[email protected]> * fix and add tests Signed-off-by: Jens Langhammer <[email protected]> * fix ui, fix missing api Signed-off-by: Jens Langhammer <[email protected]> * fix Signed-off-by: Jens Langhammer <[email protected]> --------- Signed-off-by: Jens Langhammer <[email protected]>
1 parent 67c22c1 commit ab8f5a2

File tree

9 files changed

+684
-18
lines changed

9 files changed

+684
-18
lines changed

authentik/policies/geoip/api.py

+6
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ class Meta:
4242
"asns",
4343
"countries",
4444
"countries_obj",
45+
"check_history_distance",
46+
"history_max_distance_km",
47+
"distance_tolerance_km",
48+
"history_login_count",
49+
"check_impossible_travel",
50+
"impossible_tolerance_km",
4551
]
4652

4753

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 5.0.10 on 2025-01-02 20:40
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("authentik_policies_geoip", "0001_initial"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="geoippolicy",
15+
name="check_history_distance",
16+
field=models.BooleanField(default=False),
17+
),
18+
migrations.AddField(
19+
model_name="geoippolicy",
20+
name="check_impossible_travel",
21+
field=models.BooleanField(default=False),
22+
),
23+
migrations.AddField(
24+
model_name="geoippolicy",
25+
name="distance_tolerance_km",
26+
field=models.PositiveIntegerField(default=50),
27+
),
28+
migrations.AddField(
29+
model_name="geoippolicy",
30+
name="history_login_count",
31+
field=models.PositiveIntegerField(default=5),
32+
),
33+
migrations.AddField(
34+
model_name="geoippolicy",
35+
name="history_max_distance_km",
36+
field=models.PositiveBigIntegerField(default=100),
37+
),
38+
migrations.AddField(
39+
model_name="geoippolicy",
40+
name="impossible_tolerance_km",
41+
field=models.PositiveIntegerField(default=100),
42+
),
43+
]

authentik/policies/geoip/models.py

+65-8
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@
44

55
from django.contrib.postgres.fields import ArrayField
66
from django.db import models
7+
from django.utils.timezone import now
78
from django.utils.translation import gettext as _
89
from django_countries.fields import CountryField
10+
from geopy import distance
911
from rest_framework.serializers import BaseSerializer
1012

13+
from authentik.events.context_processors.geoip import GeoIPDict
14+
from authentik.events.models import Event, EventAction
1115
from authentik.policies.exceptions import PolicyException
1216
from authentik.policies.geoip.exceptions import GeoIPNotFoundException
1317
from authentik.policies.models import Policy
1418
from authentik.policies.types import PolicyRequest, PolicyResult
1519

20+
MAX_DISTANCE_HOUR_KM = 1000
21+
1622

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

30+
distance_tolerance_km = models.PositiveIntegerField(default=50)
31+
32+
check_history_distance = models.BooleanField(default=False)
33+
history_max_distance_km = models.PositiveBigIntegerField(default=100)
34+
history_login_count = models.PositiveIntegerField(default=5)
35+
36+
check_impossible_travel = models.BooleanField(default=False)
37+
impossible_tolerance_km = models.PositiveIntegerField(default=100)
38+
2439
@property
2540
def serializer(self) -> type[BaseSerializer]:
2641
from authentik.policies.geoip.api import GeoIPPolicySerializer
@@ -37,21 +52,27 @@ def passes(self, request: PolicyRequest) -> PolicyResult:
3752
- the client IP is advertised by an autonomous system with ASN in the `asns`
3853
- the client IP is geolocated in a country of `countries`
3954
"""
40-
results: list[PolicyResult] = []
55+
static_results: list[PolicyResult] = []
56+
dynamic_results: list[PolicyResult] = []
4157

4258
if self.asns:
43-
results.append(self.passes_asn(request))
59+
static_results.append(self.passes_asn(request))
4460
if self.countries:
45-
results.append(self.passes_country(request))
61+
static_results.append(self.passes_country(request))
4662

47-
if not results:
63+
if self.check_history_distance or self.check_impossible_travel:
64+
dynamic_results.append(self.passes_distance(request))
65+
66+
if not static_results and not dynamic_results:
4867
return PolicyResult(True)
4968

50-
passing = any(r.passing for r in results)
51-
messages = chain(*[r.messages for r in results])
69+
passing = any(r.passing for r in static_results) and all(r.passing for r in dynamic_results)
70+
messages = chain(
71+
*[r.messages for r in static_results], *[r.messages for r in dynamic_results]
72+
)
5273

5374
result = PolicyResult(passing, *messages)
54-
result.source_results = results
75+
result.source_results = list(chain(static_results, dynamic_results))
5576

5677
return result
5778

@@ -73,7 +94,7 @@ def passes_asn(self, request: PolicyRequest) -> PolicyResult:
7394

7495
def passes_country(self, request: PolicyRequest) -> PolicyResult:
7596
# This is not a single get chain because `request.context` can contain `{ "geoip": None }`.
76-
geoip_data = request.context.get("geoip")
97+
geoip_data: GeoIPDict | None = request.context.get("geoip")
7798
country = geoip_data.get("country") if geoip_data else None
7899

79100
if not country:
@@ -87,6 +108,42 @@ def passes_country(self, request: PolicyRequest) -> PolicyResult:
87108

88109
return PolicyResult(True)
89110

111+
def passes_distance(self, request: PolicyRequest) -> PolicyResult:
112+
"""Check if current policy execution is out of distance range compared
113+
to previous authentication requests"""
114+
# Get previous login event and GeoIP data
115+
previous_logins = Event.objects.filter(
116+
action=EventAction.LOGIN, user__pk=request.user.pk, context__geo__isnull=False
117+
).order_by("-created")[: self.history_login_count]
118+
_now = now()
119+
geoip_data: GeoIPDict | None = request.context.get("geoip")
120+
if not geoip_data:
121+
return PolicyResult(False)
122+
for previous_login in previous_logins:
123+
previous_login_geoip: GeoIPDict = previous_login.context["geo"]
124+
125+
# Figure out distance
126+
dist = distance.geodesic(
127+
(previous_login_geoip["lat"], previous_login_geoip["long"]),
128+
(geoip_data["lat"], geoip_data["long"]),
129+
)
130+
if self.check_history_distance and dist.km >= (
131+
self.history_max_distance_km - self.distance_tolerance_km
132+
):
133+
return PolicyResult(
134+
False, _("Distance from previous authentication is larger than threshold.")
135+
)
136+
# Check if distance between `previous_login` and now is more
137+
# than max distance per hour times the amount of hours since the previous login
138+
# (round down to the lowest closest time of hours)
139+
# clamped to be at least 1 hour
140+
rel_time_hours = max(int((_now - previous_login.created).total_seconds() / 3600), 1)
141+
if self.check_impossible_travel and dist.km >= (
142+
(MAX_DISTANCE_HOUR_KM * rel_time_hours) - self.distance_tolerance_km
143+
):
144+
return PolicyResult(False, _("Distance is further than possible."))
145+
return PolicyResult(True)
146+
90147
class Meta(Policy.PolicyMeta):
91148
verbose_name = _("GeoIP Policy")
92149
verbose_name_plural = _("GeoIP Policies")

authentik/policies/geoip/tests.py

+72-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""geoip policy tests"""
22

33
from django.test import TestCase
4-
from guardian.shortcuts import get_anonymous_user
54

5+
from authentik.core.tests.utils import create_test_user
6+
from authentik.events.models import Event, EventAction
7+
from authentik.events.utils import get_user
68
from authentik.policies.engine import PolicyRequest, PolicyResult
79
from authentik.policies.exceptions import PolicyException
810
from authentik.policies.geoip.exceptions import GeoIPNotFoundException
@@ -14,8 +16,8 @@ class TestGeoIPPolicy(TestCase):
1416

1517
def setUp(self):
1618
super().setUp()
17-
18-
self.request = PolicyRequest(get_anonymous_user())
19+
self.user = create_test_user()
20+
self.request = PolicyRequest(self.user)
1921

2022
self.context_disabled_geoip = {}
2123
self.context_unknown_ip = {"asn": None, "geoip": None}
@@ -126,3 +128,70 @@ def test_policy_requires_only_one_match(self):
126128
result: PolicyResult = policy.passes(self.request)
127129

128130
self.assertTrue(result.passing)
131+
132+
def test_history(self):
133+
"""Test history checks"""
134+
Event.objects.create(
135+
action=EventAction.LOGIN,
136+
user=get_user(self.user),
137+
context={
138+
# Random location in Canada
139+
"geo": {"lat": 55.868351, "long": -104.441011},
140+
},
141+
)
142+
# Random location in Poland
143+
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}
144+
145+
policy = GeoIPPolicy.objects.create(check_history_distance=True)
146+
147+
result: PolicyResult = policy.passes(self.request)
148+
self.assertFalse(result.passing)
149+
150+
def test_history_no_data(self):
151+
"""Test history checks (with no geoip data in context)"""
152+
Event.objects.create(
153+
action=EventAction.LOGIN,
154+
user=get_user(self.user),
155+
context={
156+
# Random location in Canada
157+
"geo": {"lat": 55.868351, "long": -104.441011},
158+
},
159+
)
160+
161+
policy = GeoIPPolicy.objects.create(check_history_distance=True)
162+
163+
result: PolicyResult = policy.passes(self.request)
164+
self.assertFalse(result.passing)
165+
166+
def test_history_impossible_travel(self):
167+
"""Test history checks"""
168+
Event.objects.create(
169+
action=EventAction.LOGIN,
170+
user=get_user(self.user),
171+
context={
172+
# Random location in Canada
173+
"geo": {"lat": 55.868351, "long": -104.441011},
174+
},
175+
)
176+
# Random location in Poland
177+
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}
178+
179+
policy = GeoIPPolicy.objects.create(check_impossible_travel=True)
180+
181+
result: PolicyResult = policy.passes(self.request)
182+
self.assertFalse(result.passing)
183+
184+
def test_history_no_geoip(self):
185+
"""Test history checks (previous login with no geoip data)"""
186+
Event.objects.create(
187+
action=EventAction.LOGIN,
188+
user=get_user(self.user),
189+
context={},
190+
)
191+
# Random location in Poland
192+
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}
193+
194+
policy = GeoIPPolicy.objects.create(check_history_distance=True)
195+
196+
result: PolicyResult = policy.passes(self.request)
197+
self.assertFalse(result.passing)

blueprints/schema.json

+32
Original file line numberDiff line numberDiff line change
@@ -5232,6 +5232,38 @@
52325232
},
52335233
"maxItems": 249,
52345234
"title": "Countries"
5235+
},
5236+
"check_history_distance": {
5237+
"type": "boolean",
5238+
"title": "Check history distance"
5239+
},
5240+
"history_max_distance_km": {
5241+
"type": "integer",
5242+
"minimum": 0,
5243+
"maximum": 9223372036854775807,
5244+
"title": "History max distance km"
5245+
},
5246+
"distance_tolerance_km": {
5247+
"type": "integer",
5248+
"minimum": 0,
5249+
"maximum": 2147483647,
5250+
"title": "Distance tolerance km"
5251+
},
5252+
"history_login_count": {
5253+
"type": "integer",
5254+
"minimum": 0,
5255+
"maximum": 2147483647,
5256+
"title": "History login count"
5257+
},
5258+
"check_impossible_travel": {
5259+
"type": "boolean",
5260+
"title": "Check impossible travel"
5261+
},
5262+
"impossible_tolerance_km": {
5263+
"type": "integer",
5264+
"minimum": 0,
5265+
"maximum": 2147483647,
5266+
"title": "Impossible tolerance km"
52355267
}
52365268
},
52375269
"required": []

0 commit comments

Comments
 (0)