Compare commits

...

6 Commits

Author SHA1 Message Date
6c0e2b270b add options
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-21 00:26:34 +02:00
045202bea4 allow other stages to set known_device, and if set don't override it
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-21 00:26:16 +02:00
7b0ed9106c fix tests
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-21 00:26:16 +02:00
cfccf01598 also check currently authenticated sessions
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-21 00:26:16 +02:00
5a3eb7cde3 initial implementation
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-21 00:26:16 +02:00
ae19fbf298 unrelated: add tests for session binding
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-21 00:26:15 +02:00
12 changed files with 199 additions and 19 deletions

View File

@ -17,6 +17,7 @@ from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
from authentik.stages.user_login.stage import PLAN_CONTEXT_METHOD_ARGS_KNOWN_DEVICE
# Header we get from chrome that initiates verified access
HEADER_DEVICE_TRUST = "X-Device-Trust"
@ -27,6 +28,8 @@ HEADER_ACCESS_CHALLENGE_RESPONSE = "X-Verified-Access-Challenge-Response"
# Header value for x-device-trust that initiates the flow
DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess"
PLAN_CONTEXT_METHOD_ARGS_ENDPOINTS = "endpoints"
@method_decorator(xframe_options_sameorigin, name="dispatch")
class GoogleChromeDeviceTrustConnector(View):
@ -81,7 +84,14 @@ class GoogleChromeDeviceTrustConnector(View):
)
flow_plan.context.setdefault(PLAN_CONTEXT_METHOD, "trusted_endpoint")
flow_plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {})
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS].setdefault("endpoints", [])
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS]["endpoints"].append(response)
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS].setdefault(
PLAN_CONTEXT_METHOD_ARGS_ENDPOINTS, []
)
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS][PLAN_CONTEXT_METHOD_ARGS_ENDPOINTS].append(
response
)
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS].setdefault(
PLAN_CONTEXT_METHOD_ARGS_KNOWN_DEVICE, True
)
request.session[SESSION_KEY_PLAN] = flow_plan
return TemplateResponse(request, "stages/authenticator_endpoint/google_chrome_dtc.html")

View File

