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_NAME_MFA,
cookie, cookie,
expires=expiry, expires=expiry,
path="/", path=settings.SESSION_COOKIE_PATH,
domain=settings.SESSION_COOKIE_DOMAIN, domain=settings.SESSION_COOKIE_DOMAIN,
samesite="Lax", samesite=settings.SESSION_COOKIE_SAMESITE,
) )
return response return response

View File

@ -14,6 +14,7 @@ class UserLoginStageSerializer(StageSerializer):
fields = StageSerializer.Meta.fields + [ fields = StageSerializer.Meta.fields + [
"session_duration", "session_duration",
"terminate_other_sessions", "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( terminate_other_sessions = models.BooleanField(
default=False, help_text=_("Terminate all other sessions of the user logging in.") 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 @property
def serializer(self) -> type[BaseSerializer]: def serializer(self) -> type[BaseSerializer]:

View File

@ -3,23 +3,61 @@ from django.contrib import messages
from django.contrib.auth import login from django.contrib.auth import login
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework.fields import BooleanField, CharField
from authentik.core.models import AuthenticatedSession, User 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.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.lib.utils.time import timedelta_from_string
from authentik.stages.password import BACKEND_INBUILT 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
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""" """Finalise Authentication flow by logging the user in"""
def post(self, request: HttpRequest) -> HttpResponse: response_class = UserLoginChallengeResponse
"""Wrapper for post requests"""
return self.get(request)
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""" """Attach the currently pending user to the current session"""
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
message = _("No Pending user to login.") message = _("No Pending user to login.")
@ -33,6 +71,9 @@ class UserLoginStageView(StageView):
if not user.is_active: if not user.is_active:
self.logger.warning("User is not active, login will not work.") self.logger.warning("User is not active, login will not work.")
delta = timedelta_from_string(self.executor.current_stage.session_duration) 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: if delta.total_seconds() == 0:
self.request.session.set_expiry(0) self.request.session.set_expiry(0)
else: else:
@ -47,7 +88,7 @@ class UserLoginStageView(StageView):
backend=backend, backend=backend,
user=user.username, user=user.username,
flow_slug=self.executor.flow.slug, 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 # Only show success message if we don't have a source in the flow
# as sources show their own success messages # as sources show their own success messages

View File

@ -116,6 +116,36 @@ class TestUserLoginStage(FlowTestCase):
self.client.session.clear_expired() self.client.session.clear_expired()
self.assertEqual(list(self.client.session.keys()), []) 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( @patch(
"authentik.flows.views.executor.to_stage_response", "authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK, TO_STAGE_RESPONSE_MOCK,

View File

@ -3,10 +3,11 @@ package flow
type StageComponent string type StageComponent string
const ( const (
StageAccessDenied = StageComponent("ak-stage-access-denied")
StageAuthenticatorValidate = StageComponent("ak-stage-authenticator-validate")
StageIdentification = StageComponent("ak-stage-identification") StageIdentification = StageComponent("ak-stage-identification")
StagePassword = StageComponent("ak-stage-password") StagePassword = StageComponent("ak-stage-password")
StageAuthenticatorValidate = StageComponent("ak-stage-authenticator-validate") StageUserLogin = StageComponent("ak-stage-user-login")
StageAccessDenied = StageComponent("ak-stage-access-denied")
) )
const ( const (

View File

@ -75,6 +75,7 @@ func NewFlowExecutor(ctx context.Context, flowSlug string, refConfig *api.Config
StageIdentification: fe.solveChallenge_Identification, StageIdentification: fe.solveChallenge_Identification,
StagePassword: fe.solveChallenge_Password, StagePassword: fe.solveChallenge_Password,
StageAuthenticatorValidate: fe.solveChallenge_AuthenticatorValidate, StageAuthenticatorValidate: fe.solveChallenge_AuthenticatorValidate,
StageUserLogin: fe.solveChallenge_UserLogin,
} }
// Create new http client that also sets the correct ip // Create new http client that also sets the correct ip
config := api.NewConfiguration() config := api.NewConfiguration()

View File

@ -30,6 +30,11 @@ func (fe *FlowExecutor) solveChallenge_Password(challenge *api.ChallengeTypes, r
return api.PasswordChallengeResponseRequestAsFlowChallengeResponseRequest(r), nil 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) { 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 // We only support duo and code-based authenticators, check if that's allowed
var deviceChallenge *api.DeviceChallenge var deviceChallenge *api.DeviceChallenge

View File

@ -24994,6 +24994,10 @@ paths:
description: Number of results to return per page. description: Number of results to return per page.
schema: schema:
type: integer type: integer
- in: query
name: remember_me_offset
schema:
type: string
- name: search - name: search
required: false required: false
in: query in: query
@ -27515,6 +27519,7 @@ components:
- $ref: '#/components/schemas/PromptChallenge' - $ref: '#/components/schemas/PromptChallenge'
- $ref: '#/components/schemas/RedirectChallenge' - $ref: '#/components/schemas/RedirectChallenge'
- $ref: '#/components/schemas/ShellChallenge' - $ref: '#/components/schemas/ShellChallenge'
- $ref: '#/components/schemas/UserLoginChallenge'
discriminator: discriminator:
propertyName: component propertyName: component
mapping: mapping:
@ -27540,6 +27545,7 @@ components:
ak-stage-prompt: '#/components/schemas/PromptChallenge' ak-stage-prompt: '#/components/schemas/PromptChallenge'
xak-flow-redirect: '#/components/schemas/RedirectChallenge' xak-flow-redirect: '#/components/schemas/RedirectChallenge'
xak-flow-shell: '#/components/schemas/ShellChallenge' xak-flow-shell: '#/components/schemas/ShellChallenge'
ak-stage-user-login: '#/components/schemas/UserLoginChallenge'
ClientTypeEnum: ClientTypeEnum:
enum: enum:
- confidential - confidential
@ -29023,6 +29029,7 @@ components:
- $ref: '#/components/schemas/PasswordChallengeResponseRequest' - $ref: '#/components/schemas/PasswordChallengeResponseRequest'
- $ref: '#/components/schemas/PlexAuthenticationChallengeResponseRequest' - $ref: '#/components/schemas/PlexAuthenticationChallengeResponseRequest'
- $ref: '#/components/schemas/PromptChallengeResponseRequest' - $ref: '#/components/schemas/PromptChallengeResponseRequest'
- $ref: '#/components/schemas/UserLoginChallengeResponseRequest'
discriminator: discriminator:
propertyName: component propertyName: component
mapping: mapping:
@ -29044,6 +29051,7 @@ components:
ak-stage-password: '#/components/schemas/PasswordChallengeResponseRequest' ak-stage-password: '#/components/schemas/PasswordChallengeResponseRequest'
ak-source-plex: '#/components/schemas/PlexAuthenticationChallengeResponseRequest' ak-source-plex: '#/components/schemas/PlexAuthenticationChallengeResponseRequest'
ak-stage-prompt: '#/components/schemas/PromptChallengeResponseRequest' ak-stage-prompt: '#/components/schemas/PromptChallengeResponseRequest'
ak-stage-user-login: '#/components/schemas/UserLoginChallengeResponseRequest'
FlowDesignationEnum: FlowDesignationEnum:
enum: enum:
- authentication - authentication
@ -36807,6 +36815,12 @@ components:
terminate_other_sessions: terminate_other_sessions:
type: boolean type: boolean
description: Terminate all other sessions of the user logging in. 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: PatchedUserLogoutStageRequest:
type: object type: object
description: UserLogoutStage Serializer description: UserLogoutStage Serializer
@ -40170,6 +40184,43 @@ components:
additionalProperties: {} additionalProperties: {}
required: required:
- name - 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: UserLoginStage:
type: object type: object
description: UserLoginStage Serializer description: UserLoginStage Serializer
@ -40208,6 +40259,11 @@ components:
terminate_other_sessions: terminate_other_sessions:
type: boolean type: boolean
description: Terminate all other sessions of the user logging in. 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: required:
- component - component
- meta_model_name - meta_model_name
@ -40234,6 +40290,12 @@ components:
terminate_other_sessions: terminate_other_sessions:
type: boolean type: boolean
description: Terminate all other sessions of the user logging in. 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: required:
- name - name
UserLogoutStage: UserLogoutStage:

View File

@ -81,6 +81,22 @@ export class UserLoginStageForm extends ModelForm<UserLoginStage, string> {
</a> </a>
</ak-alert> </ak-alert>
</ak-form-element-horizontal> </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"> <ak-form-element-horizontal name="terminateOtherSessions">
<label class="pf-c-switch"> <label class="pf-c-switch">
<input <input

View File

@ -372,6 +372,12 @@ export class FlowExecutor extends Interface implements StageHost {
.host=${this as StageHost} .host=${this as StageHost}
.challenge=${this.challenge} .challenge=${this.challenge}
></ak-stage-authenticator-validate>`; ></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 // Sources
case "ak-source-plex": case "ak-source-plex":
await import("@goauthentik/flow/sources/plex/PlexLoginInit"); await import("@goauthentik/flow/sources/plex/PlexLoginInit");

View File

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

View File

@ -41,7 +41,9 @@ export class ConsentStage extends BaseStage<ConsentChallenge, ConsentChallengeRe
renderNoPrevious(): TemplateResult { renderNoPrevious(): TemplateResult {
return html` return html`
<div class="pf-c-form__group"> <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 ${this.challenge.permissions.length > 0
? html` ? html`
<p class="pf-u-mb-sm"> <p class="pf-u-mb-sm">
@ -59,7 +61,9 @@ export class ConsentStage extends BaseStage<ConsentChallenge, ConsentChallengeRe
renderAdditional(): TemplateResult { renderAdditional(): TemplateResult {
return html` return html`
<div class="pf-c-form__group"> <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 ${this.challenge.permissions.length > 0
? html` ? html`
<p class="pf-u-mb-sm"> <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. 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 #### 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. 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 ## Terminate other sessions
When enabled, previous sessions of the user logging in will be revoked. This has no affect on OAuth refresh tokens. 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 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. 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 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) - [Deny](../../flow/stages/deny.md)
#### Direct bind #### Direct bind

View File

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