Skip to content

Commit 39a7be2

Browse files
authored
Merge pull request #913 from basedosdados/feat/google-access
Feat/google access
2 parents 2c3fd5f + da0d2bd commit 39a7be2

8 files changed

Lines changed: 243 additions & 3 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ REDIS_HOST="localhost"
2020
REDIS_PORT="6379"
2121
# Index
2222
ELASTICSEARCH_URL=http://localhost:9200
23+
# Google
24+
GOOGLE_OAUTH_CLIENT_ID="google_key"
25+
GOOGLE_OAUTH_CLIENT_SECRET="google_secret"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated manually for google_sub field
3+
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("account", "0023_alter_career_role_old_alter_career_team_old"),
10+
]
11+
12+
operations = [
13+
migrations.RunSQL(
14+
sql="ALTER TABLE account ADD COLUMN IF NOT EXISTS google_sub VARCHAR(255) NULL;",
15+
reverse_sql="ALTER TABLE account DROP COLUMN google_sub;",
16+
state_operations=[
17+
migrations.AddField(
18+
model_name="account",
19+
name="google_sub",
20+
field=models.CharField(
21+
blank=True, max_length=255, null=True, unique=True, verbose_name="Google Sub"
22+
),
23+
),
24+
],
25+
),
26+
]

backend/apps/account/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ class Account(BaseModel, AbstractBaseUser, PermissionsMixin):
212212

213213
email = models.EmailField("Email", unique=True)
214214
gcp_email = models.EmailField("GCP email", null=True, blank=True) # Google Cloud Platform email
215+
google_sub = models.CharField("Google Sub", max_length=255, null=True, blank=True, unique=True) # Google OAuth subject identifier
215216
username = models.CharField("Username", max_length=40, blank=True, null=True, unique=True)
216217

217218
first_name = models.CharField("Nome", max_length=40, blank=True)
@@ -331,6 +332,7 @@ class Account(BaseModel, AbstractBaseUser, PermissionsMixin):
331332
"is_admin",
332333
"is_superuser",
333334
"staff_groups",
335+
"google_sub",
334336
*BaseModel.graphql_fields_blacklist,
335337
]
336338
graphql_filter_fields_blacklist = ["internal_subscription"]

backend/apps/account/urls.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from backend.apps.account.views import (
55
AccountActivateConfirmView,
66
AccountActivateView,
7+
GoogleAuthView,
8+
GoogleCallbackView,
79
PasswordResetConfirmView,
810
PasswordResetView,
911
)
@@ -29,4 +31,14 @@
2931
PasswordResetConfirmView.as_view(),
3032
name="password_reset_confirm",
3133
),
34+
path(
35+
"account/google/login/",
36+
GoogleAuthView.as_view(),
37+
name="google_auth",
38+
),
39+
path(
40+
"account/google/callback/",
41+
GoogleCallbackView.as_view(),
42+
name="google_callback",
43+
),
3244
]

backend/apps/account/views.py

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
# -*- coding: utf-8 -*-
22
from json import loads
33
from typing import Any
4+
import secrets
5+
6+
from graphql_jwt.shortcuts import get_token
47

58
from django.conf import settings
69
from django.contrib.auth import get_user_model
710
from django.contrib.auth.views import PasswordResetConfirmView, PasswordResetView
811
from django.contrib.messages.views import SuccessMessageMixin
912
from django.core.mail import EmailMultiAlternatives
10-
from django.http import JsonResponse
13+
from django.http import JsonResponse, HttpResponseRedirect
1114
from django.template.loader import render_to_string
1215
from django.urls import reverse_lazy as r
1316
from django.utils.decorators import method_decorator
@@ -16,6 +19,7 @@
1619
from django.views import View
1720
from django.views.decorators.csrf import csrf_exempt
1821
from loguru import logger
22+
import requests
1923

