An Opinionated Guide to DRF OAuth
In this tutorial I'll demonstrate an opinionated approach to building social authentication into a Django app using REST Framework. I say opinionated because it avoids using any social auth app, relying only on django.contrib.auth and the standalone requests-oauthlib module. After experimenting with social auth apps, I decided I preferred this approach because it felt distinctly less magical and easier to extend.
Assuming you're sold on this idea, how does it work then?
Getting Started
Dive in straightaway with creating a new Django project and app.
django-admin startproject drf_auth_tutorial
cd drf_auth_tutorial
python manage.py startapp tutorial
We have very few dependencies for this tutorial. In fact, we just need DRF and requests-oauthlib installed. We'll also use django-sslserver to enable SSL during local development. Our requirements.txt file should look like below.
django-sslserver==0.22
djangorestframework==3.14.0
requests-oauthlib==1.3.1
Next we need to add rest_framework and our app to the INSTALLED_APPS list. We'll also need rest_framework.authtoken to allow issuing authentication tokens once a successful signup or login has happened. You'll also want to add sslserver for local development, because the OAuth2 flow will require a secure connection, even during local testing.
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"sslserver",
"rest_framework",
"rest_framework.authtoken",
"tutorial.apps.TutorialConfig",
]
For this tutorial we'll set up OAuth2 for both Google and GitHub. Before going any further, we'll need credentials from both before we can start testing.
To set up Google first go to the Google Cloud console and create a project. Once the project is created, proceed to the Credentials page and click Create Credentials. You should choose OAuth Client ID and then choose Web Application as the credential type. Here we'll configure what domains and URLs are allowed. The allowed redirect URLs must match the routes created in urls.py exactly, or Google will display an error message to your users.
We'll go through a similar process for setting up GitHub credentials. For GitHub, go to Settings and then to Developer Settings (bottom left of the page), and then select OAuth Apps. Configure your OAuth app similarly to what's shown below. You can put whatever you'd like in the Homepage URL field.
For both Google and GitHub you should have a client ID and secret. These values should go into a .env file that is not checked into source control. To pull these values into Django, we'll update settings.py to retrieve them from the environment. Add the following to the settings file.
GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
GOOGLE_CLIENT_SECRET = os.environ["GOOGLE_CLIENT_SECRET"]
GOOGLE_TOKEN_URL = "https://www.googleapis.com/oauth2/v4/token"
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GITHUB_CLIENT_ID = os.environ["GITHUB_CLIENT_ID"]
GITHUB_CLIENT_SECRET = os.environ["GITHUB_CLIENT_SECRET"]
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"
Here's an overview of the project structure before we dive into specific files.
├── db.sqlite3
├── dev.env
├── drf_auth_tutorial
│ ├── asgi.py
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── manage.py
├── requirements.txt
└── tutorial
├── admin.py
├── apps.py
├── __init__.py
├── models.py
├── tests.py
├── urls.py
├── utils.py
└── views
├── github_oauth_login_callback.py
├── github_oauth_login.py
├── github_oauth_signup_callback.py
├── github_oauth_signup.py
├── google_oauth_login_callback.py
├── google_oauth_login.py
├── google_oauth_signup_callback.py
├── google_oauth_signup.py
└── __init__.py
3 directories, 25 files
Each OAuth2 provider has four Views associated with them, with one pair of files implementing login and the other the signup flow. As we look into each of these files, we'll see that they contain only a small amount of code. This is because most of the logic is reusable and centralized in the tutorial/utils.py file.
We'll start off with looking at the Google signup flow.
from django.shortcuts import redirect
from django.urls import reverse
from rest_framework.views import APIView
from tutorial.utils import google_setup
class GoogleOAuth2SignUpView(APIView):
def get(self, request):
# The redirect_uri should match the settings shown on the GCP OAuth config page.
# The call to build_absolute_uri returns the full URL including domain.
redirect_uri = request.build_absolute_uri(reverse("google_signup_callback"))
return redirect(google_setup(redirect_uri))
When the user visits the /signup/google/ URL they are automatically redirected to the Google hosted auth page, where they'll select which account they want to use with your application.
Google will then send the user back to the redirect_uri passed to the google_setup function in the above view. The URL will also include an OAuth code that'll be used to obtain an auth token in the next step. The google_setup function belongs to the shared utils.py file that I mentioned earlier.
The utils.py file contains all of the code related to using the requests-oauthlib library. I've centralized the code there because the process of logging in and signing up are largely the same.
Here I'll share the google_setup and google_callback methods that are used in both the login and signup process.
from django.conf import settings
from requests_oauthlib import OAuth2Session
def google_setup(redirect_uri: str):
session = OAuth2Session(
settings.GOOGLE_CLIENT_ID,
redirect_uri=redirect_uri,
scope=[
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
],
)
authorization_url, _ = session.authorization_url(
settings.GOOGLE_AUTH_URL, access_type="offline", prompt="select_account"
)
return authorization_url
def google_callback(redirect_uri: str, auth_uri: str):
session = OAuth2Session(
settings.GOOGLE_CLIENT_ID,
redirect_uri=redirect_uri,
scope=[
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
],
)
session.fetch_token(
settings.GOOGLE_TOKEN_URL,
client_secret=settings.GOOGLE_CLIENT_SECRET,
authorization_response=auth_uri,
)
user_data = session.get("https://www.googleapis.com/oauth2/v1/userinfo").json()
return user_data
Once the user has selected which account they want to use, Google will redirect their browser to the URL we built in the GoogleOAuth2SignUpView class. This URL must match one of the authorized redirect URLs set on the GCP config page. Google will send extra query parameters along with the request, including an OAuth code.
This OAuth code is embedded in what I've called the auth_uri
in the class seen below, which handles the redirected request.
from django.contrib.auth.models import User
from django.http import JsonResponse
from django.urls import reverse
from rest_framework.authtoken.models import Token
from rest_framework.views import APIView
from tutorial.utils import google_callback
from tutorial.models import UserProfile
class GoogleOAuth2SignUpCallbackView(APIView):
def get(self, request):
redirect_uri = request.build_absolute_uri(reverse("google_signup_callback"))
auth_uri = request.build_absolute_uri()
user_data = google_callback(redirect_uri, auth_uri)
# Use get_or_create since an existing user may end up signing in
# through the sign up route.
user, _ = User.objects.get_or_create(
username=user_data["email"],
defaults={"first_name": user_data["given_name"]},
)
# Populate the extended user data stored in UserProfile.
UserProfile.objects.get_or_create(
user=user, defaults={"google_id": user_data["id"]}
)
# Create the auth token for the frontend to use.
token, _ = Token.objects.get_or_create(user=user)
# Here we assume that once we are logged in we should send
# a token to the frontend that a framework like React or Angular
# can use to authenticate further requests.
return JsonResponse({"token": token.key})
Note that we've extended the built-in Django User model with our own model named UserProfile that stores social auth info. In this tutorial app, every User will have an associated UserProfile in a one-to-one relationship. The UserProfile is used to store the google_id and github_id values, and at least one of them must be populated.
It's also worth noting that if an existing user tries to go through sign-up again, they are simply logged back in. You could of course choose to do something different in this case, like raise an error and inform the user that they are already signed up.
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
from django.db import models
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
google_id = models.CharField(max_length=255, unique=True, null=True)
github_id = models.CharField(max_length=255, unique=True, null=True)
def clean(self):
if self.google_id is None and self.github_id is None:
raise ValidationError("One of google_id or github_id must be set.")
Now that we've seen how the sign-up process with Google works, I'll show how little the login process actually differs.
from django.shortcuts import redirect
from django.urls import reverse
from rest_framework.views import APIView
from tutorial.utils import google_setup
class GoogleOAuth2LoginView(APIView):
def get(self, request):
# The redirect_uri should match the settings shown on the GCP OAuth config page.
# The call to build_absolute_uri returns the full URL including domain.
redirect_uri = request.build_absolute_uri(reverse("google_login_callback"))
return redirect(google_setup(redirect_uri))
If this looks remarkably familiar to you, it's because it is! The only difference here from the GoogleOAuth2SignUpView class is the redirect URI that is used. Instead of getting sent back to the google_signup_callback we are sending the user to the google_login_callback URL.
We need a separate view for the login callback because the logic is of course slightly different. Namely, if there is no existing user associated with the email, we should return an error.
from django.contrib.auth.models import User
from django.http import JsonResponse
from django.urls import reverse
from rest_framework.authtoken.models import Token
from rest_framework.views import APIView
from tutorial.utils import google_callback
class GoogleOAuth2LoginCallbackView(APIView):
def get(self, request):
redirect_uri = request.build_absolute_uri(reverse("google_login_callback"))
auth_uri = request.build_absolute_uri()
user_data = google_callback(redirect_uri, auth_uri)
try:
user = User.objects.get(username=user_data["email"])
except User.DoesNotExist:
return JsonResponse(
{"error": "User does not exist. Please sign up first."}, status=400
)
# Create the auth token for the frontend to use.
token, _ = Token.objects.get_or_create(user=user)
# Here we assume that once we are logged in we should send
# a token to the frontend that a framework like React or Angular
# can use to authenticate further requests.
return JsonResponse({"token": token.key})
Notice that here we're reusing the google_callback again, so that the logic for requesting user details is not repeated.
To tie it all together, I'll show the urls.py
for the tutorial app below.
from django.urls import path
from .views.google_oauth_signup import GoogleOAuth2SignUpView
from .views.google_oauth_signup_callback import GoogleOAuth2SignUpCallbackView
from .views.google_oauth_login import GoogleOAuth2LoginView
from .views.google_oauth_login_callback import GoogleOAuth2LoginCallbackView
from .views.github_oauth_signup import GitHubOAuth2SignUpView
from .views.github_oauth_signup_callback import GitHubOAuth2SignUpCallbackView
from .views.github_oauth_login import GitHubOAuth2LoginView
from .views.github_oauth_login_callback import GitHubOAuth2LoginCallbackView
urlpatterns = [
path("signup/google/", GoogleOAuth2SignUpView.as_view(), name="google_signup"),
path(
"google/callback/signup",
GoogleOAuth2SignUpCallbackView.as_view(),
name="google_signup_callback",
),
path("login/google/", GoogleOAuth2LoginView.as_view(), name="google_login"),
path(
"google/callback/login",
GoogleOAuth2LoginCallbackView.as_view(),
name="google_login_callback",
),
path("signup/github/", GitHubOAuth2SignUpView.as_view(), name="github_signup"),
path(
"github/callback/signup",
GitHubOAuth2SignUpCallbackView.as_view(),
name="github_signup_callback",
),
path("login/github/", GitHubOAuth2LoginView.as_view(), name="github_login"),
path(
"github/callback/login/",
GitHubOAuth2LoginCallbackView.as_view(),
name="github_login_callback",
),
]
I hope this has been a helpful tutorial for OAuth with Django REST Framework! I'm not showing the GitHub views here because they are very similar to the Google process, but if you'd like to see all of the code, you can always visit the GitHub repo for the tutorial project.