@ -173,6 +173,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase):
{
"auth_method": "auth_mfa",
"auth_method_args": {
"known_device": False,
"mfa_devices": [
{
"app": "authentik_stages_authenticator_duo",
@ -180,7 +181,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase):
"name": "",
"pk": duo_device.pk,
}
]
],
},
"http_request": {
"args": {},

View File

@ -156,7 +156,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
session = self.client.session
plan = FlowPlan(flow_pk=flow.pk.hex)
plan.append_stage(stage)
plan.append_stage(UserLoginStage(name=generate_id()))
plan.append_stage(UserLoginStage.objects.create(name=generate_id()))
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session[SESSION_KEY_PLAN] = plan
session.save()
@ -265,7 +265,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
session = self.client.session
plan = FlowPlan(flow_pk=flow.pk.hex)
plan.append_stage(stage)
plan.append_stage(UserLoginStage(name=generate_id()))
plan.append_stage(UserLoginStage.objects.create(name=generate_id()))
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
{
@ -342,7 +342,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
session = self.client.session
plan = FlowPlan(flow_pk=flow.pk.hex)
plan.append_stage(stage)
plan.append_stage(UserLoginStage(name=generate_id()))
plan.append_stage(UserLoginStage.objects.create(name=generate_id()))
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
{
@ -424,7 +424,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
session = self.client.session
plan = FlowPlan(flow_pk=flow.pk.hex)
plan.append_stage(stage)
plan.append_stage(UserLoginStage(name=generate_id()))
plan.append_stage(UserLoginStage.objects.create(name=generate_id()))
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
{
"device_class": device.__class__.__name__.lower().replace("device", ""),

View File

@ -18,6 +18,7 @@ class UserLoginStageSerializer(StageSerializer):
"remember_me_offset",
"network_binding",
"geoip_binding",
"remember_device",
]

View File

@ -6,6 +6,7 @@ from django.contrib.auth.views import redirect_to_login
from django.http.request import HttpRequest
from structlog.stdlib import get_logger
from authentik.core.models import Session
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
@ -89,7 +90,7 @@ class BoundSessionMiddleware(SessionMiddleware):
def recheck_session(self, request: HttpRequest):
"""Check if a session is still valid with a changed IP"""
last_ip = request.session.get(request.session.model.Keys.LAST_IP)
last_ip = request.session.get(Session.Keys.LAST_IP)
new_ip = ClientIPMiddleware.get_client_ip(request)
# Check changed IP
if new_ip == last_ip:
@ -109,7 +110,7 @@ class BoundSessionMiddleware(SessionMiddleware):
if SESSION_KEY_BINDING_NET in request.session or SESSION_KEY_BINDING_GEO in request.session:
# Only set the last IP in the session if there's a binding specified
# (== basically requires the user to be logged in)
request.session[request.session.model.Keys.LAST_IP] = new_ip
request.session[Session.Keys.LAST_IP] = new_ip
@staticmethod
def recheck_session_net(binding: NetworkBinding, last_ip: str, new_ip: str):

View File

@ -0,0 +1,23 @@
# Generated by Django 5.1.11 on 2025-06-18 16:21
import authentik.lib.utils.time
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_user_login", "0006_userloginstage_geoip_binding_and_more"),
]
operations = [
migrations.AddField(
model_name="userloginstage",
name="remember_device",
field=models.TextField(
default="days=30",
help_text="When set to a non-zero value, authentik will save a cookie with a longer expiry,to remember the device the user is logging in from. (Format: hours=-1;minutes=-2;seconds=-3)",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
]

View File

@ -63,6 +63,15 @@ class UserLoginStage(Stage):
"(Format: hours=-1;minutes=-2;seconds=-3)"
),
)
remember_device = models.TextField(
default="days=30",
validators=[timedelta_string_validator],
help_text=_(
"When set to a non-zero value, authentik will save a cookie with a longer expiry,"
"to remember the device the user is logging in from. "
"(Format: hours=-1;minutes=-2;seconds=-3)"
),
)
@property
def serializer(self) -> type[BaseSerializer]:

View File

@ -1,14 +1,17 @@
"""Login stage logic"""
from datetime import timedelta
from datetime import datetime, timedelta
from hashlib import sha256
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from jwt import PyJWTError, decode, encode
from rest_framework.fields import BooleanField, CharField
from authentik.core.models import Session, User
from authentik.core.models import AuthenticatedSession, Session, User
from authentik.events.middleware import audit_ignore
from authentik.flows.challenge import ChallengeResponse, WithUserInfoChallenge
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
@ -16,12 +19,20 @@ 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.password.stage import (
PLAN_CONTEXT_AUTHENTICATION_BACKEND,
PLAN_CONTEXT_METHOD_ARGS,
)
from authentik.stages.user_login.middleware import (
SESSION_KEY_BINDING_GEO,
SESSION_KEY_BINDING_NET,
)
from authentik.stages.user_login.models import UserLoginStage
from authentik.tenants.utils import get_unique_identifier
COOKIE_NAME_KNOWN_DEVICE = "authentik_device"
PLAN_CONTEXT_METHOD_ARGS_KNOWN_DEVICE = "known_device"
class UserLoginChallenge(WithUserInfoChallenge):
@ -78,12 +89,63 @@ class UserLoginStageView(ChallengeStageView):
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"""
# FIXME: identical function in authenticator_validate
@property
def cookie_jwt_key(self) -> str:
"""Signing key for Known-device Cookie for this stage"""
return sha256(
f"{get_unique_identifier()}:{self.executor.current_stage.pk.hex}".encode("ascii")
).hexdigest()
def set_known_device_cookie(self, user: User):
"""Set a cookie, valid longer than the session, which denotes that this user
has logged in on this device before."""
delta = timedelta_from_string(self.executor.current_stage.remember_device)
response = self.executor.stage_ok()
if delta.total_seconds() < 1:
return response
expiry = datetime.now() + delta
cookie_payload = {
"sub": user.uid,
"exp": expiry.timestamp(),
}
cookie = encode(cookie_payload, self.cookie_jwt_key)
response.set_cookie(
COOKIE_NAME_KNOWN_DEVICE,
cookie,
expires=expiry,
path=settings.SESSION_COOKIE_PATH,
domain=settings.SESSION_COOKIE_DOMAIN,
samesite=settings.SESSION_COOKIE_SAMESITE,
)
return response
def is_known_device(self, user: User):
"""Returns `True` if the login happened on a "known" device, by the same user."""
client_ip = ClientIPMiddleware.get_client_ip(self.request)
if AuthenticatedSession.objects.filter(session__last_ip=client_ip).exists():
return True
if COOKIE_NAME_KNOWN_DEVICE not in self.request.COOKIES:
return False
try:
payload = decode(
self.request.COOKIES[COOKIE_NAME_KNOWN_DEVICE], self.cookie_jwt_key, ["HS256"]
)
if payload["sub"] == user.uid:
return True
return False
except (PyJWTError, ValueError, TypeError) as exc:
self.logger.info("eh", exc=exc)
return False
def do_login(self, request: HttpRequest, remember: bool | None = None) -> HttpResponse:
"""Attach the currently pending user to the current session.
`remember` Argument should be `None` if not configured, otherwise set to `True`/`False`
representative of the user's choice."""
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
message = _("No Pending user to login.")
messages.error(request, message)
self.logger.debug(message)
self.logger.warning(message)
return self.executor.stage_invalid()
backend = self.executor.plan.context.get(
PLAN_CONTEXT_AUTHENTICATION_BACKEND, BACKEND_INBUILT
@ -91,8 +153,13 @@ 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 = self.set_session_duration(remember)
delta = self.set_session_duration(bool(remember))
self.set_session_ip()
# Check if the login request is coming from a known device
self.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {})
self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS].setdefault(
PLAN_CONTEXT_METHOD_ARGS_KNOWN_DEVICE, self.is_known_device(user)
)
# the `user_logged_in` signal will update the user to write the `last_login` field
# which we don't want to log as we already have a dedicated login event
with audit_ignore():
@ -112,4 +179,6 @@ class UserLoginStageView(ChallengeStageView):
Session.objects.filter(
authenticatedsession__user=user,
).exclude(session_key=self.request.session.session_key).delete()
if remember is None:
return self.set_known_device_cookie(user)
return self.executor.stage_ok()

View File

@ -8,17 +8,18 @@ from django.urls import reverse
from django.utils.timezone import now
from authentik.core.models import AuthenticatedSession, Session
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.lib.utils.time import timedelta_from_string
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.user_login.middleware import (
SESSION_KEY_BINDING_NET,
BoundSessionMiddleware,
SessionBindingBroken,
logout_extra,
@ -31,7 +32,7 @@ class TestUserLoginStage(FlowTestCase):
def setUp(self):
super().setUp()
self.user = create_test_admin_user()
self.user = create_test_user()
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
self.stage = UserLoginStage.objects.create(name="login")
@ -247,3 +248,21 @@ class TestUserLoginStage(FlowTestCase):
request.session = self.client.session
request.user = self.user
logout_extra(request, cm.exception)
def test_session_binding_broken(self):
"""Test session binding"""
self.client.force_login(self.user)
session = self.client.session
session[Session.Keys.LAST_IP] = "192.0.2.1"
session[SESSION_KEY_BINDING_NET] = NetworkBinding.BIND_ASN_NETWORK_IP
session.save()
res = self.client.get(reverse("authentik_api:user-me"))
self.assertEqual(res.status_code, 302)
self.assertEqual(
res.url,
reverse(
"authentik_flows:default-authentication",
)
+ f"?{NEXT_ARG_NAME}={reverse("authentik_api:user-me")}",
)

View File

@ -15540,6 +15540,12 @@
],
"title": "Geoip binding",
"description": "Bind sessions created by this stage to the configured GeoIP location"
},
"remember_device": {
"type": "string",
"minLength": 1,
"title": "Remember device",
"description": "When set to a non-zero value, authentik will save a cookie with a longer expiry,to remember the device the user is logging in from. (Format: hours=-1;minutes=-2;seconds=-3)"
}
},
"required": []

