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:
Jens L 2023-12-23 01:20:23 +01:00 committed by GitHub
parent 9a261c52d1
commit 02869d8173
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 595 additions and 25 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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",

View File

@ -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",
] ]

View 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,
)

View File

@ -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",
),
),
]

View File

@ -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.")
) )

View File

@ -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,

View File

@ -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": []

View File

@ -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:

View File

@ -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

View File

@ -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",

View File

@ -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.