diff --git a/authentik/core/models.py b/authentik/core/models.py index f81e846e1..0eb611a9d 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -22,7 +22,6 @@ from structlog.stdlib import get_logger from authentik.blueprints.models import ManagedModel from authentik.core.exceptions import PropertyMappingExpressionException -from authentik.core.signals import password_changed from authentik.core.types import UILoginButton, UserSettingSerializer from authentik.lib.avatars import get_avatar from authentik.lib.config import CONFIG @@ -189,6 +188,8 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser): def set_password(self, raw_password, signal=True): if self.pk and signal: + from authentik.core.signals import password_changed + password_changed.send(sender=self, user=self, password=raw_password) self.password_change_date = now() return super().set_password(raw_password) diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 74491ba4a..4120855dc 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -10,25 +10,25 @@ from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from django.http.request import HttpRequest +from authentik.core.models import Application, AuthenticatedSession + # Arguments: user: User, password: str password_changed = Signal() # Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage login_failed = Signal() if TYPE_CHECKING: - from authentik.core.models import AuthenticatedSession, User + from authentik.core.models import User -@receiver(post_save) +@receiver(post_save, sender=Application) def post_save_application(sender: type[Model], instance, created: bool, **_): """Clear user's application cache upon application creation""" from authentik.core.api.applications import user_app_cache_key - from authentik.core.models import Application - if sender != Application: - return if not created: # pragma: no cover return + # Also delete user application cache keys = cache.keys(user_app_cache_key("*")) cache.delete_many(keys) @@ -37,7 +37,6 @@ def post_save_application(sender: type[Model], instance, created: bool, **_): @receiver(user_logged_in) def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): """Create an AuthenticatedSession from request""" - from authentik.core.models import AuthenticatedSession session = AuthenticatedSession.from_request(request, user) if session: @@ -47,18 +46,11 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_): @receiver(user_logged_out) def user_logged_out_session(sender, request: HttpRequest, user: "User", **_): """Delete AuthenticatedSession if it exists""" - from authentik.core.models import AuthenticatedSession - AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete() -@receiver(pre_delete) +@receiver(pre_delete, sender=AuthenticatedSession) def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): """Delete session when authenticated session is deleted""" - from authentik.core.models import AuthenticatedSession - - if sender != AuthenticatedSession: - return - cache_key = f"{KEY_PREFIX}{instance.session_key}" cache.delete(cache_key) diff --git a/authentik/stages/user_login/api.py b/authentik/stages/user_login/api.py index ca5937fa8..f438c3ff0 100644 --- a/authentik/stages/user_login/api.py +++ b/authentik/stages/user_login/api.py @@ -13,6 +13,7 @@ class UserLoginStageSerializer(StageSerializer): model = UserLoginStage fields = StageSerializer.Meta.fields + [ "session_duration", + "terminate_other_sessions", ] diff --git a/authentik/stages/user_login/migrations/0004_userloginstage_terminate_other_sessions.py b/authentik/stages/user_login/migrations/0004_userloginstage_terminate_other_sessions.py new file mode 100644 index 000000000..e6e891230 --- /dev/null +++ b/authentik/stages/user_login/migrations/0004_userloginstage_terminate_other_sessions.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.7 on 2023-02-22 11:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_stages_user_login", "0003_session_duration_delta"), + ] + + operations = [ + migrations.AddField( + model_name="userloginstage", + name="terminate_other_sessions", + field=models.BooleanField( + default=False, help_text="Terminate all other sessions of the user logging in." + ), + ), + ] diff --git a/authentik/stages/user_login/models.py b/authentik/stages/user_login/models.py index 8ba581aa8..eec75f9a5 100644 --- a/authentik/stages/user_login/models.py +++ b/authentik/stages/user_login/models.py @@ -21,6 +21,9 @@ class UserLoginStage(Stage): "(Format: hours=-1;minutes=-2;seconds=-3)" ), ) + terminate_other_sessions = models.BooleanField( + default=False, help_text=_("Terminate all other sessions of the user logging in.") + ) @property def serializer(self) -> type[BaseSerializer]: diff --git a/authentik/stages/user_login/stage.py b/authentik/stages/user_login/stage.py index ec26c2de3..f07af9b15 100644 --- a/authentik/stages/user_login/stage.py +++ b/authentik/stages/user_login/stage.py @@ -4,7 +4,7 @@ from django.contrib.auth import login from django.http import HttpRequest, HttpResponse from django.utils.translation import gettext as _ -from authentik.core.models import User +from authentik.core.models import AuthenticatedSession, User from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE from authentik.flows.stage import StageView from authentik.lib.utils.time import timedelta_from_string @@ -56,4 +56,8 @@ class UserLoginStageView(StageView): # as sources show their own success messages if not self.executor.plan.context.get(PLAN_CONTEXT_SOURCE, None): messages.success(self.request, _("Successfully logged in!")) + if self.executor.current_stage.terminate_other_sessions: + AuthenticatedSession.objects.filter( + user=user, + ).exclude(session_key=self.request.session.session_key).delete() return self.executor.stage_ok() diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py index 21753c9d9..c84fda793 100644 --- a/authentik/stages/user_login/tests.py +++ b/authentik/stages/user_login/tests.py @@ -2,8 +2,11 @@ from time import sleep from unittest.mock import patch +from django.contrib.sessions.backends.cache import KEY_PREFIX +from django.core.cache import cache from django.urls import reverse +from authentik.core.models import AuthenticatedSession from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.flows.markers import StageMarker from authentik.flows.models import FlowDesignation, FlowStageBinding @@ -11,6 +14,8 @@ 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.lib.generators import generate_id +from authentik.lib.utils.http import DEFAULT_IP from authentik.stages.user_login.models import UserLoginStage @@ -55,6 +60,33 @@ class TestUserLoginStage(FlowTestCase): self.assertEqual(response.status_code, 200) self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) + def test_terminate_other_sessions(self): + """Test terminate_other_sessions""" + self.stage.terminate_other_sessions = True + self.stage.save() + plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + key = generate_id() + other_session = AuthenticatedSession.objects.create( + user=self.user, + session_key=key, + last_ip=DEFAULT_IP, + ) + cache.set(f"{KEY_PREFIX}{other_session.session_key}", "foo") + + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + + self.assertEqual(response.status_code, 200) + self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) + self.assertFalse(AuthenticatedSession.objects.filter(session_key=key)) + self.assertFalse(cache.has_key(f"{KEY_PREFIX}{key}")) + def test_expiry(self): """Test with expiry""" self.stage.session_duration = "seconds=2" diff --git a/schema.yml b/schema.yml index d274d834c..7709b63b0 100644 --- a/schema.yml +++ b/schema.yml @@ -24129,6 +24129,10 @@ paths: schema: type: string format: uuid + - in: query + name: terminate_other_sessions + schema: + type: boolean tags: - stages security: @@ -35024,6 +35028,9 @@ components: minLength: 1 description: 'Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)' + terminate_other_sessions: + type: boolean + description: Terminate all other sessions of the user logging in. PatchedUserLogoutStageRequest: type: object description: UserLogoutStage Serializer @@ -38000,6 +38007,9 @@ components: type: string description: 'Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)' + terminate_other_sessions: + type: boolean + description: Terminate all other sessions of the user logging in. required: - component - meta_model_name @@ -38023,6 +38033,9 @@ components: minLength: 1 description: 'Determines how long a session lasts. Default of 0 means that the sessions lasts until the browser is closed. (Format: hours=-1;minutes=-2;seconds=-3)' + terminate_other_sessions: + type: boolean + description: Terminate all other sessions of the user logging in. required: - name UserLogoutStage: diff --git a/web/src/admin/stages/user_login/UserLoginStageForm.ts b/web/src/admin/stages/user_login/UserLoginStageForm.ts index 502fe65a0..3d83fcc87 100644 --- a/web/src/admin/stages/user_login/UserLoginStageForm.ts +++ b/web/src/admin/stages/user_login/UserLoginStageForm.ts @@ -81,6 +81,24 @@ export class UserLoginStageForm extends ModelForm { + + +

