policies/geoip: distance + impossible travel (#12541)

* add history distance checks

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start impossible travel

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* optimise

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ui start

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix and add tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix ui, fix missing api

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2025-02-17 18:47:25 +01:00
committed by GitHub
parent 67c22c1313
commit ab8f5a2ac4
9 changed files with 684 additions and 18 deletions

View File

@ -42,6 +42,12 @@ class GeoIPPolicySerializer(CountryFieldMixin, PolicySerializer):
"asns",
"countries",
"countries_obj",
"check_history_distance",
"history_max_distance_km",
"distance_tolerance_km",
"history_login_count",
"check_impossible_travel",
"impossible_tolerance_km",
]

View File

@ -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),
),
]

View File

@ -4,15 +4,21 @@ from itertools import chain
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
@ -21,6 +27,15 @@ class GeoIPPolicy(Policy):
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
@ -37,21 +52,27 @@ class GeoIPPolicy(Policy):
- 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] = []
if self.asns:
results.append(self.passes_asn(request))
static_results.append(self.passes_asn(request))
if self.countries:
results.append(self.passes_country(request))
static_results.append(self.passes_country(request))
if not results:
if self.check_history_distance or self.check_impossible_travel:
dynamic_results.append(self.passes_distance(request))
if not static_results and not dynamic_results:
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(
*[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))
return result
@ -73,7 +94,7 @@ class GeoIPPolicy(Policy):
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")
country = geoip_data.get("country") if geoip_data else None
if not country:
@ -87,6 +108,42 @@ class GeoIPPolicy(Policy):
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(
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["geo"]
# Figure out distance
dist = distance.geodesic(
(previous_login_geoip["lat"], previous_login_geoip["long"]),
(geoip_data["lat"], geoip_data["long"]),
)
if self.check_history_distance and dist.km >= (
self.history_max_distance_km - self.distance_tolerance_km
):
return PolicyResult(
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() / 3600), 1)
if self.check_impossible_travel and dist.km >= (
(MAX_DISTANCE_HOUR_KM * rel_time_hours) - self.distance_tolerance_km
):
return PolicyResult(False, _("Distance is further than possible."))
return PolicyResult(True)
class Meta(Policy.PolicyMeta):
verbose_name = _("GeoIP Policy")
verbose_name_plural = _("GeoIP Policies")

View File

@ -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
from authentik.policies.engine import PolicyRequest, PolicyResult
from authentik.policies.exceptions import PolicyException
from authentik.policies.geoip.exceptions import GeoIPNotFoundException
@ -14,8 +16,8 @@ class TestGeoIPPolicy(TestCase):
def setUp(self):
super().setUp()
self.request = PolicyRequest(get_anonymous_user())
self.user = create_test_user()
self.request = PolicyRequest(self.user)
self.context_disabled_geoip = {}
self.context_unknown_ip = {"asn": None, "geoip": None}
@ -126,3 +128,70 @@ class TestGeoIPPolicy(TestCase):
result: PolicyResult = policy.passes(self.request)
self.assertTrue(result.passing)
def test_history(self):
"""Test history checks"""
Event.objects.create(
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}
policy = GeoIPPolicy.objects.create(check_history_distance=True)
result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)
def test_history_no_data(self):
"""Test history checks (with no geoip data in context)"""
Event.objects.create(
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)
result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)
def test_history_impossible_travel(self):
"""Test history checks"""
Event.objects.create(
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}
policy = GeoIPPolicy.objects.create(check_impossible_travel=True)
result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)
def test_history_no_geoip(self):
"""Test history checks (previous login with no geoip data)"""
Event.objects.create(
action=EventAction.LOGIN,
user=get_user(self.user),
context={},
)
# Random location in Poland
self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679}
policy = GeoIPPolicy.objects.create(check_history_distance=True)
result: PolicyResult = policy.passes(self.request)
self.assertFalse(result.passing)

View File

@ -5232,6 +5232,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": []

288
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -113,6 +113,7 @@ duo-client = "*"
fido2 = "*"
flower = "*"
geoip2 = "*"
geopy = "*"
google-api-python-client = "*"
gunicorn = "*"
gssapi = "*"

