Skip to content

Instantly share code, notes, and snippets.

@dgilge
Created June 15, 2018 11:15
Show Gist options
  • Star 45 You must be signed in to star a gist
  • Fork 7 You must be signed in to fork a gist
  • Save dgilge/dbe9260208aadee535cef7c412a1162e to your computer and use it in GitHub Desktop.
Save dgilge/dbe9260208aadee535cef7c412a1162e to your computer and use it in GitHub Desktop.
How to implement all needed auth endpoints including login with OAuth2 for a SPA using Django REST framework, django-rest-auth and django-allauth

Complete authentication as API including OAuth2 endpoints

I implemented an auth API for a SPA. This is rarely documented and therefore I want to share here how I did it hoping it will be a help for others.

We are still working on it and I'll update this document accordingly.

This tutorial uses following versions:

Package Version
python 3.6
django 2.0
rest_framework 3.8
allauth 0.36.0
rest_auth 0.9.3

1. Installation

pip install django-rest-auth[with_social]

We also have to install Django and the Django REST framework.

2. Configuration

We have

  1. to override a method of the DefaultAccountAdapter because it defines a reverse URL we won't be using and
  2. to update our settings.py:
from allauth.account.adapter import DefaultAccountAdapter
class AccountAPIAdapter(DefaultAccountAdapter):
def respond_email_verification_sent(self, request, user):
"""
We don't need this redirect.
"""
pass
# ...
SITE_ID = 1
INSTALLED_APPS = [
'myapp',
# ...
'django.contrib.sites',
'rest_framework',
'rest_framework.authtoken',
'rest_auth',
'rest_auth.registration',
'allauth',
'allauth.account',
'allauth.socialaccount',
# As an example
'allauth.socialaccount.providers.github',
]
AUTHENTICATION_BACKENDS = (
'allauth.account.auth_backends.AuthenticationBackend',
'django.contrib.auth.backends.ModelBackend',
)
# Allauth settings
# Here we tell allauth to use our adapter from above
ACCOUNT_ADAPTER = 'myapp.adapter.AccountAPIAdapter'
# All the following settings are not required
# but I included them here because I think they might be a good advice
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
ACCOUNT_EMAIL_REQUIRED = True
# This doesn't seem to work reliable in combination with rest_auth
ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
ACCOUNT_DEFAULT_HTTP_PROTOCOL = 'https'
ACCOUNT_USERNAME_MIN_LENGTH = 4
# Rest auth settings
OLD_PASSWORD_FIELD_ENABLED = True
LOGOUT_ON_PASSWORD_CHANGE = False

3. Serializer

This step is not necessary here.

But I decided to validate the state (CSRF protection in OAuth2) in the backend because it makes the SPA lighter and allauth does the most stuff for you in the backend.

If you prefer to validate the state in the frontend you may use the browser's session storage. Auth0 explains how to do that.

from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext as _
from allauth.socialaccount.models import SocialLogin
from rest_auth.registration.serializers import SocialLoginSerializer
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
class CallbackSerializer(SocialLoginSerializer):
state = serializers.CharField()
def validate_state(self, value):
"""
Checks that the state is equal to the one stored in the session.
"""
try:
SocialLogin.verify_and_unstash_state(
self.context['request'],
value,
)
# Allauth raises PermissionDenied if the validation fails
except PermissionDenied:
raise ValidationError(_('State did not match.'))
return value

4. Views

Next let's implement the API views.

I use GitHub as example here but you can use any other OAuth2 provider included in allauth or write your own provider.

At this point you may ask yourself why so much code is needed. I think the answer is that rest_auth doesn't yet support OAuth in the extent it should.

