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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,8 @@ class UserLoginStageSerializer(StageSerializer):
"session_duration",
"terminate_other_sessions",
"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
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.")
)

View file

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

View file

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

View file

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

View file

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

View file

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

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)
## 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.