From f7b16ed72361258bf2f50bfce8c7383ddbc0fc44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simonyi=20Gerg=C5=91?= <28359278+gergosimonyi@users.noreply.github.com> Date: Tue, 6 Aug 2024 12:37:29 +0200 Subject: [PATCH] policies: add GeoIP policy (#10454) * add GeoIP policy * handle empty lists of ASNs and countries * handle missing GeoIP database or missing IP from the database The exceptions raised here are `PolicyException`s to let admins bypass an execution failure. * fix translations whoops * remove `GeoIPPolicyMode` Use the policy binding's `negate` option instead * fix `DataProvision` typing `ak-dual-select-provider` can handle unpaginated data * use `django-countries` instead of a static list of countries for ISO-3166 * simplify `GeoIPPolicyForm` * pass `GeoIPPolicy` on empty policy * add backend tests to `GeoIPPolicy` * revise translations * move `iso-3166/` to `policies/geoip_iso3166/` * add client-side caching to ISO3166 API call * fix `GeoIPPolicy` creation The automatically generated APIs can't seem to handle `CountryField`, so I'll have to do this by hand too. * add docs for GeoIP Policy * docs: stylize add review suggestions from @tanberry * refactor `GeoIPPolicy` API It is now as declarative as I could make it. * clean up `api.py` and `views.py` --- authentik/policies/geoip/__init__.py | 0 authentik/policies/geoip/api.py | 55 ++ authentik/policies/geoip/apps.py | 11 + authentik/policies/geoip/exceptions.py | 5 + .../policies/geoip/migrations/0001_initial.py | 52 ++ .../policies/geoip/migrations/__init__.py | 0 authentik/policies/geoip/models.py | 92 +++ authentik/policies/geoip/serializer_fields.py | 21 + authentik/policies/geoip/tests.py | 128 ++++ authentik/policies/geoip/urls.py | 10 + authentik/root/settings.py | 3 + blueprints/schema.json | 362 +++++++++ poetry.lock | 23 +- pyproject.toml | 1 + schema.yml | 706 ++++++++++++++++++ web/src/admin/policies/PolicyWizard.ts | 1 + web/src/admin/policies/geoip/CountryCache.ts | 35 + .../admin/policies/geoip/GeoIPPolicyForm.ts | 125 ++++ web/src/elements/ak-dual-select/types.ts | 2 +- website/docs/core/geoip.mdx | 2 +- website/docs/policies/expression.mdx | 10 +- website/docs/policies/index.md | 16 +- 22 files changed, 1650 insertions(+), 10 deletions(-) create mode 100644 authentik/policies/geoip/__init__.py create mode 100644 authentik/policies/geoip/api.py create mode 100644 authentik/policies/geoip/apps.py create mode 100644 authentik/policies/geoip/exceptions.py create mode 100644 authentik/policies/geoip/migrations/0001_initial.py create mode 100644 authentik/policies/geoip/migrations/__init__.py create mode 100644 authentik/policies/geoip/models.py create mode 100644 authentik/policies/geoip/serializer_fields.py create mode 100644 authentik/policies/geoip/tests.py create mode 100644 authentik/policies/geoip/urls.py create mode 100644 web/src/admin/policies/geoip/CountryCache.ts create mode 100644 web/src/admin/policies/geoip/GeoIPPolicyForm.ts diff --git a/authentik/policies/geoip/__init__.py b/authentik/policies/geoip/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/policies/geoip/api.py b/authentik/policies/geoip/api.py new file mode 100644 index 0000000000..5ea0b1e4aa --- /dev/null +++ b/authentik/policies/geoip/api.py @@ -0,0 +1,55 @@ +"""GeoIP Policy API Views""" + +from django_countries import countries +from django_countries.serializer_fields import CountryField +from django_countries.serializers import CountryFieldMixin +from rest_framework import serializers +from rest_framework.generics import ListAPIView +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.used_by import UsedByMixin +from authentik.policies.api.policies import PolicySerializer +from authentik.policies.geoip.models import GeoIPPolicy +from authentik.policies.geoip.serializer_fields import DetailedCountryField + + +class DetailedCountrySerializer(serializers.Serializer): + code = CountryField() + name = serializers.CharField() + + +class ISO3166View(ListAPIView): + """Get all countries in ISO-3166-1""" + + permission_classes = [AllowAny] + queryset = [{"code": code, "name": name} for (code, name) in countries] + serializer_class = DetailedCountrySerializer + filter_backends = [] + pagination_class = None + + +class GeoIPPolicySerializer(CountryFieldMixin, PolicySerializer): + """GeoIP Policy Serializer""" + + countries_obj = serializers.ListField( + child=DetailedCountryField(), source="countries", read_only=True + ) + + class Meta: + model = GeoIPPolicy + fields = PolicySerializer.Meta.fields + [ + "asns", + "countries", + "countries_obj", + ] + + +class GeoIPPolicyViewSet(UsedByMixin, ModelViewSet): + """GeoIP Viewset""" + + queryset = GeoIPPolicy.objects.all() + serializer_class = GeoIPPolicySerializer + filterset_fields = ["name"] + ordering = ["name"] + search_fields = ["name"] diff --git a/authentik/policies/geoip/apps.py b/authentik/policies/geoip/apps.py new file mode 100644 index 0000000000..ae64d29d4e --- /dev/null +++ b/authentik/policies/geoip/apps.py @@ -0,0 +1,11 @@ +"""Authentik policy geoip app config""" + +from django.apps import AppConfig + + +class AuthentikPolicyGeoIPConfig(AppConfig): + """Authentik policy_geoip app config""" + + name = "authentik.policies.geoip" + label = "authentik_policies_geoip" + verbose_name = "authentik Policies.GeoIP" diff --git a/authentik/policies/geoip/exceptions.py b/authentik/policies/geoip/exceptions.py new file mode 100644 index 0000000000..a735902632 --- /dev/null +++ b/authentik/policies/geoip/exceptions.py @@ -0,0 +1,5 @@ +from authentik.lib.sentry import SentryIgnoredException + + +class GeoIPNotFoundException(SentryIgnoredException): + """Exception raised when an IP is not found in a GeoIP database""" diff --git a/authentik/policies/geoip/migrations/0001_initial.py b/authentik/policies/geoip/migrations/0001_initial.py new file mode 100644 index 0000000000..a1556080a1 --- /dev/null +++ b/authentik/policies/geoip/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 5.0.7 on 2024-07-16 11:23 + +import django.contrib.postgres.fields +import django.db.models.deletion +import django_countries.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_policies", "0011_policybinding_failure_result_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="GeoIPPolicy", + fields=[ + ( + "policy_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_policies.policy", + ), + ), + ( + "asns", + django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField(), blank=True, default=list, size=None + ), + ), + ( + "countries", + django_countries.fields.CountryField(blank=True, max_length=746, multiple=True), + ), + ], + options={ + "verbose_name": "GeoIP Policy", + "verbose_name_plural": "GeoIP Policies", + "indexes": [ + models.Index(fields=["policy_ptr_id"], name="authentik_p_policy__5cc4a9_idx") + ], + }, + bases=("authentik_policies.policy",), + ), + ] diff --git a/authentik/policies/geoip/migrations/__init__.py b/authentik/policies/geoip/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/policies/geoip/models.py b/authentik/policies/geoip/models.py new file mode 100644 index 0000000000..bd83ad12f6 --- /dev/null +++ b/authentik/policies/geoip/models.py @@ -0,0 +1,92 @@ +"""GeoIP policy""" + +from itertools import chain + +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.utils.translation import gettext as _ +from django_countries.fields import CountryField +from rest_framework.serializers import BaseSerializer + +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 + + +class GeoIPPolicy(Policy): + """Ensure the user satisfies requirements of geography or network topology, based on IP + address.""" + + asns = ArrayField(models.IntegerField(), blank=True, default=list) + countries = CountryField(multiple=True, blank=True) + + @property + def serializer(self) -> type[BaseSerializer]: + from authentik.policies.geoip.api import GeoIPPolicySerializer + + return GeoIPPolicySerializer + + @property + def component(self) -> str: # pragma: no cover + return "ak-policy-geoip-form" + + def passes(self, request: PolicyRequest) -> PolicyResult: + """ + Passes if any of the following is true: + - 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] = [] + + if self.asns: + results.append(self.passes_asn(request)) + if self.countries: + results.append(self.passes_country(request)) + + if not results: + return PolicyResult(True) + + passing = any(r.passing for r in results) + messages = chain(*[r.messages for r in results]) + + result = PolicyResult(passing, *messages) + result.source_results = results + + return result + + def passes_asn(self, request: PolicyRequest) -> PolicyResult: + # This is not a single get chain because `request.context` can contain `{ "asn": None }`. + asn_data = request.context.get("asn") + asn = asn_data.get("asn") if asn_data else None + + if not asn: + raise PolicyException( + GeoIPNotFoundException(_("GeoIP: client IP not found in ASN database.")) + ) + + if asn not in self.asns: + message = _("Client IP is not part of an allowed autonomous system.") + return PolicyResult(False, message) + + return PolicyResult(True) + + 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") + country = geoip_data.get("country") if geoip_data else None + + if not country: + raise PolicyException( + GeoIPNotFoundException(_("GeoIP: client IP address not found in City database.")) + ) + + if country not in self.countries: + message = _("Client IP is not in an allowed country.") + return PolicyResult(False, message) + + return PolicyResult(True) + + class Meta(Policy.PolicyMeta): + verbose_name = _("GeoIP Policy") + verbose_name_plural = _("GeoIP Policies") diff --git a/authentik/policies/geoip/serializer_fields.py b/authentik/policies/geoip/serializer_fields.py new file mode 100644 index 0000000000..35a9819ecf --- /dev/null +++ b/authentik/policies/geoip/serializer_fields.py @@ -0,0 +1,21 @@ +"""Workaround for https://github.com/SmileyChris/django-countries/issues/441""" + +from django_countries.serializer_fields import CountryField +from drf_spectacular.utils import extend_schema_field, inline_serializer +from rest_framework import serializers + +DETAILED_COUNTRY_SCHEMA = { + "code": CountryField(), + "name": serializers.CharField(), +} + + +@extend_schema_field( + inline_serializer( + "DetailedCountryField", + DETAILED_COUNTRY_SCHEMA, + ) +) +class DetailedCountryField(CountryField): + def __init__(self): + super().__init__(country_dict=True) diff --git a/authentik/policies/geoip/tests.py b/authentik/policies/geoip/tests.py new file mode 100644 index 0000000000..f84727d1df --- /dev/null +++ b/authentik/policies/geoip/tests.py @@ -0,0 +1,128 @@ +"""geoip policy tests""" + +from django.test import TestCase +from guardian.shortcuts import get_anonymous_user + +from authentik.policies.engine import PolicyRequest, PolicyResult +from authentik.policies.exceptions import PolicyException +from authentik.policies.geoip.exceptions import GeoIPNotFoundException +from authentik.policies.geoip.models import GeoIPPolicy + + +class TestGeoIPPolicy(TestCase): + """Test GeoIP Policy""" + + def setUp(self): + super().setUp() + + self.request = PolicyRequest(get_anonymous_user()) + + self.context_disabled_geoip = {} + self.context_unknown_ip = {"asn": None, "geoip": None} + # 8.8.8.8 + self.context = { + "asn": {"asn": 15169, "as_org": "GOOGLE", "network": "8.8.8.0/24"}, + "geoip": { + "continent": "NA", + "country": "US", + "lat": 37.751, + "long": -97.822, + "city": "", + }, + } + + self.matching_asns = [13335, 15169] + self.matching_countries = ["US", "CA"] + self.mismatching_asns = [1, 2] + self.mismatching_countries = ["MX", "UA"] + + def enrich_context_disabled_geoip(self): + pass + + def enrich_context_unknown_ip(self): + self.request.context["asn"] = self.context_unknown_ip["asn"] + self.request.context["geoip"] = self.context_unknown_ip["geoip"] + + def enrich_context(self): + self.request.context["asn"] = self.context["asn"] + self.request.context["geoip"] = self.context["geoip"] + + def test_disabled_geoip(self): + """Test that disabled GeoIP raises PolicyException with GeoIPNotFoundException""" + self.enrich_context_disabled_geoip() + policy = GeoIPPolicy.objects.create( + asns=self.matching_asns, countries=self.matching_countries + ) + + with self.assertRaises(PolicyException) as cm: + policy.passes(self.request) + + self.assertIsInstance(cm.exception.src_exc, GeoIPNotFoundException) + + def test_unknown_ip(self): + """Test that unknown IP raises PolicyException with GeoIPNotFoundException""" + self.enrich_context_unknown_ip() + policy = GeoIPPolicy.objects.create( + asns=self.matching_asns, countries=self.matching_countries + ) + + with self.assertRaises(PolicyException) as cm: + policy.passes(self.request) + + self.assertIsInstance(cm.exception.src_exc, GeoIPNotFoundException) + + def test_empty_policy(self): + """Test that empty policy passes""" + self.enrich_context() + policy = GeoIPPolicy.objects.create() + + result: PolicyResult = policy.passes(self.request) + + self.assertTrue(result.passing) + + def test_policy_with_matching_asns(self): + """Test that a policy with matching ASNs passes""" + self.enrich_context() + policy = GeoIPPolicy.objects.create(asns=self.matching_asns) + + result: PolicyResult = policy.passes(self.request) + + self.assertTrue(result.passing) + + def test_policy_with_mismatching_asns(self): + """Test that a policy with mismatching ASNs fails""" + self.enrich_context() + policy = GeoIPPolicy.objects.create(asns=self.mismatching_asns) + + result: PolicyResult = policy.passes(self.request) + + self.assertFalse(result.passing) + + def test_policy_with_matching_countries(self): + """Test that a policy with matching countries passes""" + self.enrich_context() + policy = GeoIPPolicy.objects.create(countries=self.matching_countries) + + result: PolicyResult = policy.passes(self.request) + + self.assertTrue(result.passing) + + def test_policy_with_mismatching_countries(self): + """Test that a policy with mismatching countries fails""" + self.enrich_context() + policy = GeoIPPolicy.objects.create(countries=self.mismatching_countries) + + result: PolicyResult = policy.passes(self.request) + + self.assertFalse(result.passing) + + def test_policy_requires_only_one_match(self): + """Test that a policy with one matching value passes""" + self.enrich_context() + policy = GeoIPPolicy.objects.create( + asns=self.mismatching_asns, countries=self.matching_countries + ) + + result: PolicyResult = policy.passes(self.request) + + self.assertTrue(result.passing) diff --git a/authentik/policies/geoip/urls.py b/authentik/policies/geoip/urls.py new file mode 100644 index 0000000000..7f7c09f384 --- /dev/null +++ b/authentik/policies/geoip/urls.py @@ -0,0 +1,10 @@ +"""API URLs""" + +from django.urls import path + +from authentik.policies.geoip.api import GeoIPPolicyViewSet, ISO3166View + +api_urlpatterns = [ + ("policies/geoip", GeoIPPolicyViewSet), + path("policies/geoip_iso3166/", ISO3166View.as_view(), name="iso-3166-view"), +] diff --git a/authentik/root/settings.py b/authentik/root/settings.py index a824ded93f..0639998098 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -59,6 +59,7 @@ SHARED_APPS = [ "django_filters", "drf_spectacular", "django_prometheus", + "django_countries", "pgactivity", "pglock", "channels", @@ -76,6 +77,7 @@ TENANT_APPS = [ "authentik.policies.event_matcher", "authentik.policies.expiry", "authentik.policies.expression", + "authentik.policies.geoip", "authentik.policies.password", "authentik.policies.reputation", "authentik.policies", @@ -146,6 +148,7 @@ SPECTACULAR_SETTINGS = { "url": "https://github.com/goauthentik/authentik/blob/main/LICENSE", }, "ENUM_NAME_OVERRIDES": { + "CountryCodeEnum": "django_countries.countries", "EventActions": "authentik.events.models.EventAction", "FlowDesignationEnum": "authentik.flows.models.FlowDesignation", "FlowLayoutEnum": "authentik.flows.models.FlowLayout", diff --git a/blueprints/schema.json b/blueprints/schema.json index eeb2d772dd..cf2d42abc4 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -481,6 +481,46 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_policies_geoip.geoippolicy" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_policies_geoip.geoippolicy_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_policies_geoip.geoippolicy" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_policies_geoip.geoippolicy" + } + } + }, { "type": "object", "required": [ @@ -3979,6 +4019,7 @@ "authentik.policies.event_matcher", "authentik.policies.expiry", "authentik.policies.expression", + "authentik.policies.geoip", "authentik.policies.password", "authentik.policies.reputation", "authentik.policies", @@ -4047,6 +4088,7 @@ "authentik_policies_event_matcher.eventmatcherpolicy", "authentik_policies_expiry.passwordexpirypolicy", "authentik_policies_expression.expressionpolicy", + "authentik_policies_geoip.geoippolicy", "authentik_policies_password.passwordpolicy", "authentik_policies_reputation.reputationpolicy", "authentik_policies.policybinding", @@ -4250,6 +4292,318 @@ } } }, + "model_authentik_policies_geoip.geoippolicy": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "execution_logging": { + "type": "boolean", + "title": "Execution logging", + "description": "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged." + }, + "asns": { + "type": "array", + "items": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647, + "title": "Asns" + }, + "title": "Asns" + }, + "countries": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "", + "AF", + "AX", + "AL", + "DZ", + "AS", + "AD", + "AO", + "AI", + "AQ", + "AG", + "AR", + "AM", + "AW", + "AU", + "AT", + "AZ", + "BS", + "BH", + "BD", + "BB", + "BY", + "BE", + "BZ", + "BJ", + "BM", + "BT", + "BO", + "BQ", + "BA", + "BW", + "BV", + "BR", + "IO", + "BN", + "BG", + "BF", + "BI", + "CV", + "KH", + "CM", + "CA", + "KY", + "CF", + "TD", + "CL", + "CN", + "CX", + "CC", + "CO", + "KM", + "CG", + "CD", + "CK", + "CR", + "CI", + "HR", + "CU", + "CW", + "CY", + "CZ", + "DK", + "DJ", + "DM", + "DO", + "EC", + "EG", + "SV", + "GQ", + "ER", + "EE", + "SZ", + "ET", + "FK", + "FO", + "FJ", + "FI", + "FR", + "GF", + "PF", + "TF", + "GA", + "GM", + "GE", + "DE", + "GH", + "GI", + "GR", + "GL", + "GD", + "GP", + "GU", + "GT", + "GG", + "GN", + "GW", + "GY", + "HT", + "HM", + "VA", + "HN", + "HK", + "HU", + "IS", + "IN", + "ID", + "IR", + "IQ", + "IE", + "IM", + "IL", + "IT", + "JM", + "JP", + "JE", + "JO", + "KZ", + "KE", + "KI", + "KW", + "KG", + "LA", + "LV", + "LB", + "LS", + "LR", + "LY", + "LI", + "LT", + "LU", + "MO", + "MG", + "MW", + "MY", + "MV", + "ML", + "MT", + "MH", + "MQ", + "MR", + "MU", + "YT", + "MX", + "FM", + "MD", + "MC", + "MN", + "ME", + "MS", + "MA", + "MZ", + "MM", + "NA", + "NR", + "NP", + "NL", + "NC", + "NZ", + "NI", + "NE", + "NG", + "NU", + "NF", + "KP", + "MK", + "MP", + "NO", + "OM", + "PK", + "PW", + "PS", + "PA", + "PG", + "PY", + "PE", + "PH", + "PN", + "PL", + "PT", + "PR", + "QA", + "RE", + "RO", + "RU", + "RW", + "BL", + "SH", + "KN", + "LC", + "MF", + "PM", + "VC", + "WS", + "SM", + "ST", + "SA", + "SN", + "RS", + "SC", + "SL", + "SG", + "SX", + "SK", + "SI", + "SB", + "SO", + "ZA", + "GS", + "KR", + "SS", + "ES", + "LK", + "SD", + "SR", + "SJ", + "SE", + "CH", + "SY", + "TW", + "TJ", + "TZ", + "TH", + "TL", + "TG", + "TK", + "TO", + "TT", + "TN", + "TR", + "TM", + "TC", + "TV", + "UG", + "UA", + "AE", + "GB", + "UM", + "US", + "UY", + "UZ", + "VU", + "VE", + "VN", + "VG", + "VI", + "WF", + "EH", + "YE", + "ZM", + "ZW" + ] + }, + "maxItems": 249, + "title": "Countries" + } + }, + "required": [] + }, + "model_authentik_policies_geoip.geoippolicy_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_geoippolicy", + "change_geoippolicy", + "delete_geoippolicy", + "view_geoippolicy" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, "model_authentik_policies_password.passwordpolicy": { "type": "object", "properties": { @@ -5579,6 +5933,10 @@ "authentik_policies_expression.change_expressionpolicy", "authentik_policies_expression.delete_expressionpolicy", "authentik_policies_expression.view_expressionpolicy", + "authentik_policies_geoip.add_geoippolicy", + "authentik_policies_geoip.change_geoippolicy", + "authentik_policies_geoip.delete_geoippolicy", + "authentik_policies_geoip.view_geoippolicy", "authentik_policies_password.add_passwordpolicy", "authentik_policies_password.change_passwordpolicy", "authentik_policies_password.delete_passwordpolicy", @@ -10903,6 +11261,10 @@ "authentik_policies_expression.change_expressionpolicy", "authentik_policies_expression.delete_expressionpolicy", "authentik_policies_expression.view_expressionpolicy", + "authentik_policies_geoip.add_geoippolicy", + "authentik_policies_geoip.change_geoippolicy", + "authentik_policies_geoip.delete_geoippolicy", + "authentik_policies_geoip.view_geoippolicy", "authentik_policies_password.add_passwordpolicy", "authentik_policies_password.change_passwordpolicy", "authentik_policies_password.delete_passwordpolicy", diff --git a/poetry.lock b/poetry.lock index eefd6bd359..2dcf543c25 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1210,6 +1210,27 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django-countries" +version = "7.6.1" +description = "Provides a country field for Django models." +optional = false +python-versions = "*" +files = [ + {file = "django-countries-7.6.1.tar.gz", hash = "sha256:c772d4e3e54afcc5f97a018544e96f246c6d9f1db51898ab0c15cd57e19437cf"}, + {file = "django_countries-7.6.1-py3-none-any.whl", hash = "sha256:1ed20842fe0f6194f91faca21076649513846a8787c9eb5aeec3cbe1656b8acc"}, +] + +[package.dependencies] +asgiref = "*" +typing-extensions = "*" + +[package.extras] +dev = ["black", "django", "djangorestframework", "graphene-django", "pytest", "pytest-django", "tox (==4.*)"] +maintainer = ["django", "zest.releaser[recommended]"] +pyuca = ["pyuca"] +test = ["djangorestframework", "graphene-django", "pytest", "pytest-cov", "pytest-django"] + [[package]] name = "django-cte" version = "1.3.3" @@ -5463,4 +5484,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.12" -content-hash = "1147e0dceb83f7c487e6b4d96270bad644e6618585e00fa2a1fc89cbf2efe300" +content-hash = "ef49ce543812a47597b9108ca277cd4b6563fe00d0739e763b6e1e1151c95eba" diff --git a/pyproject.toml b/pyproject.toml index 45fae0506b..c7b1764c65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ dacite = "*" deepmerge = "*" defusedxml = "*" django = "*" +django-countries = "*" django-cte = "*" django-filter = "*" django-guardian = "*" diff --git a/schema.yml b/schema.yml index e8839957af..0ef79ec321 100644 --- a/schema.yml +++ b/schema.yml @@ -12075,6 +12075,305 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /policies/geoip/: + get: + operationId: policies_geoip_list + description: GeoIP Viewset + parameters: + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - policies + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedGeoIPPolicyList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: policies_geoip_create + description: GeoIP Viewset + tags: + - policies + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GeoIPPolicyRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/GeoIPPolicy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /policies/geoip/{policy_uuid}/: + get: + operationId: policies_geoip_retrieve + description: GeoIP Viewset + parameters: + - in: path + name: policy_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this GeoIP Policy. + required: true + tags: + - policies + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GeoIPPolicy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: policies_geoip_update + description: GeoIP Viewset + parameters: + - in: path + name: policy_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this GeoIP Policy. + required: true + tags: + - policies + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GeoIPPolicyRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GeoIPPolicy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: policies_geoip_partial_update + description: GeoIP Viewset + parameters: + - in: path + name: policy_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this GeoIP Policy. + required: true + tags: + - policies + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedGeoIPPolicyRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/GeoIPPolicy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: policies_geoip_destroy + description: GeoIP Viewset + parameters: + - in: path + name: policy_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this GeoIP Policy. + required: true + tags: + - policies + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /policies/geoip/{policy_uuid}/used_by/: + get: + operationId: policies_geoip_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: policy_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this GeoIP Policy. + required: true + tags: + - policies + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /policies/geoip_iso3166/: + get: + operationId: policies_geoip_iso3166_list + description: Get all countries in ISO-3166-1 + tags: + - policies + security: + - authentik: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DetailedCountry' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /policies/password/: get: operationId: policies_password_list @@ -21287,6 +21586,7 @@ paths: - authentik_policies_event_matcher.eventmatcherpolicy - authentik_policies_expiry.passwordexpirypolicy - authentik_policies_expression.expressionpolicy + - authentik_policies_geoip.geoippolicy - authentik_policies_password.passwordpolicy - authentik_policies_reputation.reputationpolicy - authentik_providers_google_workspace.googleworkspaceprovider @@ -21514,6 +21814,7 @@ paths: - authentik_policies_event_matcher.eventmatcherpolicy - authentik_policies_expiry.passwordexpirypolicy - authentik_policies_expression.expressionpolicy + - authentik_policies_geoip.geoippolicy - authentik_policies_password.passwordpolicy - authentik_policies_reputation.reputationpolicy - authentik_providers_google_workspace.googleworkspaceprovider @@ -33705,6 +34006,7 @@ components: - authentik.policies.event_matcher - authentik.policies.expiry - authentik.policies.expression + - authentik.policies.geoip - authentik.policies.password - authentik.policies.reputation - authentik.policies @@ -35742,6 +36044,258 @@ components: required: - x_cord - y_cord + CountryCodeEnum: + enum: + - AF + - AX + - AL + - DZ + - AS + - AD + - AO + - AI + - AQ + - AG + - AR + - AM + - AW + - AU + - AT + - AZ + - BS + - BH + - BD + - BB + - BY + - BE + - BZ + - BJ + - BM + - BT + - BO + - BQ + - BA + - BW + - BV + - BR + - IO + - BN + - BG + - BF + - BI + - CV + - KH + - CM + - CA + - KY + - CF + - TD + - CL + - CN + - CX + - CC + - CO + - KM + - CG + - CD + - CK + - CR + - CI + - HR + - CU + - CW + - CY + - CZ + - DK + - DJ + - DM + - DO + - EC + - EG + - SV + - GQ + - ER + - EE + - SZ + - ET + - FK + - FO + - FJ + - FI + - FR + - GF + - PF + - TF + - GA + - GM + - GE + - DE + - GH + - GI + - GR + - GL + - GD + - GP + - GU + - GT + - GG + - GN + - GW + - GY + - HT + - HM + - VA + - HN + - HK + - HU + - IS + - IN + - ID + - IR + - IQ + - IE + - IM + - IL + - IT + - JM + - JP + - JE + - JO + - KZ + - KE + - KI + - KW + - KG + - LA + - LV + - LB + - LS + - LR + - LY + - LI + - LT + - LU + - MO + - MG + - MW + - MY + - MV + - ML + - MT + - MH + - MQ + - MR + - MU + - YT + - MX + - FM + - MD + - MC + - MN + - ME + - MS + - MA + - MZ + - MM + - NA + - NR + - NP + - NL + - NC + - NZ + - NI + - NE + - NG + - NU + - NF + - KP + - MK + - MP + - 'NO' + - OM + - PK + - PW + - PS + - PA + - PG + - PY + - PE + - PH + - PN + - PL + - PT + - PR + - QA + - RE + - RO + - RU + - RW + - BL + - SH + - KN + - LC + - MF + - PM + - VC + - WS + - SM + - ST + - SA + - SN + - RS + - SC + - SL + - SG + - SX + - SK + - SI + - SB + - SO + - ZA + - GS + - KR + - SS + - ES + - LK + - SD + - SR + - SJ + - SE + - CH + - SY + - TW + - TJ + - TZ + - TH + - TL + - TG + - TK + - TO + - TT + - TN + - TR + - TM + - TC + - TV + - UG + - UA + - AE + - GB + - UM + - US + - UY + - UZ + - VU + - VE + - VN + - VG + - VI + - WF + - EH + - YE + - ZM + - ZW + type: string CurrentBrand: type: object description: Partial brand information for styling @@ -35848,6 +36402,37 @@ components: type: string required: - name + DetailedCountry: + type: object + properties: + code: + $ref: '#/components/schemas/CountryCodeEnum' + name: + type: string + required: + - code + - name + DetailedCountryField: + type: object + properties: + code: + $ref: '#/components/schemas/CountryCodeEnum' + name: + type: string + required: + - code + - name + DetailedCountryFieldRequest: + type: object + properties: + code: + $ref: '#/components/schemas/CountryCodeEnum' + name: + type: string + minLength: 1 + required: + - code + - name Device: type: object description: Serializer for Duo authenticator devices @@ -37441,6 +38026,92 @@ components: type: string required: - detail + GeoIPPolicy: + type: object + description: GeoIP Policy Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Policy uuid + name: + type: string + execution_logging: + type: boolean + description: When this option is enabled, all executions of this policy + will be logged. By default, only execution errors are logged. + component: + type: string + description: Get object component so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + bound_to: + type: integer + description: Return objects policy is bound to + readOnly: true + asns: + type: array + items: + type: integer + maximum: 2147483647 + minimum: -2147483648 + countries: + type: array + items: + $ref: '#/components/schemas/CountryCodeEnum' + maxItems: 249 + countries_obj: + type: array + items: + $ref: '#/components/schemas/DetailedCountryField' + readOnly: true + required: + - bound_to + - component + - countries + - countries_obj + - meta_model_name + - name + - pk + - verbose_name + - verbose_name_plural + GeoIPPolicyRequest: + type: object + description: GeoIP Policy Serializer + properties: + name: + type: string + minLength: 1 + execution_logging: + type: boolean + description: When this option is enabled, all executions of this policy + will be logged. By default, only execution errors are logged. + asns: + type: array + items: + type: integer + maximum: 2147483647 + minimum: -2147483648 + countries: + type: array + items: + $ref: '#/components/schemas/CountryCodeEnum' + maxItems: 249 + required: + - countries + - name GeoipBindingEnum: enum: - no_binding @@ -39405,6 +40076,7 @@ components: - authentik_policies_event_matcher.eventmatcherpolicy - authentik_policies_expiry.passwordexpirypolicy - authentik_policies_expression.expressionpolicy + - authentik_policies_geoip.geoippolicy - authentik_policies_password.passwordpolicy - authentik_policies_reputation.reputationpolicy - authentik_policies.policybinding @@ -40790,6 +41462,18 @@ components: required: - pagination - results + PaginatedGeoIPPolicyList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/GeoIPPolicy' + required: + - pagination + - results PaginatedGoogleWorkspaceProviderGroupList: type: object properties: @@ -42894,6 +43578,28 @@ components: to a challenge. RETRY returns the error message and a similar challenge to the executor. RESTART restarts the flow from the beginning, and RESTART_WITH_CONTEXT restarts the flow while keeping the current context. + PatchedGeoIPPolicyRequest: + type: object + description: GeoIP Policy Serializer + properties: + name: + type: string + minLength: 1 + execution_logging: + type: boolean + description: When this option is enabled, all executions of this policy + will be logged. By default, only execution errors are logged. + asns: + type: array + items: + type: integer + maximum: 2147483647 + minimum: -2147483648 + countries: + type: array + items: + $ref: '#/components/schemas/CountryCodeEnum' + maxItems: 249 PatchedGoogleWorkspaceProviderMappingRequest: type: object description: GoogleWorkspaceProviderMapping Serializer diff --git a/web/src/admin/policies/PolicyWizard.ts b/web/src/admin/policies/PolicyWizard.ts index f6a9b48114..94817c71ee 100644 --- a/web/src/admin/policies/PolicyWizard.ts +++ b/web/src/admin/policies/PolicyWizard.ts @@ -3,6 +3,7 @@ import "@goauthentik/admin/policies/dummy/DummyPolicyForm"; import "@goauthentik/admin/policies/event_matcher/EventMatcherPolicyForm"; import "@goauthentik/admin/policies/expiry/ExpiryPolicyForm"; import "@goauthentik/admin/policies/expression/ExpressionPolicyForm"; +import "@goauthentik/admin/policies/geoip/GeoIPPolicyForm"; import "@goauthentik/admin/policies/password/PasswordPolicyForm"; import "@goauthentik/admin/policies/reputation/ReputationPolicyForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; diff --git a/web/src/admin/policies/geoip/CountryCache.ts b/web/src/admin/policies/geoip/CountryCache.ts new file mode 100644 index 0000000000..076d314d86 --- /dev/null +++ b/web/src/admin/policies/geoip/CountryCache.ts @@ -0,0 +1,35 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; + +import { DetailedCountry, PoliciesApi } from "@goauthentik/api"; + +class CountryCache { + countries: DetailedCountry[]; + lastReceivedAt?: number; + TTL: number; + + constructor() { + this.countries = []; + this.lastReceivedAt = undefined; + // 1 minute + this.TTL = 60 * 1000; + } + + async getCountries() { + const shouldInvalidate = + this.lastReceivedAt === undefined || + new Date().getTime() - this.lastReceivedAt > this.TTL; + + if (!shouldInvalidate) { + return this.countries; + } + + await new PoliciesApi(DEFAULT_CONFIG).policiesGeoipIso3166List().then((response) => { + this.countries = response; + this.lastReceivedAt = new Date().getTime(); + }); + + return this.countries; + } +} + +export const countryCache = new CountryCache(); diff --git a/web/src/admin/policies/geoip/GeoIPPolicyForm.ts b/web/src/admin/policies/geoip/GeoIPPolicyForm.ts new file mode 100644 index 0000000000..7acfad8803 --- /dev/null +++ b/web/src/admin/policies/geoip/GeoIPPolicyForm.ts @@ -0,0 +1,125 @@ +import { BasePolicyForm } from "@goauthentik/admin/policies/BasePolicyForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/ak-dual-select"; +import { DataProvision, DualSelectPair } from "@goauthentik/elements/ak-dual-select/types"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/SearchSelect"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import { DetailedCountry, GeoIPPolicy, PoliciesApi } from "@goauthentik/api"; + +import { countryCache } from "./CountryCache"; + +function countryToPair(country: DetailedCountry): DualSelectPair { + return [country.code, country.name]; +} + +@customElement("ak-policy-geoip-form") +export class GeoIPPolicyForm extends BasePolicyForm { + loadInstance(pk: string): Promise { + return new PoliciesApi(DEFAULT_CONFIG).policiesGeoipRetrieve({ + policyUuid: pk, + }); + } + + async send(data: GeoIPPolicy): Promise { + if (data.asns?.toString() === "") { + data.asns = []; + } else { + data.asns = (data.asns as unknown as string).split(",").map(Number); + } + + if (this.instance) { + return new PoliciesApi(DEFAULT_CONFIG).policiesGeoipUpdate({ + policyUuid: this.instance.pk || "", + geoIPPolicyRequest: data, + }); + } else { + return new PoliciesApi(DEFAULT_CONFIG).policiesGeoipCreate({ + geoIPPolicyRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + ${msg( + "Ensure the user satisfies requirements of geography or network topology, based on IP address. If any of the configured values match, the policy passes.", + )} + + + + + + +

+ ${msg( + "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged.", + )} +

+
+ + ${msg("Policy-specific settings")} +
+ + +

+ ${msg( + "List of autonomous system numbers. Comma separated. E.g. 13335, 15169, 20940", + )} +

+
+ + => { + return countryCache + .getCountries() + .then((results) => { + if (!search) return results; + return results.filter((result) => + result.name + .toLowerCase() + .includes(search.toLowerCase()), + ); + }) + .then((results) => { + return { + options: results.map(countryToPair), + }; + }); + }} + .selected=${(this.instance?.countriesObj ?? []).map(countryToPair)} + available-label="${msg("Available Countries")}" + selected-label="${msg("Selected Countries")}" + > + + +
+
`; + } +} diff --git a/web/src/elements/ak-dual-select/types.ts b/web/src/elements/ak-dual-select/types.ts index 10da0a56e1..a98b44c5a3 100644 --- a/web/src/elements/ak-dual-select/types.ts +++ b/web/src/elements/ak-dual-select/types.ts @@ -12,7 +12,7 @@ export type BasePagination = Pick< >; export type DataProvision = { - pagination: Pagination; + pagination?: Pagination; options: DualSelectPair[]; }; diff --git a/website/docs/core/geoip.mdx b/website/docs/core/geoip.mdx index 185c299e00..62156dc906 100644 --- a/website/docs/core/geoip.mdx +++ b/website/docs/core/geoip.mdx @@ -1,6 +1,6 @@ # GeoIP -authentik supports GeoIP to add additional information to login/authorization/enrollment requests, and make policy decisions based on the lookup result. +authentik supports GeoIP to add additional information to login/authorization/enrollment requests. Additionally, a [GeoIP policy](../policies/#geoip-policy) can be used to make policy decisions based on the lookup result. ### Configuration diff --git a/website/docs/policies/expression.mdx b/website/docs/policies/expression.mdx index be94ce1302..f8f682c6ca 100644 --- a/website/docs/policies/expression.mdx +++ b/website/docs/policies/expression.mdx @@ -55,6 +55,10 @@ import Objects from "../expressions/_objects.md"; - `geoip`: GeoIP dictionary. The following fields are available: + :::info + For basic country matching, consider using a [GeoIP policy](index.md#geoip-policy). + ::: + - `continent`: a two character continent code like `NA` (North America) or `OC` (Oceania). - `country`: the two character [ISO 3166-1](https://en.wikipedia.org/wiki/ISO_3166-1) alpha code for the country. - `lat`: the approximate latitude of the location associated with the IP address. @@ -62,11 +66,15 @@ import Objects from "../expressions/_objects.md"; - `city`: the name of the city. May be empty. ```python - return context["geoip"]["country"] == "US" + return context["geoip"]["continent"] == "EU" ``` - `asn`: ASN dictionary. The follow fields are available: + :::info + For basic ASN matching, consider using a [GeoIP policy](index.md#geoip-policy). + ::: + - `asn`: the autonomous system number associated with the IP address. - `as_org`: the organization associated with the registered autonomous system number for the IP address. - `network`: the network associated with the record. In particular, this is the largest network where all of the fields except `ip_address` have the same value. diff --git a/website/docs/policies/index.md b/website/docs/policies/index.md index 502a1e1114..d1da93acc0 100644 --- a/website/docs/policies/index.md +++ b/website/docs/policies/index.md @@ -6,22 +6,26 @@ title: Policies This policy is used by the events subsystem. You can use this policy to match events by multiple different criteria, to choose when you get notified. -## Expression Policy +## Expression policy -See [Expression Policy](expression.mdx). +See [Expression policy](expression.mdx). -## Have I Been Pwned Policy +## GeoIP policy + +Use this policy for simple GeoIP lookups, such as country or ASN matching. (For a more advanced GeoIP lookup, use an [Expression policy](expression.mdx).) + +## Have I Been Pwned policy :::info This policy is deprecated since authentik 2022.11.0, as this can be done with the password policy now. ::: This policy checks the hashed password against the [Have I Been Pwned](https://haveibeenpwned.com/) API. This only sends the first 5 characters of the hashed password. The remaining comparison is done within authentik. -## Password-Expiry Policy +## Password-Expiry policy This policy can enforce regular password rotation by expiring set passwords after a finite amount of time. This forces users to set a new password. -## Password Policy +## Password policy This policy allows you to specify password rules, such as length and required characters. The following rules can be set: @@ -37,7 +41,7 @@ Starting with authentik 2022.11.0, the following checks can also be done with th - Check the password hash against the database of [Have I Been Pwned](https://haveibeenpwned.com/). Only the first 5 characters of the hashed password are transmitted, the rest is compared in authentik - Check the password against the password complexity checker [zxcvbn](https://github.com/dropbox/zxcvbn), which detects weak password on various metrics. -## Reputation Policy +## Reputation policy authentik keeps track of failed login attempts by source IP and attempted username. These values are saved as scores. Each failed login decreases the score for the client IP as well as the targeted username by 1 (one).