from django.utils.translation import gettext as _
from allauth.socialaccount.models import SocialLogin
from allauth.socialaccount.providers.base import AuthAction
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
from allauth.socialaccount.providers.oauth2.client import OAuth2Client
from allauth.socialaccount.providers.oauth2.views import OAuth2LoginView
from rest_auth.registration.views import SocialConnectView, SocialLoginView
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
class CallbackMixin:
adapter_class = GitHubOAuth2Adapter
client_class = OAuth2Client
# This is our serializer from above
# You can omit this if you handle CSRF protection in the frontend
serializer_class = CallbackSerializer
# Not the prettiest but single source of truth
@property
def callback_url(self):
url = self.adapter_class(self.request).get_callback_url(
self.request,
None,
)
return url
class CallbackCreate(CallbackMixin, SocialLoginView):
"""
Logs the user in with the providers data.
Creates a new user account if it doesn't exist yet.
"""
class CallbackConnect(CallbackMixin, SocialConnectView):
"""
Connects a provider's user account to the currently logged in user.
"""
# You can override this method here if you don't want to
# receive a token. Omit it otherwise.
def get_response(self):
return Response({'detail': _('Connection completed.')})
class Login(APIView):
adapter_class = GitHubOAuth2Adapter
permission_classes = (AllowAny,)
def post(self, request, format=None):
"""
Returns the URL to the login page of provider's authentication server.
"""
# You should have CSRF protection enabled, see
# https://security.stackexchange.com/a/104390 (point 3).
# Therefore this is a POST endpoint.
# This code is inspired by `OAuth2LoginView.dispatch`.
adapter = self.adapter_class(request)
provider = adapter.get_provider()
app = provider.get_app(request)
view = OAuth2LoginView()
view.request = request
view.adapter = adapter
client = view.get_client(request, app)
# You can modify `action` if you have more steps in your auth flow
action = AuthAction.AUTHENTICATE
auth_params = provider.get_auth_params(request, action)
# You can omit this if you want to validate the state in the frontend
client.state = SocialLogin.stash_state(request)
url = client.get_redirect_url(adapter.authorize_url, auth_params)
return Response({'url': url})

5. URLs

Right, the URLs are still missing.

It is correctly that we don't include any allauth URLs here. As a result we can't modify some URL names.

from django.urls import include, path, re_path
from django.views.generic import TemplateView
from rest_auth.registration.views import (
SocialAccountListView, SocialAccountDisconnectView
)
from . import views
# Frontend URLs
EMAIL_CONFIRMATION = r'^auth/confirm-email/(?P<key>[-:\w]+)$'
PASSWORD_RESET = (
r'^auth/password/reset/confirm/'
'(?P<uidb64>[0-9A-Za-z_\-]+)-'
'(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})$'
)
# NOTE: If you change this URL you have to update the callback URL
# in the OAuth providers' accounts, too
OAUTH_CALLBACK = 'auth/social/{provider}/callback'
# URL patterns
github_urlpatterns = [
path('auth-server/', views.Login.as_view(), name='github_auth_server'),
path(
'login/',
views.CallbackCreate.as_view(),
name='github_callback_login',
),
path(
'connect/',
views.CallbackConnect.as_view(),
name='github_callback_connect',
),
]
api_urlpatterns = [
path('auth/', include('rest_auth.urls')),
path('auth/registration/', include('rest_auth.registration.urls')),
path('auth/social/github/', include(github_urlpatterns)),
path(
'auth/user/accounts/',
SocialAccountListView.as_view(),
name='social_account_list',
),
path(
'auth/user/accounts/<int:pk>/disconnect/',
SocialAccountDisconnectView.as_view(),
name='social_account_disconnect',
),
]
urlpatterns = [
# The SPA serves these URLs but the backend has to know
# where they point to for reference, don't change the url names.
re_path(
EMAIL_CONFIRMATION,
TemplateView.as_view(),
name='account_confirm_email',
),
re_path(
PASSWORD_RESET,
TemplateView.as_view(),
name='password_reset_confirm',
),
path(
OAUTH_CALLBACK.format('github'),
TemplateView.as_view(),
name='github_callback',
),
# This has to be last because rest_auth.registration.urls
# also defines `account_confirm_email` what we override above.
path('api/', include(api_urlpatterns)),
]

What next?

6. Templates

