Compare commits
6 Commits
main
...
stages/use
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c0e2b270b | |||
| 045202bea4 | |||
| 7b0ed9106c | |||
| cfccf01598 | |||
| 5a3eb7cde3 | |||
| ae19fbf298 |
@ -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")
|
||||
|
||||
@ -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": {},
|
||||
|
||||
@ -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", ""),
|
||||
|
||||
@ -18,6 +18,7 @@ class UserLoginStageSerializer(StageSerializer):
|
||||
"remember_me_offset",
|
||||
"network_binding",
|
||||
"geoip_binding",
|
||||
"remember_device",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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],
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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]:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")}",
|
||||
)
|
||||
|
||||
@ -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": []
|
||||
|
||||
21
schema.yml
21
schema.yml
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user