CZ-NIC / django-fido

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

Rest framework integration #136

Open variable opened 2 years ago

variable commented 2 years ago

Hello, thanks for the package, it works great with django views.

Now I need to integrate it with our single page app, which the login is via API, is there a chance for you to include a guide or restframework integration code so I can use in my project?

variable commented 2 years ago

I managed to create some work in progress for the auth request and auth api, borrowing code from the views.py

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

from django.contrib.auth import get_user_model, authenticate, login
from django.contrib.auth.base_user import AbstractBaseUser
from django.utils.encoding import force_text
from django_fido.constants import AUTHENTICATION_USER_SESSION_KEY
from django_fido.views import Fido2ViewMixin, Fido2ServerError, Fido2Error
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 GetUserMixin(object):
    def get_user(self):
        # borrowed from django-fido Fido2AuthenticationViewMixin
        user_pk = self.request.session.get(AUTHENTICATION_USER_SESSION_KEY)
        return get_user_model().objects.get(pk=user_pk)

class TwoStepAuthRequestView(GetUserMixin, 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.
        """
        user = self.get_user()
        assert user and user.is_authenticated, "User must not be anonymous for FIDO 2 requests."
        credentials = self.get_credentials(user)
        if not credentials:
            raise Fido2Error("Can't create FIDO 2 authentication request, no authenticators found.",
                             error_code=Fido2ServerError.NO_AUTHENTICATORS)

        return self.server.authenticate_begin(credentials, 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 TwoStepAuthView(GetUserMixin, Fido2ViewMixin, APIView):
    authentication_classes = []
    permission_classes = []

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

        user = self.get_user()
        login(self.request, form.get_user())

        return Response()

    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, user=self.get_user(), **fido_kwargs)

        if user is None:
            raise ValidationError(_('Authentication failed.'), code='invalid')
        return user
variable commented 2 years ago

And the js part for auth process

// /js/api/auth.js

export default {
    ....
    fidoTwoStepAuthRequest(){
        return client.get('auth/fido/auth-request/')
    },
    fidoTwoStepAuthenticate(data){
        return client.post('auth/fido/authenticate/', data)
    },
}
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;
Frikster commented 2 years ago

Wow, you are a God. This code has already helped me immensely.

Any idea why I get this error below when I try your AuthRequest endpoint though? This clearly happens because self.get_credentials(user) returns [] looking at my code. My user model just has user.authenticators as django_fido.Authenticator.None. Is this because I need to register a yubikey to the User through some other endpoint? How would I do that so that your endpoint works?

image

For instance, if I should just use the /registration/ endpoint provided by this library, how do I so so that the Yubikey is linked to the authenticated user? Thank you.

variable commented 2 years ago

The /registration/ is registering your key and link to your user. Your error seems that you haven't done the /registration/ part.

tpazderka commented 2 years ago

This looks to be out of the scope of this library. I would suggest a separate library for this functionality.

Frikster commented 2 years ago

@variable django-trench integrates with django REST framework and quote "Comes out of a box with email, SMS, mobile apps and YubiKey support." Though I don't know if they've implemented the FIDO2 WebAuthn spec yet.

variable commented 2 years ago

It seems like to utilize yubicloud, I don't have experience using that

On Fri, 5 Nov 2021, 6:58 pm Dirk Haupt, @.***> wrote:

@variable https://github.com/variable django-trench https://github.com/merixstudio/django-trench integrates with django REST framework and quote "Comes out of a box with email, SMS, mobile apps and YubiKey support." Though I don't know if they've implemented the FIDO2 WebAuthn spec yet.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/CZ-NIC/django-fido/issues/136#issuecomment-961646367, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAEIIX2PO6TMK5YHDZCVMY3UKN6BPANCNFSM5HFJ7HPA . 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.

Frikster commented 2 years ago

@tpazderka I've gone through each Authentication package listed in Django Packages. None of them support Webauthn using django's REST framework. My impression is that the vast majority of production Django applications have/are moved/moving to using Django only as a backend and using something else on the frontend, so seems there would be high expected value to support REST framework integration eventually. My 2 cents.