Skip to content

Commit

Permalink
Merge pull request #47 from unb-mds/task/oauth
Browse files Browse the repository at this point in the history
oauth(jwt): definição do fluxo de autênticação social com oauth2
  • Loading branch information
caio-felipee authored Oct 14, 2023
2 parents 9b0af00 + 6e376dd commit 1d1d986
Show file tree
Hide file tree
Showing 18 changed files with 295 additions and 7 deletions.
4 changes: 2 additions & 2 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ POSTGRES_DB="postgres"
POSTGRES_USER="suagradeunb"
POSTGRES_PASSWORD="suagradeunb"


# Credenciais de acesso ao admin
ADMIN_NAME="admin"
ADMIN_PASS="admin"
ADMIN_PASS="admin"
ADMIN_EMAIL="[email protected]"
6 changes: 4 additions & 2 deletions api/api/management/commands/initadmin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
from users.models import User
from decouple import config


Expand All @@ -11,7 +11,9 @@ def handle(self, *args, **options):
password = config("ADMIN_PASS")
print(f'Conta do usuário {username} será criada!')
admin = User.objects.create_superuser(
username=username, password=password)
username=username, password=password,
email=config("ADMIN_EMAIL")
)
admin.is_active = True
admin.is_staff = True
admin.save()
Expand Down
44 changes: 42 additions & 2 deletions api/core/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from pathlib import Path
from decouple import config
from datetime import timedelta
import os

# Build paths inside the project like this: BASE_DIR / 'subdir'.
Expand All @@ -28,24 +29,63 @@
DEBUG = True

ALLOWED_HOSTS = ["*"]

CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOW_CREDENTIALS = True

# Application definition

INSTALLED_APPS = [
'api.apps.ApiConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'corsheaders',
'api',
'users',
'rest_framework',
'rest_framework_simplejwt',
'rest_framework_simplejwt.token_blacklist'
]

# Authentication

AUTH_USER_MODEL = 'users.User'


# Django REST Framework
# https://www.django-rest-framework.org/

REST_FRAMEWORK = {
'DEFAULT_PARSER_CLASSES': [
'rest_framework.parsers.JSONParser',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
'rest_framework_simplejwt.authentication.JWTAuthentication'
],
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
]
}


SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
'REFRESH_TOKEN_SECURE': True,
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,

"TOKEN_REFRESH_SERIALIZER": "users.simplejwt.serializers.RefreshJWTSerializer"
}

MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
Expand Down
3 changes: 2 additions & 1 deletion api/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.urls import path, include

urlpatterns = [
path('admin/', admin.site.urls),
path('users/', include('users.urls')),
]
Empty file added api/users/__init__.py
Empty file.
24 changes: 24 additions & 0 deletions api/users/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.contrib import admin
from django import forms
from .models import User


class UserForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

for field in self.fields:
self.fields[field].required = False
self.fields['email'].required = True

class Meta:
model = User
fields = '__all__'


@admin.register(User)
class UserAdmin(admin.ModelAdmin):
form = UserForm
list_display = ['uuid', 'email']
search_fields = ['email']
fields = [('first_name', 'last_name'), 'email']
6 changes: 6 additions & 0 deletions api/users/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'users'
37 changes: 37 additions & 0 deletions api/users/backends/google.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import requests
from users.models import User


class GoogleOAuth2:
GOOGLE_OAUTH2_PROVIDER = 'https://www.googleapis.com/oauth2/v3'

@classmethod
def get_user_data(cls, access_token: str) -> dict | None:
if not access_token:
return None

user_info_url = cls.GOOGLE_OAUTH2_PROVIDER + '/userinfo'
params = {'access_token': access_token}

try:
response = requests.get(user_info_url, params=params)
if response.status_code == 200:
user_data = response.json()
return user_data
else:
return None
except requests.exceptions.RequestException as e:
return None

@staticmethod
def do_auth(user_data: dict) -> User | None:
user, created = User.objects.get_or_create(
first_name=user_data['given_name'],
last_name=user_data['family_name'],
)

if created:
user.email = user_data['email']
user.save()

return user
9 changes: 9 additions & 0 deletions api/users/backends/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from importlib import import_module


def get_backend(backend: str) -> object | None:
try:
module = import_module('users.backends.' + backend)
return getattr(module, backend.title() + 'OAuth2')
except (ImportError, AttributeError):
return None
46 changes: 46 additions & 0 deletions api/users/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Generated by Django 4.2.5 on 2023-10-13 19:19

import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
import uuid


class Migration(migrations.Migration):

initial = True

dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]

operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('first_name', models.CharField(max_length=64)),
('last_name', models.CharField(max_length=128)),
('email', models.EmailField(max_length=254, unique=True)),
('is_active', models.BooleanField(default=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]
Empty file.
13 changes: 13 additions & 0 deletions api/users/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
import uuid


class User(AbstractUser):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
first_name = models.CharField(max_length=64)
last_name = models.CharField(max_length=128)
email = models.EmailField(blank=False, unique=True)
is_active = models.BooleanField(default=True)

REQUIRED_FIELDS = ["email"]
22 changes: 22 additions & 0 deletions api/users/simplejwt/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from django.conf import settings
from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response
import functools


def move_refresh_token_to_cookie(view_func: callable) -> callable:

@functools.wraps(view_func)
def wrapper(request: Request, *args, **kwargs) -> Response:
response = view_func(request, *args, **kwargs)

if (response.status_code == status.HTTP_200_OK or response.status_code == status.HTTP_201_CREATED):
jwt_settings = settings.SIMPLE_JWT
refresh_token = response.data.pop('refresh')
response.set_cookie(key="refresh", value=refresh_token,
max_age=jwt_settings["REFRESH_TOKEN_LIFETIME"],
secure=jwt_settings["REFRESH_TOKEN_SECURE"],
httponly=True, samesite="Lax")
return response
return wrapper
18 changes: 18 additions & 0 deletions api/users/simplejwt/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from rest_framework_simplejwt.serializers import TokenRefreshSerializer
from rest_framework_simplejwt.authentication import JWTAuthentication


class RefreshJWTSerializer(TokenRefreshSerializer):
def validate(self, attrs: dict[str, any]) -> dict[str, any]:
data = super().validate(attrs)

jwt_authentication = JWTAuthentication()

validated_token = jwt_authentication.get_validated_token(data["access"])
user = jwt_authentication.get_user(validated_token)

data["first_name"] = str(user.first_name)
data["last_name"] = str(user.last_name)
data["email"] = str(user.email)

return data
3 changes: 3 additions & 0 deletions api/users/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.test import TestCase

# Create your tests here.
9 changes: 9 additions & 0 deletions api/users/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.urls import re_path, path
from users import views

app_name = 'users'

urlpatterns = [
re_path('register/' + r'(?P<oauth2>[^/]+)/$', views.Register.as_view(), name='register'),
path('login/', views.RefreshJWTView.as_view(), name='login'),
]
54 changes: 54 additions & 0 deletions api/users/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from rest_framework import status
from rest_framework import exceptions
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from users.backends.utils import get_backend
from users.simplejwt.decorators import move_refresh_token_to_cookie


class Register(TokenObtainPairView):

@move_refresh_token_to_cookie
def post(self, request: Request, *args, **kwargs) -> Response:
token = request.data.get('access_token')

backend = get_backend(kwargs['oauth2'])
if not backend:
return Response(
{
'errors': f'Invalid provider {kwargs["oauth2"]}'
}, status=status.HTTP_400_BAD_REQUEST)

user_data = backend.get_user_data(token)
if user_data:
user = backend.do_auth(user_data)

serializer = self.get_serializer()
refresh = serializer.get_token(user)
data = {
'access': str(refresh.access_token),
'refresh': str(refresh),
'first_name': user.first_name,
'last_name': user.last_name,
'email': user.email,
}

return Response(data, status.HTTP_200_OK)

return Response(
{
'errors': 'Invalid token'
}, status.HTTP_400_BAD_REQUEST)


class RefreshJWTView(TokenRefreshView):

@move_refresh_token_to_cookie
def post(self, request, *args, **kwargs):
try:
request.data['refresh'] = request.COOKIES['refresh']
except KeyError:
raise exceptions.NotAuthenticated('Refresh cookie error.')

return super().post(request, *args, **kwargs)
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
asgiref==3.7.2
autopep8==2.0.4
Babel==2.12.1
beautifulsoup4==4.12.2
cachetools==5.3.1
Expand All @@ -11,7 +12,9 @@ cryptography==41.0.4
defusedxml==0.7.1
Django==4.2.5
django-allauth==0.57.0
django-cors-headers==4.3.0
djangorestframework==3.14.0
djangorestframework-simplejwt==5.3.0
ghp-import==2.1.0
google-api-core==2.12.0
google-api-python-client==2.102.0
Expand All @@ -36,6 +39,7 @@ protobuf==4.24.4
psycopg2-binary==2.9.7
pyasn1==0.5.0
pyasn1-modules==0.3.0
pycodestyle==2.11.1
pycparser==2.21
Pygments==2.16.1
PyJWT==2.8.0
Expand Down

0 comments on commit 1d1d986

Please sign in to comment.