more fixes, start implementing validate
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
154b91cc92
commit
bb8a70448f
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
try:
|
||||
response = send(message)
|
||||
# Response is a message ID string.
|
||||
print('Successfully sent message:', response)
|
||||
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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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, {})
|
||||
|
|
|
@ -6693,7 +6693,8 @@
|
|||
"totp",
|
||||
"webauthn",
|
||||
"duo",
|
||||
"sms"
|
||||
"sms",
|
||||
"mobile"
|
||||
],
|
||||
"title": "Device classes"
|
||||
},
|
||||
|
|
11
schema.yml
11
schema.yml
|
@ -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
|
||||
|
|
|
@ -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``;
|
||||
}
|
||||
|
|
|
@ -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>`;
|
||||
}
|
||||
}
|
Reference in New Issue