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",
|
||||
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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -15,6 +15,8 @@ class UserLoginStageSerializer(StageSerializer):
|
|||
"session_duration",
|
||||
"terminate_other_sessions",
|
||||
"remember_me_offset",
|
||||
"network_binding",
|
||||
"geoip_binding",
|
||||
]
|
||||
|
||||
|
||||
|
|
212
authentik/stages/user_login/middleware.py
Normal file
212
authentik/stages/user_login/middleware.py
Normal file
|
@ -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
|
||||
|
||||
|
||||
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.")
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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": []
|
||||
|
|
120
schema.yml
120
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:
|
||||
|
|
|
@ -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<UserLoginStage> {
|
||||
|
@ -93,6 +93,74 @@ export class UserLoginStageForm extends BaseStageForm<UserLoginStage> {
|
|||
</p>
|
||||
<ak-utils-time-delta-help></ak-utils-time-delta-help>
|
||||
</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">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
|
|
|
@ -231,6 +231,11 @@ A user authorizes an application.
|
|||
"action": "authorize_application",
|
||||
"app": "authentik.providers.oauth2.views.authorize",
|
||||
"context": {
|
||||
"asn": {
|
||||
"asn": 6805,
|
||||
"as_org": "Telefonica Germany",
|
||||
"network": "5.4.0.0/14"
|
||||
},
|
||||
"geo": {
|
||||
"lat": 42.0,
|
||||
"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)
|
||||
|
||||
## 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
|
||||
|
||||
When enabled, previous sessions of the user logging in will be revoked. This has no affect on OAuth refresh tokens.
|
||||
|
|
Reference in a new issue