more fixes, start implementing validate

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens Langhammer 2023-09-04 17:56:24 +02:00
parent 154b91cc92
commit bb8a70448f
No known key found for this signature in database
9 changed files with 222 additions and 28 deletions

View File

@ -1,7 +1,6 @@
"""AuthenticatorMobileStage API Views"""
from django_filters.rest_framework.backends import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, inline_serializer
from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.fields import CharField, ChoiceField
@ -101,7 +100,7 @@ class MobileDeviceViewSet(
)
@extend_schema(
request=OpenApiTypes.NONE,
request=None,
responses={
200: inline_serializer(
"MobileDeviceEnrollmentStatusSerializer",
@ -128,7 +127,7 @@ class MobileDeviceViewSet(
@extend_schema(
responses={
204: OpenApiTypes.STR,
204: OpenApiResponse(description="Key successfully set"),
},
request=MobileDeviceSetPushKeySerializer,
)
@ -138,10 +137,10 @@ class MobileDeviceViewSet(
permission_classes=[],
authentication_classes=[MobileDeviceTokenAuthentication],
)
def set_notification_key(self, request: Request) -> Response:
def set_notification_key(self, request: Request, pk: str) -> Response:
"""Called by the phone whenever the firebase key changes and we need to update it"""
device: MobileDevice = self.get_object()
data = MobileDeviceSetPushKeySerializer(data=request)
data = MobileDeviceSetPushKeySerializer(data=request.data)
data.is_valid(raise_exception=True)
device.firebase_token = data.validated_data["firebase_key"]
device.save()
@ -153,7 +152,7 @@ class MobileDeviceViewSet(
permission_classes=[],
authentication_classes=[MobileDeviceTokenAuthentication],
)
def receive_response(self, request: Request) -> Response:
def receive_response(self, request: Request, pk: str) -> Response:
"""Get response from notification on phone"""
print(request.data)
return Response(status=204)

View File

@ -1,8 +1,18 @@
"""Mobile authenticator stage"""
from typing import Optional
from uuid import uuid4
from firebase_admin.messaging import Message, send
from firebase_admin.messaging import (
Message,
send,
AndroidConfig,
AndroidNotification,
APNSConfig,
APNSPayload,
Notification,
Aps,
)
from firebase_admin.exceptions import FirebaseError
from structlog.stdlib import get_logger
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
@ -16,6 +26,13 @@ from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
from authentik.lib.generators import generate_id
from authentik.lib.models import SerializerModel
from firebase_admin import initialize_app
from firebase_admin import credentials
cred = credentials.Certificate("firebase.json")
initialize_app(cred)
LOGGER = get_logger()
def default_token_key():
"""Default token key"""
@ -78,21 +95,29 @@ class MobileDevice(SerializerModel, Device):
return MobileDeviceSerializer
def send_message(self):
# See documentation on defining a message payload.
def send_message(self, **context):
message = Message(
data={
'score': '850',
'time': '2:45',
},
notification=Notification(
title="$GOOG up 1.43% on the day",
body="$GOOG gained 11.80 points to close at 835.67, up 1.43% on the day.",
),
android=AndroidConfig(
priority="normal",
notification=AndroidNotification(icon="stock_ticker_update", color="#f45342"),
),
apns=APNSConfig(
payload=APNSPayload(
aps=Aps(badge=0),
interruption_level="time-sensitive",
),
),
token=self.firebase_token,
)
# Send a message to the device corresponding to the provided
# registration token.
response = send(message)
# Response is a message ID string.
print('Successfully sent message:', response)
try:
response = send(message)
LOGGER.debug("Sent notification", id=response)
except (ValueError, FirebaseError) as exc:
LOGGER.warning("failed to push", exc=exc)
def __str__(self):
return str(self.name) or str(self.user)

View File

@ -26,6 +26,7 @@ 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
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
@ -176,6 +177,45 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -
return device
def validate_challenge_mobile(device_pk: str, stage_view: StageView, user: User) -> Device:
device: MobileDevice = get_object_or_404(MobileDevice, pk=device_pk)
if device.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
try:
response = device.send_message(**push_context)
# {'result': 'allow', 'status': 'allow', 'status_msg': 'Success. Logging you in...'}
if response["result"] == "deny":
LOGGER.debug("mobile push response", result=response)
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=response,
)
raise ValidationError("Mobile denied access", code="denied")
return device
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)
raise ValidationError("Mobile denied access", code="denied")
def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) -> Device:
"""Duo authentication"""
device = get_object_or_404(DuoDevice, pk=device_pk)

View File

@ -20,6 +20,7 @@ class DeviceClasses(models.TextChoices):
WEBAUTHN = "webauthn", _("WebAuthn")
DUO = "duo", _("Duo")
SMS = "sms", _("SMS")
MOBILE = "mobile", _("authentik Mobile")
def default_device_classes() -> list:

View File

@ -29,6 +29,7 @@ from authentik.stages.authenticator_validate.challenge import (
select_challenge,
validate_challenge_code,
validate_challenge_duo,
validate_challenge_mobile,
validate_challenge_webauthn,
)
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
@ -70,6 +71,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
code = CharField(required=False)
webauthn = JSONDictField(required=False)
duo = IntegerField(required=False)
mobile = CharField(required=False)
component = CharField(default="ak-stage-authenticator-validate")
def _challenge_allowed(self, classes: list):
@ -100,6 +102,12 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
self.device = validate_challenge_duo(duo, self.stage, self.stage.get_pending_user())
return duo
def validate_mobile(self, mobile: str) -> str:
"""Initiate mobile authentication"""
self._challenge_allowed([DeviceClasses.MOBILE])
self.device = validate_challenge_mobile(mobile, self.stage, self.stage.get_pending_user())
return mobile
def validate_selected_challenge(self, challenge: dict) -> dict:
"""Check which challenge the user has selected. Actual logic only used for SMS stage."""
# First check if the challenge is valid
@ -134,7 +142,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
def validate(self, attrs: dict):
# Checking if the given data is from a valid device class is done above
# Here we only check if the any data was sent at all
if "code" not in attrs and "webauthn" not in attrs and "duo" not in attrs:
if "code" not in attrs and "webauthn" not in attrs and "duo" not in attrs and "mobile" not in attrs:
raise ValidationError("Empty response")
self.stage.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD, "auth_mfa")
self.stage.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {})