2024
from backend.apps.account.signals import send_activation_email
2125
from backend.apps.account.token import token_generator
@@ -139,3 +143,184 @@ def dispatch(self, request, uidb64, token):
139143
return JsonResponse({}, status=200)
140144
else:
141145
return JsonResponse({}, status=422)
146+
147+
class GoogleAuthView(View):
148+
"""View para iniciar o fluxo de autenticação Google OAuth"""
149+
150+
@method_decorator(csrf_exempt, name="dispatch")
151+
def dispatch(self, request, *args: Any, **kwargs: Any):
152+
return super().dispatch(request, *args, **kwargs)
153+
154+
def get(self, request):
155+
"""Inicia o fluxo de autenticação Google OAuth"""
156+
try:
157+
state = secrets.token_urlsafe(32)
158+
request.session['oauth_state'] = state
159+
160+
auth_url = (
161+
"https://accounts.google.com/o/oauth2/v2/auth?"
162+
f"client_id={settings.GOOGLE_OAUTH_CLIENT_ID}&"
163+
f"redirect_uri={settings.BACKEND_URL}/account/google/callback/&"
164+
f"scope=openid email profile&"
165+
f"response_type=code&"
166+
f"state={state}&"
167+
f"access_type=offline&"
168+
f"include_granted_scopes=true"
169+
)
170+
return HttpResponseRedirect(auth_url)
171+
except Exception as e:
172+
logger.error(f"Erro ao iniciar autenticação Google: {e}")
173+
return JsonResponse({"error": "Erro interno do servidor"}, status=500)
174+
175+
176+
class GoogleCallbackView(View):
177+
"""View para processar o callback do Google OAuth"""
178+
179+
@method_decorator(csrf_exempt, name="dispatch")
180+
def dispatch(self, request, *args: Any, **kwargs: Any):
181+
return super().dispatch(request, *args, **kwargs)
182+
183+
def get(self, request):
184+
"""Processa o callback do Google OAuth"""
185+
try:
186+
auth_code = request.GET.get('code')
187+
state = request.GET.get('state')
188+
error = request.GET.get('error')
189+
190+
if error:
191+
logger.error(f"Erro do Google OAuth: {error}")
192+
return JsonResponse({"error": f"Erro de autorização: {error}"}, status=400)
193+
194+
if not auth_code:
195+
logger.error("Código de autorização não fornecido")
196+
return JsonResponse({"error": "Código de autorização não fornecido"}, status=400)
197+
198+
if not state or state != request.session.get('oauth_state'):
199+
logger.error("Estado inválido - possível ataque CSRF")
200+
return JsonResponse({"error": "Estado inválido"}, status=400)
201+
202+
if 'oauth_state' in request.session:
203+
del request.session['oauth_state']
204+
205+
token_data = self._exchange_code_for_token(auth_code)
206+
if not token_data:
207+
logger.error("Falha ao trocar código por token")
208+
error_url = f"{settings.FRONTEND_URL}/user/login?error=auth_failed"
209+
return HttpResponseRedirect(error_url)
210+
211+
user_info = self._get_user_info(token_data['access_token'])
212+
if not user_info:
213+
logger.error("Não foi possível obter informações do usuário")
214+
error_url = f"{settings.FRONTEND_URL}/user/login?error=user_info_failed"
215+
return HttpResponseRedirect(error_url)
216+
217+
account = self._create_or_update_account(user_info, token_data.get('id_token'))
218+
219+
if account:
220+
jwt_token = get_token(account)
221+
frontend_url = f"{settings.FRONTEND_URL}/user/login?login=success&token={jwt_token}&id={account.id}"
222+
return HttpResponseRedirect(frontend_url)
223+
else:
224+
logger.error("Erro ao criar/atualizar conta")
225+
error_url = f"{settings.FRONTEND_URL}/user/login?error=account_creation_failed"
226+
return HttpResponseRedirect(error_url)
227+
228+
except Exception as e:
229+
logger.error(f"Erro no callback Google OAuth: {e}")
230+
error_url = f"{settings.FRONTEND_URL}/user/login?error=internal_server_error"
231+
return HttpResponseRedirect(error_url)
232+
233+
def _exchange_code_for_token(self, auth_code):
234+
"""Troca código de autorização por token de acesso"""
235+
try:
236+
token_url = "https://oauth2.googleapis.com/token"
237+
238+
data = {
239+
'client_id': settings.GOOGLE_OAUTH_CLIENT_ID,
240+
'client_secret': settings.GOOGLE_OAUTH_CLIENT_SECRET,
241+
'code': auth_code,
242+
'grant_type': 'authorization_code',
243+
'redirect_uri': f"{settings.BACKEND_URL}/account/google/callback/"
244+
}
245+
246+
response = requests.post(token_url, data=data)
247+
response.raise_for_status()
248+
249+
token_data = response.json()
250+
logger.info("Token obtido com sucesso")
251+
252+
return token_data
253+
254+
except requests.RequestException as e:
255+
error_details = e.response.json() if e.response else str(e)
256+
logger.error(f"Erro ao trocar código por token: {error_details}")
257+
return None
258+
259+
def _get_user_info(self, access_token):
260+
"""Obtém informações do usuário do Google usando token de acesso"""
261+
try:
262+
userinfo_url = "https://www.googleapis.com/oauth2/v2/userinfo"
263+
headers = {'Authorization': f'Bearer {access_token}'}
264+
265+
response = requests.get(userinfo_url, headers=headers)
266+
response.raise_for_status()
267+
268+
user_info = response.json()
269+
logger.info(f"Informações do usuário obtidas: {user_info.get('email')}")
270+
271+
return user_info
272+
273+
except requests.RequestException as e:
274+
error_details = e.response.json() if e.response else str(e)
275+
logger.error(f"Erro ao obter informações do usuário: {error_details}")
276+
return None
277+
278+
def _create_or_update_account(self, user_info, id_token=None):
279+
"""Cria nova conta ou atualiza conta existente com dados do Google"""
280+
try:
281+
user_model = get_user_model()
282+
email = user_info.get('email')
283+
google_sub = user_info.get('id')
284+
285+
if not email or not google_sub:
286+
logger.error("Email ou Google Sub não fornecidos")
287+
return None
288+
289+
name_parts = user_info.get('name', '').split(' ', 1)
290+
first_name = name_parts[0] if name_parts else ''
291+
last_name = name_parts[1] if len(name_parts) > 1 else ''
292+
293+
username = email.split('@')[0]
294+
counter = 1
295+
original_username = username
296+
while user_model.objects.filter(username=username).exists():
297+
username = f"{original_username}{counter}"
298+
counter += 1
299+
300+
account, created = user_model.objects.get_or_create(
301+
email=email,
302+
defaults={
303+
'username': username,
304+
'first_name': first_name,
305+
'last_name': last_name,
306+
'google_sub': google_sub,
307+
'is_active': True,
308+
}
309+
)
310+
311+
if created:
312+
logger.info(f"Nova conta criada para {email}")
313+
else:
314+
logger.info(f"Conta Existente encontrada: {email}")
315+
if not account.google_sub:
316+
account.google_sub = google_sub
317+
logger.info(f"Conta {email} vinculada ao Google Sub")
318+
319+
account.is_active = True
320+
account.save()
321+
322+
return account
323+
324+
except Exception as e:
325+
logger.error(f"Erro ao criar/atualizar conta: {e}")
326+
return None

