stages/user_login: stay logged in ()

* 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
authentik/stages
internal/outpost/flow
schema.yml
web/src
admin/stages/user_login
flow
website

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

(image error) 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",
],