View File

@ -44006,6 +44006,27 @@ components:
items:
$ref: '#/components/schemas/DetailedCountryField'
readOnly: true
check_history_distance:
type: boolean
history_max_distance_km:
type: integer
maximum: 9223372036854775807
minimum: 0
format: int64
distance_tolerance_km:
type: integer
maximum: 2147483647
minimum: 0
history_login_count:
type: integer
maximum: 2147483647
minimum: 0
check_impossible_travel:
type: boolean
impossible_tolerance_km:
type: integer
maximum: 2147483647
minimum: 0
required:
- bound_to
- component
@ -44038,6 +44059,27 @@ components:
items:
$ref: '#/components/schemas/CountryCodeEnum'
maxItems: 249
check_history_distance:
type: boolean
history_max_distance_km:
type: integer
maximum: 9223372036854775807
minimum: 0
format: int64
distance_tolerance_km:
type: integer
maximum: 2147483647
minimum: 0
history_login_count:
type: integer
maximum: 2147483647
minimum: 0
check_impossible_travel:
type: boolean
impossible_tolerance_km:
type: integer
maximum: 2147483647
minimum: 0
required:
- countries
- name
@ -50557,6 +50599,27 @@ components:
items:
$ref: '#/components/schemas/CountryCodeEnum'
maxItems: 249
check_history_distance:
type: boolean
history_max_distance_km:
type: integer
maximum: 9223372036854775807
minimum: 0
format: int64
distance_tolerance_km:
type: integer
maximum: 2147483647
minimum: 0
history_login_count:
type: integer
maximum: 2147483647
minimum: 0
check_impossible_travel:
type: boolean
impossible_tolerance_km:
type: integer
maximum: 2147483647
minimum: 0
PatchedGoogleWorkspaceProviderMappingRequest:
type: object
description: GoogleWorkspaceProviderMapping Serializer

View File

@ -1,5 +1,6 @@
import { BasePolicyForm } from "@goauthentik/admin/policies/BasePolicyForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/ak-dual-select";
import { DataProvision, DualSelectPair } from "@goauthentik/elements/ak-dual-select/types";
import "@goauthentik/elements/forms/FormGroup";
@ -79,13 +80,125 @@ export class GeoIPPolicyForm extends BasePolicyForm<GeoIPPolicy> {
)}
</p>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Policy-specific settings")} </span>
<ak-form-group>
<span slot="header"> ${msg("Distance settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal name="checkHistoryDistance">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.instance?.checkHistoryDistance ?? false}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Check historical distance of logins")}</span
>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When this option enabled, the GeoIP data of the policy request is compared to the specified number of historical logins.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Distance tolerance")}
name="distanceToleranceKm"
>
<input
type="number"
min="1"
value="${first(this.instance?.distanceToleranceKm, 50)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg("Tolerance in checking for distances in kilometers.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Historical Login Count")}
name="historyLoginCount"
>
<input
type="number"
min="1"
value="${first(this.instance?.historyLoginCount, 5)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg("Amount of previous login events to check against.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Maximum distance")}
name="historyMaxDistanceKm"
>
<input
type="number"
min="1"
value="${first(this.instance?.historyMaxDistanceKm, 100)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg(
"Maximum distance a login attempt is allowed from in kilometers.",
)}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("Distance settings (Impossible travel)")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal name="checkImpossibleTravel">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.instance?.checkImpossibleTravel ?? true}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Check impossible travel")}</span
>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When this option enabled, the GeoIP data of the policy request is compared to the specified number of historical logins and if the travel would have been possible in the amount of time since the previous event.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Impossible travel tolerance")}
name="impossibleToleranceKm"
>
<input
type="number"
min="1"
value="${first(this.instance?.impossibleToleranceKm, 50)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg("Tolerance in checking for distances in kilometers.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header">${msg("Static rule settings")}</span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("ASNs")} name="asns">
<input
type="text"
value="${this.instance?.asns ?? ""}"
value="${this.instance?.asns?.join(",") ?? ""}"
class="pf-c-form-control pf-m-monospace"
autocomplete="off"
spellcheck="false"