diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index 8f4d39a52..35cf6a6fc 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -368,9 +368,9 @@ class AuthenticatorValidateStageView(ChallengeStageView): COOKIE_NAME_MFA, cookie, expires=expiry, - path="/", + path=settings.SESSION_COOKIE_PATH, domain=settings.SESSION_COOKIE_DOMAIN, - samesite="Lax", + samesite=settings.SESSION_COOKIE_SAMESITE, ) return response diff --git a/authentik/stages/user_login/api.py b/authentik/stages/user_login/api.py index f438c3ff0..c0acc8d5c 100644 --- a/authentik/stages/user_login/api.py +++ b/authentik/stages/user_login/api.py @@ -14,6 +14,7 @@ class UserLoginStageSerializer(StageSerializer): fields = StageSerializer.Meta.fields + [ "session_duration", "terminate_other_sessions", + "remember_me_offset", ] diff --git a/authentik/stages/user_login/migrations/0005_userloginstage_remember_me_offset.py b/authentik/stages/user_login/migrations/0005_userloginstage_remember_me_offset.py new file mode 100644 index 000000000..3ac589219 --- /dev/null +++ b/authentik/stages/user_login/migrations/0005_userloginstage_remember_me_offset.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.7 on 2023-03-15 13:16 + +from django.db import migrations, models + +import authentik.lib.utils.time + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_stages_user_login", "0004_userloginstage_terminate_other_sessions"), + ] + + operations = [ + migrations.AddField( + model_name="userloginstage", + name="remember_me_offset", + field=models.TextField( + default="seconds=0", + help_text="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)", + validators=[authentik.lib.utils.time.timedelta_string_validator], + ), + ), + ] diff --git a/authentik/stages/user_login/models.py b/authentik/stages/user_login/models.py index eec75f9a5..01c85b4fa 100644 --- a/authentik/stages/user_login/models.py +++ b/authentik/stages/user_login/models.py @@ -24,6 +24,15 @@ class UserLoginStage(Stage): terminate_other_sessions = models.BooleanField( default=False, help_text=_("Terminate all other sessions of the user logging in.") ) + remember_me_offset = models.TextField( + default="seconds=0", + validators=[timedelta_string_validator], + help_text=_( + "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)" + ), + ) @property def serializer(self) -> type[BaseSerializer]: diff --git a/authentik/stages/user_login/stage.py b/authentik/stages/user_login/stage.py index d08ef07f2..e4c27973d 100644 --- a/authentik/stages/user_login/stage.py +++ b/authentik/stages/user_login/stage.py @@ -3,23 +3,61 @@ 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 rest_framework.fields import BooleanField, CharField from authentik.core.models import AuthenticatedSession, User +from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUserInfoChallenge from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE -from authentik.flows.stage import StageView +from authentik.flows.stage import ChallengeStageView from authentik.lib.utils.time import timedelta_from_string from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND +from authentik.stages.user_login.models import UserLoginStage -class UserLoginStageView(StageView): +class UserLoginChallenge(WithUserInfoChallenge): + """Empty challenge""" + + component = CharField(default="ak-stage-user-login") + + +class UserLoginChallengeResponse(ChallengeResponse): + """User login challenge""" + + component = CharField(default="ak-stage-user-login") + + remember_me = BooleanField(required=True) + + +class UserLoginStageView(ChallengeStageView): """Finalise Authentication flow by logging the user in""" - def post(self, request: HttpRequest) -> HttpResponse: - """Wrapper for post requests""" - return self.get(request) + response_class = UserLoginChallengeResponse - def get(self, request: HttpRequest) -> HttpResponse: + def get_challenge(self, *args, **kwargs) -> UserLoginChallenge: + return UserLoginChallenge( + data={ + "type": ChallengeTypes.NATIVE.value, + } + ) + + def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Wrapper for post requests""" + stage: UserLoginStage = self.executor.current_stage + if timedelta_from_string(stage.remember_me_offset).total_seconds() > 0: + return super().post(request, *args, **kwargs) + return self.do_login(request) + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + stage: UserLoginStage = self.executor.current_stage + if timedelta_from_string(stage.remember_me_offset).total_seconds() > 0: + return super().get(request, *args, **kwargs) + return self.do_login(request) + + def challenge_valid(self, response: UserLoginChallengeResponse) -> HttpResponse: + return self.do_login(self.request, response.validated_data["remember_me"]) + + 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: message = _("No Pending user to login.") @@ -33,6 +71,9 @@ class UserLoginStageView(StageView): 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: @@ -47,7 +88,7 @@ class UserLoginStageView(StageView): backend=backend, user=user.username, flow_slug=self.executor.flow.slug, - session_duration=self.executor.current_stage.session_duration, + session_duration=delta, ) # Only show success message if we don't have a source in the flow # as sources show their own success messages diff --git a/authentik/stages/user_login/tests.py b/authentik/stages/user_login/tests.py index 6d0e39495..f63f8eb3e 100644 --- a/authentik/stages/user_login/tests.py +++ b/authentik/stages/user_login/tests.py @@ -116,6 +116,36 @@ class TestUserLoginStage(FlowTestCase): self.client.session.clear_expired() self.assertEqual(list(self.client.session.keys()), []) + def test_expiry_remember(self): + """Test with expiry""" + self.stage.session_duration = "seconds=2" + self.stage.remember_me_offset = "seconds=2" + 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() + + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + data={"remember_me": True}, + ) + self.assertEqual(response.status_code, 200) + self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) + self.assertNotEqual(list(self.client.session.keys()), []) + session_key = self.client.session.session_key + session = AuthenticatedSession.objects.filter(session_key=session_key).first() + self.assertAlmostEqual( + session.expires.timestamp() - now().timestamp(), + timedelta_from_string(self.stage.session_duration).total_seconds() + + timedelta_from_string(self.stage.remember_me_offset).total_seconds(), + delta=1, + ) + sleep(5) + self.client.session.clear_expired() + self.assertEqual(list(self.client.session.keys()), []) + @patch( "authentik.flows.views.executor.to_stage_response", TO_STAGE_RESPONSE_MOCK, diff --git a/internal/outpost/flow/const.go b/internal/outpost/flow/const.go index 7b4436a63..acecf3bd9 100644 --- a/internal/outpost/flow/const.go +++ b/internal/outpost/flow/const.go @@ -3,10 +3,11 @@ package flow type StageComponent string const ( + StageAccessDenied = StageComponent("ak-stage-access-denied") + StageAuthenticatorValidate = StageComponent("ak-stage-authenticator-validate") StageIdentification = StageComponent("ak-stage-identification") StagePassword = StageComponent("ak-stage-password") - StageAuthenticatorValidate = StageComponent("ak-stage-authenticator-validate") - StageAccessDenied = StageComponent("ak-stage-access-denied") + StageUserLogin = StageComponent("ak-stage-user-login") ) const ( diff --git a/internal/outpost/flow/executor.go b/internal/outpost/flow/executor.go index 5bcafd687..585159841 100644 --- a/internal/outpost/flow/executor.go +++ b/internal/outpost/flow/executor.go @@ -75,6 +75,7 @@ func NewFlowExecutor(ctx context.Context, flowSlug string, refConfig *api.Config StageIdentification: fe.solveChallenge_Identification, StagePassword: fe.solveChallenge_Password, StageAuthenticatorValidate: fe.solveChallenge_AuthenticatorValidate, + StageUserLogin: fe.solveChallenge_UserLogin, } // Create new http client that also sets the correct ip config := api.NewConfiguration() diff --git a/internal/outpost/flow/solvers.go b/internal/outpost/flow/solvers.go index de20b3cfc..a809571a3 100644 --- a/internal/outpost/flow/solvers.go +++ b/internal/outpost/flow/solvers.go @@ -30,6 +30,11 @@ func (fe *FlowExecutor) solveChallenge_Password(challenge *api.ChallengeTypes, r return api.PasswordChallengeResponseRequestAsFlowChallengeResponseRequest(r), nil } +func (fe *FlowExecutor) solveChallenge_UserLogin(challenge *api.ChallengeTypes, req api.ApiFlowsExecutorSolveRequest) (api.FlowChallengeResponseRequest, error) { + r := api.NewUserLoginChallengeResponseRequest(true) + return api.UserLoginChallengeResponseRequestAsFlowChallengeResponseRequest(r), nil +} + func (fe *FlowExecutor) solveChallenge_AuthenticatorValidate(challenge *api.ChallengeTypes, req api.ApiFlowsExecutorSolveRequest) (api.FlowChallengeResponseRequest, error) { // We only support duo and code-based authenticators, check if that's allowed var deviceChallenge *api.DeviceChallenge diff --git a/schema.yml b/schema.yml index 505a8e74d..fd4d1fddf 100644 --- a/schema.yml +++ b/schema.yml @@ -24994,6 +24994,10 @@ paths: description: Number of results to return per page. schema: type: integer + - in: query + name: remember_me_offset + schema: + type: string - name: search required: false in: query @@ -27515,6 +27519,7 @@ components: - $ref: '#/components/schemas/PromptChallenge' - $ref: '#/components/schemas/RedirectChallenge' - $ref: '#/components/schemas/ShellChallenge' + - $ref: '#/components/schemas/UserLoginChallenge' discriminator: propertyName: component mapping: @@ -27540,6 +27545,7 @@ components: ak-stage-prompt: '#/components/schemas/PromptChallenge' xak-flow-redirect: '#/components/schemas/RedirectChallenge' xak-flow-shell: '#/components/schemas/ShellChallenge' + ak-stage-user-login: '#/components/schemas/UserLoginChallenge' ClientTypeEnum: enum: - confidential @@ -29023,6 +29029,7 @@ components: - $ref: '#/components/schemas/PasswordChallengeResponseRequest' - $ref: '#/components/schemas/PlexAuthenticationChallengeResponseRequest' - $ref: '#/components/schemas/PromptChallengeResponseRequest' + - $ref: '#/components/schemas/UserLoginChallengeResponseRequest' discriminator: propertyName: component mapping: @@ -29044,6 +29051,7 @@ components: ak-stage-password: '#/components/schemas/PasswordChallengeResponseRequest' ak-source-plex: '#/components/schemas/PlexAuthenticationChallengeResponseRequest' ak-stage-prompt: '#/components/schemas/PromptChallengeResponseRequest' + ak-stage-user-login: '#/components/schemas/UserLoginChallengeResponseRequest' FlowDesignationEnum: enum: - authentication @@ -36807,6 +36815,12 @@ components: terminate_other_sessions: type: boolean description: Terminate all other sessions of the user logging in. + remember_me_offset: + type: string + minLength: 1 + 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)' PatchedUserLogoutStageRequest: type: object description: UserLogoutStage Serializer @@ -40170,6 +40184,43 @@ components: additionalProperties: {} required: - name + UserLoginChallenge: + type: object + description: Empty challenge + properties: + type: + $ref: '#/components/schemas/ChallengeChoices' + flow_info: + $ref: '#/components/schemas/ContextualFlowInfo' + component: + type: string + default: ak-stage-user-login + response_errors: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/ErrorDetail' + pending_user: + type: string + pending_user_avatar: + type: string + required: + - pending_user + - pending_user_avatar + - type + UserLoginChallengeResponseRequest: + type: object + description: User login challenge + properties: + component: + type: string + minLength: 1 + default: ak-stage-user-login + remember_me: + type: boolean + required: + - remember_me UserLoginStage: type: object description: UserLoginStage Serializer @@ -40208,6 +40259,11 @@ components: terminate_other_sessions: type: boolean description: Terminate all other sessions of the user logging in. + remember_me_offset: + type: string + 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)' required: - component - meta_model_name @@ -40234,6 +40290,12 @@ components: terminate_other_sessions: type: boolean description: Terminate all other sessions of the user logging in. + remember_me_offset: + type: string + minLength: 1 + 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)' required: - name UserLogoutStage: diff --git a/web/src/admin/stages/user_login/UserLoginStageForm.ts b/web/src/admin/stages/user_login/UserLoginStageForm.ts index 3d83fcc87..e2837ce2f 100644 --- a/web/src/admin/stages/user_login/UserLoginStageForm.ts +++ b/web/src/admin/stages/user_login/UserLoginStageForm.ts @@ -81,6 +81,22 @@ export class UserLoginStageForm extends ModelForm { + + +

+ ${t`If set to a duration above 0, the user will have the option to choose to "stay signed in", which will extend their session by the time specified here.`} +

+ +