diff --git a/authentik/events/context_processors/asn.py b/authentik/events/context_processors/asn.py index afefbcbc6..4478c7fb0 100644 --- a/authentik/events/context_processors/asn.py +++ b/authentik/events/context_processors/asn.py @@ -51,7 +51,7 @@ class ASNContextProcessor(MMDBContextProcessor): op="authentik.events.asn.asn", description=ip_address, ): - if not self.enabled: + if not self.configured(): return None self.check_expired() try: @@ -59,8 +59,10 @@ class ASNContextProcessor(MMDBContextProcessor): except (GeoIP2Error, ValueError): return None - def asn_to_dict(self, asn: ASN) -> ASNDict: + def asn_to_dict(self, asn: ASN | None) -> ASNDict: """Convert ASN to dict""" + if not asn: + return {} asn_dict: ASNDict = { "asn": asn.autonomous_system_number, "as_org": asn.autonomous_system_organization, diff --git a/authentik/events/context_processors/geoip.py b/authentik/events/context_processors/geoip.py index ca00a4c54..40ea0b012 100644 --- a/authentik/events/context_processors/geoip.py +++ b/authentik/events/context_processors/geoip.py @@ -52,7 +52,7 @@ class GeoIPContextProcessor(MMDBContextProcessor): op="authentik.events.geo.city", description=ip_address, ): - if not self.enabled: + if not self.configured(): return None self.check_expired() try: @@ -60,8 +60,10 @@ class GeoIPContextProcessor(MMDBContextProcessor): except (GeoIP2Error, ValueError): return None - def city_to_dict(self, city: City) -> GeoIPDict: + def city_to_dict(self, city: City | None) -> GeoIPDict: """Convert City to dict""" + if not city: + return {} city_dict: GeoIPDict = { "continent": city.continent.code, "country": city.country.iso_code, diff --git a/authentik/events/context_processors/mmdb.py b/authentik/events/context_processors/mmdb.py index 09c17f91f..45bd85411 100644 --- a/authentik/events/context_processors/mmdb.py +++ b/authentik/events/context_processors/mmdb.py @@ -31,7 +31,7 @@ class MMDBContextProcessor(EventContextProcessor): self._last_mtime = Path(path).stat().st_mtime self.logger.info("Loaded MMDB database", last_write=self._last_mtime, file=path) except OSError as exc: - self.logger.warning("Failed to load MMDB database", exc=exc) + self.logger.warning("Failed to load MMDB database", path=path, exc=exc) def check_expired(self): """Check if the modification date of the MMDB database has @@ -48,7 +48,6 @@ class MMDBContextProcessor(EventContextProcessor): except OSError as exc: self.logger.warning("Failed to check MMDB age", exc=exc) - @property - def enabled(self) -> bool: - """Check if MMDB is enabled""" + def configured(self) -> bool: + """Return true if this context processor is configured""" return bool(self.reader) diff --git a/authentik/events/signals.py b/authentik/events/signals.py index 0473ef013..d0b7ec06a 100644 --- a/authentik/events/signals.py +++ b/authentik/events/signals.py @@ -45,9 +45,14 @@ def get_login_event(request: HttpRequest) -> Optional[Event]: @receiver(user_logged_out) -def on_user_logged_out(sender, request: HttpRequest, user: User, **_): +def on_user_logged_out(sender, request: HttpRequest, user: User, **kwargs): """Log successfully logout""" - Event.new(EventAction.LOGOUT).from_http(request, user=user) + # Check if this even comes from the user_login stage's middleware, which will set an extra + # argument + event = Event.new(EventAction.LOGOUT) + if "event_extra" in kwargs: + event.context.update(kwargs["event_extra"]) + event.from_http(request, user=user) @receiver(user_write) diff --git a/authentik/root/middleware.py b/authentik/root/middleware.py index 5eca4a6e5..ba5465d4f 100644 --- a/authentik/root/middleware.py +++ b/authentik/root/middleware.py @@ -56,7 +56,7 @@ class SessionMiddleware(UpstreamSessionMiddleware): pass return session_key - def process_request(self, request): + def process_request(self, request: HttpRequest): raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME) session_key = SessionMiddleware.decode_session_key(raw_session) request.session = self.SessionStore(session_key) @@ -297,7 +297,7 @@ class LoggingMiddleware: response = self.get_response(request) status_code = response.status_code kwargs = { - "request_id": request.request_id, + "request_id": getattr(request, "request_id", None), } kwargs.update(getattr(response, "ak_context", {})) self.log(request, status_code, int((default_timer() - start) * 1000), **kwargs) diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 526cf27ce..87d4bde9f 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -218,7 +218,7 @@ MIDDLEWARE = [ "authentik.root.middleware.LoggingMiddleware", "django_prometheus.middleware.PrometheusBeforeMiddleware", "authentik.root.middleware.ClientIPMiddleware", - "authentik.root.middleware.SessionMiddleware", + "authentik.stages.user_login.middleware.BoundSessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "authentik.core.middleware.RequestIDMiddleware", "authentik.tenants.middleware.TenantMiddleware", diff --git a/authentik/stages/user_login/api.py b/authentik/stages/user_login/api.py index c0acc8d5c..dad5f777c 100644 --- a/authentik/stages/user_login/api.py +++ b/authentik/stages/user_login/api.py @@ -15,6 +15,8 @@ class UserLoginStageSerializer(StageSerializer): "session_duration", "terminate_other_sessions", "remember_me_offset", + "network_binding", + "geoip_binding", ] diff --git a/authentik/stages/user_login/middleware.py b/authentik/stages/user_login/middleware.py new file mode 100644 index 000000000..8fea4c408 --- /dev/null +++ b/authentik/stages/user_login/middleware.py @@ -0,0 +1,212 @@ +"""Sessions bound to ASN/Network and GeoIP/Continent/etc""" +from django.conf import settings +from django.contrib.auth.middleware import AuthenticationMiddleware +from django.contrib.auth.signals import user_logged_out +from django.http.request import HttpRequest +from django.shortcuts import redirect +from structlog.stdlib import get_logger + +from authentik.core.models import AuthenticatedSession +from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR +from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR +from authentik.lib.sentry import SentryIgnoredException +from authentik.root.middleware import ClientIPMiddleware, SessionMiddleware +from authentik.stages.user_login.models import GeoIPBinding, NetworkBinding + +SESSION_KEY_LAST_IP = "authentik/stages/user_login/last_ip" +SESSION_KEY_BINDING_NET = "authentik/stages/user_login/binding/net" +SESSION_KEY_BINDING_GEO = "authentik/stages/user_login/binding/geo" +LOGGER = get_logger() + + +class SessionBindingBroken(SentryIgnoredException): + """Session binding was broken due to specified `reason`""" + + # pylint: disable=too-many-arguments + def __init__( + self, reason: str, old_value: str, new_value: str, old_ip: str, new_ip: str + ) -> None: + self.reason = reason + self.old_value = old_value + self.new_value = new_value + self.old_ip = old_ip + self.new_ip = new_ip + + def __repr__(self) -> str: + return ( + f"Session binding broken due to {self.reason}; " + f"old value: {self.old_value}, new value: {self.new_value}" + ) + + def to_event(self) -> dict: + """Convert to dict for usage with event""" + return { + "logout_reason": "Session binding broken", + "binding": { + "reason": self.reason, + "previous_value": self.old_value, + "new_value": self.new_value, + }, + "ip": { + "previous": self.old_ip, + "new": self.new_ip, + }, + } + + +def logout_extra(request: HttpRequest, exc: SessionBindingBroken): + """Similar to django's logout method, but able to carry more info to the signal""" + # Dispatch the signal before the user is logged out so the receivers have a + # chance to find out *who* logged out. + user = getattr(request, "user", None) + if not getattr(user, "is_authenticated", True): + user = None + user_logged_out.send( + sender=user.__class__, request=request, user=user, event_extra=exc.to_event() + ) + request.session.flush() + if hasattr(request, "user"): + from django.contrib.auth.models import AnonymousUser + + request.user = AnonymousUser() + + +class BoundSessionMiddleware(SessionMiddleware): + """Sessions bound to ASN/Network and GeoIP/Continent/etc""" + + def process_request(self, request: HttpRequest): + super().process_request(request) + try: + self.recheck_session(request) + except SessionBindingBroken as exc: + LOGGER.warning("Session binding broken", exc=exc) + # At this point, we need to logout the current user + # however since this middleware has to run before the `AuthenticationMiddleware` + # we don't have access to the user yet + # Logout will still work, however event logs won't display the user being logged out + AuthenticationMiddleware(lambda request: request).process_request(request) + logout_extra(request, exc) + request.session.clear() + return redirect(settings.LOGIN_URL) + return None + + def recheck_session(self, request: HttpRequest): + """Check if a session is still valid with a changed IP""" + last_ip = request.session.get(SESSION_KEY_LAST_IP) + new_ip = ClientIPMiddleware.get_client_ip(request) + # Check changed IP + if new_ip == last_ip: + return + configured_binding_net = request.session.get( + SESSION_KEY_BINDING_NET, NetworkBinding.NO_BINDING + ) + configured_binding_geo = request.session.get( + SESSION_KEY_BINDING_GEO, GeoIPBinding.NO_BINDING + ) + if configured_binding_net != NetworkBinding.NO_BINDING: + self.recheck_session_net(configured_binding_net, last_ip, new_ip) + if configured_binding_geo != GeoIPBinding.NO_BINDING: + self.recheck_session_geo(configured_binding_geo, last_ip, new_ip) + # If we got to this point without any error being raised, we need to + # update the last saved IP to the current one + request.session[SESSION_KEY_LAST_IP] = new_ip + AuthenticatedSession.objects.filter(session_key=request.session.session_key).update( + last_ip=new_ip, last_user_agent=request.META.get("HTTP_USER_AGENT", "") + ) + + def recheck_session_net(self, binding: NetworkBinding, last_ip: str, new_ip: str): + """Check network/ASN binding""" + last_asn = ASN_CONTEXT_PROCESSOR.asn(last_ip) + new_asn = ASN_CONTEXT_PROCESSOR.asn(new_ip) + if not last_asn or not new_asn: + raise SessionBindingBroken( + "network.missing", + ASN_CONTEXT_PROCESSOR.asn_to_dict(last_asn), + ASN_CONTEXT_PROCESSOR.asn_to_dict(new_asn), + last_ip, + new_ip, + ) + if binding in [ + NetworkBinding.BIND_ASN, + NetworkBinding.BIND_ASN_NETWORK, + NetworkBinding.BIND_ASN_NETWORK_IP, + ]: + # Check ASN which is required for all 3 modes + if last_asn.autonomous_system_number != new_asn.autonomous_system_number: + raise SessionBindingBroken( + "network.asn", + last_asn.autonomous_system_number, + new_asn.autonomous_system_number, + last_ip, + new_ip, + ) + if binding in [NetworkBinding.BIND_ASN_NETWORK, NetworkBinding.BIND_ASN_NETWORK_IP]: + # Check Network afterwards + if last_asn.network != new_asn.network: + raise SessionBindingBroken( + "network.asn_network", + last_asn.network, + new_asn.network, + last_ip, + new_ip, + ) + if binding in [NetworkBinding.BIND_ASN_NETWORK_IP]: + # Only require strict IP checking + if last_ip != new_ip: + raise SessionBindingBroken( + "network.ip", + last_ip, + new_ip, + last_ip, + new_ip, + ) + + def recheck_session_geo(self, binding: GeoIPBinding, last_ip: str, new_ip: str): + """Check GeoIP binding""" + last_geo = GEOIP_CONTEXT_PROCESSOR.city(last_ip) + new_geo = GEOIP_CONTEXT_PROCESSOR.city(new_ip) + if not last_geo or not new_geo: + raise SessionBindingBroken( + "geoip.missing", + GEOIP_CONTEXT_PROCESSOR.city_to_dict(last_geo), + GEOIP_CONTEXT_PROCESSOR.city_to_dict(new_geo), + last_ip, + new_ip, + ) + if binding in [ + GeoIPBinding.BIND_CONTINENT, + GeoIPBinding.BIND_CONTINENT_COUNTRY, + GeoIPBinding.BIND_CONTINENT_COUNTRY_CITY, + ]: + # Check Continent which is required for all 3 modes + if last_geo.continent != new_geo.continent: + raise SessionBindingBroken( + "geoip.continent", + last_geo.continent, + new_geo.continent, + last_ip, + new_ip, + ) + if binding in [ + GeoIPBinding.BIND_CONTINENT_COUNTRY, + GeoIPBinding.BIND_CONTINENT_COUNTRY_CITY, + ]: + # Check Country afterwards + if last_geo.country != new_geo.country: + raise SessionBindingBroken( + "geoip.country", + last_geo.country, + new_geo.country, + last_ip, + new_ip, + ) + if binding in [GeoIPBinding.BIND_CONTINENT_COUNTRY_CITY]: + # Check city afterwards + if last_geo.city != new_geo.city: + raise SessionBindingBroken( + "geoip.city", + last_geo.city, + new_geo.city, + last_ip, + new_ip, + ) diff --git a/authentik/stages/user_login/migrations/0006_userloginstage_geoip_binding_and_more.py b/authentik/stages/user_login/migrations/0006_userloginstage_geoip_binding_and_more.py new file mode 100644 index 000000000..155c42b81 --- /dev/null +++ b/authentik/stages/user_login/migrations/0006_userloginstage_geoip_binding_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.7 on 2023-12-14 10:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_stages_user_login", "0005_userloginstage_remember_me_offset"), + ] + + operations = [ + migrations.AddField( + model_name="userloginstage", + name="geoip_binding", + field=models.TextField( + choices=[ + ("no_binding", "No Binding"), + ("bind_continent", "Bind Continent"), + ("bind_continent_country", "Bind Continent Country"), + ("bind_continent_country_city", "Bind Continent Country City"), + ], + default="no_binding", + help_text="Bind sessions created by this stage to the configured GeoIP location", + ), + ), + migrations.AddField( + model_name="userloginstage", + name="network_binding", + field=models.TextField( + choices=[ + ("no_binding", "No Binding"), + ("bind_asn", "Bind Asn"), + ("bind_asn_network", "Bind Asn Network"), + ("bind_asn_network_ip", "Bind Asn Network Ip"), + ], + default="no_binding", + help_text="Bind sessions created by this stage to the configured network", + ), + ), + ] diff --git a/authentik/stages/user_login/models.py b/authentik/stages/user_login/models.py index 01c85b4fa..27bb6f3bf 100644 --- a/authentik/stages/user_login/models.py +++ b/authentik/stages/user_login/models.py @@ -9,6 +9,26 @@ from authentik.flows.models import Stage from authentik.lib.utils.time import timedelta_string_validator +class NetworkBinding(models.TextChoices): + """Network session binding modes""" + + NO_BINDING = "no_binding" + BIND_ASN = "bind_asn" # Bind to ASN only + BIND_ASN_NETWORK = "bind_asn_network" # Bind to ASN and Network + BIND_ASN_NETWORK_IP = "bind_asn_network_ip" # Bind to ASN, Network and IP + + +class GeoIPBinding(models.TextChoices): + """Geo session binding modes""" + + NO_BINDING = "no_binding" + BIND_CONTINENT = "bind_continent" # Bind to continent only + BIND_CONTINENT_COUNTRY = "bind_continent_country" # Bind to continent and country + BIND_CONTINENT_COUNTRY_CITY = ( + "bind_continent_country_city" # Bind to continent, country and city + ) + + class UserLoginStage(Stage): """Attaches the currently pending user to the current session.""" @@ -21,6 +41,16 @@ class UserLoginStage(Stage): "(Format: hours=-1;minutes=-2;seconds=-3)" ), ) + network_binding = models.TextField( + choices=NetworkBinding.choices, + default=NetworkBinding.NO_BINDING, + help_text=_("Bind sessions created by this stage to the configured network"), + ) + geoip_binding = models.TextField( + choices=GeoIPBinding.choices, + default=GeoIPBinding.NO_BINDING, + help_text=_("Bind sessions created by this stage to the configured GeoIP location"), + ) terminate_other_sessions = models.BooleanField( default=False, help_text=_("Terminate all other sessions of the user logging in.") ) diff --git a/authentik/stages/user_login/stage.py b/authentik/stages/user_login/stage.py index 0475c1b84..f771f8faa 100644 --- a/authentik/stages/user_login/stage.py +++ b/authentik/stages/user_login/stage.py @@ -1,4 +1,6 @@ """Login stage logic""" +from datetime import timedelta + from django.contrib import messages from django.contrib.auth import login from django.http import HttpRequest, HttpResponse @@ -10,8 +12,14 @@ from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUse from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE from authentik.flows.stage import ChallengeStageView from authentik.lib.utils.time import timedelta_from_string +from authentik.root.middleware import ClientIPMiddleware from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from authentik.stages.user_login.middleware import ( + SESSION_KEY_BINDING_GEO, + SESSION_KEY_BINDING_NET, + SESSION_KEY_LAST_IP, +) from authentik.stages.user_login.models import UserLoginStage @@ -51,6 +59,26 @@ class UserLoginStageView(ChallengeStageView): def challenge_valid(self, response: UserLoginChallengeResponse) -> HttpResponse: return self.do_login(self.request, response.validated_data["remember_me"]) + def set_session_duration(self, remember: bool) -> timedelta: + """Update the sessions' expiry""" + delta = timedelta_from_string(self.executor.current_stage.session_duration) + if remember: + offset = timedelta_from_string(self.executor.current_stage.remember_me_offset) + delta = delta + offset + if delta.total_seconds() == 0: + self.request.session.set_expiry(0) + else: + self.request.session.set_expiry(delta) + return delta + + def set_session_ip(self): + """Set the sessions' last IP and session bindings""" + stage: UserLoginStage = self.executor.current_stage + + self.request.session[SESSION_KEY_LAST_IP] = ClientIPMiddleware.get_client_ip(self.request) + self.request.session[SESSION_KEY_BINDING_NET] = stage.network_binding + self.request.session[SESSION_KEY_BINDING_GEO] = stage.geoip_binding + def do_login(self, request: HttpRequest, remember: bool = False) -> HttpResponse: """Attach the currently pending user to the current session""" if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: @@ -64,14 +92,8 @@ class UserLoginStageView(ChallengeStageView): user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] if not user.is_active: self.logger.warning("User is not active, login will not work.") - delta = timedelta_from_string(self.executor.current_stage.session_duration) - if remember: - offset = timedelta_from_string(self.executor.current_stage.remember_me_offset) - delta = delta + offset - if delta.total_seconds() == 0: - self.request.session.set_expiry(0) - else: - self.request.session.set_expiry(delta) + delta = self.set_session_duration(remember) + self.set_session_ip() login( self.request, user, diff --git a/blueprints/schema.json b/blueprints/schema.json index eaaed0a8d..523f6e10d 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -5306,13 +5306,13 @@ "type": "string", "enum": [ "apple", + "openidconnect", "azuread", "discord", "facebook", "github", "google", "mailcow", - "openidconnect", "okta", "patreon", "reddit", @@ -8173,6 +8173,28 @@ "minLength": 1, "title": "Remember me offset", "description": "Offset the session will be extended by when the user picks the remember me option. Default of 0 means that the remember me option will not be shown. (Format: hours=-1;minutes=-2;seconds=-3)" + }, + "network_binding": { + "type": "string", + "enum": [ + "no_binding", + "bind_asn", + "bind_asn_network", + "bind_asn_network_ip" + ], + "title": "Network binding", + "description": "Bind sessions created by this stage to the configured network" + }, + "geoip_binding": { + "type": "string", + "enum": [ + "no_binding", + "bind_continent", + "bind_continent_country", + "bind_continent_country_city" + ], + "title": "Geoip binding", + "description": "Bind sessions created by this stage to the configured GeoIP location" } }, "required": [] diff --git a/schema.yml b/schema.yml index 72d68e5f8..d63b0c135 100644 --- a/schema.yml +++ b/schema.yml @@ -26955,10 +26955,42 @@ paths: operationId: stages_user_login_list description: UserLoginStage Viewset parameters: + - in: query + name: geoip_binding + schema: + type: string + enum: + - bind_continent + - bind_continent_country + - bind_continent_country_city + - no_binding + description: |- + Bind sessions created by this stage to the configured GeoIP location + + * `no_binding` - No Binding + * `bind_continent` - Bind Continent + * `bind_continent_country` - Bind Continent Country + * `bind_continent_country_city` - Bind Continent Country City - in: query name: name schema: type: string + - in: query + name: network_binding + schema: + type: string + enum: + - bind_asn + - bind_asn_network + - bind_asn_network_ip + - no_binding + description: |- + Bind sessions created by this stage to the configured network + + * `no_binding` - No Binding + * `bind_asn` - Bind Asn + * `bind_asn_network` - Bind Asn Network + * `bind_asn_network_ip` - Bind Asn Network Ip - name: ordering required: false in: query @@ -31829,6 +31861,18 @@ components: type: string required: - detail + GeoipBindingEnum: + enum: + - no_binding + - bind_continent + - bind_continent_country + - bind_continent_country_city + type: string + description: |- + * `no_binding` - No Binding + * `bind_continent` - Bind Continent + * `bind_continent_country` - Bind Continent Country + * `bind_continent_country_city` - Bind Continent Country City Group: type: object description: Group Serializer @@ -33405,6 +33449,18 @@ components: * `urn:oasis:names:tc:SAML:2.0:nameid-format:X509SubjectName` - X509 * `urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName` - Windows * `urn:oasis:names:tc:SAML:2.0:nameid-format:transient` - Transient + NetworkBindingEnum: + enum: + - no_binding + - bind_asn + - bind_asn_network + - bind_asn_network_ip + type: string + description: |- + * `no_binding` - No Binding + * `bind_asn` - Bind Asn + * `bind_asn_network` - Bind Asn Network + * `bind_asn_network_ip` - Bind Asn Network Ip NotConfiguredActionEnum: enum: - skip @@ -38080,6 +38136,26 @@ components: description: 'Offset the session will be extended by when the user picks the remember me option. Default of 0 means that the remember me option will not be shown. (Format: hours=-1;minutes=-2;seconds=-3)' + network_binding: + allOf: + - $ref: '#/components/schemas/NetworkBindingEnum' + description: |- + Bind sessions created by this stage to the configured network + + * `no_binding` - No Binding + * `bind_asn` - Bind Asn + * `bind_asn_network` - Bind Asn Network + * `bind_asn_network_ip` - Bind Asn Network Ip + geoip_binding: + allOf: + - $ref: '#/components/schemas/GeoipBindingEnum' + description: |- + Bind sessions created by this stage to the configured GeoIP location + + * `no_binding` - No Binding + * `bind_continent` - Bind Continent + * `bind_continent_country` - Bind Continent Country + * `bind_continent_country_city` - Bind Continent Country City PatchedUserLogoutStageRequest: type: object description: UserLogoutStage Serializer @@ -39111,13 +39187,13 @@ components: ProviderTypeEnum: enum: - apple + - openidconnect - azuread - discord - facebook - github - google - mailcow - - openidconnect - okta - patreon - reddit @@ -39126,13 +39202,13 @@ components: type: string description: |- * `apple` - Apple + * `openidconnect` - OpenID Connect * `azuread` - Azure AD * `discord` - Discord * `facebook` - Facebook * `github` - GitHub * `google` - Google * `mailcow` - Mailcow - * `openidconnect` - OpenID Connect * `okta` - Okta * `patreon` - Patreon * `reddit` - Reddit @@ -42052,6 +42128,26 @@ components: description: 'Offset the session will be extended by when the user picks the remember me option. Default of 0 means that the remember me option will not be shown. (Format: hours=-1;minutes=-2;seconds=-3)' + network_binding: + allOf: + - $ref: '#/components/schemas/NetworkBindingEnum' + description: |- + Bind sessions created by this stage to the configured network + + * `no_binding` - No Binding + * `bind_asn` - Bind Asn + * `bind_asn_network` - Bind Asn Network + * `bind_asn_network_ip` - Bind Asn Network Ip + geoip_binding: + allOf: + - $ref: '#/components/schemas/GeoipBindingEnum' + description: |- + Bind sessions created by this stage to the configured GeoIP location + + * `no_binding` - No Binding + * `bind_continent` - Bind Continent + * `bind_continent_country` - Bind Continent Country + * `bind_continent_country_city` - Bind Continent Country City required: - component - meta_model_name @@ -42084,6 +42180,26 @@ components: description: 'Offset the session will be extended by when the user picks the remember me option. Default of 0 means that the remember me option will not be shown. (Format: hours=-1;minutes=-2;seconds=-3)' + network_binding: + allOf: + - $ref: '#/components/schemas/NetworkBindingEnum' + description: |- + Bind sessions created by this stage to the configured network + + * `no_binding` - No Binding + * `bind_asn` - Bind Asn + * `bind_asn_network` - Bind Asn Network + * `bind_asn_network_ip` - Bind Asn Network Ip + geoip_binding: + allOf: + - $ref: '#/components/schemas/GeoipBindingEnum' + description: |- + Bind sessions created by this stage to the configured GeoIP location + + * `no_binding` - No Binding + * `bind_continent` - Bind Continent + * `bind_continent_country` - Bind Continent Country + * `bind_continent_country_city` - Bind Continent Country City required: - name UserLogoutStage: diff --git a/web/src/admin/stages/user_login/UserLoginStageForm.ts b/web/src/admin/stages/user_login/UserLoginStageForm.ts index c386a9af2..e7c94c247 100644 --- a/web/src/admin/stages/user_login/UserLoginStageForm.ts +++ b/web/src/admin/stages/user_login/UserLoginStageForm.ts @@ -10,7 +10,7 @@ import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { StagesApi, UserLoginStage } from "@goauthentik/api"; +import { GeoipBindingEnum, NetworkBindingEnum, StagesApi, UserLoginStage } from "@goauthentik/api"; @customElement("ak-stage-user-login-form") export class UserLoginStageForm extends BaseStageForm { @@ -93,6 +93,74 @@ export class UserLoginStageForm extends BaseStageForm {

+ + + +

+ ${msg( + "Configure if sessions created by this stage should be bound to the Networks they were created in.", + )} +

+
+ + + +

+ ${msg( + "Configure if sessions created by this stage should be bound to their GeoIP-based location", + )} +

+