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
+ ${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", + )} +
+