add UI to show code, add validation
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
0a254bea58
commit
55f53e64e9
|
@ -31,6 +31,7 @@ from authentik.policies.models import Policy, PolicyBindingModel
|
||||||
from authentik.policies.reputation.models import Reputation
|
from authentik.policies.reputation.models import Reputation
|
||||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
||||||
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
||||||
|
from authentik.stages.authenticator_mobile.models import MobileTransaction
|
||||||
from authentik.stages.authenticator_static.models import StaticToken
|
from authentik.stages.authenticator_static.models import StaticToken
|
||||||
|
|
||||||
IGNORED_MODELS = (
|
IGNORED_MODELS = (
|
||||||
|
@ -56,6 +57,7 @@ IGNORED_MODELS = (
|
||||||
SCIMGroup,
|
SCIMGroup,
|
||||||
Reputation,
|
Reputation,
|
||||||
ConnectionToken,
|
ConnectionToken,
|
||||||
|
MobileTransaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""Mobile authenticator stage"""
|
"""Mobile authenticator stage"""
|
||||||
from json import dumps
|
from json import dumps
|
||||||
|
from secrets import choice
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
@ -61,11 +62,22 @@ class AuthenticatorMobileStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||||
"""Create a transaction for `device` with the config of this stage."""
|
"""Create a transaction for `device` with the config of this stage."""
|
||||||
transaction = MobileTransaction(device=device)
|
transaction = MobileTransaction(device=device)
|
||||||
if self.item_matching_mode == ItemMatchingMode.ACCEPT_DENY:
|
if self.item_matching_mode == ItemMatchingMode.ACCEPT_DENY:
|
||||||
transaction.item_matching = [TransactionStates.ACCEPT, TransactionStates.DENY]
|
transaction.decision_items = [TransactionStates.ACCEPT, TransactionStates.DENY]
|
||||||
|
transaction.correct_item = TransactionStates.ACCEPT
|
||||||
if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_2:
|
if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_2:
|
||||||
transaction.item_matching = [generate_code_fixed_length(2)] * 3
|
transaction.decision_items = [
|
||||||
|
generate_code_fixed_length(2),
|
||||||
|
generate_code_fixed_length(2),
|
||||||
|
generate_code_fixed_length(2),
|
||||||
|
]
|
||||||
|
transaction.correct_item = choice(transaction.decision_items)
|
||||||
if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_3:
|
if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_3:
|
||||||
transaction.item_matching = [generate_code_fixed_length(3)] * 3
|
transaction.decision_items = [
|
||||||
|
generate_code_fixed_length(3),
|
||||||
|
generate_code_fixed_length(3),
|
||||||
|
generate_code_fixed_length(3),
|
||||||
|
]
|
||||||
|
transaction.correct_item = choice(transaction.decision_items)
|
||||||
transaction.save()
|
transaction.save()
|
||||||
return transaction
|
return transaction
|
||||||
|
|
||||||
|
@ -160,6 +172,9 @@ class MobileTransaction(ExpiringModel):
|
||||||
"""Get the status"""
|
"""Get the status"""
|
||||||
if not self.selected_item:
|
if not self.selected_item:
|
||||||
return TransactionStates.WAIT
|
return TransactionStates.WAIT
|
||||||
|
# These are two different failure cases, but currently they are handled the same
|
||||||
|
if self.selected_item not in self.decision_items:
|
||||||
|
return TransactionStates.DENY
|
||||||
if self.selected_item != self.correct_item:
|
if self.selected_item != self.correct_item:
|
||||||
return TransactionStates.DENY
|
return TransactionStates.DENY
|
||||||
return TransactionStates.ACCEPT
|
return TransactionStates.ACCEPT
|
||||||
|
@ -190,8 +205,8 @@ class MobileTransaction(ExpiringModel):
|
||||||
priority="normal",
|
priority="normal",
|
||||||
notification=AndroidNotification(icon="stock_ticker_update", color="#f45342"),
|
notification=AndroidNotification(icon="stock_ticker_update", color="#f45342"),
|
||||||
data={
|
data={
|
||||||
"tx_id": str(self.tx_id),
|
"authentik_tx_id": str(self.tx_id),
|
||||||
"user_decision_items": dumps(self.item_matching),
|
"authentik_user_decision_items": dumps(self.decision_items),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
apns=APNSConfig(
|
apns=APNSConfig(
|
||||||
|
@ -204,17 +219,17 @@ class MobileTransaction(ExpiringModel):
|
||||||
category="cat_authentik_push_authorization",
|
category="cat_authentik_push_authorization",
|
||||||
),
|
),
|
||||||
interruption_level="time-sensitive",
|
interruption_level="time-sensitive",
|
||||||
tx_id=str(self.tx_id),
|
authentik_tx_id=str(self.tx_id),
|
||||||
user_decision_items=self.item_matching,
|
authentik_user_decision_items=self.decision_items,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
token=self.device.firebase_token,
|
token=self.device.firebase_token,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = send(message, app=app)
|
response = send(message, app=app)
|
||||||
LOGGER.debug("Sent notification", id=response)
|
LOGGER.debug("Sent notification", id=response, tx_id=self.tx_id)
|
||||||
except (ValueError, FirebaseError) as exc:
|
except (ValueError, FirebaseError) as exc:
|
||||||
LOGGER.warning("failed to push", exc=exc)
|
LOGGER.warning("failed to push", exc=exc, tx_id=self.tx_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def wait_for_response(self, max_checks=30) -> TransactionStates:
|
def wait_for_response(self, max_checks=30) -> TransactionStates:
|
||||||
|
|
|
@ -16,6 +16,8 @@ from authentik.stages.authenticator_mobile.models import MobileDevice, MobileDev
|
||||||
FLOW_PLAN_MOBILE_ENROLL_TOKEN = "authentik/stages/authenticator_mobile/enroll/token" # nosec
|
FLOW_PLAN_MOBILE_ENROLL_TOKEN = "authentik/stages/authenticator_mobile/enroll/token" # nosec
|
||||||
FLOW_PLAN_MOBILE_ENROLL_DEVICE = "authentik/stages/authenticator_mobile/enroll/device"
|
FLOW_PLAN_MOBILE_ENROLL_DEVICE = "authentik/stages/authenticator_mobile/enroll/device"
|
||||||
|
|
||||||
|
SESSION_KEY_MOBILE_TRANSACTION = "authentik/stages/authenticator_mobile/transaction"
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatorMobilePayloadChallenge(PassiveSerializer):
|
class AuthenticatorMobilePayloadChallenge(PassiveSerializer):
|
||||||
"""Payload within the QR code given to the mobile app, hence the short variable names"""
|
"""Payload within the QR code given to the mobile app, hence the short variable names"""
|
||||||
|
|
|
@ -22,11 +22,13 @@ from authentik.core.signals import login_failed
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.flows.stage import StageView
|
from authentik.flows.stage import StageView
|
||||||
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE
|
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE
|
||||||
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
from authentik.root.middleware import ClientIPMiddleware
|
from authentik.root.middleware import ClientIPMiddleware
|
||||||
from authentik.stages.authenticator import match_token
|
from authentik.stages.authenticator import match_token
|
||||||
from authentik.stages.authenticator.models import Device
|
from authentik.stages.authenticator.models import Device
|
||||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||||
from authentik.stages.authenticator_mobile.models import MobileDevice, TransactionStates
|
from authentik.stages.authenticator_mobile.models import MobileDevice, TransactionStates
|
||||||
|
from authentik.stages.authenticator_mobile.stage import SESSION_KEY_MOBILE_TRANSACTION
|
||||||
from authentik.stages.authenticator_sms.models import SMSDevice
|
from authentik.stages.authenticator_sms.models import SMSDevice
|
||||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||||
|
@ -50,6 +52,8 @@ def get_challenge_for_device(
|
||||||
"""Generate challenge for a single device"""
|
"""Generate challenge for a single device"""
|
||||||
if isinstance(device, WebAuthnDevice):
|
if isinstance(device, WebAuthnDevice):
|
||||||
return get_webauthn_challenge(request, stage, device)
|
return get_webauthn_challenge(request, stage, device)
|
||||||
|
if isinstance(device, MobileDevice):
|
||||||
|
return get_mobile_challenge(request, stage, device)
|
||||||
# Code-based challenges have no hints
|
# Code-based challenges have no hints
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@ -105,6 +109,19 @@ def get_webauthn_challenge(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_mobile_challenge(
|
||||||
|
request: HttpRequest, stage: AuthenticatorValidateStage, device: Optional[MobileDevice] = None
|
||||||
|
) -> dict:
|
||||||
|
"""Create a mobile transaction"""
|
||||||
|
request.session.pop(SESSION_KEY_MOBILE_TRANSACTION, None)
|
||||||
|
transaction = device.create_transaction()
|
||||||
|
request.session[SESSION_KEY_MOBILE_TRANSACTION] = transaction
|
||||||
|
return {
|
||||||
|
"item_mode": transaction.device.stage.item_matching_mode,
|
||||||
|
"item": transaction.correct_item,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def select_challenge(request: HttpRequest, stage_view: StageView, device: Device):
|
def select_challenge(request: HttpRequest, stage_view: StageView, device: Device):
|
||||||
"""Callback when the user selected a challenge in the frontend."""
|
"""Callback when the user selected a challenge in the frontend."""
|
||||||
if isinstance(device, SMSDevice):
|
if isinstance(device, SMSDevice):
|
||||||
|
@ -129,31 +146,19 @@ def select_challenge_mobile(request: HttpRequest, stage_view: StageView, device:
|
||||||
push_context[__("Application")] = stage_view.request.session.get(
|
push_context[__("Application")] = stage_view.request.session.get(
|
||||||
SESSION_KEY_APPLICATION_PRE, Application()
|
SESSION_KEY_APPLICATION_PRE, Application()
|
||||||
).name
|
).name
|
||||||
|
if SESSION_KEY_MOBILE_TRANSACTION not in request.session:
|
||||||
|
raise ValidationError()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transaction = device.create_transaction()
|
transaction = request.session.get(SESSION_KEY_MOBILE_TRANSACTION)
|
||||||
transaction.send_message(stage_view.request, **push_context)
|
transaction.send_message(stage_view.request, **push_context)
|
||||||
status = transaction.wait_for_response()
|
|
||||||
if status == TransactionStates.DENY:
|
|
||||||
LOGGER.debug("mobile push response", result=status)
|
|
||||||
login_failed.send(
|
|
||||||
sender=__name__,
|
|
||||||
credentials={"username": user.username},
|
|
||||||
request=stage_view.request,
|
|
||||||
stage=stage_view.executor.current_stage,
|
|
||||||
device_class=DeviceClasses.MOBILE.value,
|
|
||||||
mobile_response=status,
|
|
||||||
)
|
|
||||||
raise ValidationError("Mobile denied access", code="denied")
|
|
||||||
return device
|
|
||||||
except TimeoutError:
|
|
||||||
raise ValidationError("Mobile push notification timed out.")
|
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
Event.new(
|
Event.new(
|
||||||
EventAction.CONFIGURATION_ERROR,
|
EventAction.CONFIGURATION_ERROR,
|
||||||
message=f"Failed to Mobile authenticate user: {str(exc)}",
|
message="Failed to Mobile authenticate user",
|
||||||
user=user,
|
exception=exception_to_string(exc),
|
||||||
).from_http(stage_view.request, user)
|
user=device.user,
|
||||||
|
).from_http(stage_view.request, device.user)
|
||||||
raise ValidationError("Mobile denied access", code="denied")
|
raise ValidationError("Mobile denied access", code="denied")
|
||||||
|
|
||||||
|
|
||||||
|
@ -224,18 +229,9 @@ def validate_challenge_mobile(device_pk: str, stage_view: StageView, user: User)
|
||||||
LOGGER.warning("device mismatch")
|
LOGGER.warning("device mismatch")
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
# Get additional context for push
|
transaction = stage_view.request.session[SESSION_KEY_MOBILE_TRANSACTION]
|
||||||
push_context = {
|
|
||||||
__("Domain"): stage_view.request.get_host(),
|
|
||||||
}
|
|
||||||
if SESSION_KEY_APPLICATION_PRE in stage_view.request.session:
|
|
||||||
push_context[__("Application")] = stage_view.request.session.get(
|
|
||||||
SESSION_KEY_APPLICATION_PRE, Application()
|
|
||||||
).name
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transaction = device.create_transaction()
|
|
||||||
transaction.send_message(stage_view.request, **push_context)
|
|
||||||
status = transaction.wait_for_response()
|
status = transaction.wait_for_response()
|
||||||
if status == TransactionStates.DENY:
|
if status == TransactionStates.DENY:
|
||||||
LOGGER.debug("mobile push response", result=status)
|
LOGGER.debug("mobile push response", result=status)
|
||||||
|
@ -258,6 +254,8 @@ def validate_challenge_mobile(device_pk: str, stage_view: StageView, user: User)
|
||||||
user=user,
|
user=user,
|
||||||
).from_http(stage_view.request, user)
|
).from_http(stage_view.request, user)
|
||||||
raise ValidationError("Mobile denied access", code="denied")
|
raise ValidationError("Mobile denied access", code="denied")
|
||||||
|
finally:
|
||||||
|
stage_view.request.session.delete(SESSION_KEY_MOBILE_TRANSACTION)
|
||||||
|
|
||||||
|
|
||||||
def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) -> Device:
|
def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) -> Device:
|
||||||
|
|
|
@ -21,6 +21,7 @@ from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.root.install_id import get_install_id
|
from authentik.root.install_id import get_install_id
|
||||||
from authentik.stages.authenticator import devices_for_user
|
from authentik.stages.authenticator import devices_for_user
|
||||||
from authentik.stages.authenticator.models import Device
|
from authentik.stages.authenticator.models import Device
|
||||||
|
from authentik.stages.authenticator_mobile.models import MobileDevice
|
||||||
from authentik.stages.authenticator_sms.models import SMSDevice
|
from authentik.stages.authenticator_sms.models import SMSDevice
|
||||||
from authentik.stages.authenticator_validate.challenge import (
|
from authentik.stages.authenticator_validate.challenge import (
|
||||||
DeviceChallenge,
|
DeviceChallenge,
|
||||||
|
@ -122,12 +123,16 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise ValidationError("invalid challenge selected")
|
raise ValidationError("invalid challenge selected")
|
||||||
|
|
||||||
if challenge.get("device_class", "") != "sms":
|
device = None
|
||||||
return challenge
|
match challenge.get("device_class", ""):
|
||||||
devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0")))
|
# This is a bit unclean and hardcoded, but alas
|
||||||
if not devices.exists():
|
case "mobile":
|
||||||
|
device = MobileDevice.objects.filter(pk=challenge.get("device_uid")).first()
|
||||||
|
case "sms":
|
||||||
|
device = SMSDevice.objects.filter(pk=int(challenge.get("device_uid"))).first()
|
||||||
|
if not device:
|
||||||
raise ValidationError("invalid challenge selected")
|
raise ValidationError("invalid challenge selected")
|
||||||
select_challenge(self.stage.request, self.stage, devices.first())
|
select_challenge(self.stage.request, self.stage, device)
|
||||||
return challenge
|
return challenge
|
||||||
|
|
||||||
def validate_selected_stage(self, stage_pk: str) -> str:
|
def validate_selected_stage(self, stage_pk: str) -> str:
|
||||||
|
|
|
@ -5,11 +5,12 @@ import { AuthenticatorValidateStage } from "@goauthentik/flow/stages/authenticat
|
||||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, TemplateResult, html } from "lit";
|
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import { ifDefined } from "lit/directives/if-defined.js";
|
import { ifDefined } from "lit/directives/if-defined.js";
|
||||||
|
|
||||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||||
|
import PFContent from "@patternfly/patternfly/components/Content/content.css";
|
||||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||||
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
|
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
|
||||||
|
@ -20,6 +21,7 @@ import {
|
||||||
AuthenticatorValidationChallenge,
|
AuthenticatorValidationChallenge,
|
||||||
AuthenticatorValidationChallengeResponseRequest,
|
AuthenticatorValidationChallengeResponseRequest,
|
||||||
DeviceChallenge,
|
DeviceChallenge,
|
||||||
|
ItemMatchingModeEnum,
|
||||||
} from "@goauthentik/api";
|
} from "@goauthentik/api";
|
||||||
|
|
||||||
@customElement("ak-stage-authenticator-validate-mobile")
|
@customElement("ak-stage-authenticator-validate-mobile")
|
||||||
|
@ -34,13 +36,32 @@ export class AuthenticatorValidateStageWebMobile extends BaseStage<
|
||||||
showBackButton = false;
|
showBackButton = false;
|
||||||
|
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
|
return [
|
||||||
|
PFBase,
|
||||||
|
PFContent,
|
||||||
|
PFLogin,
|
||||||
|
PFForm,
|
||||||
|
PFFormControl,
|
||||||
|
PFTitle,
|
||||||
|
PFButton,
|
||||||
|
css`
|
||||||
|
.pf-c-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pf-c-content h1 {
|
||||||
|
font-size: calc(var(--pf-c-content--h1--FontSize) * 2);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
firstUpdated(): void {
|
firstUpdated(): void {
|
||||||
this.host?.submit({
|
this.host?.submit({
|
||||||
mobile: this.deviceChallenge?.deviceUid,
|
mobile: this.deviceChallenge?.deviceUid,
|
||||||
});
|
});
|
||||||
|
this.host.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
|
@ -49,6 +70,21 @@ export class AuthenticatorValidateStageWebMobile extends BaseStage<
|
||||||
</ak-empty-state>`;
|
</ak-empty-state>`;
|
||||||
}
|
}
|
||||||
const errors = this.challenge.responseErrors?.mobile || [];
|
const errors = this.challenge.responseErrors?.mobile || [];
|
||||||
|
const challengeData = this.deviceChallenge?.challenge as {
|
||||||
|
item_mode: ItemMatchingModeEnum;
|
||||||
|
item: string;
|
||||||
|
};
|
||||||
|
let body = html``;
|
||||||
|
if (
|
||||||
|
challengeData.item_mode === ItemMatchingModeEnum.NumberMatching2 ||
|
||||||
|
challengeData.item_mode === ItemMatchingModeEnum.NumberMatching3
|
||||||
|
) {
|
||||||
|
body = html`
|
||||||
|
<div class="pf-c-content">
|
||||||
|
<h1>${challengeData.item}</h1>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
return html`<div class="pf-c-login__main-body">
|
return html`<div class="pf-c-login__main-body">
|
||||||
<form
|
<form
|
||||||
class="pf-c-form"
|
class="pf-c-form"
|
||||||
|
@ -67,7 +103,7 @@ export class AuthenticatorValidateStageWebMobile extends BaseStage<
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</ak-form-static>
|
</ak-form-static>
|
||||||
|
${body}
|
||||||
${errors.length > 0
|
${errors.length > 0
|
||||||
? errors.map((err) => {
|
? errors.map((err) => {
|
||||||
if (err.code === "denied") {
|
if (err.code === "denied") {
|
||||||
|
|
Reference in New Issue