stages/authenticator_validate: send challenge for each device

This commit is contained in:
Jens Langhammer 2021-02-23 18:24:38 +01:00
parent 3894895d32
commit 8878fac4e7
16 changed files with 230 additions and 149 deletions

View File

@ -0,0 +1,137 @@
"""Validation stage challenge checking"""
from django.db.models import Model
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from django_otp import match_token
from django_otp.models import Device
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
from rest_framework.fields import CharField, JSONField
from rest_framework.serializers import Serializer, ValidationError
from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser
from webauthn.webauthn import (
AuthenticationRejectedException,
RegistrationRejectedException,
WebAuthnUserDataMissing,
)
from authentik.core.models import User
from authentik.lib.templatetags.authentik_utils import avatar
from authentik.stages.authenticator_validate.models import DeviceClasses
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import generate_challenge
class DeviceChallenge(Serializer):
"""Single device challenge"""
device_class = CharField()
device_uid = CharField()
challenge = JSONField()
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
def get_challenge_for_device(request: HttpRequest, device: Device) -> dict:
"""Generate challenge for a single device"""
if isinstance(device, (TOTPDevice, StaticDevice)):
# Code-based challenges have no hints
return {}
return get_webauthn_challenge(request, device)
def get_webauthn_challenge(request: HttpRequest, device: WebAuthnDevice) -> dict:
"""Send the client a challenge that we'll check later"""
request.session.pop("challenge", None)
challenge = generate_challenge(32)
# We strip the padding from the challenge stored in the session
# for the reasons outlined in the comment in webauthn_begin_activate.
request.session["challenge"] = challenge.rstrip("=")
webauthn_user = WebAuthnUser(
device.user.uid,
device.user.username,
device.user.name,
avatar(device.user),
device.credential_id,
device.public_key,
device.sign_count,
device.rp_id,
)
webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge)
return webauthn_assertion_options.assertion_dict
def validate_challenge(
challenge: DeviceChallenge, request: HttpRequest, user: User
) -> DeviceChallenge:
"""main entry point for challenge validation"""
if challenge.validated_data["device_class"] in (
DeviceClasses.TOTP,
DeviceClasses.STATIC,
):
return validate_challenge_code(challenge, request, user)
return validate_challenge_webauthn(challenge, request, user)
def validate_challenge_code(
challenge: DeviceChallenge, request: HttpRequest, user: User
) -> DeviceChallenge:
"""Validate code-based challenges. We test against every device, on purpose, as
the user mustn't choose between totp and static devices."""
device = match_token(user, challenge.validated_data["challenge"].get("code", None))
if not device:
raise ValidationError(_("Invalid Token"))
return challenge
def validate_challenge_webauthn(
challenge: DeviceChallenge, request: HttpRequest, user: User
) -> DeviceChallenge:
"""Validate WebAuthn Challenge"""
challenge = request.session.get("challenge")
assertion_response = challenge.validated_data["challenge"]
credential_id = assertion_response.get("id")
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
if not device:
raise ValidationError("Device does not exist.")
webauthn_user = WebAuthnUser(
user.uid,
user.username,
user.name,
avatar(user),
device.credential_id,
device.public_key,
device.sign_count,
device.rp_id,
)
webauthn_assertion_response = WebAuthnAssertionResponse(
webauthn_user,
assertion_response,
challenge,
request.build_absolute_uri("/"),
uv_required=False,
) # User Verification
try:
sign_count = webauthn_assertion_response.verify()
except (
AuthenticationRejectedException,
WebAuthnUserDataMissing,
RegistrationRejectedException,
) as exc:
raise ValidationError("Assertion failed") from exc
device.set_sign_count(sign_count)
return challenge

View File