+ ${t`When enabled, all previous sessions of the user will be terminated.`} +

+
`; diff --git a/web/src/elements/user/SessionList.ts b/web/src/elements/user/SessionList.ts index 6761bd92b..c7bc35eac 100644 --- a/web/src/elements/user/SessionList.ts +++ b/web/src/elements/user/SessionList.ts @@ -29,12 +29,7 @@ export class AuthenticatedSessionList extends Table { order = "-expires"; columns(): TableColumn[] { - return [ - new TableColumn(t`Last IP`, "last_ip"), - new TableColumn(t`Browser`, "user_agent"), - new TableColumn(t`Device`, "user_agent"), - new TableColumn(t`Expires`, "expires"), - ]; + return [new TableColumn(t`Last IP`, "last_ip"), new TableColumn(t`Expires`, "expires")]; } renderToolbarSelected(): TemplateResult { @@ -67,9 +62,10 @@ export class AuthenticatedSessionList extends Table { row(item: AuthenticatedSession): TemplateResult[] { return [ - html`${item.lastIp}`, - html`${item.userAgent.userAgent?.family}`, - html`${item.userAgent.os?.family}`, + html`
+ ${item.current ? html`${t`(Current session)`} ` : html``}${item.lastIp} +
+ ${item.userAgent.userAgent?.family}, ${item.userAgent.os?.family}`, html`${item.expires?.toLocaleString()}`, ]; } diff --git a/website/docs/flow/stages/user_login.md b/website/docs/flow/stages/user_login.md index 17bb6da14..621d6727e 100644 --- a/website/docs/flow/stages/user_login.md +++ b/website/docs/flow/stages/user_login.md @@ -25,3 +25,7 @@ You can set the session to expire after any duration using the syntax of `hours= - Weeks All values accept floating-point values. + +## Terminate other sessions + +When enabled, previous sessions of the user logging in will be revoked. This has no affect on OAuth refresh tokens.