stages/user_login: stay logged in (#4958)

* add initial remember me offset

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add to go executor

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add ui for user login stage

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-03-15 20:21:05 +01:00 committed by GitHub
parent fd9293e3e8
commit eaf56f4f3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 311 additions and 18 deletions

View File

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

View File

@ -14,6 +14,7 @@ class UserLoginStageSerializer(StageSerializer):
fields = StageSerializer.Meta.fields + [
"session_duration",
"terminate_other_sessions",
"remember_me_offset",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -81,6 +81,22 @@ export class UserLoginStageForm extends ModelForm<UserLoginStage, string> {
</a>
</ak-alert>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Stay signed in offset`}
?required=${true}
name="rememberMeOffset"
>
<input
type="text"
value="${first(this.instance?.rememberMeOffset, "seconds=0")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${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.`}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="terminateOtherSessions">
<label class="pf-c-switch">
<input

View File

@ -372,6 +372,12 @@ export class FlowExecutor extends Interface implements StageHost {
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-validate>`;
case "ak-stage-user-login":
await import("@goauthentik/flow/stages/user_login/UserLoginStage");
return html`<ak-stage-user-login
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-user-login>`;
// Sources
case "ak-source-plex":
await import("@goauthentik/flow/sources/plex/PlexLoginInit");

View File

@ -32,7 +32,7 @@ export class BaseStage<Tin, Tout> extends AKElement {
@property({ attribute: false })
challenge!: Tin;
async submitForm(e: Event, defaults?: KeyUnknown): Promise<boolean> {
async submitForm(e: Event, defaults?: Tout): Promise<boolean> {
e.preventDefault();
const object: KeyUnknown = defaults || {};
const form = new FormData(this.shadowRoot?.querySelector("form") || undefined);

View File

@ -41,7 +41,9 @@ export class ConsentStage extends BaseStage<ConsentChallenge, ConsentChallengeRe
renderNoPrevious(): TemplateResult {
return html`
<div class="pf-c-form__group">
<p id="header-text" class="pf-u-mb-xl">${this.challenge.headerText}</p>
<h3 id="header-text" class="pf-c-title pf-m-xl pf-u-mb-xl">
${this.challenge.headerText}
</h3>
${this.challenge.permissions.length > 0
? html`
<p class="pf-u-mb-sm">
@ -59,7 +61,9 @@ export class ConsentStage extends BaseStage<ConsentChallenge, ConsentChallengeRe
renderAdditional(): TemplateResult {
return html`
<div class="pf-c-form__group">
<p id="header-text" class="pf-u-mb-xl">${this.challenge.headerText}</p>
<h3 id="header-text" class="pf-c-title pf-m-xl pf-u-mb-xl">
${this.challenge.headerText}
</h3>
${this.challenge.permissions.length > 0
? html`
<p class="pf-u-mb-sm">

View File

@ -0,0 +1,88 @@
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { t } from "@lingui/macro";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css";
import { UserLoginChallenge, UserLoginChallengeResponseRequest } from "@goauthentik/api";
@customElement("ak-stage-user-login")
export class PasswordStage extends BaseStage<
UserLoginChallenge,
UserLoginChallengeResponseRequest
> {
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFForm, PFFormControl, PFSpacing, PFButton, PFTitle];
}
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-empty-state ?loading="${true}" header=${t`Loading`}> </ak-empty-state>`;
}
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form">
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${t`Not you?`}</a
>
</div>
</ak-form-static>
<div class="pf-c-form__group">
<h3 id="header-text" class="pf-c-title pf-m-xl pf-u-mb-xl">
${t`Stay signed in?`}
</h3>
<p class="pf-u-mb-sm">
${t`Select Yes to reduce the number of times you're asked to sign in.`}
</p>
</div>
<div class="pf-c-form__group pf-m-action">
<button
@click=${(e: Event) => {
this.submitForm(e, {
rememberMe: true,
});
}}
class="pf-c-button pf-m-primary"
>
${t`Yes`}
</button>
<button
@click=${(e: Event) => {
this.submitForm(e, {
rememberMe: false,
});
}}
class="pf-c-button pf-m-secondary"
>
${t`No`}
</button>
</div>
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links"></ul>
</footer>`;
}
}

View File

@ -36,7 +36,7 @@ Flows are designated for a single purpose. This designation changes when a flow
This is designates a flow to be used for authentication.
The authentication flow should always contain a [**User Login**](stages/user_login.md) stage, which attaches the staged user to the current session.
The authentication flow should always contain a [**User Login**](stages/user_login/index.md) stage, which attaches the staged user to the current session.
#### Invalidation

View File

@ -26,6 +26,12 @@ You can set the session to expire after any duration using the syntax of `hours=
All values accept floating-point values.
## Stay signed in offset
When this is set to a higher value than the default _seconds=0_, a prompt is shown, allowing the users to choose if their session should be extended or not. The same syntax as for _Session duration_ applies.
![](./stay_signed_in.png)
## Terminate other sessions
When enabled, previous sessions of the user logging in will be revoked. This has no affect on OAuth refresh tokens.

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -2,4 +2,4 @@
title: User logout stage
---
Opposite stage of [User Login Stages](user_login.md). It removes the user from the current session.
Opposite stage of [User Login Stages](user_login/index.md). It removes the user from the current session.

View File

@ -89,7 +89,7 @@ The following stages are supported:
SMS-based authenticators are not supported as they require a code to be sent from authentik, which is not possible during the bind.
- [User Logout](../../flow/stages/user_logout.md)
- [User Login](../../flow/stages/user_login.md)
- [User Login](../../flow/stages/user_login/index.md)
- [Deny](../../flow/stages/deny.md)
#### Direct bind

View File

@ -171,7 +171,7 @@ module.exports = {
"flow/stages/password/index",
"flow/stages/prompt/index",
"flow/stages/user_delete",
"flow/stages/user_login",
"flow/stages/user_login/index",
"flow/stages/user_logout",
"flow/stages/user_write",
],