stages/authenticator_validate: autoselect last used 2fa device (#11087)
* authenticator_validate: autoselect last used device class * improve usability of `AuthenticatorValidationStage` * don't automatically offer the recovery key authenticator validation I believe this could confuse users more than help them * web: move mutator block into the `willUpdate` override Removed the section of code from the renderer that updates the state of the component; Mutating in the middle of a render is strongly discouraged. This block contains an algorithm for determining if the selectedDeviceChallenge should be set and how; since `selectedDeviceChallenge` is a state, we don't want to be changing it outside of those lifecycle methods that do not trigger a rerender. * web: move styles() to top of class, extract custom CSS to a named block. * lint: collapse multiple early returns, missing curly brace. * autoselect device only once even if the user only has 1 device * make `DeviceChallenge.last_used` nullable instead of optional * clarify button text * fix typo * add docs for automatic device selection * update docs Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com> Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com> * fix punctuation --------- Signed-off-by: Simonyi Gergő <28359278+gergosimonyi@users.noreply.github.com> Co-authored-by: Ken Sternberg <ken@goauthentik.io> Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
This commit is contained in:
@ -8,7 +8,7 @@ from django.http.response import Http404
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.translation import gettext as __
|
from django.utils.translation import gettext as __
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework.fields import CharField
|
from rest_framework.fields import CharField, DateTimeField
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
from webauthn import options_to_json
|
from webauthn import options_to_json
|
||||||
@ -45,6 +45,7 @@ class DeviceChallenge(PassiveSerializer):
|
|||||||
device_class = CharField()
|
device_class = CharField()
|
||||||
device_uid = CharField()
|
device_uid = CharField()
|
||||||
challenge = JSONDictField()
|
challenge = JSONDictField()
|
||||||
|
last_used = DateTimeField(allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
def get_challenge_for_device(
|
def get_challenge_for_device(
|
||||||
|
|||||||
@ -217,6 +217,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
|||||||
"device_class": device_class,
|
"device_class": device_class,
|
||||||
"device_uid": device.pk,
|
"device_uid": device.pk,
|
||||||
"challenge": get_challenge_for_device(self.request, stage, device),
|
"challenge": get_challenge_for_device(self.request, stage, device),
|
||||||
|
"last_used": device.last_used,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
challenge.is_valid()
|
challenge.is_valid()
|
||||||
@ -237,6 +238,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
|||||||
self.request,
|
self.request,
|
||||||
self.executor.current_stage,
|
self.executor.current_stage,
|
||||||
),
|
),
|
||||||
|
"last_used": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
challenge.is_valid()
|
challenge.is_valid()
|
||||||
|
|||||||
@ -107,6 +107,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase):
|
|||||||
"device_class": "sms",
|
"device_class": "sms",
|
||||||
"device_uid": str(device.pk),
|
"device_uid": str(device.pk),
|
||||||
"challenge": {},
|
"challenge": {},
|
||||||
|
"last_used": None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -169,6 +169,7 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
|||||||
"device_class": "baz",
|
"device_class": "baz",
|
||||||
"device_uid": "quox",
|
"device_uid": "quox",
|
||||||
"challenge": {},
|
"challenge": {},
|
||||||
|
"last_used": None,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -188,6 +189,7 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
|||||||
"device_class": "static",
|
"device_class": "static",
|
||||||
"device_uid": "1",
|
"device_uid": "1",
|
||||||
"challenge": {},
|
"challenge": {},
|
||||||
|
"last_used": None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -274,6 +274,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
||||||
"device_uid": device.pk,
|
"device_uid": device.pk,
|
||||||
"challenge": {},
|
"challenge": {},
|
||||||
|
"last_used": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
session[SESSION_KEY_PLAN] = plan
|
session[SESSION_KEY_PLAN] = plan
|
||||||
@ -352,6 +353,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
||||||
"device_uid": device.pk,
|
"device_uid": device.pk,
|
||||||
"challenge": {},
|
"challenge": {},
|
||||||
|
"last_used": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
session[SESSION_KEY_PLAN] = plan
|
session[SESSION_KEY_PLAN] = plan
|
||||||
@ -432,6 +434,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
|
|||||||
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
"device_class": device.__class__.__name__.lower().replace("device", ""),
|
||||||
"device_uid": device.pk,
|
"device_uid": device.pk,
|
||||||
"challenge": {},
|
"challenge": {},
|
||||||
|
"last_used": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
session[SESSION_KEY_PLAN] = plan
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
|||||||
10
schema.yml
10
schema.yml
@ -40204,10 +40204,15 @@ components:
|
|||||||
challenge:
|
challenge:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
|
last_used:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
required:
|
required:
|
||||||
- challenge
|
- challenge
|
||||||
- device_class
|
- device_class
|
||||||
- device_uid
|
- device_uid
|
||||||
|
- last_used
|
||||||
DeviceChallengeRequest:
|
DeviceChallengeRequest:
|
||||||
type: object
|
type: object
|
||||||
description: Single device challenge
|
description: Single device challenge
|
||||||
@ -40221,10 +40226,15 @@ components:
|
|||||||
challenge:
|
challenge:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: {}
|
additionalProperties: {}
|
||||||
|
last_used:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
required:
|
required:
|
||||||
- challenge
|
- challenge
|
||||||
- device_class
|
- device_class
|
||||||
- device_uid
|
- device_uid
|
||||||
|
- last_used
|
||||||
DeviceClassesEnum:
|
DeviceClassesEnum:
|
||||||
enum:
|
enum:
|
||||||
- static
|
- static
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { BaseStage, StageHost, SubmitOptions } from "@goauthentik/flow/stages/ba
|
|||||||
import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage";
|
import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
|
||||||
import { customElement, state } from "lit/decorators.js";
|
import { customElement, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||||
@ -25,6 +25,37 @@ import {
|
|||||||
FlowsApi,
|
FlowsApi,
|
||||||
} from "@goauthentik/api";
|
} from "@goauthentik/api";
|
||||||
|
|
||||||
|
const customCSS = css`
|
||||||
|
ul {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
ul > li:not(:last-child) {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.authenticator-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
:host([theme="dark"]) .authenticator-button {
|
||||||
|
color: var(--ak-dark-foreground) !important;
|
||||||
|
}
|
||||||
|
i {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
width: 3rem;
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.right > * {
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
@customElement("ak-stage-authenticator-validate")
|
@customElement("ak-stage-authenticator-validate")
|
||||||
export class AuthenticatorValidateStage
|
export class AuthenticatorValidateStage
|
||||||
extends BaseStage<
|
extends BaseStage<
|
||||||
@ -33,6 +64,10 @@ export class AuthenticatorValidateStage
|
|||||||
>
|
>
|
||||||
implements StageHost
|
implements StageHost
|
||||||
{
|
{
|
||||||
|
static get styles(): CSSResult[] {
|
||||||
|
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, customCSS];
|
||||||
|
}
|
||||||
|
|
||||||
flowSlug = "";
|
flowSlug = "";
|
||||||
|
|
||||||
set loading(value: boolean) {
|
set loading(value: boolean) {
|
||||||
@ -47,14 +82,18 @@ export class AuthenticatorValidateStage
|
|||||||
return this.host.brand;
|
return this.host.brand;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@state()
|
||||||
|
_firstInitialized: boolean = false;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
_selectedDeviceChallenge?: DeviceChallenge;
|
_selectedDeviceChallenge?: DeviceChallenge;
|
||||||
|
|
||||||
set selectedDeviceChallenge(value: DeviceChallenge | undefined) {
|
set selectedDeviceChallenge(value: DeviceChallenge | undefined) {
|
||||||
const previousChallenge = this._selectedDeviceChallenge;
|
const previousChallenge = this._selectedDeviceChallenge;
|
||||||
this._selectedDeviceChallenge = value;
|
this._selectedDeviceChallenge = value;
|
||||||
if (!value) return;
|
if (value === undefined || value === previousChallenge) {
|
||||||
if (value === previousChallenge) return;
|
return;
|
||||||
|
}
|
||||||
// We don't use this.submit here, as we don't want to advance the flow.
|
// We don't use this.submit here, as we don't want to advance the flow.
|
||||||
// We just want to notify the backend which challenge has been selected.
|
// We just want to notify the backend which challenge has been selected.
|
||||||
new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({
|
new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({
|
||||||
@ -79,37 +118,39 @@ export class AuthenticatorValidateStage
|
|||||||
return this.host?.submit(payload, options) || Promise.resolve();
|
return this.host?.submit(payload, options) || Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
static get styles(): CSSResult[] {
|
willUpdate(_changed: PropertyValues<this>) {
|
||||||
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton].concat(css`
|
if (this._firstInitialized || !this.challenge) {
|
||||||
ul {
|
return;
|
||||||
padding-top: 1rem;
|
}
|
||||||
}
|
|
||||||
ul > li:not(:last-child) {
|
this._firstInitialized = true;
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
// If user only has a single device, autoselect that device.
|
||||||
.authenticator-button {
|
if (this.challenge.deviceChallenges.length === 1) {
|
||||||
display: flex;
|
this.selectedDeviceChallenge = this.challenge.deviceChallenges[0];
|
||||||
align-items: center;
|
return;
|
||||||
}
|
}
|
||||||
:host([theme="dark"]) .authenticator-button {
|
|
||||||
color: var(--ak-dark-foreground) !important;
|
// If TOTP is allowed from the backend and we have a pre-filled value
|
||||||
}
|
// from the password manager, autoselect TOTP.
|
||||||
i {
|
const totpChallenge = this.challenge.deviceChallenges.find(
|
||||||
font-size: 1.5rem;
|
(challenge) => challenge.deviceClass === DeviceClassesEnum.Totp,
|
||||||
padding: 1rem 0;
|
);
|
||||||
width: 3rem;
|
if (PasswordManagerPrefill.totp && totpChallenge) {
|
||||||
}
|
console.debug(
|
||||||
.right {
|
"authentik/stages/authenticator_validate: found prefill totp code, selecting totp challenge",
|
||||||
display: flex;
|
);
|
||||||
flex-direction: column;
|
this.selectedDeviceChallenge = totpChallenge;
|
||||||
justify-content: space-between;
|
return;
|
||||||
height: 100%;
|
}
|
||||||
text-align: left;
|
|
||||||
}
|
// If the last used device is not Static, autoselect that device.
|
||||||
.right > * {
|
const lastUsedChallenge = this.challenge.deviceChallenges
|
||||||
height: 50%;
|
.filter((deviceChallenge) => deviceChallenge.lastUsed)
|
||||||
}
|
.sort((a, b) => b.lastUsed!.valueOf() - a.lastUsed!.valueOf())[0];
|
||||||
`);
|
if (lastUsedChallenge && lastUsedChallenge.deviceClass !== DeviceClassesEnum.Static) {
|
||||||
|
this.selectedDeviceChallenge = lastUsedChallenge;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderDevicePickerSingle(deviceChallenge: DeviceChallenge) {
|
renderDevicePickerSingle(deviceChallenge: DeviceChallenge) {
|
||||||
@ -228,45 +269,28 @@ export class AuthenticatorValidateStage
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
if (!this.challenge) {
|
return this.challenge
|
||||||
return html`<ak-empty-state loading> </ak-empty-state>`;
|
? html`<header class="pf-c-login__main-header">
|
||||||
}
|
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
||||||
// User only has a single device class, so we don't show a picker
|
</header>
|
||||||
if (this.challenge?.deviceChallenges.length === 1) {
|
${this.selectedDeviceChallenge
|
||||||
this.selectedDeviceChallenge = this.challenge.deviceChallenges[0];
|
? this.renderDeviceChallenge()
|
||||||
}
|
: html`<div class="pf-c-login__main-body">
|
||||||
// TOTP is a bit special, assuming that TOTP is allowed from the backend,
|
<form class="pf-c-form">
|
||||||
// and we have a pre-filled value from the password manager,
|
${this.renderUserInfo()}
|
||||||
// directly set the the TOTP device Challenge as active.
|
${this.selectedDeviceChallenge
|
||||||
const totpChallenge = this.challenge.deviceChallenges.find(
|
? ""
|
||||||
(challenge) => challenge.deviceClass === DeviceClassesEnum.Totp,
|
: html`<p>${msg("Select an authentication method.")}</p>`}
|
||||||
);
|
${this.challenge.configurationStages.length > 0
|
||||||
if (PasswordManagerPrefill.totp && totpChallenge) {
|
? this.renderStagePicker()
|
||||||
console.debug(
|
: html``}
|
||||||
"authentik/stages/authenticator_validate: found prefill totp code, selecting totp challenge",
|
</form>
|
||||||
);
|
${this.renderDevicePicker()}
|
||||||
this.selectedDeviceChallenge = totpChallenge;
|
</div>
|
||||||
}
|
<footer class="pf-c-login__main-footer">
|
||||||
return html`<header class="pf-c-login__main-header">
|
<ul class="pf-c-login__main-footer-links"></ul>
|
||||||
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
</footer>`}`
|
||||||
</header>
|
: html`<ak-empty-state loading> </ak-empty-state>`;
|
||||||
${this.selectedDeviceChallenge
|
|
||||||
? this.renderDeviceChallenge()
|
|
||||||
: html`<div class="pf-c-login__main-body">
|
|
||||||
<form class="pf-c-form">
|
|
||||||
${this.renderUserInfo()}
|
|
||||||
${this.selectedDeviceChallenge
|
|
||||||
? ""
|
|
||||||
: html`<p>${msg("Select an authentication method.")}</p>`}
|
|
||||||
${this.challenge.configurationStages.length > 0
|
|
||||||
? this.renderStagePicker()
|
|
||||||
: html``}
|
|
||||||
</form>
|
|
||||||
${this.renderDevicePicker()}
|
|
||||||
</div>
|
|
||||||
<footer class="pf-c-login__main-footer">
|
|
||||||
<ul class="pf-c-login__main-footer-links"></ul>
|
|
||||||
</footer>`}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,34 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deviceMessage(): string {
|
||||||
|
switch (this.deviceChallenge?.deviceClass) {
|
||||||
|
case DeviceClassesEnum.Sms:
|
||||||
|
return msg("A code has been sent to you via SMS.");
|
||||||
|
case DeviceClassesEnum.Totp:
|
||||||
|
return msg(
|
||||||
|
"Open your two-factor authenticator app to view your authentication code.",
|
||||||
|
);
|
||||||
|
case DeviceClassesEnum.Static:
|
||||||
|
return msg("Enter a one-time recovery code for this user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg("Enter the code from your authenticator device.");
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceIcon(): string {
|
||||||
|
switch (this.deviceChallenge?.deviceClass) {
|
||||||
|
case DeviceClassesEnum.Sms:
|
||||||
|
return "fa-key";
|
||||||
|
case DeviceClassesEnum.Totp:
|
||||||
|
return "fa-mobile-alt";
|
||||||
|
case DeviceClassesEnum.Static:
|
||||||
|
return "fa-sticky-note";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "fa-mobile-alt";
|
||||||
|
}
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
if (!this.challenge) {
|
if (!this.challenge) {
|
||||||
return html`<ak-empty-state loading> </ak-empty-state>`;
|
return html`<ak-empty-state loading> </ak-empty-state>`;
|
||||||
@ -44,19 +72,8 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
|
|||||||
>
|
>
|
||||||
${this.renderUserInfo()}
|
${this.renderUserInfo()}
|
||||||
<div class="icon-description">
|
<div class="icon-description">
|
||||||
<i
|
<i class="fa ${this.deviceIcon()}" aria-hidden="true"></i>
|
||||||
class="fa ${this.deviceChallenge?.deviceClass == DeviceClassesEnum.Sms
|
<p>${this.deviceMessage()}</p>
|
||||||
? "fa-key"
|
|
||||||
: "fa-mobile-alt"}"
|
|
||||||
aria-hidden="true"
|
|
||||||
></i>
|
|
||||||
${this.deviceChallenge?.deviceClass == DeviceClassesEnum.Sms
|
|
||||||
? html`<p>${msg("A code has been sent to you via SMS.")}</p>`
|
|
||||||
: html`<p>
|
|
||||||
${msg(
|
|
||||||
"Open your two-factor authenticator app to view your authentication code.",
|
|
||||||
)}
|
|
||||||
</p>`}
|
|
||||||
</div>
|
</div>
|
||||||
<ak-form-element
|
<ak-form-element
|
||||||
label="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
|
label="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export class BaseDeviceStage<
|
|||||||
(this.host as AuthenticatorValidateStage).selectedDeviceChallenge = undefined;
|
(this.host as AuthenticatorValidateStage).selectedDeviceChallenge = undefined;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${msg("Return to device picker")}
|
${msg("Select another authentication method")}
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,10 +5,10 @@ title: Authenticator validation stage
|
|||||||
This stage validates an already configured Authenticator Device. This device has to be configured using any of the other authenticator stages:
|
This stage validates an already configured Authenticator Device. This device has to be configured using any of the other authenticator stages:
|
||||||
|
|
||||||
- [Duo authenticator stage](../authenticator_duo/index.md)
|
- [Duo authenticator stage](../authenticator_duo/index.md)
|
||||||
- [SMS authenticator stage](../authenticator_sms/index.md).
|
- [SMS authenticator stage](../authenticator_sms/index.md)
|
||||||
- [Static authenticator stage](../authenticator_static/index.md).
|
- [Static authenticator stage](../authenticator_static/index.md)
|
||||||
- [TOTP authenticator stage](../authenticator_totp/index.md)
|
- [TOTP authenticator stage](../authenticator_totp/index.md)
|
||||||
- [WebAuth authenticator stage](../authenticator_webauthn/index.md).
|
- [WebAuthn authenticator stage](../authenticator_webauthn/index.md)
|
||||||
|
|
||||||
You can select which type of device classes are allowed.
|
You can select which type of device classes are allowed.
|
||||||
|
|
||||||
@ -75,3 +75,7 @@ Optionally restrict which WebAuthn device types can be used to authenticate.
|
|||||||
When no restriction is set, all WebAuthn devices a user has registered are allowed.
|
When no restriction is set, all WebAuthn devices a user has registered are allowed.
|
||||||
|
|
||||||
These restrictions only apply to WebAuthn devices created with authentik 2024.4 or later.
|
These restrictions only apply to WebAuthn devices created with authentik 2024.4 or later.
|
||||||
|
|
||||||
|
#### Automatic device selection
|
||||||
|
|
||||||
|
If the user has more than one device, the user is prompted to select which device they want to use for validation. After the user successfully authenticates with a certain device, that device is marked as "last used". In subsequent prompts by the Authenticator validation stage, the last used device is automatically selected for the user. Should they wish to use another device, the user can return to the device selection screen.
|
||||||
|
|||||||
Reference in New Issue
Block a user