@ -1,9 +1,11 @@
"""Authenticator Validation"""
from django.http import HttpRequest, HttpResponse
from django_otp import devices_for_user, user_has_device
from rest_framework.fields import CharField, DictField, IntegerField, JSONField, ListField
from django.http.request import QueryDict
from django_otp import devices_for_user
from rest_framework.fields import ListField
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.flows.challenge import (
ChallengeResponse,
ChallengeTypes,
@ -12,6 +14,11 @@ from authentik.flows.challenge import (
from authentik.flows.models import NotConfiguredAction
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_validate.challenge import (
DeviceChallenge,
get_challenge_for_device,
validate_challenge,
)
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
LOGGER = get_logger()
@ -20,17 +27,20 @@ LOGGER = get_logger()
class AuthenticatorChallenge(WithUserInfoChallenge):
"""Authenticator challenge"""
users_device_classes = ListField(child=CharField())
class_challenges = DictField(JSONField())
device_challenges = ListField(child=DeviceChallenge())
class AuthenticatorChallengeResponse(ChallengeResponse):
"""Challenge used for Code-based authenticators"""
device_challenges = DictField(JSONField())
response = DeviceChallenge()
def validate_device_challenges(self, value: dict[str, dict]):
return value
request: HttpRequest
user: User
def validate_response(self, value: DeviceChallenge):
"""Validate response"""
return validate_challenge(value, self.request, self.user)
class AuthenticatorValidateStageView(ChallengeStageView):
@ -38,7 +48,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
response_class = AuthenticatorChallengeResponse
allowed_device_classes: set[str]
challenges: list[DeviceChallenge]
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Check if a user is set, and check if the user has any devices
@ -47,22 +57,27 @@ class AuthenticatorValidateStageView(ChallengeStageView):
if not user:
LOGGER.debug("No pending user, continuing")
return self.executor.stage_ok()
has_devices = user_has_device(user)
stage: AuthenticatorValidateStage = self.executor.current_stage
self.challenges = []
user_devices = devices_for_user(self.get_pending_user())
user_device_classes = set(
[
device.__class__.__name__.lower().replace("device", "")
for device in user_devices
]
)
stage_device_classes = set(self.executor.current_stage.device_classes)
self.allowed_device_classes = user_device_classes.intersection(stage_device_classes)
# User has no devices, or the devices they have don't overlap with the allowed
# classes
if not has_devices or len(self.allowed_device_classes) < 1:
for device in user_devices:
device_class = device.__class__.__name__.lower().replace("device", "")
if device_class not in stage.device_classes:
continue
self.challenges.append(
DeviceChallenge(
data={
"device_class": device_class,
"device_uid": device.pk,
"challenge": get_challenge_for_device(request, device),
}
)
)
# No allowed devices
if len(self.challenges) < 1:
if stage.not_configured_action == NotConfiguredAction.SKIP:
LOGGER.debug("Authenticator not configured, skipping stage")
return self.executor.stage_ok()
@ -76,10 +91,16 @@ class AuthenticatorValidateStageView(ChallengeStageView):
data={
"type": ChallengeTypes.native,
"component": "ak-stage-authenticator-validate",
"users_device_classes": self.allowed_device_classes,
"device_challenges": self.challenges,
}
)
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
response: AuthenticatorChallengeResponse = super().get_response_instance(data)
response.request = self.request
response.user = self.get_pending_user()
return response
def challenge_valid(
self, challenge: AuthenticatorChallengeResponse
) -> HttpResponse:

View File

@ -1,83 +0,0 @@
from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser
from webauthn.webauthn import (
AuthenticationRejectedException,
RegistrationRejectedException,
WebAuthnUserDataMissing,
)
class BeginAssertion(FlowUserRequiredView):
"""Send the client a challenge that we'll check later"""
def post(self, request: HttpRequest) -> HttpResponse:
"""Send the client a challenge that we'll check later"""
request.session.pop("challenge", None)
challenge = generate_challenge(32)
# We strip the padding from the challenge stored in the session
# for the reasons outlined in the comment in webauthn_begin_activate.
request.session["challenge"] = challenge.rstrip("=")
devices = WebAuthnDevice.objects.filter(user=self.user)
if not devices.exists():
return HttpResponseBadRequest()
device: WebAuthnDevice = devices.first()
webauthn_user = WebAuthnUser(
self.user.uid,
self.user.username,
self.user.name,
avatar(self.user),
device.credential_id,
device.public_key,
device.sign_count,
device.rp_id,
)
webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge)
return JsonResponse(webauthn_assertion_options.assertion_dict)
class VerifyAssertion(FlowUserRequiredView):
"""Verify assertion result that we've sent to the client"""
def post(self, request: HttpRequest) -> HttpResponse:
"""Verify assertion result that we've sent to the client"""
challenge = request.session.get("challenge")
assertion_response = request.POST
credential_id = assertion_response.get("id")
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
if not device:
return JsonResponse({"fail": "Device does not exist."}, status=401)
webauthn_user = WebAuthnUser(
self.user.uid,
self.user.username,
self.user.name,
avatar(self.user),
device.credential_id,
device.public_key,
device.sign_count,
device.rp_id,
)
webauthn_assertion_response = WebAuthnAssertionResponse(
webauthn_user, assertion_response, challenge, ORIGIN, uv_required=False
) # User Verification
try:
sign_count = webauthn_assertion_response.verify()
except (
AuthenticationRejectedException,
WebAuthnUserDataMissing,
RegistrationRejectedException,
) as exc:
return JsonResponse({"fail": "Assertion failed. Error: {}".format(exc)})
device.set_sign_count(sign_count)
request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True
return JsonResponse(
{"success": "Successfully authenticated as {}".format(self.user.username)}
)

