stages/user_login: session binding (#7881)
* start with user_login stage Signed-off-by: Jens Langhammer <jens@goauthentik.io> # Conflicts: # authentik/root/settings.py * fix and improve logout event Signed-off-by: Jens Langhammer <jens@goauthentik.io> * lint pass Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update authenticated session when IP changes and binding doesn't break Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update docs, always keep old and new IP in event Signed-off-by: Jens Langhammer <jens@goauthentik.io> * re-gen api schema Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
9a261c52d1
commit
02869d8173
|
@ -51,7 +51,7 @@ class ASNContextProcessor(MMDBContextProcessor):
|
||||||
op="authentik.events.asn.asn",
|
op="authentik.events.asn.asn",
|
||||||
description=ip_address,
|
description=ip_address,
|
||||||
):
|
):
|
||||||
if not self.enabled:
|
if not self.configured():
|
||||||
return None
|
return None
|
||||||
self.check_expired()
|
self.check_expired()
|
||||||
try:
|
try:
|
||||||
|
@ -59,8 +59,10 @@ class ASNContextProcessor(MMDBContextProcessor):
|
||||||
except (GeoIP2Error, ValueError):
|
except (GeoIP2Error, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def asn_to_dict(self, asn: ASN) -> ASNDict:
|
def asn_to_dict(self, asn: ASN | None) -> ASNDict:
|
||||||
"""Convert ASN to dict"""
|
"""Convert ASN to dict"""
|
||||||
|
if not asn:
|
||||||
|
return {}
|
||||||
asn_dict: ASNDict = {
|
asn_dict: ASNDict = {
|
||||||
"asn": asn.autonomous_system_number,
|
"asn": asn.autonomous_system_number,
|
||||||
"as_org": asn.autonomous_system_organization,
|
"as_org": asn.autonomous_system_organization,
|
||||||
|
|
|
@ -52,7 +52,7 @@ class GeoIPContextProcessor(MMDBContextProcessor):
|
||||||
op="authentik.events.geo.city",
|
op="authentik.events.geo.city",
|
||||||
description=ip_address,
|
description=ip_address,
|
||||||
):
|
):
|
||||||
if not self.enabled:
|
if not self.configured():
|
||||||
return None
|
return None
|
||||||
self.check_expired()
|
self.check_expired()
|
||||||
try:
|
try:
|
||||||
|
@ -60,8 +60,10 @@ class GeoIPContextProcessor(MMDBContextProcessor):
|
||||||
except (GeoIP2Error, ValueError):
|
except (GeoIP2Error, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def city_to_dict(self, city: City) -> GeoIPDict:
|
def city_to_dict(self, city: City | None) -> GeoIPDict:
|
||||||
"""Convert City to dict"""
|
"""Convert City to dict"""
|
||||||
|
if not city:
|
||||||
|
return {}
|
||||||
city_dict: GeoIPDict = {
|
city_dict: GeoIPDict = {
|
||||||
"continent": city.continent.code,
|
"continent": city.continent.code,
|
||||||
"country": city.country.iso_code,
|
"country": city.country.iso_code,
|
||||||
|
|
|
@ -31,7 +31,7 @@ class MMDBContextProcessor(EventContextProcessor):
|
||||||
self._last_mtime = Path(path).stat().st_mtime
|
self._last_mtime = Path(path).stat().st_mtime
|
||||||
self.logger.info("Loaded MMDB database", last_write=self._last_mtime, file=path)
|
self.logger.info("Loaded MMDB database", last_write=self._last_mtime, file=path)
|
||||||
except OSError as exc:
|
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):
|
def check_expired(self):
|
||||||
"""Check if the modification date of the MMDB database has
|
"""Check if the modification date of the MMDB database has
|
||||||
|
@ -48,7 +48,6 @@ class MMDBContextProcessor(EventContextProcessor):
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
self.logger.warning("Failed to check MMDB age", exc=exc)
|
self.logger.warning("Failed to check MMDB age", exc=exc)
|
||||||
|
|
||||||
@property
|
def configured(self) -> bool:
|
||||||
def enabled(self) -> bool:
|
"""Return true if this context processor is configured"""
|
||||||
"""Check if MMDB is enabled"""
|
|
||||||
return bool(self.reader)
|
return bool(self.reader)
|
||||||
|
|
|
@ -45,9 +45,14 @@ def get_login_event(request: HttpRequest) -> Optional[Event]:
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
@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"""
|
"""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)
|
@receiver(user_write)
|
||||||
|
|
|
@ -56,7 +56,7 @@ class SessionMiddleware(UpstreamSessionMiddleware):
|
||||||
pass
|
pass
|
||||||
return session_key
|
return session_key
|
||||||
|
|
||||||
def process_request(self, request):
|
def process_request(self, request: HttpRequest):
|
||||||
raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
|
raw_session = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
|
||||||
session_key = SessionMiddleware.decode_session_key(raw_session)
|
session_key = SessionMiddleware.decode_session_key(raw_session)
|
||||||
request.session = self.SessionStore(session_key)
|
request.session = self.SessionStore(session_key)
|
||||||
|
@ -297,7 +297,7 @@ class LoggingMiddleware:
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
status_code = response.status_code
|
status_code = response.status_code
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"request_id": request.request_id,
|
"request_id": getattr(request, "request_id", None),
|
||||||
}
|
}
|
||||||
kwargs.update(getattr(response, "ak_context", {}))
|
kwargs.update(getattr(response, "ak_context", {}))
|
||||||
self.log(request, status_code, int((default_timer() - start) * 1000), **kwargs)
|
self.log(request, status_code, int((default_timer() - start) * 1000), **kwargs)
|
||||||
|
|
|
@ -218,7 +218,7 @@ MIDDLEWARE = [
|
||||||
"authentik.root.middleware.LoggingMiddleware",
|
"authentik.root.middleware.LoggingMiddleware",
|
||||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||||
"authentik.root.middleware.ClientIPMiddleware",
|
"authentik.root.middleware.ClientIPMiddleware",
|
||||||
"authentik.root.middleware.SessionMiddleware",
|
"authentik.stages.user_login.middleware.BoundSessionMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"authentik.core.middleware.RequestIDMiddleware",
|
"authentik.core.middleware.RequestIDMiddleware",
|
||||||
"authentik.tenants.middleware.TenantMiddleware",
|
"authentik.tenants.middleware.TenantMiddleware",
|
||||||
|
|
|
@ -15,6 +15,8 @@ class UserLoginStageSerializer(StageSerializer):
|
||||||
"session_duration",
|
"session_duration",
|
||||||
"terminate_other_sessions",
|
"terminate_other_sessions",
|
||||||
"remember_me_offset",
|
"remember_me_offset",
|
||||||
|
"network_binding",
|
||||||
|
"geoip_binding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -9,6 +9,26 @@ from authentik.flows.models import Stage
|
||||||
from authentik.lib.utils.time import timedelta_string_validator
|
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):
|
class UserLoginStage(Stage):
|
||||||
"""Attaches the currently pending user to the current session."""
|
"""Attaches the currently pending user to the current session."""
|
||||||
|
|
||||||
|
@ -21,6 +41,16 @@ class UserLoginStage(Stage):
|
||||||
"(Format: hours=-1;minutes=-2;seconds=-3)"
|
"(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(
|
terminate_other_sessions = models.BooleanField(
|
||||||
default=False, help_text=_("Terminate all other sessions of the user logging in.")
|
default=False, help_text=_("Terminate all other sessions of the user logging in.")
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
"""Login stage logic"""
|
"""Login stage logic"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import login
|
from django.contrib.auth import login
|
||||||
from django.http import HttpRequest, HttpResponse
|
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.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE
|
||||||
from authentik.flows.stage import ChallengeStageView
|
from authentik.flows.stage import ChallengeStageView
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
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 import BACKEND_INBUILT
|
||||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
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
|
from authentik.stages.user_login.models import UserLoginStage
|
||||||
|
|
||||||
|
|
||||||
|
@ -51,6 +59,26 @@ class UserLoginStageView(ChallengeStageView):
|
||||||
def challenge_valid(self, response: UserLoginChallengeResponse) -> HttpResponse:
|
def challenge_valid(self, response: UserLoginChallengeResponse) -> HttpResponse:
|
||||||
return self.do_login(self.request, response.validated_data["remember_me"])
|
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:
|
def do_login(self, request: HttpRequest, remember: bool = False) -> HttpResponse:
|
||||||
"""Attach the currently pending user to the current session"""
|
"""Attach the currently pending user to the current session"""
|
||||||
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
|
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]
|
user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
self.logger.warning("User is not active, login will not work.")
|
self.logger.warning("User is not active, login will not work.")
|
||||||
delta = timedelta_from_string(self.executor.current_stage.session_duration)
|
delta = self.set_session_duration(remember)
|
||||||
if remember:
|
self.set_session_ip()
|
||||||
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)
|
|
||||||
login(
|
login(
|
||||||
self.request,
|
self.request,
|
||||||
user,
|
user,
|
||||||
|
|
|
@ -5306,13 +5306,13 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"apple",
|
"apple",
|
||||||
|
"openidconnect",
|
||||||
"azuread",
|
"azuread",
|
||||||
"discord",
|
"discord",
|
||||||
"facebook",
|
"facebook",
|
||||||
"github",
|
"github",
|
||||||
"google",
|
"google",
|
||||||
"mailcow",
|
"mailcow",
|
||||||
"openidconnect",
|
|
||||||
"okta",
|
"okta",
|
||||||
"patreon",
|
"patreon",
|
||||||
"reddit",
|
"reddit",
|
||||||
|
@ -8173,6 +8173,28 @@
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"title": "Remember me offset",
|
"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)"
|
"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": []
|
"required": []
|
||||||
|
|
120
schema.yml
120
schema.yml
|
@ -26955,10 +26955,42 @@ paths:
|
||||||
operationId: stages_user_login_list
|
operationId: stages_user_login_list
|
||||||
description: UserLoginStage Viewset
|
description: UserLoginStage Viewset
|
||||||
parameters:
|
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
|
- in: query
|
||||||
name: name
|
name: name
|
||||||
schema:
|
schema:
|
||||||
type: string
|
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
|
- name: ordering
|
||||||
required: false
|
required: false
|
||||||
in: query
|
in: query
|
||||||
|
@ -31829,6 +31861,18 @@ components:
|
||||||
type: string
|
type: string
|
||||||
required:
|
required:
|
||||||
- detail
|
- 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:
|
Group:
|
||||||
type: object
|
type: object
|
||||||
description: Group Serializer
|
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:X509SubjectName` - X509
|
||||||
* `urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName` - Windows
|
* `urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName` - Windows
|
||||||
* `urn:oasis:names:tc:SAML:2.0:nameid-format:transient` - Transient
|
* `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:
|
NotConfiguredActionEnum:
|
||||||
enum:
|
enum:
|
||||||
- skip
|
- skip
|
||||||
|
@ -38080,6 +38136,26 @@ components:
|
||||||
description: 'Offset the session will be extended by when the user picks
|
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
|
the remember me option. Default of 0 means that the remember me option
|
||||||
will not be shown. (Format: hours=-1;minutes=-2;seconds=-3)'
|
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:
|
PatchedUserLogoutStageRequest:
|
||||||
type: object
|
type: object
|
||||||
description: UserLogoutStage Serializer
|
description: UserLogoutStage Serializer
|
||||||
|
@ -39111,13 +39187,13 @@ components:
|
||||||
ProviderTypeEnum:
|
ProviderTypeEnum:
|
||||||
enum:
|
enum:
|
||||||
- apple
|
- apple
|
||||||
|
- openidconnect
|
||||||
- azuread
|
- azuread
|
||||||
- discord
|
- discord
|
||||||
- facebook
|
- facebook
|
||||||
- github
|
- github
|
||||||
- google
|
- google
|
||||||
- mailcow
|
- mailcow
|
||||||
- openidconnect
|
|
||||||
- okta
|
- okta
|
||||||
- patreon
|
- patreon
|
||||||
- reddit
|
- reddit
|
||||||
|
@ -39126,13 +39202,13 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: |-
|
description: |-
|
||||||
* `apple` - Apple
|
* `apple` - Apple
|
||||||
|
* `openidconnect` - OpenID Connect
|
||||||
* `azuread` - Azure AD
|
* `azuread` - Azure AD
|
||||||
* `discord` - Discord
|
* `discord` - Discord
|
||||||
* `facebook` - Facebook
|
* `facebook` - Facebook
|
||||||
* `github` - GitHub
|
* `github` - GitHub
|
||||||
* `google` - Google
|
* `google` - Google
|
||||||
* `mailcow` - Mailcow
|
* `mailcow` - Mailcow
|
||||||
* `openidconnect` - OpenID Connect
|
|
||||||
* `okta` - Okta
|
* `okta` - Okta
|
||||||
* `patreon` - Patreon
|
* `patreon` - Patreon
|
||||||
* `reddit` - Reddit
|
* `reddit` - Reddit
|
||||||
|
@ -42052,6 +42128,26 @@ components:
|
||||||
description: 'Offset the session will be extended by when the user picks
|
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
|
the remember me option. Default of 0 means that the remember me option
|
||||||
will not be shown. (Format: hours=-1;minutes=-2;seconds=-3)'
|
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:
|
required:
|
||||||
- component
|
- component
|
||||||
- meta_model_name
|
- meta_model_name
|
||||||
|
@ -42084,6 +42180,26 @@ components:
|
||||||
description: 'Offset the session will be extended by when the user picks
|
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
|
the remember me option. Default of 0 means that the remember me option
|
||||||
will not be shown. (Format: hours=-1;minutes=-2;seconds=-3)'
|
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:
|
required:
|
||||||
- name
|
- name
|
||||||
UserLogoutStage:
|
UserLogoutStage:
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { msg } from "@lit/localize";
|
||||||
import { TemplateResult, html } from "lit";
|
import { TemplateResult, html } from "lit";
|
||||||
import { customElement } from "lit/decorators.js";
|
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")
|
@customElement("ak-stage-user-login-form")
|
||||||
export class UserLoginStageForm extends BaseStageForm<UserLoginStage> {
|
export class UserLoginStageForm extends BaseStageForm<UserLoginStage> {
|
||||||
|
@ -93,6 +93,74 @@ export class UserLoginStageForm extends BaseStageForm<UserLoginStage> {
|
||||||
</p>
|
</p>
|
||||||
<ak-utils-time-delta-help></ak-utils-time-delta-help>
|
<ak-utils-time-delta-help></ak-utils-time-delta-help>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>
|
||||||
|
<ak-form-element-horizontal
|
||||||
|
label=${msg("Network binding")}
|
||||||
|
?required=${true}
|
||||||
|
name="networkBinding"
|
||||||
|
>
|
||||||
|
<ak-radio
|
||||||
|
.options=${[
|
||||||
|
{
|
||||||
|
label: msg("No binding"),
|
||||||
|
value: NetworkBindingEnum.NoBinding,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: msg("Bind ASN"),
|
||||||
|
value: NetworkBindingEnum.BindAsn,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: msg("Bind ASN and Network"),
|
||||||
|
value: NetworkBindingEnum.BindAsnNetwork,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: msg("Bind ASN, Network and IP"),
|
||||||
|
value: NetworkBindingEnum.BindAsnNetworkIp,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
.value=${this.instance?.networkBinding}
|
||||||
|
>
|
||||||
|
</ak-radio>
|
||||||
|
<p class="pf-c-form__helper-text">
|
||||||
|
${msg(
|
||||||
|
"Configure if sessions created by this stage should be bound to the Networks they were created in.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
<ak-form-element-horizontal
|
||||||
|
label=${msg("GeoIP binding")}
|
||||||
|
?required=${true}
|
||||||
|
name="geoipBinding"
|
||||||
|
>
|
||||||
|
<ak-radio
|
||||||
|
.options=${[
|
||||||
|
{
|
||||||
|
label: msg("No binding"),
|
||||||
|
value: GeoipBindingEnum.NoBinding,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: msg("Bind Continent"),
|
||||||
|
value: GeoipBindingEnum.BindContinent,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: msg("Bind Continent and Country"),
|
||||||
|
value: GeoipBindingEnum.BindContinentCountry,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: msg("Bind Continent, Country and City"),
|
||||||
|
value: GeoipBindingEnum.BindContinentCountryCity,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
.value=${this.instance?.geoipBinding}
|
||||||
|
>
|
||||||
|
</ak-radio>
|
||||||
|
<p class="pf-c-form__helper-text">
|
||||||
|
${msg(
|
||||||
|
"Configure if sessions created by this stage should be bound to their GeoIP-based location",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
<ak-form-element-horizontal name="terminateOtherSessions">
|
<ak-form-element-horizontal name="terminateOtherSessions">
|
||||||
<label class="pf-c-switch">
|
<label class="pf-c-switch">
|
||||||
<input
|
<input
|
||||||
|
|
|
@ -231,6 +231,11 @@ A user authorizes an application.
|
||||||
"action": "authorize_application",
|
"action": "authorize_application",
|
||||||
"app": "authentik.providers.oauth2.views.authorize",
|
"app": "authentik.providers.oauth2.views.authorize",
|
||||||
"context": {
|
"context": {
|
||||||
|
"asn": {
|
||||||
|
"asn": 6805,
|
||||||
|
"as_org": "Telefonica Germany",
|
||||||
|
"network": "5.4.0.0/14"
|
||||||
|
},
|
||||||
"geo": {
|
"geo": {
|
||||||
"lat": 42.0,
|
"lat": 42.0,
|
||||||
"city": "placeholder",
|
"city": "placeholder",
|
||||||
|
|
|
@ -32,6 +32,51 @@ When this is set to a higher value than the default _seconds=0_, a prompt is sho
|
||||||
|
|
||||||
![](./stay_signed_in.png)
|
![](./stay_signed_in.png)
|
||||||
|
|
||||||
|
## Network binding/GeoIP binding
|
||||||
|
|
||||||
|
When configured, all sessions authenticated by this stage will be bound to the selected network/GeoIP criteria.
|
||||||
|
|
||||||
|
Sessions which break this binding will be terminated on use. The created [`logout`](../../../events/index.md#logout) event will contain additional data related to what caused the binding to be broken:
|
||||||
|
|
||||||
|
```json
|
||||||
|
|
||||||
|
Context
|
||||||
|
{
|
||||||
|
"asn": {
|
||||||
|
"asn": 6805,
|
||||||
|
"as_org": "Telefonica Germany",
|
||||||
|
"network": "5.4.0.0/14"
|
||||||
|
},
|
||||||
|
"geo": {
|
||||||
|
"lat": 51.2993,
|
||||||
|
"city": "",
|
||||||
|
"long": 9.491,
|
||||||
|
"country": "DE",
|
||||||
|
"continent": "EU"
|
||||||
|
},
|
||||||
|
"binding": {
|
||||||
|
"reason": "network.missing",
|
||||||
|
"new_value": {
|
||||||
|
"asn": 6805,
|
||||||
|
"as_org": "Telefonica Germany",
|
||||||
|
"network": "5.4.0.0/14"
|
||||||
|
},
|
||||||
|
"previous_value": {}
|
||||||
|
},
|
||||||
|
"ip": {
|
||||||
|
"previous": "1.2.3.4",
|
||||||
|
"new": "5.6.7.8",
|
||||||
|
},
|
||||||
|
"http_request": {
|
||||||
|
"args": {},
|
||||||
|
"path": "/if/admin/",
|
||||||
|
"method": "GET",
|
||||||
|
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
},
|
||||||
|
"logout_reason": "Session binding broken"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Terminate other sessions
|
## Terminate other sessions
|
||||||
|
|
||||||
When enabled, previous sessions of the user logging in will be revoked. This has no affect on OAuth refresh tokens.
|
When enabled, previous sessions of the user logging in will be revoked. This has no affect on OAuth refresh tokens.
|
||||||
|
|
Reference in New Issue