backend/settings/base.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,8 @@
301301
}
302302

303303
# URLs
304-
BACKEND_URL = getenv("BASE_URL_BACKEND", "https://localhost:8080")
305-
FRONTEND_URL = getenv("BASE_URL_FRONTEND", "https://localhost:3000")
304+
BACKEND_URL = getenv("BASE_URL_BACKEND", "http://localhost:8000")
305+
FRONTEND_URL = getenv("BASE_URL_FRONTEND", "http://localhost:3000")
306306

307307
# Discord
308308
DISCORD_BACKEND_WEBHOOK_URL = getenv("DISCORD_BACKEND_WEBHOOK_URL")
@@ -313,3 +313,7 @@
313313
# reCAPTCHA
314314
RECAPTCHA_SITE_KEY = getenv("RECAPTCHA_SITE_KEY")
315315
RECAPTCHA_SECRET_KEY = getenv("RECAPTCHA_SECRET_KEY")
316+
317+
# Google OAuth
318+
GOOGLE_OAUTH_CLIENT_ID = getenv("GOOGLE_OAUTH_CLIENT_ID")
319+
GOOGLE_OAUTH_CLIENT_SECRET = getenv("GOOGLE_OAUTH_CLIENT_SECRET")

backend/settings/local.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,7 @@ def as_bool(var):
8181
DJSTRIPE_WEBHOOK_SECRET = getenv("DJSTRIPE_WEBHOOK_SECRET")
8282
DJSTRIPE_USE_NATIVE_JSONFIELD = True
8383
DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id"
84+
85+
# Google OAuth
86+
GOOGLE_OAUTH_CLIENT_ID = getenv("GOOGLE_OAUTH_CLIENT_ID")
87+
GOOGLE_OAUTH_CLIENT_SECRET = getenv("GOOGLE_OAUTH_CLIENT_SECRET")

backend/settings/remote.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,7 @@ def as_bool(var):
9393
DJSTRIPE_WEBHOOK_SECRET = getenv("DJSTRIPE_WEBHOOK_SECRET")
9494
DJSTRIPE_USE_NATIVE_JSONFIELD = True
9595
DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id"
96+
97+
# Google OAuth
98+
GOOGLE_OAUTH_CLIENT_ID = getenv("GOOGLE_OAUTH_CLIENT_ID")
99+
GOOGLE_OAUTH_CLIENT_SECRET = getenv("GOOGLE_OAUTH_CLIENT_SECRET")

0 commit comments

Comments
 (0)