View File

@ -20,9 +20,7 @@ from authentik.lib.templatetags.authentik_utils import avatar
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import generate_challenge
RP_ID = "localhost"
RP_NAME = "authentik"
ORIGIN = "http://localhost:8000"
LOGGER = get_logger()
@ -54,8 +52,8 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
none_attestation_permitted = True
webauthn_registration_response = WebAuthnRegistrationResponse(
RP_ID,
ORIGIN,
self.request.get_host(),
self.request.build_absolute_uri("/"),
response,
challenge,
trusted_attestation_cert_required=trusted_attestation_cert_required,
@ -112,7 +110,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
make_credential_options = WebAuthnMakeCredentialOptions(
challenge,
RP_NAME,
RP_ID,
self.request.get_host(),
user.uid,
user.username,
user.name,
@ -156,7 +154,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
public_key=webauthn_credential.public_key,
credential_id=webauthn_credential.credential_id,
sign_count=webauthn_credential.sign_count,
rp_id=RP_ID,
rp_id=self.request.get_host(),
)
else:
return self.executor.stage_invalid(

View File

@ -1,10 +1,7 @@
"""WebAuthn urls"""
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from authentik.stages.authenticator_webauthn.views import (
UserSettingsView,
)
from authentik.stages.authenticator_webauthn.views import UserSettingsView
urlpatterns = [
path(

View File

@ -1,29 +1,12 @@
"""webauthn views"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404
from django.views import View
from django.views.generic import TemplateView
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.views import SESSION_KEY_PLAN
from authentik.lib.templatetags.authentik_utils import avatar
from authentik.stages.authenticator_webauthn.models import (
AuthenticateWebAuthnStage,
WebAuthnDevice,
)
from authentik.stages.authenticator_webauthn.stage import (
SESSION_KEY_WEBAUTHN_AUTHENTICATED,
)
from authentik.stages.authenticator_webauthn.utils import generate_challenge
LOGGER = get_logger()
RP_ID = "localhost"
RP_NAME = "authentik"
ORIGIN = "http://localhost:8000"
class UserSettingsView(LoginRequiredMixin, TemplateView):

View File

@ -11048,6 +11048,7 @@ definitions:
type: string
enum:
- skip
- deny
device_classes:
description: ''
type: array

View File

@ -42,7 +42,7 @@ export class AuthenticatorStaticStage extends BaseStage {
</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form" @submit=${(e: Event) => { this.submit(e); }}>
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
<div class="pf-c-form__group">
<div class="form-control-static">
<div class="left">

View File

@ -30,7 +30,7 @@ export class AuthenticatorTOTPStage extends BaseStage {
</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form" @submit=${(e: Event) => { this.submit(e); }}>
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
<div class="pf-c-form__group">
<div class="form-control-static">
<div class="left">

View File

@ -9,13 +9,18 @@ export enum DeviceClasses {
WEBAUTHN = "webauthn",
}
export interface DeviceChallenge {
device_class: DeviceClasses;
device_uid: string;
challenge: unknown;
}
export interface AuthenticatorValidateStageChallenge extends WithUserInfoChallenge {
users_device_classes: DeviceClasses[];
class_challenges: { [key in DeviceClasses]: unknown };
device_challenges: DeviceChallenge[];
}
export interface AuthenticatorValidateStageChallengeResponse {
device_challenges: { [key in DeviceClasses]: unknown} ;
response: DeviceChallenge;
}
@customElement("ak-stage-authenticator-validate")
@ -24,13 +29,24 @@ export class AuthenticatorValidateStage extends BaseStage implements StageHost {
@property({ attribute: false })
challenge?: AuthenticatorValidateStageChallenge;
renderDeviceClass(deviceClass: DeviceClasses): TemplateResult {
switch (deviceClass) {
@property({attribute: false})
selectedDeviceChallenge?: DeviceChallenge;
renderDeviceChallenge(): TemplateResult {
if (!this.selectedDeviceChallenge) {
return html``;
}
switch (this.selectedDeviceChallenge?.device_class) {
case DeviceClasses.STATIC:
case DeviceClasses.TOTP:
// TODO: Create input for code
return html``;
case DeviceClasses.WEBAUTHN:
return html`<ak-stage-authenticator-validate-webauthn .host=${this} .challenge=${this.challenge}></ak-stage-authenticator-validate-webauthn>`;
return html`<ak-stage-authenticator-validate-webauthn
.host=${this}
.challenge=${this.challenge}
.deviceChallenge=${this.selectedDeviceChallenge}>
</ak-stage-authenticator-validate-webauthn>`;
}
}
@ -40,9 +56,13 @@ export class AuthenticatorValidateStage extends BaseStage implements StageHost {
render(): TemplateResult {
// User only has a single device class, so we don't show a picker
if (this.challenge?.users_device_classes.length === 1) {
return this.renderDeviceClass(this.challenge.users_device_classes[0]);
if (this.challenge?.device_challenges.length === 1) {
this.selectedDeviceChallenge = this.challenge.device_challenges[0];
}
if (this.selectedDeviceChallenge) {
return this.renderDeviceChallenge();
}
// TODO: Create picker between challenges
return html`ak-stage-authenticator-validate`;
}

View File

@ -3,7 +3,7 @@ import { customElement, html, property, TemplateResult } from "lit-element";
import { SpinnerSize } from "../../Spinner";
import { transformAssertionForServer, transformCredentialRequestOptions } from "../authenticator_webauthn/utils";
import { BaseStage } from "../base";
import { AuthenticatorValidateStageChallenge, DeviceClasses } from "./AuthenticatorValidateStage";
import { AuthenticatorValidateStageChallenge, DeviceChallenge } from "./AuthenticatorValidateStage";
@customElement("ak-stage-authenticator-validate-webauthn")
export class AuthenticatorValidateStageWebAuthn extends BaseStage {
@ -11,6 +11,9 @@ export class AuthenticatorValidateStageWebAuthn extends BaseStage {
@property({attribute: false})
challenge?: AuthenticatorValidateStageChallenge;
@property({attribute: false})
deviceChallenge?: DeviceChallenge;
@property({ type: Boolean })
authenticateRunning = false;
@ -20,7 +23,7 @@ export class AuthenticatorValidateStageWebAuthn extends BaseStage {
async authenticate(): Promise<void> {
// convert certain members of the PublicKeyCredentialRequestOptions into
// byte arrays as expected by the spec.
const credentialRequestOptions = <PublicKeyCredentialRequestOptions>this.challenge?.class_challenges[DeviceClasses.WEBAUTHN];
const credentialRequestOptions = <PublicKeyCredentialRequestOptions>this.deviceChallenge?.challenge;
const transformedCredentialRequestOptions = transformCredentialRequestOptions(credentialRequestOptions);
// request the authenticator to create an assertion signature using the
@ -44,7 +47,11 @@ export class AuthenticatorValidateStageWebAuthn extends BaseStage {
// post the assertion to the server for verification.
try {
const formData = new FormData();
formData.set(`response[${DeviceClasses.WEBAUTHN}]`, JSON.stringify(transformedAssertionForServer));
formData.set("response", JSON.stringify(<DeviceChallenge>{
device_class: this.deviceChallenge?.device_class,
device_uid: this.deviceChallenge?.device_uid,
challenge: transformedAssertionForServer,
}));
await this.host?.submit(formData);
} catch (err) {
throw new Error(gettext(`Error when validating assertion on server: ${err}`));

View File

@ -36,7 +36,7 @@ export class ConsentStage extends BaseStage {
</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form" @submit=${(e: Event) => { this.submit(e); }}>
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
<div class="pf-c-form__group">
<div class="form-control-static">
<div class="left">

View File

@ -26,7 +26,7 @@ export class EmailStage extends BaseStage {
</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form" @submit=${(e: Event) => { this.submit(e); }}>
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
<div class="pf-c-form__group">
<p>
${gettext("Check your Emails for a password reset link.")}

View File

@ -74,7 +74,7 @@ export class IdentificationStage extends BaseStage {
</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form" @submit=${(e: Event) => {this.submit(e);}}>
<form class="pf-c-form" @submit=${(e: Event) => {this.submitForm(e);}}>
${this.challenge.application_pre ?
html`<p>
${gettext(`Login to continue to ${this.challenge.application_pre}.`)}

View File

@ -29,7 +29,7 @@ export class PasswordStage extends BaseStage {
</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form" @submit=${(e: Event) => {this.submit(e);}}>
<form class="pf-c-form" @submit=${(e: Event) => {this.submitForm(e);}}>
<div class="pf-c-form__group">
<div class="form-control-static">
<div class="left">

View File

@ -119,7 +119,7 @@ export class PromptStage extends BaseStage {
</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form" @submit=${(e: Event) => {this.submit(e);}}>
<form class="pf-c-form" @submit=${(e: Event) => {this.submitForm(e);}}>
${this.challenge.fields.map((prompt) => {
return html`<ak-form-element
label="${prompt.label}"