Skip to content

Commit 77a1c60

Browse files
authored
Merge pull request #359 from HackSoftware/google-oauth
Blog example for Google OAuth2 with Django
2 parents 11796f9 + 65072a1 commit 77a1c60

File tree

20 files changed

+668
-5
lines changed

20 files changed

+668
-5
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ AWS_S3_SECRET_ACCESS_KEY=""
99
AWS_STORAGE_BUCKET_NAME=""
1010
AWS_S3_REGION_NAME=""
1111
AWS_S3_CUSTOM_DOMAIN=""
12+
13+
DJANGO_GOOGLE_OAUTH2_CLIENT_ID=""
14+
DJANGO_GOOGLE_OAUTH2_CLIENT_SECRET=""
15+
DJANGO_GOOGLE_OAUTH2_PROJECT_ID=""

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,5 @@ dmypy.json
134134

135135
node_modules/
136136
package*.json
137+
138+
google_client_secret.json

config/django/base.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727

2828
ALLOWED_HOSTS = ["*"]
2929

30-
3130
# Application definition
3231

3332
LOCAL_APPS = [
@@ -99,7 +98,6 @@
9998

10099
WSGI_APPLICATION = "config.wsgi.application"
101100

102-
103101
# Database
104102
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
105103

@@ -120,7 +118,6 @@
120118
}
121119
}
122120

123-
124121
# Password validation
125122
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
126123

@@ -141,7 +138,6 @@
141138

142139
AUTH_USER_MODEL = "users.BaseUser"
143140

144-
145141
# Internationalization
146142
# https://docs.djangoproject.com/en/3.0/topics/i18n/
147143

@@ -155,7 +151,6 @@
155151

156152
USE_TZ = True
157153

158-
159154
# Static files (CSS, JavaScript, Images)
160155
# https://docs.djangoproject.com/en/3.0/howto/static-files/
161156

@@ -178,6 +173,7 @@
178173
from config.settings.cors import * # noqa
179174
from config.settings.email_sending import * # noqa
180175
from config.settings.files_and_storages import * # noqa
176+
from config.settings.google_oauth2 import * # noqa
181177
from config.settings.jwt import * # noqa
182178
from config.settings.sentry import * # noqa
183179
from config.settings.sessions import * # noqa

config/settings/cors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
1+
from config.env import env
2+
13
CORS_ALLOW_CREDENTIALS = True
24
CORS_ALLOW_ALL_ORIGINS = True
5+
6+
BASE_BACKEND_URL = env.str("DJANGO_BASE_BACKEND_URL", default="http://localhost:8000")
7+
BASE_FRONTEND_URL = env.str("DJANGO_BASE_FRONTEND_URL", default="http://localhost:3000")
8+
CORS_ORIGIN_WHITELIST = env.list("DJANGO_CORS_ORIGIN_WHITELIST", default=[BASE_FRONTEND_URL])

config/settings/google_oauth2.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from config.env import env
2+
3+
GOOGLE_OAUTH2_CLIENT_ID = env.str("DJANGO_GOOGLE_OAUTH2_CLIENT_ID", default="")
4+
GOOGLE_OAUTH2_CLIENT_SECRET = env.str("DJANGO_GOOGLE_OAUTH2_CLIENT_SECRET", default="")
5+
GOOGLE_OAUTH2_PROJECT_ID = env.str("DJANGO_GOOGLE_OAUTH2_PROJECT_ID", default="")

mypy.ini

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,11 @@ ignore_missing_imports = True
3333
[mypy-rest_framework_jwt.*]
3434
# Remove this when rest_framework_jwt stubs are present
3535
ignore_missing_imports = True
36+
37+
[mypy-google_auth_oauthlib.*]
38+
# Remove this when google_auth_oauthlib stubs are present
39+
ignore_missing_imports = True
40+
41+
[mypy-oauthlib.*]
42+
# Remove this when oauthlib stubs are present
43+
ignore_missing_imports = True

requirements/base.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,10 @@ attrs==22.1.0
2121

2222
gunicorn==20.1.0
2323
sentry-sdk==1.14.0
24+
25+
requests==2.30.0
26+
27+
google-api-python-client==2.86.0
28+
google-auth==2.18.0
29+
google-auth-httplib2==0.1.0
30+
google-auth-oauthlib==1.0.0

styleguide_example/api/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@
55
path("users/", include(("styleguide_example.users.urls", "users"))),
66
path("errors/", include(("styleguide_example.errors.urls", "errors"))),
77
path("files/", include(("styleguide_example.files.urls", "files"))),
8+
path(
9+
"google-oauth2/", include(("styleguide_example.blog_examples.google_login_server_flow.urls", "google-oauth2"))
10+
),
811
]

