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

View File

@ -1,8 +1,18 @@
"""Mobile authenticator stage""" """Mobile authenticator stage"""
from typing import Optional from typing import Optional
from uuid import uuid4 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.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ 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.generators import generate_id
from authentik.lib.models import SerializerModel 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(): def default_token_key():
"""Default token key""" """Default token key"""
@ -78,21 +95,29 @@ class MobileDevice(SerializerModel, Device):
return MobileDeviceSerializer return MobileDeviceSerializer
def send_message(self): def send_message(self, **context):
# See documentation on defining a message payload.
message = Message( message = Message(
data={ notification=Notification(
'score': '850', title="$GOOG up 1.43% on the day",
'time': '2:45', 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, token=self.firebase_token,
) )
try:
# Send a message to the device corresponding to the provided response = send(message)
# registration token. LOGGER.debug("Sent notification", id=response)
response = send(message) except (ValueError, FirebaseError) as exc:
# Response is a message ID string. LOGGER.warning("failed to push", exc=exc)
print('Successfully sent message:', response)
def __str__(self): def __str__(self):
return str(self.name) or str(self.user) 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 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
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
@ -176,6 +177,45 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -
return device 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: def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) -> Device:
"""Duo authentication""" """Duo authentication"""
device = get_object_or_404(DuoDevice, pk=device_pk) device = get_object_or_404(DuoDevice, pk=device_pk)

View File

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

View File

@ -29,6 +29,7 @@ from authentik.stages.authenticator_validate.challenge import (
select_challenge, select_challenge,
validate_challenge_code, validate_challenge_code,
validate_challenge_duo, validate_challenge_duo,
validate_challenge_mobile,
validate_challenge_webauthn, validate_challenge_webauthn,
) )
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
@ -70,6 +71,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
code = CharField(required=False) code = CharField(required=False)
webauthn = JSONDictField(required=False) webauthn = JSONDictField(required=False)
duo = IntegerField(required=False) duo = IntegerField(required=False)
mobile = CharField(required=False)
component = CharField(default="ak-stage-authenticator-validate") component = CharField(default="ak-stage-authenticator-validate")
def _challenge_allowed(self, classes: list): 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()) self.device = validate_challenge_duo(duo, self.stage, self.stage.get_pending_user())
return duo 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: def validate_selected_challenge(self, challenge: dict) -> dict:
"""Check which challenge the user has selected. Actual logic only used for SMS stage.""" """Check which challenge the user has selected. Actual logic only used for SMS stage."""
# First check if the challenge is valid # First check if the challenge is valid
@ -134,7 +142,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
def validate(self, attrs: dict): def validate(self, attrs: dict):
# Checking if the given data is from a valid device class is done above # 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 # 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") raise ValidationError("Empty response")
self.stage.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD, "auth_mfa") self.stage.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD, "auth_mfa")
self.stage.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {}) self.stage.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {})

View File

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

View File

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

View File

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