If you want to customize the email templates here are the paths:

  • templates/account/email/email_confirmation_message.txt (you can provide an additional .html file according to the allauth docs but the ACCOUNT_TEMPLATE_EXTENSION doesn't seem to have an effect here)
  • templates/registration/password_reset_email.html

Note that it is important to list the app where you include these templates above the other apps in INSTALLED_APPS. Otherwise Django normally won't use them.

7. Migrations

Run the migrations of the new packages.

8. Social app

Go to the admin and create a social app. You just need a client ID and a client secret from GitHub and select the Django site you're using. And remember, we are using the OAuth2 Authorization Code grant type here.

9. OAuth2 workflow

So how does it finally all work together when somebody wants to login via GitHub?

  1. The SPA POSTs to /api/auth/social/github/auth-server/ to receive the complete URL to redirect to GitHub's authorization server.
  2. As the user is now there he enters their GitHub credentials and authorizes our website to receive data from GitHub's resource server.
  3. The authorization server redirects to our SPA. The SPA takes code and state and POSTs it to /api/auth/social/github/login/ to just login or to create a new account if it doesn't exist or to /api/auth/social/github/connect/ to connect an existing account (user has to be logged in) with the GitHub account.
  4. Now the backend internally contacts GitHub's authorization server a second time, receives the access token and then contacts GitHub's recource server to get the username, e-mail address, etc.
  5. The backend creates the user account with that data, generates a token and now responses to the SPA with that token.

Errors? Yes, they will happen. Maybe I'll share how we deal with them another time.

10. Sessions

I decided to not use tokens (or JWTs) but just session cookies (even without rest_framework.authtoken in INSTALLED_APPS). This can be done with just a few modifications. If you are interested in that I might share that, too.

MIT License
Copyright (c) 2018 Daniel Gilge
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@ramsrib
Copy link

ramsrib commented Jun 19, 2018

Thanks for the detailed steps and this is better than the official documentation.

I found an simple issue:

  1. In file 09_urls.py, the following throws an error:

    OAUTH_CALLBACK.format('github')

    It needs to be replaced with named argument,

    OAUTH_CALLBACK.format(provider='github')

Also I made couple of changes in the OAuth2 Flow to reduce the friction between frontend and backend (based on my needs).

  1. Changed the step 1, request to /api/auth/social/github/auth-server/ to return 303 response with github auth url (so that, it automatically redirects to the github login page.
  2. Changed the step 3 and added a GET method to CallbackCreate to read the code and state (instead of SPA) and do the login process as POST method does.

@dgilge
Copy link
Author

dgilge commented Nov 9, 2018

Maybe I should comment in pennersr/django-allauth#1678 (comment) some suggestions where to change allauth to make this easier.

@gonzaloamadio
Copy link

Hello Daniel,

Can you share the token (JWT token) authentication part?
I am trying to set it up on my backend to use it with a react SPA. But having some problems with the email confirmation and to have it up and running.

Thank you

@dgilge
Copy link
Author

dgilge commented Jun 24, 2019

@gonzaloamadio This should be quite easy and might work out of the box but as I've written we currently do not use tokens. So, I'm afraid I can't give you details on this. However, did you read my comment in https://gist.github.com/dgilge/dbe9260208aadee535cef7c412a1162e#file-07_views-py-L44?

@cjfd481980
Copy link

cjfd481980 commented Feb 27, 2020

Hi daniel, sorry to bother you, Thanks for the detailed steps on how to implement the backend api for auth. But Im new to SPA, and haven't figured it what the OAuth flow goes and how to implent it on react, so the callbacks function.

This what i've came up with:

  1. react app asks for provider url with params. Guess must be from here https://gist.github.com/dgilge/dbe9260208aadee535cef7c412a1162e#file-09_urls-py-L29
  2. React opens a popup, or another tab or another window where asks to authorize authentication(I think it is) and returns to SPA(?) where should redirect to backendapi(where?)
  3. backend api give response with token for authorization(that's what i've understand)
  4. from now on user requests to api and api authorizes

As yoy can see, I'm missing a crucial part of the process.
Perhaps I'm wrong on how the auth flow should work, in that case I'll appreciate any help you can provide me.

Thanks in advance

By the way, my backend api, is configured as you described in the gist-.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment