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

View File

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

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

View File

@ -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,7 +109,20 @@ def get_webauthn_challenge(
) )
def select_challenge(request: HttpRequest,stage_view: StageView, device: Device): 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.""" """Callback when the user selected a challenge in the frontend."""
if isinstance(device, SMSDevice): if isinstance(device, SMSDevice):
select_challenge_sms(request, stage_view, device) select_challenge_sms(request, stage_view, device)
@ -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:

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

View File

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