styleguide_example/blog_examples/google_login_server_flow/__init__.py

Whitespace-only changes.

styleguide_example/blog_examples/google_login_server_flow/raw/__init__.py

Whitespace-only changes.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from django.contrib.auth import login
2+
from django.shortcuts import redirect
3+
from rest_framework import serializers, status
4+
from rest_framework.response import Response
5+
from rest_framework.views import APIView
6+
7+
from styleguide_example.blog_examples.google_login_server_flow.raw.service import (
8+
GoogleRawLoginFlowService,
9+
)
10+
from styleguide_example.users.selectors import user_list
11+
12+
13+
class PublicApi(APIView):
14+
authentication_classes = ()
15+
permission_classes = ()
16+
17+
18+
class GoogleLoginRedirectApi(PublicApi):
19+
def get(self, request, *args, **kwargs):
20+
google_login_flow = GoogleRawLoginFlowService()
21+
22+
authorization_url, state = google_login_flow.get_authorization_url()
23+
24+
request.session["google_oauth2_state"] = state
25+
26+
return redirect(authorization_url)
27+
28+
29+
class GoogleLoginApi(PublicApi):
30+
class InputSerializer(serializers.Serializer):
31+
code = serializers.CharField(required=False)
32+
error = serializers.CharField(required=False)
33+
state = serializers.CharField(required=False)
34+
35+
def get(self, request, *args, **kwargs):
36+
input_serializer = self.InputSerializer(data=request.GET)
37+
input_serializer.is_valid(raise_exception=True)
38+
39+
validated_data = input_serializer.validated_data
40+
41+
code = validated_data.get("code")
42+
error = validated_data.get("error")
43+
state = validated_data.get("state")
44+
45+
if error is not None:
46+
return Response({"error": error}, status=status.HTTP_400_BAD_REQUEST)
47+
48+
if code is None or state is None:
49+
return Response({"error": "Code and state are required."}, status=status.HTTP_400_BAD_REQUEST)
50+
51+
session_state = request.session.get("google_oauth2_state")
52+
53+
if session_state is None:
54+
return Response({"error": "CSRF check failed."}, status=status.HTTP_400_BAD_REQUEST)
55+
56+
del request.session["google_oauth2_state"]
57+
58+
if state != session_state:
59+
return Response({"error": "CSRF check failed."}, status=status.HTTP_400_BAD_REQUEST)
60+
61+
google_login_flow = GoogleRawLoginFlowService()
62+
63+
google_tokens = google_login_flow.get_tokens(code=code)
64+
65+
id_token_decoded = google_tokens.decode_id_token()
66+
user_info = google_login_flow.get_user_info(google_tokens=google_tokens)
67+
68+
user_email = id_token_decoded["email"]
69+
request_user_list = user_list(filters={"email": user_email})
70+
user = request_user_list.get() if request_user_list else None
71+
72+
if user is None:
73+
return Response({"error": f"User with email {user_email} is not found."}, status=status.HTTP_404_NOT_FOUND)
74+
75+
login(request, user)
76+
77+
result = {
78+
"id_token_decoded": id_token_decoded,
79+
"user_info": user_info,
80+
}
81+
82+
return Response(result)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
from random import SystemRandom
2+
from typing import Any, Dict
3+
from urllib.parse import urlencode
4+
5+
import jwt
6+
import requests
7+
from attr import define
8+
from django.conf import settings
9+
from django.core.exceptions import ImproperlyConfigured
10+
from django.urls import reverse_lazy
11+
from oauthlib.common import UNICODE_ASCII_CHARACTER_SET
12+
13+
from styleguide_example.core.exceptions import ApplicationError
14+
15+
16+
@define
17+
class GoogleRawLoginCredentials:
18+
client_id: str
19+
client_secret: str
20+
project_id: str
21+
22+
23+
@define
24+
class GoogleAccessTokens:
25+
id_token: str
26+
access_token: str
27+
28+
def decode_id_token(self) -> Dict[str, str]:
29+
id_token = self.id_token
30+
decoded_token = jwt.decode(jwt=id_token, options={"verify_signature": False})
31+
return decoded_token
32+
33+
34+
class GoogleRawLoginFlowService:
35+
API_URI = reverse_lazy("api:google-oauth2:login-raw:callback-raw")
36+
37+
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/auth"
38+
GOOGLE_ACCESS_TOKEN_OBTAIN_URL = "https://oauth2.googleapis.com/token"
39+
GOOGLE_USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
40+
41+
SCOPES = [
42+
"https://www.googleapis.com/auth/userinfo.email",
43+
"https://www.googleapis.com/auth/userinfo.profile",
44+
"openid",
45+
]
46+
47+
def __init__(self):
48+
self._credentials = google_raw_login_get_credentials()
49+
50+
@staticmethod
51+
def _generate_state_session_token(length=30, chars=UNICODE_ASCII_CHARACTER_SET):
52+
# This is how it's implemented in the official SDK
53+
rand = SystemRandom()
54+
state = "".join(rand.choice(chars) for _ in range(length))
55+
return state
56+
57+
def _get_redirect_uri(self):
58+
domain = settings.BASE_BACKEND_URL
59+
api_uri = self.API_URI
60+
redirect_uri = f"{domain}{api_uri}"
61+
return redirect_uri
62+
63+
def get_authorization_url(self):
64+
redirect_uri = self._get_redirect_uri()
65+
66+
state = self._generate_state_session_token()
67+
68+
params = {
69+
"response_type": "code",
70+
"client_id": self._credentials.client_id,
71+
"redirect_uri": redirect_uri,
72+
"scope": " ".join(self.SCOPES),
73+
"state": state,
74+
"access_type": "offline",
75+
"include_granted_scopes": "true",
76+
"prompt": "select_account",
77+
}
78+
79+
query_params = urlencode(params)
80+
authorization_url = f"{self.GOOGLE_AUTH_URL}?{query_params}"
81+
82+
return authorization_url, state
83+
84+
def get_tokens(self, *, code: str) -> GoogleAccessTokens:
85+
redirect_uri = self._get_redirect_uri()
86+
87+
# Reference: https://developers.google.com/identity/protocols/oauth2/web-server#obtainingaccesstokens
88+
data = {
89+
"code": code,
90+
"client_id": self._credentials.client_id,
91+
"client_secret": self._credentials.client_secret,
92+
"redirect_uri": redirect_uri,
93+
"grant_type": "authorization_code",
94+
}
95+
96+
response = requests.post(self.GOOGLE_ACCESS_TOKEN_OBTAIN_URL, data=data)
97+
98+
if not response.ok:
99+
raise ApplicationError("Failed to obtain access token from Google.")
100+
101+
tokens = response.json()
102+
google_tokens = GoogleAccessTokens(id_token=tokens["id_token"], access_token=tokens["access_token"])
103+
104+
return google_tokens
105+
106+
def get_user_info(self, *, google_tokens: GoogleAccessTokens) -> Dict[str, Any]:
107+
access_token = google_tokens.access_token
108+
# Reference: https://developers.google.com/identity/protocols/oauth2/web-server#callinganapi
109+
response = requests.get(self.GOOGLE_USER_INFO_URL, params={"access_token": access_token})
110+
111+
if not response.ok:
112+
raise ApplicationError("Failed to obtain user info from Google.")
113+
114+
return response.json()
115+
116+
117+
def google_raw_login_get_credentials() -> GoogleRawLoginCredentials:
118+
client_id = settings.GOOGLE_OAUTH2_CLIENT_ID
119+
client_secret = settings.GOOGLE_OAUTH2_CLIENT_SECRET
120+
project_id = settings.GOOGLE_OAUTH2_PROJECT_ID
121+
122+
if not client_id:
123+
raise ImproperlyConfigured("GOOGLE_OAUTH2_CLIENT_ID missing in env.")
124+
125+
if not client_secret:
126+
raise ImproperlyConfigured("GOOGLE_OAUTH2_CLIENT_SECRET missing in env.")
127+
128+
if not project_id:
129+
raise ImproperlyConfigured("GOOGLE_OAUTH2_PROJECT_ID missing in env.")
130+
131+
credentials = GoogleRawLoginCredentials(client_id=client_id, client_secret=client_secret, project_id=project_id)
132+
133+
return credentials
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.urls import path
2+
3+
from styleguide_example.blog_examples.google_login_server_flow.raw.apis import (
4+
GoogleLoginApi,
5+
GoogleLoginRedirectApi,
6+
)
7+
8+
urlpatterns = [
9+
path("callback/", GoogleLoginApi.as_view(), name="callback-raw"),
10+
path("redirect/", GoogleLoginRedirectApi.as_view(), name="redirect-raw"),
11+
]

0 commit comments

Comments
 (0)