View File

@ -6693,7 +6693,8 @@
"totp",
"webauthn",
"duo",
"sms"
"sms",
"mobile"
],
"title": "Device classes"
},

View File

@ -2307,11 +2307,7 @@ paths:
- mobile_device_token: []
responses:
'204':
content:
application/json:
schema:
type: string
description: ''
description: Key successfully set
'400':
content:
application/json:
@ -30985,6 +30981,9 @@ components:
additionalProperties: {}
duo:
type: integer
mobile:
type: string
minLength: 1
AuthenticatorWebAuthnChallenge:
type: object
description: WebAuthn Challenge
@ -31897,6 +31896,7 @@ components:
- webauthn
- duo
- sms
- mobile
type: string
description: |-
* `static` - Static
@ -31904,6 +31904,7 @@ components:
* `webauthn` - WebAuthn
* `duo` - Duo
* `sms` - SMS
* `mobile` - authentik Mobile
DigestAlgorithmEnum:
enum:
- http://www.w3.org/2000/09/xmldsig#sha1

View File

@ -1,6 +1,7 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageCode";
import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageDuo";
import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageMobile";
import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn";
import { BaseStage, StageHost } from "@goauthentik/flow/stages/base";
import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage";
@ -118,6 +119,12 @@ export class AuthenticatorValidateStage
<p>${msg("Duo push-notifications")}</p>
<small>${msg("Receive a push notification on your device.")}</small>
</div>`;
case DeviceClassesEnum.Mobile:
return html`<i class="fas fa-mobile-alt"></i>
<div class="right">
<p>${msg("Push-notifications")}</p>
<small>${msg("Receive a push notification on your device.")}</small>
</div>`;
case DeviceClassesEnum.Webauthn:
return html`<i class="fas fa-mobile-alt"></i>
<div class="right">
@ -221,6 +228,14 @@ export class AuthenticatorValidateStage
.showBackButton=${(this.challenge?.deviceChallenges || []).length > 1}
>
</ak-stage-authenticator-validate-duo>`;
case DeviceClassesEnum.Mobile:
return html` <ak-stage-authenticator-validate-mobile
.host=${this}
.challenge=${this.challenge}
.deviceChallenge=${this.selectedDeviceChallenge}
.showBackButton=${(this.challenge?.deviceChallenges || []).length > 1}
>
</ak-stage-authenticator-validate-mobile>`;
}
return html``;
}

View File

@ -0,0 +1,104 @@
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement";
import "@goauthentik/flow/FormStatic";
import { AuthenticatorValidateStage } from "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStage";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, 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 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";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
AuthenticatorValidationChallenge,
AuthenticatorValidationChallengeResponseRequest,
DeviceChallenge,
} from "@goauthentik/api";
@customElement("ak-stage-authenticator-validate-mobile")
export class AuthenticatorValidateStageWebMobile extends BaseStage<
AuthenticatorValidationChallenge,
AuthenticatorValidationChallengeResponseRequest
> {
@property({ attribute: false })
deviceChallenge?: DeviceChallenge;
@property({ type: Boolean })
showBackButton = false;
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
}
firstUpdated(): void {
this.host?.submit({
duo: this.deviceChallenge?.deviceUid,
});
}
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
</ak-empty-state>`;
}
const errors = this.challenge.responseErrors?.duo || [];
return html`<div class="pf-c-login__main-body">
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${errors.length > 0
? errors.map((err) => {
if (err.code === "denied") {
return html` <ak-stage-access-denied-icon
errorMessage=${err.string}
>
</ak-stage-access-denied-icon>`;
}
return html`<p>${err.string}</p>`;
})
: html`${msg("Sending Duo push notification")}`}
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links">
${this.showBackButton
? html`<li class="pf-c-login__main-footer-links-item">
<button
class="pf-c-button pf-m-secondary pf-m-block"
@click=${() => {
if (!this.host) return;
(
this.host as AuthenticatorValidateStage
).selectedDeviceChallenge = undefined;
}}
>
${msg("Return to device picker")}
</button>
</li>`
: html``}
</ul>
</footer>`;
}
}