0

The package does two-factor fido2 authentication, even the one step authentication requires the user to specify the username.

How do use this package to perform passwordless authentication?

James Lin
  • 25,028
  • 36
  • 133
  • 233

1 Answers1

1

As far as I know, to perform passwordless the credential needs to be able to look up by the rpId, however the django-fido package currently register the key without storing the credential onto the yubikey.

In order to store the credential to the yubikey, we need to set resident_key=True which buried inside the views.py, see this question and answer

I created a feature request on the package repo to allow setting the resident_key

If you edit the code inside the package to enable resident_key=True, you can verify by using the ykman tool to list the credentials

ykman fido credentials list

And you should be able to see the credential listed similar to this

Jamess-MacBook-Pro:tigerpaw_webui jlin$ ykman fido credentials list 
Enter your PIN: 
demo.yubico.com c5bcb2d737f91739151e150a942928fb6c5d00d6bb8380475efdbc2761a3xxxx jlin
localhost 6c6461705f6a616d65732e6cxxxx ldap_james.lin

I have hacked up some code (mostly borrowed from the package's views.py) in API style to facilitate the passwordless authentication below

NOTE the way below I am looking up the user is via credential id. To use user.id (userHandle from navigator.crendentials.get()) according to the doc, will need my PR to be merged

API

import base64
from http.client import BAD_REQUEST
from typing import Tuple, Dict

from django.contrib.auth import authenticate, login
from django.contrib.auth.base_user import AbstractBaseUser
from django.utils.encoding import force_text
from django_fido.views import Fido2ViewMixin, Fido2ServerError
from django.utils.translation import gettext_lazy as _
from fido2.client import ClientData
from fido2.ctap2 import AuthenticatorData
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework.exceptions import ValidationError
from rest_framework import serializers


class FidoAuthenticationSerializer(serializers.Serializer):
    client_data = serializers.CharField()
    credential_id = serializers.CharField()
    authenticator_data = serializers.CharField()
    signature = serializers.CharField()

    def validate_client_data(self, value) -> ClientData:
        """Return decoded client data."""
        try:
            return ClientData(base64.b64decode(value))
        except ValueError:
            raise ValidationError(_('FIDO 2 response is malformed.'), code='invalid')

    def validate_credential_id(self, value) -> bytes:
        """Return decoded credential ID."""
        try:
            return base64.b64decode(value)
        except ValueError:
            raise ValidationError(_('FIDO 2 response is malformed.'), code='invalid')

    def validate_authenticator_data(self, value) -> AuthenticatorData:
        """Return decoded authenticator data."""
        try:
            return AuthenticatorData(base64.b64decode(value))
        except ValueError:
            raise ValidationError(_('FIDO 2 response is malformed.'), code='invalid')

    def validate_signature(self, value) -> bytes:
        """Return decoded signature."""
        try:
            return base64.b64decode(value)
        except ValueError:
            raise ValidationError(_('FIDO 2 response is malformed.'), code='invalid')


class PasswordlessAuthRequestView(Fido2ViewMixin, APIView):
    authentication_classes = []
    permission_classes = []

    def create_fido2_request(self) -> Tuple[Dict, Dict]:
        """Create and return FIDO 2 authentication request.

        @raise ValueError: If request can't be created.
        """
        return self.server.authenticate_begin([], user_verification=self.user_verification)

    def get(self, request: Request) -> Response:
        """Return JSON with FIDO 2 request."""
        try:
            request_data, state = self.create_fido2_request()
        except ValueError as error:
            return Response({
                'error_code': getattr(error, 'error_code', Fido2ServerError.DEFAULT),
                'message': force_text(error),
                'error': force_text(error),  # error key is deprecated and will be removed in the future
            }, status=BAD_REQUEST)

        # Encode challenge into base64 encoding
        challenge = request_data['publicKey']['challenge']
        challenge = base64.b64encode(challenge).decode('utf-8')
        request_data['publicKey']['challenge'] = challenge

        # Encode credential IDs, if exists - registration
        if 'excludeCredentials' in request_data['publicKey']:
            encoded_credentials = []
            for credential in request_data['publicKey']['excludeCredentials']:
                encoded_credential = credential.copy()
                encoded_credential['id'] = base64.b64encode(encoded_credential['id']).decode('utf-8')
                encoded_credentials.append(encoded_credential)
            request_data['publicKey']['excludeCredentials'] = encoded_credentials

        # Encode credential IDs, if exists - authentication
        if 'allowCredentials' in request_data['publicKey']:
            encoded_credentials = []
            for credential in request_data['publicKey']['allowCredentials']:
                encoded_credential = credential.copy()
                encoded_credential['id'] = base64.b64encode(encoded_credential['id']).decode('utf-8')
                encoded_credentials.append(encoded_credential)
            request_data['publicKey']['allowCredentials'] = encoded_credentials

        # Store the state into session
        self.request.session[self.session_key] = state

        return Response(request_data)


class PasswordlessAuthView(Fido2ViewMixin, APIView):
    authentication_classes = []
    permission_classes = []

    def post(self, request, *args, **kwargs):
        serializer = FidoAuthenticationSerializer(data=request.data)
        serializer.is_valid()
        user = self.complete_authentication(serializer.validated_data)

        login(request, user, 'btg_auth_pp.backends.PasswordlessAuthenticationBackend')
        return Response(response_payload)

    def complete_authentication(self, data) -> AbstractBaseUser:
        """
        Complete the authentication.

        @raise ValidationError: If the authentication can't be completed.
        """
        state = self.request.session.pop(self.session_key, None)
        if state is None:
            raise ValidationError(_('Authentication request not found.'), code='missing')

        fido_kwargs = dict(
            fido2_server=self.server,
            fido2_state=state,
            fido2_response=data,
        )
        user = authenticate(request=self.request, **fido_kwargs)

        if user is None:
            raise ValidationError(_('Authentication failed.'), code='invalid')
        return user

Authentication backend

import base64
import logging
from typing import Any, Dict, Optional
from django.contrib import messages
from django.contrib.auth import get_backends
from django.contrib.auth.base_user import AbstractBaseUser
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest
from fido2.server import Fido2Server
from django_fido.models import Authenticator
from django.utils.translation import gettext_lazy as _


def is_fido_backend_used() -> bool:
    """Detect whether FIDO2 authentication backend is used."""
    for auth_backend in get_backends():
        if isinstance(auth_backend, (PasswordlessAuthenticationBackend,)):
            return True

    return False


class PasswordlessAuthenticationBackend(object):
    """
    Authenticate user using FIDO 2.

    @cvar counter_error_message: Error message in case FIDO 2 device counter didn't increase.
    """

    counter_error_message = _("Counter of the FIDO 2 device decreased. Device may have been duplicated.")

    def authenticate(self, request: HttpRequest, fido2_server: Fido2Server,
                     fido2_state: Dict[str, bytes], fido2_response: Dict[str, Any]) -> Optional[AbstractBaseUser]:
        """Authenticate using FIDO 2."""
        credential_id_data = base64.b64encode(fido2_response['credential_id']).decode('utf-8')

        authenticator = Authenticator.objects.get(credential_id_data=credential_id_data)
        user = authenticator.user
        credentials = [authenticator.credential]

        try:
            credential = fido2_server.authenticate_complete(
                fido2_state, credentials, fido2_response['credential_id'], fido2_response['client_data'],
                fido2_response['authenticator_data'], fido2_response['signature'])
        except ValueError as error:
            _LOGGER.info("FIDO 2 authentication failed with error: %r", error)
            return None

        device = user.authenticators.get(credential_id_data=base64.b64encode(credential.credential_id).decode('utf-8'))
        try:
            self.mark_device_used(device, fido2_response['authenticator_data'].counter)
        except ValueError:
            # Raise `PermissionDenied` to stop the authentication process and skip remaining backends.
            messages.error(request, self.counter_error_message)
            raise PermissionDenied("Counter didn't increase.")
        return user

    def mark_device_used(self, device, counter):
        """Update FIDO 2 device usage information."""
        if counter == 0 and device.counter == 0:
            # Counter is unsupported by the device, bail out early
            return
        if counter <= device.counter:
            _LOGGER.info("FIDO 2 authentication failed because of not increasing counter.")
            raise ValueError("Counter didn't increase.")
        device.counter = counter
        device.full_clean()
        device.save()

    def get_user(self, user_id):
        """Return user based on its ID."""
        try:
            return get_user_model().objects.get(pk=user_id)
        except get_user_model().DoesNotExist:
            return Non

frontend trigger

import React from 'react';
import {Button} from 'react-bootstrap';
import AuthAPI from '@/js/api/auth';


const FidoForm = ({onSuccess}) => {
    const base64ToArrayBuffer = (base64) => {
        const binaryString = window.atob(base64);
        const bytes = new Uint8Array(binaryString.length)
        for (let i = 0; i < binaryString.length; i++) {
            bytes[i] = binaryString.charCodeAt(i)
        }
        return bytes
    }

    const arrayBufferToBase64 = (buffer) => {
        let binary = ''
        const bytes = new Uint8Array(buffer)
        for (const byte of bytes)
            binary += String.fromCharCode(byte)
        return window.btoa(binary)
    }

    const onFidoSubmit = (formData) => {
        AuthAPI.fidoTwoStepAuthRequest().then(
            data => {
                const publicKey = data.publicKey;
                publicKey.challenge = base64ToArrayBuffer(publicKey.challenge)

                // Decode credentials
                const decodedCredentials = []
                for (const credential of publicKey.allowCredentials){
                    credential.id = base64ToArrayBuffer(credential.id)
                    decodedCredentials.push(credential)
                }
                publicKey.allowCredentials = decodedCredentials;
                navigator.credentials.get({ publicKey }).then(result => {
                    const authData = {
                        client_data: arrayBufferToBase64(result.response.clientDataJSON),
                        credential_id: arrayBufferToBase64(result.rawId),
                        authenticator_data: arrayBufferToBase64(result.response.authenticatorData),
                        signature: arrayBufferToBase64(result.response.signature)
                    }
                    AuthAPI.fidoTwoStepAuthenticate(authData).then(resp=>onSuccess(resp.token));
                });
            }
        );
    }

    return (
        <div>
            <Button onClick={onFidoSubmit}>Login with YUBI key</Button>
        </div>
    );
};

export default FidoForm;
James Lin
  • 25,028
  • 36
  • 133
  • 233