CZ-NIC / django-fido

Django application for FIDO protocol U2F
GNU General Public License v3.0
28 stars 11 forks source link

Passwordless #137

Closed variable closed 2 years ago

variable commented 2 years ago

Just wondering if this package support passwordless single factor?

Am I understanding right that passwordless is just we skip the username+password step and completely rely on the fido2 authentication?

https://aws.amazon.com/blogs/security/how-to-implement-password-less-authentication-with-amazon-cognito-and-webauthn/

The user provides their user name and selects the sign-in button, script (running in browser) starts the sign-in process using Amazon Cognito InitiateAuth API passing the user name and indicating that authentication flow is CUSTOM_AUTH. In the demo project, this part is performed in the signIn function in webauthn-client.js.

tpazderka commented 2 years ago

You should be able to write a custom authentication backend (similar to Fido2GeneralAuthenticationBackend) which gets user by username and passes that user instance to Fido2AuthenticationBackend.

variable commented 2 years ago

I have read some more doc that I do not even need to specify the username for passwordless, when the /registration/ is done, does it write to the yubikey or just the django server knows the key?

On Wed, 3 Nov 2021 at 22:05, tpazderka @.***> wrote:

You should be able to write a custom authentication backend (similar to Fido2GeneralAuthenticationBackend) which gets user by username and passes that user instance to Fido2AuthenticationBackend.

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/CZ-NIC/django-fido/issues/137#issuecomment-958761240, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAEIIX5P56X7A3XIAKSWEYLUKD3NTANCNFSM5HHXZRPA . Triage notifications on the go with GitHub Mobile for iOS https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675 or Android https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub.

variable commented 2 years ago

OK, digging into the views.py found that I need to specify resident_key=True to store the credential on the key

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

        @raise ValueError: If request can't be created.
        """
        user = self.get_user()
        assert user.is_authenticated, "User must not be anonymous for FIDO 2 requests."
        credentials = self.get_credentials(user)
        return self.server.register_begin(self.get_user_data(user), credentials,
                                          user_verification=self.user_verification, resident_key=True)
variable commented 2 years ago

So below is my hack on the API integration to get passwordless working, this relies on the ability to set resident_key=True in https://github.com/CZ-NIC/django-fido/issues/138

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()

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;