Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rest framework integration #136

Open
variable opened this issue Nov 2, 2021 · 8 comments
Open

Rest framework integration #136

variable opened this issue Nov 2, 2021 · 8 comments

Comments

@variable
Copy link
Contributor

variable commented Nov 2, 2021

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
Copy link
Contributor Author

variable commented Nov 2, 2021

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
Copy link
Contributor Author

variable commented Nov 2, 2021

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
Copy link

Frikster commented Nov 3, 2021

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
Copy link
Contributor Author

variable commented Nov 3, 2021

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

@tpazderka
Copy link
Contributor

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

@Frikster
Copy link

Frikster commented Nov 5, 2021

@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
Copy link
Contributor Author

variable commented Nov 5, 2021 via email

@Frikster
Copy link

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants