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.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
||||
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
||||
from authentik.stages.authenticator_mobile.models import MobileTransaction
|
||||
from authentik.stages.authenticator_static.models import StaticToken
|
||||
|
||||
IGNORED_MODELS = (
|
||||
|
@ -56,6 +57,7 @@ IGNORED_MODELS = (
|
|||
SCIMGroup,
|
||||
Reputation,
|
||||
ConnectionToken,
|
||||
MobileTransaction,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
"""Mobile authenticator stage"""
|
||||
from json import dumps
|
||||
from secrets import choice
|
||||
from time import sleep
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
@ -61,11 +62,22 @@ class AuthenticatorMobileStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
|||
"""Create a transaction for `device` with the config of this stage."""
|
||||
transaction = MobileTransaction(device=device)
|
||||
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:
|
||||
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:
|
||||
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()
|
||||
return transaction
|
||||
|
||||
|
@ -160,6 +172,9 @@ class MobileTransaction(ExpiringModel):
|
|||
"""Get the status"""
|
||||
if not self.selected_item:
|
||||
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:
|
||||
return TransactionStates.DENY
|
||||
return TransactionStates.ACCEPT
|
||||
|
@ -190,8 +205,8 @@ class MobileTransaction(ExpiringModel):
|
|||
priority="normal",
|
||||
notification=AndroidNotification(icon="stock_ticker_update", color="#f45342"),
|
||||
data={
|
||||
"tx_id": str(self.tx_id),
|
||||
"user_decision_items": dumps(self.item_matching),
|
||||
"authentik_tx_id": str(self.tx_id),
|
||||
"authentik_user_decision_items": dumps(self.decision_items),
|
||||
},
|
||||
),
|
||||
apns=APNSConfig(
|
||||
|
@ -204,17 +219,17 @@ class MobileTransaction(ExpiringModel):
|
|||
category="cat_authentik_push_authorization",
|
||||
),
|
||||
interruption_level="time-sensitive",
|
||||
tx_id=str(self.tx_id),
|
||||
user_decision_items=self.item_matching,
|
||||
authentik_tx_id=str(self.tx_id),
|
||||
authentik_user_decision_items=self.decision_items,
|
||||
),
|
||||
),
|
||||
token=self.device.firebase_token,
|
||||
)
|
||||
try:
|
||||
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:
|
||||
LOGGER.warning("failed to push", exc=exc)
|
||||
LOGGER.warning("failed to push", exc=exc, tx_id=self.tx_id)
|
||||
return True
|
||||
|
||||
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_DEVICE = "authentik/stages/authenticator_mobile/enroll/device"
|
||||
|
||||
SESSION_KEY_MOBILE_TRANSACTION = "authentik/stages/authenticator_mobile/transaction"
|
||||
|
||||
|
||||
class AuthenticatorMobilePayloadChallenge(PassiveSerializer):
|
||||
"""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.flows.stage import StageView
|
||||
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.stages.authenticator import match_token
|
||||
from authentik.stages.authenticator.models import Device
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
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_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
|
||||
|
@ -50,6 +52,8 @@ def get_challenge_for_device(
|
|||
"""Generate challenge for a single device"""
|
||||
if isinstance(device, WebAuthnDevice):
|
||||
return get_webauthn_challenge(request, stage, device)
|
||||
if isinstance(device, MobileDevice):
|
||||
return get_mobile_challenge(request, stage, device)
|
||||
# Code-based challenges have no hints
|
||||
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):
|
||||
"""Callback when the user selected a challenge in the frontend."""
|
||||
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(
|
||||
SESSION_KEY_APPLICATION_PRE, Application()
|
||||
).name
|
||||
if SESSION_KEY_MOBILE_TRANSACTION not in request.session:
|
||||
raise ValidationError()
|
||||
|
||||
try:
|
||||
transaction = device.create_transaction()
|
||||
transaction = request.session.get(SESSION_KEY_MOBILE_TRANSACTION)
|
||||
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:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=f"Failed to Mobile authenticate user: {str(exc)}",
|
||||
user=user,
|
||||
).from_http(stage_view.request, user)
|
||||
message="Failed to Mobile authenticate user",
|
||||
exception=exception_to_string(exc),
|
||||
user=device.user,
|
||||
).from_http(stage_view.request, device.user)
|
||||
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")
|
||||
raise Http404
|
||||
|
||||
# Get additional context for push
|
||||
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
|
||||
transaction = stage_view.request.session[SESSION_KEY_MOBILE_TRANSACTION]
|
||||
|
||||
try:
|
||||
transaction = device.create_transaction()
|
||||
transaction.send_message(stage_view.request, **push_context)
|
||||
status = transaction.wait_for_response()
|
||||
if status == TransactionStates.DENY:
|
||||
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,
|
||||
).from_http(stage_view.request, user)
|
||||
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:
|
||||
|
|
|
@ -21,6 +21,7 @@ from authentik.lib.utils.time import timedelta_from_string
|
|||
from authentik.root.install_id import get_install_id
|
||||
from authentik.stages.authenticator import devices_for_user
|
||||
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_validate.challenge import (
|
||||
DeviceChallenge,
|
||||
|
@ -122,12 +123,16 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
|||
if not allowed:
|
||||
raise ValidationError("invalid challenge selected")
|
||||
|
||||
if challenge.get("device_class", "") != "sms":
|
||||
return challenge
|
||||
devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0")))
|
||||
if not devices.exists():
|
||||
device = None
|
||||
match challenge.get("device_class", ""):
|
||||
# This is a bit unclean and hardcoded, but alas
|
||||
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")
|
||||
select_challenge(self.stage.request, self.stage, devices.first())
|
||||
select_challenge(self.stage.request, self.stage, device)
|
||||
return challenge
|
||||
|
||||
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 { 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 { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
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 PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
|
||||
|
@ -20,6 +21,7 @@ import {
|
|||
AuthenticatorValidationChallenge,
|
||||
AuthenticatorValidationChallengeResponseRequest,
|
||||
DeviceChallenge,
|
||||
ItemMatchingModeEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-stage-authenticator-validate-mobile")
|
||||
|
@ -34,13 +36,32 @@ export class AuthenticatorValidateStageWebMobile extends BaseStage<
|
|||
showBackButton = false;
|
||||
|
||||
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 {
|
||||
this.host?.submit({
|
||||
mobile: this.deviceChallenge?.deviceUid,
|
||||
});
|
||||
this.host.loading = false;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
|
@ -49,6 +70,21 @@ export class AuthenticatorValidateStageWebMobile extends BaseStage<
|
|||
</ak-empty-state>`;
|
||||
}
|
||||
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">
|
||||
<form
|
||||
class="pf-c-form"
|
||||
|
@ -67,7 +103,7 @@ export class AuthenticatorValidateStageWebMobile extends BaseStage<
|
|||
>
|
||||
</div>
|
||||
</ak-form-static>
|
||||
|
||||
${body}
|
||||
${errors.length > 0
|
||||
? errors.map((err) => {
|
||||
if (err.code === "denied") {
|
||||
|
|
Reference in New Issue