diff --git a/queryzen-api/apps/authentication/__init__.py b/queryzen-api/apps/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/queryzen-api/apps/authentication/apps.py b/queryzen-api/apps/authentication/apps.py new file mode 100644 index 0000000..4c71d2b --- /dev/null +++ b/queryzen-api/apps/authentication/apps.py @@ -0,0 +1,7 @@ +# pylint: disable=C0114 +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.authentication' diff --git a/queryzen-api/apps/authentication/migrations/0001_initial.py b/queryzen-api/apps/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..7b645be --- /dev/null +++ b/queryzen-api/apps/authentication/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.6 on 2025-07-24 15:07 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='QueryzenUser', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('email', models.EmailField(max_length=254, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('is_superuser', models.BooleanField(default=False)), + ('is_staff', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/queryzen-api/apps/authentication/migrations/__init__.py b/queryzen-api/apps/authentication/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/queryzen-api/apps/authentication/models.py b/queryzen-api/apps/authentication/models.py new file mode 100644 index 0000000..e498591 --- /dev/null +++ b/queryzen-api/apps/authentication/models.py @@ -0,0 +1,52 @@ +# pylint: disable=C0114 +from apps.shared.mixins import UUIDMixin +from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager +from django.db import models + + +class QueryzenUserManager(BaseUserManager): + """Custom manager for QueryzenUser model.""" + def create_user(self, email, password, **extra_fields): + """Create and return a regular user with the given email and password.""" + if not email: + raise ValueError('Users must have an email address') + if not password: + raise ValueError('Users must have a password') + + 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, **extra_fields): + """Create and return a superuser with the given email and password.""" + extra_fields.setdefault('is_superuser', True) + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_active', True) + + return self.create_user(email, password, **extra_fields) + + +class QueryzenUser(AbstractBaseUser, UUIDMixin): + """Custom user model that uses email as the unique identifier.""" + email = models.EmailField(unique=True) + created_at = models.DateTimeField(auto_now_add=True) + + is_superuser = models.BooleanField(default=False) + is_staff = models.BooleanField(default=False) # Needed for admin access + is_active = models.BooleanField(default=True) # Needed for login system + + objects = QueryzenUserManager() + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = [] + + def __str__(self): + return str(self.email) + + def has_perm(self): + return self.is_superuser + + def has_module_perms(self): + return self.is_superuser diff --git a/queryzen-api/apps/authentication/urls.py b/queryzen-api/apps/authentication/urls.py new file mode 100644 index 0000000..18c781e --- /dev/null +++ b/queryzen-api/apps/authentication/urls.py @@ -0,0 +1,11 @@ +# pylint: disable=C0114 +from django.urls import path +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, +) + +urlpatterns = [ + path('auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), +] diff --git a/queryzen-api/apps/core/views.py b/queryzen-api/apps/core/views.py index 765bc12..f0e0a95 100644 --- a/queryzen-api/apps/core/views.py +++ b/queryzen-api/apps/core/views.py @@ -6,6 +6,7 @@ from django.shortcuts import get_object_or_404 from django_filters import rest_framework as filters +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import mixins, viewsets, status, views @@ -33,6 +34,7 @@ class ZenFilterViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): Check ``QueryZenFilter.Meta.fields`` to see the available ones. """ + permission_classes = (IsAuthenticated,) queryset = Zen.objects.all() serializer_class = ZenSerializer filter_backends = (filters.DjangoFilterBackend,) @@ -46,6 +48,7 @@ class ZenView(views.APIView): PUT: Create a Zen. DELETE: Delete a Zen. """ + permission_classes = (IsAuthenticated,) def _validate_parameters_replacement(self, zen: Zen, parameters: dict) -> None: """Validates that the required parameters to run the query are given by the user @@ -153,6 +156,7 @@ def delete(self, request, collection: str, name: str, version: str): # pylint: class StatisticsView(views.APIView): """View to retrieve statistical execution time metrics for a given Zen version.""" + permission_classes = (IsAuthenticated,) def get(self, request, collection: str, name: str, version: str): # pylint: disable=W0613 """ diff --git a/queryzen-api/apps/testing/views.py b/queryzen-api/apps/testing/views.py index fb0d91a..01c9ce9 100644 --- a/queryzen-api/apps/testing/views.py +++ b/queryzen-api/apps/testing/views.py @@ -1,8 +1,13 @@ # pylint: skip-file +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password + from rest_framework import status from rest_framework.decorators import api_view from rest_framework.response import Response +User = get_user_model() + @api_view(('GET',)) def clean_up_db(request): @@ -16,4 +21,8 @@ def clean_up_db(request): model.objects.all().delete() else: assert False + + # Create a new testing user for auth + User.objects.get_or_create(email="test@test.com", password=make_password('test')) + return Response(status=status.HTTP_200_OK) diff --git a/queryzen-api/pyproject.toml b/queryzen-api/pyproject.toml index 7f49324..1143e90 100644 --- a/queryzen-api/pyproject.toml +++ b/queryzen-api/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "httpx>=0.28.1,<0.29", "psycopg2-binary>=2.9.10", "django-cors-headers>=4.7.0", + "djangorestframework-simplejwt>=5.5.1", ] [dependency-groups] diff --git a/queryzen-api/queryzen_api/settings.py b/queryzen-api/queryzen_api/settings.py index 104fe44..7c92f57 100644 --- a/queryzen-api/queryzen_api/settings.py +++ b/queryzen-api/queryzen_api/settings.py @@ -53,11 +53,13 @@ # Application definition INSTALLED_APPS = [ 'apps.core', + 'apps.authentication', 'rest_framework', 'django_filters', 'django_celery_results', 'corsheaders', + 'rest_framework_simplejwt', 'django.contrib.admin', 'django.contrib.auth', @@ -154,6 +156,9 @@ 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', ), + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ) } if not DEBUG: REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ( @@ -174,3 +179,6 @@ CORS_ALLOWED_ORIGINS = get_split_env('CORS_ALLOWED_ORIGINS', []) CORS_ALLOWED_ORIGIN_REGEXES = get_split_env('CORS_ALLOWED_ORIGIN_REGEXES', []) CORS_ALLOW_ALL_ORIGINS = strtobool(os.getenv('CORS_ALLOW_ALL_ORIGINS', 'False')) + +#### Authentication section #### +AUTH_USER_MODEL = 'authentication.QueryzenUser' diff --git a/queryzen-api/queryzen_api/urls.py b/queryzen-api/queryzen_api/urls.py index 1877348..c8002b1 100644 --- a/queryzen-api/queryzen_api/urls.py +++ b/queryzen-api/queryzen_api/urls.py @@ -20,6 +20,7 @@ urlpatterns = [ path('', include('apps.core.urls')), + path('', include('apps.authentication.urls')), path('_healthcheck', lambda r: HttpResponse()), ] diff --git a/queryzen-api/uv.lock b/queryzen-api/uv.lock index c661310..5c8285d 100644 --- a/queryzen-api/uv.lock +++ b/queryzen-api/uv.lock @@ -215,6 +215,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235 }, ] +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674 }, +] + [[package]] name = "factory-boy" version = "3.3.3" @@ -402,6 +416,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224 }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + [[package]] name = "pylint" version = "3.3.4" @@ -443,6 +466,7 @@ dependencies = [ { name = "django-cors-headers" }, { name = "django-filter" }, { name = "djangorestframework" }, + { name = "djangorestframework-simplejwt" }, { name = "factory-boy" }, { name = "faker" }, { name = "httpx" }, @@ -464,6 +488,7 @@ requires-dist = [ { name = "django-cors-headers", specifier = ">=4.7.0" }, { name = "django-filter", specifier = "~=25.1" }, { name = "djangorestframework", specifier = ">=3.15.2,<4" }, + { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, { name = "factory-boy", specifier = ">=3.3.1,<4" }, { name = "faker", specifier = ">=36.1.0,<38" }, { name = "httpx", specifier = ">=0.28.1,<0.29" }, diff --git a/queryzen-client/queryzen/backend.py b/queryzen-client/queryzen/backend.py index 1e135e1..9173775 100644 --- a/queryzen-client/queryzen/backend.py +++ b/queryzen-client/queryzen/backend.py @@ -15,6 +15,8 @@ from . import constants from .constants import DEFAULT_COLLECTION +from .exceptions import AuthenticationError +from .http_wrapper import HttpxWrapper from .types import _AUTO, AUTO, Default @@ -115,11 +117,31 @@ class QueryZenHttpClient(QueryZenClientABC): COLLECTIONS = 'collection/' VERSION = 'version/' - def __init__(self, client: httpx.Client = None): - self.client: httpx.Client = (client - or httpx.Client(timeout=int(constants.DEFAULT_HTTP_TIMEOUT))) + def __init__(self, user: str = None, password: str = None, client: httpx.Client = None): + self.client: HttpxWrapper = HttpxWrapper( + (client or httpx.Client(timeout=int(constants.DEFAULT_HTTP_TIMEOUT))) + ) self.url: Url = Url(constants.BACKEND_URL or constants.LOCAL_URL) + # Everytime a QueryZenHttpClient is declared, a new pair is generated + # It'd be interesting to keep this in mind for future auto refresh features + # For the moment, clients won't be open so much time + self.access_token, self.refresh_token = self.get_jwt_pair(user, password) + + self.client.access_token = self.access_token + + def get_jwt_pair(self, email: str, password: str) -> (str, str): + """Authenticates user against queryzen auth service""" + response = self.client.post( + self.url / 'auth/token/', json={'email': email, 'password': password}) + + if response.status_code == 401: + raise AuthenticationError('Authentication failed. Please check your credentials.') + + payload = response.json() + return payload['access'], payload['refresh'] + + def make_url(self, collection: str, name: str, version: str) -> str: # todo make test """Creates a valid QueryZen REST url @@ -198,7 +220,8 @@ def create(self, return self.make_response(response) def filter(self, **filters) -> QueryZenResponse: - response = httpx.get(self.url / self.MAIN_ENDPOINT / '?' + urllib.parse.urlencode(filters)) + response = self.client.get( + self.url / self.MAIN_ENDPOINT / '?' + urllib.parse.urlencode(filters)) return self.make_response(response) def get(self, diff --git a/queryzen-client/queryzen/exceptions.py b/queryzen-client/queryzen/exceptions.py index 2cbe935..8da6be8 100644 --- a/queryzen-client/queryzen/exceptions.py +++ b/queryzen-client/queryzen/exceptions.py @@ -16,6 +16,9 @@ class IncompatibleAPIError(Exception): """ # Todo add message +class AuthenticationError(Exception): + """Authentication failed.""" + class ExecutionEngineError(Exception): """Workers or the broker is unavailable.""" diff --git a/queryzen-client/queryzen/http_wrapper.py b/queryzen-client/queryzen/http_wrapper.py new file mode 100644 index 0000000..321805d --- /dev/null +++ b/queryzen-client/queryzen/http_wrapper.py @@ -0,0 +1,34 @@ +""" +This module defines a wrapper class for httpx.Client to handle +authenticated HTTP requests with optional bearer token support. +""" +import httpx + + +class HttpxWrapper: + """ + Wrapper around httpx.Client to provide centralized handling of + authentication headers and request execution logic. + """ + access_token: str | None = None + + def __init__(self, client: httpx.Client | None = None, **kwargs): + self._client = client or httpx.Client(**kwargs) + + def _get_headers(self) -> dict[str, str]: + return {'Authorization': f'Bearer {self.access_token}'} if self.access_token else {} + + def _handle_request(self, method: str, url: str, **kwargs): + return getattr(self._client, method)(url, headers=self._get_headers(), **kwargs) + + def get(self, url, **kwargs): + return self._handle_request('get', url, **kwargs) + + def post(self, url, **kwargs): + return self._handle_request('post', url, **kwargs) + + def put(self, url, **kwargs): + return self._handle_request('put', url, **kwargs) + + def delete(self, url, **kwargs): + return self._handle_request('delete', url, **kwargs) diff --git a/queryzen-client/queryzen/queryzen.py b/queryzen-client/queryzen/queryzen.py index a65e3c5..e2871db 100644 --- a/queryzen-client/queryzen/queryzen.py +++ b/queryzen-client/queryzen/queryzen.py @@ -18,7 +18,7 @@ MissingParametersError, DatabaseDoesNotExistError, DefaultValueDoesNotExistError, - ParametersMissmatchError) + ParametersMissmatchError, AuthenticationError) from .types import AUTO, Rows, Columns, _AUTO, Default, ZenState from .constants import DEFAULT_COLLECTION from .table import make_table, ColumnCenter @@ -255,8 +255,11 @@ class QueryZen: ``` """ - def __init__(self, client: QueryZenClientABC | None = None): - self._client: QueryZenClientABC = client or QueryZenHttpClient() + def __init__( + self, user: str = None, + password: str = None, + client: QueryZenClientABC | None = None): + self._client: QueryZenClientABC = client or QueryZenHttpClient(user, password) def _validate_version(self, version) -> str: """ @@ -547,6 +550,9 @@ def run(self, if response.error_code == 400: raise MissingParametersError(response.error) + if response.error_code == 401: + raise AuthenticationError() + if response.error_code == 409: raise ParametersMissmatchError(response.error) diff --git a/queryzen-client/tests/conftest.py b/queryzen-client/tests/conftest.py index e0acbd4..9d0722f 100644 --- a/queryzen-client/tests/conftest.py +++ b/queryzen-client/tests/conftest.py @@ -114,7 +114,7 @@ def local_queryzen(): response = httpx.get(f'{constants.BACKEND_URL}/_testing/clean_db', timeout=1) assert response.status_code == 200 - qz = QueryZen() + qz = QueryZen(user='test@test.com', password='test') yield qz diff --git a/queryzen-client/tests/queryzen/test_auth.py b/queryzen-client/tests/queryzen/test_auth.py new file mode 100644 index 0000000..6585ff3 --- /dev/null +++ b/queryzen-client/tests/queryzen/test_auth.py @@ -0,0 +1,13 @@ +import pytest + +from queryzen import exceptions, QueryZen + + +def test_instantiate_queryzen_client_without_valid_credentials(queryzen): + """ + Test that instantiating a QueryZen client without a valid credentials raises an exception. + All operations must proceed through queryzen client so if auth failed here, the client won't be authorized. + """ + with pytest.raises(exceptions.AuthenticationError): + QueryZen(user='bad-email@test.com', password='test') +