add UI to show code, add validation

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens Langhammer 2023-12-14 23:17:49 +01:00
parent 0a254bea58
commit 55f53e64e9
No known key found for this signature in database
6 changed files with 105 additions and 47 deletions

View File

@ -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,
)

View File

@ -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:

View File

@ -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"""

View File

@ -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:

View File

@ -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:

View File

@ -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") {