diff --git a/apps/properties/apps.py b/apps/properties/apps.py index 8da26fd..ff2575e 100644 --- a/apps/properties/apps.py +++ b/apps/properties/apps.py @@ -1,8 +1,9 @@ from django.apps import AppConfig - class PropertiesConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.properties" + label = "properties" + def ready(self): - import apps.properties.signals # importa o arquivo de signals + import apps.properties.signals \ No newline at end of file diff --git a/apps/users/migrations/0003_alter_user_managers.py b/apps/users/migrations/0003_alter_user_managers.py new file mode 100644 index 0000000..9f0873c --- /dev/null +++ b/apps/users/migrations/0003_alter_user_managers.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2026-03-30 20:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_alter_user_options_remove_user_username_user_name_and_more'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ], + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index d507f58..6712b4e 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -1,7 +1,27 @@ # apps/users/models.py -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AbstractUser, BaseUserManager from django.db import models +class UserManager(BaseUserManager): + def create_user(self, email, password=None, **extra_fields): + if not email: + raise ValueError("Email is required") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + def create_superuser(self, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser need is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser need is_superuser=True.") + + return self.create_user(email, password, **extra_fields) + class User(AbstractUser): class UserType(models.TextChoices): ADVERTISER = "A", "Advertiser" @@ -10,7 +30,7 @@ class UserType(models.TextChoices): username = None name = models.CharField(max_length=120) email = models.EmailField(unique=True) - + objects = UserManager() USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['name'] @@ -25,7 +45,7 @@ class UserType(models.TextChoices): # favorites = models.ManyToManyField( - # 'properties.Property', + # 'properties.Properties', # related_name='favorited_by', # blank=True # ) diff --git a/apps/users/serializers.py b/apps/users/serializers.py index c1a02b8..69ef43a 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -28,4 +28,25 @@ def update(self, instance, validated_data): defaults=preferences_data ) - return instance \ No newline at end of file + return instance + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ['id', 'name', 'email', 'password', 'user_type'] + + def create(self, validated_data): + user = User.objects.create_user( + email=validated_data['email'], + name=validated_data['name'], + user_type=validated_data['user_type'], + password=validated_data['password'] + ) + return user + def validate_email(self, value): + email = value.lower() + if User.objects.filter(email=email).exists(): + raise serializers.ValidationError("This email is currently in use.") + return email \ No newline at end of file diff --git a/apps/users/urls.py b/apps/users/urls.py index f13fdf9..18760a7 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -1,6 +1,14 @@ from rest_framework.routers import DefaultRouter -from .views import UserViewSet +from .views import UserViewSet, RegisterUserView +from django.urls import path +from rest_framework_simplejwt.views import (TokenObtainPairView, TokenRefreshView, TokenBlacklistView) router = DefaultRouter() router.register(r"", UserViewSet, basename="user") -urlpatterns = router.urls \ No newline at end of file +urlpatterns = [ + path('register/', RegisterUserView.as_view(), name='register'), + path('login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('logout/', TokenBlacklistView.as_view(), name='logout') +] + router.urls + diff --git a/apps/users/views.py b/apps/users/views.py index 0125765..c152f3f 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -1,18 +1,23 @@ -from rest_framework import viewsets, status +from rest_framework import viewsets, status, generics from rest_framework.decorators import action from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, AllowAny from django.shortcuts import get_object_or_404 from .models import User -from .serializers import UserSerializer +from .serializers import UserSerializer, RegisterSerializer + +class RegisterUserView(generics.CreateAPIView): + queryset = User.objects.all() + permission_classes = [AllowAny] + serializer_class = RegisterSerializer class UserViewSet(viewsets.GenericViewSet): queryset = User.objects.all() serializer_class = UserSerializer permission_classes = [IsAuthenticated] - @action(detail=False, methods=['get', 'patch'], url_path='profile') - def profile(self, request): + @action(detail=False, methods=['get', 'patch'], url_path='me') + def me(self, request): user = request.user if request.method == 'PATCH': @@ -27,8 +32,8 @@ def profile(self, request): # GET, POST e DELETE em /api/users/favorites/ @action(detail=False, methods=['get', 'post', 'delete'], url_path='favorites') def favorites(self, request): - from apps.properties.serializers import PropertiesSerializer # Sei que soa estranho, mas esse import tem que tá aqui praa poder não ter import repetido - from apps.properties.models import Properties + from apps.properties.serializers import PropertiesSerializer # Sei que soa estranho, mas esse import tem que tá aqui para poder não ter import repetido + from apps.properties.models import Properties user = request.user @@ -48,7 +53,6 @@ def favorites(self, request): user.favorites.add(property_obj) return Response({"message": "Property added to favorites"}, status=status.HTTP_200_OK) - elif request.method == 'DELETE': user.favorites.remove(property_obj) return Response({"message": "Property removed from favorites"}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index a4b7673..dcf6702 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,5 +1,6 @@ from pathlib import Path from decouple import config +from datetime import timedelta BASE_DIR = Path(__file__).resolve().parent.parent @@ -20,7 +21,9 @@ THIRD_PARTY_APPS = [ "rest_framework", "rest_framework_simplejwt", + "rest_framework_simplejwt.token_blacklist", "corsheaders", + ] LOCAL_APPS = [ @@ -75,9 +78,7 @@ } AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" - }, + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, @@ -105,6 +106,12 @@ "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"] } +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, +} CORS_ALLOW_ALL_ORIGINS = True diff --git a/docs/diagram.png b/docs/diagram.png new file mode 100644 index 0000000..a59e37c Binary files /dev/null and b/docs/diagram.png differ diff --git a/docs/diagrama_database.mermaid b/docs/diagrama_database.mermaid new file mode 100644 index 0000000..3a1412e --- /dev/null +++ b/docs/diagrama_database.mermaid @@ -0,0 +1,93 @@ +erDiagram + USERS { + bigserial id PK + text name + int age + varchar gender + text email UK "Login principal" + varchar user_type "A, S" + timestamp created_at + } + + SEARCH_PREFERENCES { + bigserial id PK + bigint user_id FK + char property_type "H, A" + numeric min_price + numeric max_price + varchar city + varchar neighborhood + } + + USER_FAVORITES { + bigint user_id FK + bigint property_id FK + } + + PROPERTIES { + bigserial id PK + bigint owner_id FK + bigint rooms_id FK + bigint rooms_extras_id FK + bigint condo_id FK + char property_purpose "R, S, B" + char type "H, A" + float area + int floors + int floor_number + numeric price + text address + varchar neighborhood + varchar city + boolean status + boolean has_mobilia + text description + vector embedding "1536 dim" + timestamp created_at + } + + ROOMS { + bigserial id PK + int bedrooms + int bathrooms + int parking_spots + } + + ROOMS_EXTRAS { + bigserial id PK + boolean living_room + boolean garden + boolean kitchen + boolean laundry_room + boolean pool + boolean office + } + + CONDO { + bigserial id PK + text name + text address + boolean gym + boolean pool + boolean court + boolean parks + boolean party_spaces + boolean concierge + boolean laundry_room + } + + PROPERTIES_PHOTOS { + bigserial id PK + bigint property_id FK + text r2_key "Cloudflare R2" + int order + } + + USERS ||--o{ PROPERTIES : "owns" + USERS ||--o| SEARCH_PREFERENCES : "has" + USERS }o--o{ USER_FAVORITES : "saves" + PROPERTIES }o--o{ USER_FAVORITES : "saved_by" + PROPERTIES ||--|| ROOMS : "has" + PROPERTIES ||--|| ROOMS_EXTRAS : "features" + PROPERTIES }o--|| CONDO : "belongs_to" + PROPERTIES ||--o{ PROPERTIES_PHOTOS : "has_images" \ No newline at end of file diff --git a/docs/jwt.md b/docs/jwt.md new file mode 100644 index 0000000..97e2b6b --- /dev/null +++ b/docs/jwt.md @@ -0,0 +1,83 @@ +# Authentication & Security Documentation + +Esta documentação detalha a implementação do sistema de segurança e autenticação do projeto **HomeMatch**. + +## Visão Geral +A plataforma utiliza **JSON Web Tokens (JWT)** via `djangorestframework-simplejwt` para gerenciar sessões e permissões. O diferencial do nosso modelo é a autenticação baseada exclusivamente em **E-mail**, tendo o campo `username` sido removido para simplificar o fluxo do usuário. + +--- + +## Modelo de Usuário Customizado +O modelo `User` herda de `AbstractUser`, mas utiliza um `UserManager` customizado para suportar o e-mail como identificador único. + +* **Identificador de Login**: `email`. +* **Campos Obrigatórios**: `email`, `name`. +* **Tipos de Usuário (user_type)**: + * `A` (Advertiser/Anunciante). + * `S` (Seeker/Buscador). + +--- + +## Endpoints de Autenticação (JWT) + +| Método | Endpoint | Acesso | Descrição | +| :--- | :--- | :--- | :--- | +| `POST` | `/api/users/register/` | Público | Registra um novo usuário na plataforma. | +| `POST` | `/api/users/login/` | Público | Recebe as credenciais e retorna os tokens `access` e `refresh`. | +| `POST` | `/api/users/token/refresh/` | Público | Gera um novo `access` token utilizando um `refresh` válido. | +| `POST` | `/api/users/logout/` | Autenticado | Invalida o `refresh` token (blacklist), encerrando a sessão. | +| `GET` | `/api/users/me/` | Autenticado | Retorna os dados do perfil do usuário logado. | + +--- + +## Proteção de Rotas: Imóveis (Properties) + +As rotas do app `properties` seguem a política de permissão `IsAuthenticatedOrReadOnly`. + +### 1. Leitura de Dados (Público) +Qualquer usuário pode realizar as seguintes operações sem necessidade de token: +* `GET /api/properties/`: Lista todos os imóveis cadastrados. +* `GET /api/properties/{id}/`: Visualiza detalhes de um imóvel específico. + +### 2. Escrita de Dados (Autenticado) +Requer o envio do cabeçalho `Authorization: Bearer `. + +* **Criação (`POST /api/properties/`)**: Permitida apenas para usuários com `user_type = 'A'` (Advertiser). +* **Edição (`PATCH /api/properties/{id}/`)**: Restrita ao proprietário do imóvel (`owner_id`). +* **Remoção (`DELETE /api/properties/{id}/`)**: Restrita ao proprietário do imóvel (`owner_id`). + +--- + +## Tratamento de Erros + +O sistema utiliza códigos de erro padronizados do Django REST Framework: + +* **401 Unauthorized**: Token ausente, expirado ou inválido. +* **403 Forbidden**: Usuário autenticado tenta realizar uma ação sem permissão (ex: Seeker tentando postar imóvel ou editar imóvel de terceiros). +* **400 Bad Request**: Erros de validação (ex: e-mail já cadastrado ou senha fora do padrão). + +--- + +## Exemplo de Requisição (CURL) + +Para acessar rotas protegidas no terminal, utilize: + +```bash +docker compose exec web python manage.py createsuperuser # Pra criar seu usuario - Se ainda não criado +``` + +```bash +curl -X POST http://localhost:8000/api/users/login/ -H "Content-Type: application/json" -d '{ + "email": "seu email cadastrado", + "password": "sua senha cadastrada" + }' +``` + * Só ai, você testa com seu token. + +```bash +curl -X GET http://localhost:8000/api/users/me/ \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" +``` + +