diff --git a/.env.example b/.env.example index 5d566ae8..9e57be91 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,6 @@ REDIS_HOST="localhost" REDIS_PORT="6379" # Index ELASTICSEARCH_URL=http://localhost:9200 +# Google +GOOGLE_OAUTH_CLIENT_ID="google_key" +GOOGLE_OAUTH_CLIENT_SECRET="google_secret" diff --git a/backend/apps/account/migrations/0024_account_google_sub.py b/backend/apps/account/migrations/0024_account_google_sub.py new file mode 100644 index 00000000..ea50ac2e --- /dev/null +++ b/backend/apps/account/migrations/0024_account_google_sub.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated manually for google_sub field + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0023_alter_career_role_old_alter_career_team_old"), + ] + + operations = [ + migrations.RunSQL( + sql="ALTER TABLE account ADD COLUMN IF NOT EXISTS google_sub VARCHAR(255) NULL;", + reverse_sql="ALTER TABLE account DROP COLUMN google_sub;", + state_operations=[ + migrations.AddField( + model_name="account", + name="google_sub", + field=models.CharField( + blank=True, max_length=255, null=True, unique=True, verbose_name="Google Sub" + ), + ), + ], + ), + ] diff --git a/backend/apps/account/models.py b/backend/apps/account/models.py index a2be8cab..4ff5babe 100644 --- a/backend/apps/account/models.py +++ b/backend/apps/account/models.py @@ -212,6 +212,7 @@ class Account(BaseModel, AbstractBaseUser, PermissionsMixin): email = models.EmailField("Email", unique=True) gcp_email = models.EmailField("GCP email", null=True, blank=True) # Google Cloud Platform email + google_sub = models.CharField("Google Sub", max_length=255, null=True, blank=True, unique=True) # Google OAuth subject identifier username = models.CharField("Username", max_length=40, blank=True, null=True, unique=True) first_name = models.CharField("Nome", max_length=40, blank=True) @@ -331,6 +332,7 @@ class Account(BaseModel, AbstractBaseUser, PermissionsMixin): "is_admin", "is_superuser", "staff_groups", + "google_sub", *BaseModel.graphql_fields_blacklist, ] graphql_filter_fields_blacklist = ["internal_subscription"] diff --git a/backend/apps/account/urls.py b/backend/apps/account/urls.py index afa101df..628a8850 100644 --- a/backend/apps/account/urls.py +++ b/backend/apps/account/urls.py @@ -4,6 +4,8 @@ from backend.apps.account.views import ( AccountActivateConfirmView, AccountActivateView, + GoogleAuthView, + GoogleCallbackView, PasswordResetConfirmView, PasswordResetView, ) @@ -29,4 +31,14 @@ PasswordResetConfirmView.as_view(), name="password_reset_confirm", ), + path( + "account/google/login/", + GoogleAuthView.as_view(), + name="google_auth", + ), + path( + "account/google/callback/", + GoogleCallbackView.as_view(), + name="google_callback", + ), ] diff --git a/backend/apps/account/views.py b/backend/apps/account/views.py index 21ebd018..4a60aee6 100644 --- a/backend/apps/account/views.py +++ b/backend/apps/account/views.py @@ -1,13 +1,16 @@ # -*- coding: utf-8 -*- from json import loads from typing import Any +import secrets + +from graphql_jwt.shortcuts import get_token from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.views import PasswordResetConfirmView, PasswordResetView from django.contrib.messages.views import SuccessMessageMixin from django.core.mail import EmailMultiAlternatives -from django.http import JsonResponse +from django.http import JsonResponse, HttpResponseRedirect from django.template.loader import render_to_string from django.urls import reverse_lazy as r from django.utils.decorators import method_decorator @@ -16,6 +19,7 @@ from django.views import View from django.views.decorators.csrf import csrf_exempt from loguru import logger +import requests from backend.apps.account.signals import send_activation_email from backend.apps.account.token import token_generator @@ -139,3 +143,184 @@ def dispatch(self, request, uidb64, token): return JsonResponse({}, status=200) else: return JsonResponse({}, status=422) + +class GoogleAuthView(View): + """View para iniciar o fluxo de autenticação Google OAuth""" + + @method_decorator(csrf_exempt, name="dispatch") + def dispatch(self, request, *args: Any, **kwargs: Any): + return super().dispatch(request, *args, **kwargs) + + def get(self, request): + """Inicia o fluxo de autenticação Google OAuth""" + try: + state = secrets.token_urlsafe(32) + request.session['oauth_state'] = state + + auth_url = ( + "https://accounts.google.com/o/oauth2/v2/auth?" + f"client_id={settings.GOOGLE_OAUTH_CLIENT_ID}&" + f"redirect_uri={settings.BACKEND_URL}/account/google/callback/&" + f"scope=openid email profile&" + f"response_type=code&" + f"state={state}&" + f"access_type=offline&" + f"include_granted_scopes=true" + ) + return HttpResponseRedirect(auth_url) + except Exception as e: + logger.error(f"Erro ao iniciar autenticação Google: {e}") + return JsonResponse({"error": "Erro interno do servidor"}, status=500) + + +class GoogleCallbackView(View): + """View para processar o callback do Google OAuth""" + + @method_decorator(csrf_exempt, name="dispatch") + def dispatch(self, request, *args: Any, **kwargs: Any): + return super().dispatch(request, *args, **kwargs) + + def get(self, request): + """Processa o callback do Google OAuth""" + try: + auth_code = request.GET.get('code') + state = request.GET.get('state') + error = request.GET.get('error') + + if error: + logger.error(f"Erro do Google OAuth: {error}") + return JsonResponse({"error": f"Erro de autorização: {error}"}, status=400) + + if not auth_code: + logger.error("Código de autorização não fornecido") + return JsonResponse({"error": "Código de autorização não fornecido"}, status=400) + + if not state or state != request.session.get('oauth_state'): + logger.error("Estado inválido - possível ataque CSRF") + return JsonResponse({"error": "Estado inválido"}, status=400) + + if 'oauth_state' in request.session: + del request.session['oauth_state'] + + token_data = self._exchange_code_for_token(auth_code) + if not token_data: + logger.error("Falha ao trocar código por token") + error_url = f"{settings.FRONTEND_URL}/user/login?error=auth_failed" + return HttpResponseRedirect(error_url) + + user_info = self._get_user_info(token_data['access_token']) + if not user_info: + logger.error("Não foi possível obter informações do usuário") + error_url = f"{settings.FRONTEND_URL}/user/login?error=user_info_failed" + return HttpResponseRedirect(error_url) + + account = self._create_or_update_account(user_info, token_data.get('id_token')) + + if account: + jwt_token = get_token(account) + frontend_url = f"{settings.FRONTEND_URL}/user/login?login=success&token={jwt_token}&id={account.id}" + return HttpResponseRedirect(frontend_url) + else: + logger.error("Erro ao criar/atualizar conta") + error_url = f"{settings.FRONTEND_URL}/user/login?error=account_creation_failed" + return HttpResponseRedirect(error_url) + + except Exception as e: + logger.error(f"Erro no callback Google OAuth: {e}") + error_url = f"{settings.FRONTEND_URL}/user/login?error=internal_server_error" + return HttpResponseRedirect(error_url) + + def _exchange_code_for_token(self, auth_code): + """Troca código de autorização por token de acesso""" + try: + token_url = "https://oauth2.googleapis.com/token" + + data = { + 'client_id': settings.GOOGLE_OAUTH_CLIENT_ID, + 'client_secret': settings.GOOGLE_OAUTH_CLIENT_SECRET, + 'code': auth_code, + 'grant_type': 'authorization_code', + 'redirect_uri': f"{settings.BACKEND_URL}/account/google/callback/" + } + + response = requests.post(token_url, data=data) + response.raise_for_status() + + token_data = response.json() + logger.info("Token obtido com sucesso") + + return token_data + + except requests.RequestException as e: + error_details = e.response.json() if e.response else str(e) + logger.error(f"Erro ao trocar código por token: {error_details}") + return None + + def _get_user_info(self, access_token): + """Obtém informações do usuário do Google usando token de acesso""" + try: + userinfo_url = "https://www.googleapis.com/oauth2/v2/userinfo" + headers = {'Authorization': f'Bearer {access_token}'} + + response = requests.get(userinfo_url, headers=headers) + response.raise_for_status() + + user_info = response.json() + logger.info(f"Informações do usuário obtidas: {user_info.get('email')}") + + return user_info + + except requests.RequestException as e: + error_details = e.response.json() if e.response else str(e) + logger.error(f"Erro ao obter informações do usuário: {error_details}") + return None + + def _create_or_update_account(self, user_info, id_token=None): + """Cria nova conta ou atualiza conta existente com dados do Google""" + try: + user_model = get_user_model() + email = user_info.get('email') + google_sub = user_info.get('id') + + if not email or not google_sub: + logger.error("Email ou Google Sub não fornecidos") + return None + + name_parts = user_info.get('name', '').split(' ', 1) + first_name = name_parts[0] if name_parts else '' + last_name = name_parts[1] if len(name_parts) > 1 else '' + + username = email.split('@')[0] + counter = 1 + original_username = username + while user_model.objects.filter(username=username).exists(): + username = f"{original_username}{counter}" + counter += 1 + + account, created = user_model.objects.get_or_create( + email=email, + defaults={ + 'username': username, + 'first_name': first_name, + 'last_name': last_name, + 'google_sub': google_sub, + 'is_active': True, + } + ) + + if created: + logger.info(f"Nova conta criada para {email}") + else: + logger.info(f"Conta Existente encontrada: {email}") + if not account.google_sub: + account.google_sub = google_sub + logger.info(f"Conta {email} vinculada ao Google Sub") + + account.is_active = True + account.save() + + return account + + except Exception as e: + logger.error(f"Erro ao criar/atualizar conta: {e}") + return None diff --git a/backend/settings/base.py b/backend/settings/base.py index 4695654a..7e606315 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -301,8 +301,8 @@ } # URLs -BACKEND_URL = getenv("BASE_URL_BACKEND", "https://localhost:8080") -FRONTEND_URL = getenv("BASE_URL_FRONTEND", "https://localhost:3000") +BACKEND_URL = getenv("BASE_URL_BACKEND", "http://localhost:8000") +FRONTEND_URL = getenv("BASE_URL_FRONTEND", "http://localhost:3000") # Discord DISCORD_BACKEND_WEBHOOK_URL = getenv("DISCORD_BACKEND_WEBHOOK_URL") @@ -313,3 +313,7 @@ # reCAPTCHA RECAPTCHA_SITE_KEY = getenv("RECAPTCHA_SITE_KEY") RECAPTCHA_SECRET_KEY = getenv("RECAPTCHA_SECRET_KEY") + +# Google OAuth +GOOGLE_OAUTH_CLIENT_ID = getenv("GOOGLE_OAUTH_CLIENT_ID") +GOOGLE_OAUTH_CLIENT_SECRET = getenv("GOOGLE_OAUTH_CLIENT_SECRET") \ No newline at end of file diff --git a/backend/settings/local.py b/backend/settings/local.py index a8a9cb81..910485cb 100644 --- a/backend/settings/local.py +++ b/backend/settings/local.py @@ -81,3 +81,7 @@ def as_bool(var): DJSTRIPE_WEBHOOK_SECRET = getenv("DJSTRIPE_WEBHOOK_SECRET") DJSTRIPE_USE_NATIVE_JSONFIELD = True DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id" + +# Google OAuth +GOOGLE_OAUTH_CLIENT_ID = getenv("GOOGLE_OAUTH_CLIENT_ID") +GOOGLE_OAUTH_CLIENT_SECRET = getenv("GOOGLE_OAUTH_CLIENT_SECRET") \ No newline at end of file diff --git a/backend/settings/remote.py b/backend/settings/remote.py index a7b9b147..2c2690fc 100644 --- a/backend/settings/remote.py +++ b/backend/settings/remote.py @@ -93,3 +93,7 @@ def as_bool(var): DJSTRIPE_WEBHOOK_SECRET = getenv("DJSTRIPE_WEBHOOK_SECRET") DJSTRIPE_USE_NATIVE_JSONFIELD = True DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id" + +# Google OAuth +GOOGLE_OAUTH_CLIENT_ID = getenv("GOOGLE_OAUTH_CLIENT_ID") +GOOGLE_OAUTH_CLIENT_SECRET = getenv("GOOGLE_OAUTH_CLIENT_SECRET") \ No newline at end of file