View File

@ -39772,6 +39772,10 @@ paths:
description: Number of results to return per page.
schema:
type: integer
- in: query
name: remember_device
schema:
type: string
- in: query
name: remember_me_offset
schema:
@ -55475,6 +55479,12 @@ components:
- $ref: '#/components/schemas/GeoipBindingEnum'
description: Bind sessions created by this stage to the configured GeoIP
location
remember_device:
type: string
minLength: 1
description: 'When set to a non-zero value, authentik will save a cookie
with a longer expiry,to remember the device the user is logging in from.
(Format: hours=-1;minutes=-2;seconds=-3)'
PatchedUserLogoutStageRequest:
type: object
description: UserLogoutStage Serializer
@ -60975,6 +60985,11 @@ components:
- $ref: '#/components/schemas/GeoipBindingEnum'
description: Bind sessions created by this stage to the configured GeoIP
location
remember_device:
type: string
description: 'When set to a non-zero value, authentik will save a cookie
with a longer expiry,to remember the device the user is logging in from.
(Format: hours=-1;minutes=-2;seconds=-3)'
required:
- component
- meta_model_name
@ -61016,6 +61031,12 @@ components:
- $ref: '#/components/schemas/GeoipBindingEnum'
description: Bind sessions created by this stage to the configured GeoIP
location
remember_device:
type: string
minLength: 1
description: 'When set to a non-zero value, authentik will save a cookie
with a longer expiry,to remember the device the user is logging in from.
(Format: hours=-1;minutes=-2;seconds=-3)'
required:
- name
UserLogoutStage:

View File

@ -95,6 +95,26 @@ 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("Remember device")}
required
name="rememberDevice"
>
<input
type="text"
value="${this.instance?.rememberDevice ?? "days=30"}"
class="pf-c-form-control pf-m-monospace"
autocomplete="off"
spellcheck="false"
required
/>
<p class="pf-c-form__helper-text">
${msg(
"If set to a duration above 0, a cookie will be stored for the duration specified which will allow authentik to know if the user is signing in from a new device.",
)}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Network binding")}
required